la-li-lu-le-lo/build/template.html
rain fbb5f02f49 Add AGENTS.md and move the verification tools into the repo
The checks that were being run by hand lived in /tmp and would have been
lost, and the colour-contrast pass was ad-hoc enough that it missed
--dim2 entirely.

- tools/verify.js: headless smoke test. Boots the page against a stubbed
  DOM/canvas and checks all 9 tabs render, all 10 maps draw with no
  non-finite geometry and no squashed labels, dog-tag sub-checkboxes cover
  every tag in every list, no photo appears in two steps, every theme has a
  CSS block, and no personal data shipped.
- tools/theme-contrast.py: WCAG check across all themes, now also covering
  --on-acc on --acc.

Running the stricter check found real problems: --dim2 (small captions) was
2.3-3.0:1 in four themes and --acc-d was 2.7-2.8:1 in two. Lightened those.
Worst theme is now 3.2:1, all on-accent pairs are 4.8:1 or better.

- AGENTS.md documents the architecture, build stages, assertions, data
  provenance, deployment and the traps already hit, so a future session does
  not rediscover them the hard way.
2026-09-15 17:41:48 -04:00

1582 lines
85 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>la-li-lu-le-lo — MGS2 Master Collection 100% Companion</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏷️</text></svg>">
<script>
/* apply the saved theme before first paint so there is no flash */
try{
var _t=localStorage.getItem('mgs2-theme');
if(_t && _t!=='codec') document.documentElement.setAttribute('data-theme',_t);
}catch(e){}
</script>
<style>
:root{
/* ---- default theme: Codec ---- */
--bg:#070c11; --bg2:#0b131a; --panel:#101c26; --panel2:#15242f; --panel3:#1a2c39;
--line:#1d3542; --line2:#2a4a5b;
--txt:#dbe9f2; --dim:#84a2b4; --dim2:#5d7b8c;
--acc:#2fe0c0; --acc-d:#0f8a76; --acc2:#4aa8ff; --warn:#ffc857; --miss:#ff6b81; --ok:#54e08a;
--on-acc:#04120f; --bright:#ffffff; --field:#0a141b;
--glow1:#10303a; --glow2:#14243d; --overlay:rgba(3,7,11,.96); --steam:#57cbde;
--k-tag:#2fe0c0; --k-item:#c79bff; --k-ach:#8fd8ee; --k-area:#7fb2d9; --k-extra:#ff9ecb;
--k-miss:var(--warn); --k-boss:var(--miss); --k-mode:var(--ok);
--map-bg:#ffffff;
--shadow:0 4px 18px rgba(0,0,0,.45);
--mono:ui-monospace,SFMono-Regular,"JetBrains Mono",Consolas,monospace;
}
/* ================= THEMES ================= */html[data-theme="gruvbox"]{
--bg:#1d2021; --bg2:#282828; --panel:#282828; --panel2:#32302f; --panel3:#3c3836;
--line:#3c3836; --line2:#504945;
--txt:#ebdbb2; --dim:#a89984; --dim2:#8f8274;
--acc:#b8bb26; --acc-d:#79740e; --acc2:#83a598; --warn:#fabd2f; --miss:#fb4934; --ok:#8ec07c;
--on-acc:#1d2021; --bright:#fbf1c7; --field:#1d2021;
--glow1:#3c3836; --glow2:#2b2b1f; --overlay:rgba(29,32,33,.96); --steam:#83a598;
--k-tag:#8ec07c; --k-item:#d3869b; --k-ach:#83a598; --k-area:#a89984; --k-extra:#d65d0e;
}
html[data-theme="monokai"]{
--bg:#1e1f1c; --bg2:#272822; --panel:#272822; --panel2:#2f3029; --panel3:#3b3c33;
--line:#3b3c33; --line2:#4e4f45;
--txt:#f8f8f2; --dim:#a6a28c; --dim2:#8d8974;
--acc:#a6e22e; --acc-d:#6b8f1a; --acc2:#66d9ef; --warn:#e6db74; --miss:#f92672; --ok:#a6e22e;
--on-acc:#1e1f1c; --bright:#ffffff; --field:#1e1f1c;
--glow1:#2f3a1e; --glow2:#33202b; --overlay:rgba(30,31,28,.96); --steam:#66d9ef;
--k-tag:#a6e22e; --k-item:#ae81ff; --k-ach:#66d9ef; --k-area:#a6a28c; --k-extra:#fd971f;
}
html[data-theme="solarized-dark"]{
--bg:#00212b; --bg2:#002b36; --panel:#002b36; --panel2:#073642; --panel3:#0b4452;
--line:#0f4b57; --line2:#1a6472;
--txt:#93a1a1; --dim:#839496; --dim2:#657b83;
--acc:#2aa198; --acc-d:#24857b; --acc2:#268bd2; --warn:#b58900; --miss:#dc322f; --ok:#859900;
--on-acc:#002b36; --bright:#eee8d5; --field:#00252f;
--glow1:#073642; --glow2:#00283a; --overlay:rgba(0,33,43,.96); --steam:#268bd2;
--k-tag:#2aa198; --k-item:#6c71c4; --k-ach:#268bd2; --k-area:#839496; --k-extra:#d33682;
}
html[data-theme="dracula"]{
--bg:#191a21; --bg2:#21222c; --panel:#282a36; --panel2:#343746; --panel3:#3d4055;
--line:#3d4055; --line2:#535680;
--txt:#f8f8f2; --dim:#b8bcd0; --dim2:#7484b8;
--acc:#50fa7b; --acc-d:#2f9e4c; --acc2:#8be9fd; --warn:#f1fa8c; --miss:#ff5555; --ok:#50fa7b;
--on-acc:#191a21; --bright:#ffffff; --field:#1e1f29;
--glow1:#2b2d5c; --glow2:#3a1f36; --overlay:rgba(25,26,33,.96); --steam:#8be9fd;
--k-tag:#50fa7b; --k-item:#bd93f9; --k-ach:#8be9fd; --k-area:#b8bcd0; --k-extra:#ff79c6;
}
html[data-theme="nord"]{
--bg:#242933; --bg2:#2e3440; --panel:#2e3440; --panel2:#3b4252; --panel3:#434c5e;
--line:#434c5e; --line2:#4c566a;
--txt:#d8dee9; --dim:#9aa5b5; --dim2:#8290a2;
--acc:#88c0d0; --acc-d:#4e8a99; --acc2:#81a1c1; --warn:#ebcb8b; --miss:#bf616a; --ok:#a3be8c;
--on-acc:#2e3440; --bright:#eceff4; --field:#272c36;
--glow1:#3b4252; --glow2:#2b3a4a; --overlay:rgba(36,41,51,.96); --steam:#88c0d0;
--k-tag:#8fbcbb; --k-item:#b48ead; --k-ach:#81a1c1; --k-area:#9aa5b5; --k-extra:#d08770;
}
html[data-theme="tokyo-night"]{
--bg:#16161e; --bg2:#1a1b26; --panel:#1f2335; --panel2:#24283b; --panel3:#2f3549;
--line:#2f3549; --line2:#3b4261;
--txt:#c0caf5; --dim:#9aa5ce; --dim2:#6f79a6;
--acc:#7aa2f7; --acc-d:#4c72c4; --acc2:#7dcfff; --warn:#e0af68; --miss:#f7768e; --ok:#9ece6a;
--on-acc:#16161e; --bright:#ffffff; --field:#1a1b26;
--glow1:#24283b; --glow2:#2a1f3d; --overlay:rgba(22,22,30,.96); --steam:#7dcfff;
--k-tag:#9ece6a; --k-item:#bb9af7; --k-ach:#7dcfff; --k-area:#9aa5ce; --k-extra:#ff9e64;
}
html[data-theme="catppuccin"]{
--bg:#181825; --bg2:#1e1e2e; --panel:#1e1e2e; --panel2:#313244; --panel3:#3b3d52;
--line:#313244; --line2:#45475a;
--txt:#cdd6f4; --dim:#a6adc8; --dim2:#6c7086;
--acc:#89b4fa; --acc-d:#4a7fd4; --acc2:#89dceb; --warn:#f9e2af; --miss:#f38ba8; --ok:#a6e3a1;
--on-acc:#1e1e2e; --bright:#ffffff; --field:#181825;
--glow1:#313244; --glow2:#2a2440; --overlay:rgba(24,24,37,.96); --steam:#89dceb;
--k-tag:#94e2d5; --k-item:#cba6f7; --k-ach:#89b4fa; --k-area:#a6adc8; --k-extra:#fab387;
}
html[data-theme="one-dark"]{
--bg:#21252b; --bg2:#282c34; --panel:#282c34; --panel2:#2c313a; --panel3:#353b45;
--line:#353b45; --line2:#3e4451;
--txt:#abb2bf; --dim:#8b93a1; --dim2:#79818f;
--acc:#61afef; --acc-d:#2f6ea8; --acc2:#56b6c2; --warn:#e5c07b; --miss:#e06c75; --ok:#98c379;
--on-acc:#21252b; --bright:#ffffff; --field:#23272e;
--glow1:#2c313a; --glow2:#2b2f45; --overlay:rgba(33,37,43,.96); --steam:#56b6c2;
--k-tag:#98c379; --k-item:#c678dd; --k-ach:#61afef; --k-area:#8b93a1; --k-extra:#d19a66;
}
html[data-theme="rose-pine"]{
--bg:#191724; --bg2:#1f1d2e; --panel:#1f1d2e; --panel2:#26233a; --panel3:#302d44;
--line:#26233a; --line2:#403d52;
--txt:#e0def4; --dim:#908caa; --dim2:#6e6a86;
--acc:#ebbcba; --acc-d:#a86f6d; --acc2:#9ccfd8; --warn:#f6c177; --miss:#eb6f92; --ok:#31748f;
--on-acc:#191724; --bright:#ffffff; --field:#1f1d2e;
--glow1:#26233a; --glow2:#2a2033; --overlay:rgba(25,23,36,.96); --steam:#9ccfd8;
--k-tag:#9ccfd8; --k-item:#c4a7e7; --k-ach:#ebbcba; --k-area:#908caa; --k-extra:#f6c177;
}
*{box-sizing:border-box}
html,body{margin:0;padding:0}
body{
background:
radial-gradient(1200px 600px at 15% -10%, var(--glow1) 0%, transparent 60%),
radial-gradient(900px 500px at 90% 0%, var(--glow2) 0%, transparent 55%),
var(--bg);
color:var(--txt);
font:15px/1.55 "Segoe UI",system-ui,-apple-system,Roboto,Helvetica,Arial,sans-serif;
-webkit-font-smoothing:antialiased;
min-height:100vh;
}
a{color:var(--acc2)}
h1,h2,h3,h4{line-height:1.25;margin:0 0 .4em}
code,kbd{font-family:var(--mono);font-size:.86em;background:var(--field);border:1px solid var(--line);border-radius:4px;padding:1px 5px;color:var(--acc)}
/* ---------- HEADER ---------- */
header{
position:sticky;top:0;z-index:50;
background:linear-gradient(180deg,rgba(8,16,22,.98),rgba(8,16,22,.93));
backdrop-filter:blur(8px);
border-bottom:1px solid var(--line);
}
.hwrap{max-width:1400px;margin:0 auto;padding:10px 16px 0}
.htop{display:flex;flex-wrap:wrap;gap:12px;align-items:center}
.brand{display:flex;align-items:baseline;gap:10px;flex:1 1 320px;min-width:0}
.brand .tag{
font:700 11px/1 var(--mono);letter-spacing:.16em;color:var(--on-acc);background:var(--acc);
padding:5px 7px;border-radius:4px;text-transform:uppercase;white-space:nowrap
}
.brand h1{font-size:17px;margin:0;font-weight:650;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.brand small{color:var(--dim);font-size:11.5px;font-family:var(--mono)}
.hbtns{display:flex;gap:6px;flex-wrap:wrap;align-items:center}
select#themeSel{font:600 12px/1 var(--mono);background:var(--panel2);color:var(--txt);
border:1px solid var(--line2);padding:7px 8px;border-radius:6px;cursor:pointer}
select#themeSel:hover{border-color:var(--acc-d);color:var(--bright)}
button,.btn{
font:600 12px/1 var(--mono);letter-spacing:.03em;
background:var(--panel2);color:var(--txt);border:1px solid var(--line2);
padding:7px 10px;border-radius:6px;cursor:pointer;transition:.14s;
}
button:hover,.btn:hover{background:var(--panel3);border-color:var(--acc-d);color:var(--bright)}
button.primary{background:var(--acc);color:var(--on-acc);border-color:var(--acc)}
button.primary:hover{background:color-mix(in srgb,var(--acc) 82%,white)}
button.danger:hover{border-color:var(--miss);color:var(--miss)}
/* stat strip */
.stats{display:flex;gap:16px;flex-wrap:wrap;padding:8px 0 6px;align-items:center}
.stat{display:flex;align-items:center;gap:8px;font-size:12px;font-family:var(--mono);color:var(--dim);white-space:nowrap}
.stat b{color:var(--txt);font-size:13px}
.bar{width:130px;height:6px;background:var(--field);border:1px solid var(--line);border-radius:99px;overflow:hidden}
.bar>i{display:block;height:100%;background:linear-gradient(90deg,var(--acc-d),var(--acc));width:0;transition:width .3s}
.bar.blue>i{background:linear-gradient(90deg,color-mix(in srgb,var(--acc2) 55%,black),var(--acc2))}
.bar.gold>i{background:linear-gradient(90deg,color-mix(in srgb,var(--warn) 55%,black),var(--warn))}
#saveflag{margin-left:auto;font:11px var(--mono);color:var(--dim2)}
/* tabs */
nav.tabs{display:flex;gap:2px;overflow-x:auto;padding:6px 0 0;scrollbar-width:thin}
nav.tabs button{
border:1px solid transparent;border-bottom:none;background:transparent;border-radius:7px 7px 0 0;
color:var(--dim);padding:9px 13px;font-size:12.5px;white-space:nowrap
}
nav.tabs button:hover{color:var(--txt);background:rgba(255,255,255,.04)}
nav.tabs button.on{background:var(--panel);border-color:var(--line);color:var(--acc);box-shadow:0 -2px 0 var(--acc) inset}
/* ---------- LAYOUT ---------- */
main{max-width:1400px;margin:0 auto;padding:18px 16px 80px}
section.tab{display:none;animation:fade .18s ease}
section.tab.on{display:block}
@keyframes fade{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}
.panel{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:16px 18px;margin:0 0 16px;box-shadow:var(--shadow)}
.panel.tight{padding:12px 14px}
.panel h2{font-size:16px;color:var(--bright);display:flex;align-items:center;gap:9px;flex-wrap:wrap}
.panel h2 .pill{font:600 10px var(--mono)}
.panel h3{font-size:13.5px;color:var(--acc);font-family:var(--mono);letter-spacing:.05em;text-transform:uppercase;margin-top:16px}
.panel p{color:var(--txt);margin:.5em 0}
.muted{color:var(--dim)}
.grid{display:grid;gap:14px}
@media(min-width:900px){.grid.g2{grid-template-columns:1fr 1fr}.grid.g3{grid-template-columns:repeat(3,1fr)}}
.pill{display:inline-block;padding:2px 7px;border-radius:99px;border:1px solid var(--line2);font-family:var(--mono);font-size:10px;color:var(--dim);white-space:nowrap}
.pill.miss{color:var(--miss);border-color:color-mix(in srgb,var(--miss) 42%,transparent);background:color-mix(in srgb,var(--miss) 15%,transparent)}
.pill.story{color:var(--acc2);border-color:color-mix(in srgb,var(--acc2) 42%,transparent);background:color-mix(in srgb,var(--acc2) 15%,transparent)}
.pill.coll{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 42%,transparent);background:color-mix(in srgb,var(--warn) 15%,transparent)}
.pill.chal{color:var(--k-item);border-color:color-mix(in srgb,var(--k-item) 42%,transparent);background:color-mix(in srgb,var(--k-item) 15%,transparent)}
.pill.mode{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 42%,transparent);background:color-mix(in srgb,var(--ok) 15%,transparent)}
.pill.extra{color:var(--k-extra);border-color:color-mix(in srgb,var(--k-extra) 42%,transparent);background:color-mix(in srgb,var(--k-extra) 15%,transparent)}
.pill.steam{color:var(--steam);border-color:color-mix(in srgb,var(--steam) 42%,transparent);background:color-mix(in srgb,var(--steam) 15%,transparent);font-weight:700}
.ach.steam{border-left:3px solid var(--steam)}
.ach.steam .aname{color:var(--steam)}
#steamdot{display:inline-block;width:7px;height:7px;border-radius:99px;background:var(--dim2);margin-right:5px;vertical-align:1px}
#steamdot.on{background:var(--steam);box-shadow:0 0 7px var(--steam)}
#steamdot.err{background:var(--miss)}
.syncrow{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:8px 0}
.syncrow input{flex:1 1 240px;max-width:340px}
.status{font:12px var(--mono);color:var(--dim);padding:8px 11px;border-radius:7px;background:var(--field);border:1px solid var(--line);margin:8px 0}
.status.ok{color:var(--txt);border-color:color-mix(in srgb,var(--steam) 45%,transparent)}
.status.bad{color:var(--miss);border-color:color-mix(in srgb,var(--miss) 45%,transparent);background:color-mix(in srgb,var(--miss) 12%,transparent)}
.steamgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:6px;margin-top:10px}
.smini{display:flex;gap:7px;align-items:center;padding:5px 8px;border:1px solid var(--line);border-radius:6px;background:var(--panel2);font-size:11.5px}
.smini img{width:22px;height:22px;border-radius:4px;flex:0 0 22px}
.smini.have{border-color:color-mix(in srgb,var(--steam) 45%,transparent);background:color-mix(in srgb,var(--steam) 12%,transparent)}
.smini.no{opacity:.5}
.smini .nm{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.warnbox{border-left:3px solid var(--warn);background:color-mix(in srgb,var(--warn) 10%,transparent);padding:9px 12px;border-radius:0 7px 7px 0;margin:10px 0;color:color-mix(in srgb,var(--warn) 30%,var(--txt));font-size:13.5px}
.infobox{border-left:3px solid var(--acc2);background:color-mix(in srgb,var(--acc2) 10%,transparent);padding:9px 12px;border-radius:0 7px 7px 0;margin:10px 0;font-size:13.5px;color:color-mix(in srgb,var(--acc2) 30%,var(--txt))}
.okbox{border-left:3px solid var(--ok);background:color-mix(in srgb,var(--ok) 10%,transparent);padding:9px 12px;border-radius:0 7px 7px 0;margin:10px 0;font-size:13.5px;color:color-mix(in srgb,var(--ok) 30%,var(--txt))}
ul.tight{margin:.4em 0;padding-left:20px}
ul.tight li{margin:.22em 0}
table{width:100%;border-collapse:collapse;font-size:13.5px;margin:.6em 0}
th,td{text-align:left;padding:6px 9px;border-bottom:1px solid var(--line)}
th{color:var(--dim);font:600 11px var(--mono);letter-spacing:.06em;text-transform:uppercase}
td.n{font-family:var(--mono);color:var(--acc)}
/* ---------- CONTROLS ---------- */
.controls{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:0 0 14px}
.chips{display:flex;flex-wrap:wrap;gap:6px}
.chip{
font:600 11.5px var(--mono);padding:6px 10px;border-radius:99px;cursor:pointer;
background:var(--panel2);border:1px solid var(--line);color:var(--dim)
}
.chip:hover{color:var(--txt);border-color:var(--line2)}
.chip.on{background:var(--acc);border-color:var(--acc);color:var(--on-acc)}
.chip .c{opacity:.75;font-size:10.5px}
input[type=search],input[type=text]{
background:var(--field);border:1px solid var(--line);color:var(--txt);border-radius:7px;
padding:8px 11px;font:13px var(--mono);min-width:210px;flex:1 1 210px;max-width:340px
}
input[type=search]:focus,input[type=text]:focus{outline:none;border-color:var(--acc-d)}
label.sw{display:flex;align-items:center;gap:7px;font:12px var(--mono);color:var(--dim);cursor:pointer;user-select:none}
label.sw input{accent-color:var(--acc);width:15px;height:15px}
/* ---------- ACHIEVEMENT CARDS ---------- */
.ach{
background:var(--panel);border:1px solid var(--line);border-radius:10px;margin-bottom:9px;
transition:.16s;overflow:hidden
}
.ach:hover{border-color:var(--line2)}
.ach.done{opacity:.62;border-color:color-mix(in srgb,var(--ok) 40%,transparent);background:color-mix(in srgb,var(--ok) 8%,transparent)}
.ach.done .aname{text-decoration:line-through;text-decoration-color:var(--ok);color:var(--dim)}
.achhead{display:flex;gap:12px;align-items:flex-start;padding:11px 13px;cursor:pointer}
.achhead input[type=checkbox]{
appearance:none;width:21px;height:21px;flex:0 0 21px;margin-top:2px;border:1.5px solid var(--line2);
border-radius:5px;background:var(--field);cursor:pointer;position:relative;transition:.14s
}
.achhead input[type=checkbox]:checked{background:var(--ok);border-color:var(--ok)}
.achhead input[type=checkbox]:checked::after{content:"✓";position:absolute;inset:0;display:grid;place-items:center;color:var(--on-acc);font:800 14px/1 sans-serif}
.achhead input[type=checkbox]:hover{border-color:var(--acc)}
.icon{width:42px;height:42px;flex:0 0 42px;border-radius:7px;object-fit:cover;border:1px solid var(--line);background:var(--field)}
.icon.ph{display:grid;place-items:center;font:700 17px var(--mono);color:var(--acc);background:linear-gradient(140deg,var(--panel3),var(--field))}
.ameta{flex:1;min-width:0}
.aname{font-weight:650;font-size:14.5px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.adesc{color:var(--dim);font-size:12.8px;margin-top:1px}
.aright{display:flex;flex-direction:column;align-items:flex-end;gap:5px;flex:0 0 auto}
.caret{color:var(--dim2);font-size:11px;font-family:var(--mono)}
.achbody{display:none;padding:2px 15px 15px 66px;border-top:1px solid var(--line);margin-top:0}
.ach.open .achbody{display:block}
.achbody>*:first-child{margin-top:10px}
.shots{display:flex;gap:8px;flex-wrap:wrap;margin:0 0 12px}
.shots a{display:block;line-height:0}
.shots img{
width:200px;height:auto;border-radius:7px;border:1px solid var(--line2);
background:var(--field);transition:.15s;cursor:zoom-in
}
.shots img:hover{border-color:var(--acc);transform:translateY(-1px)}
.shots .cap{font:10.5px var(--mono);color:var(--dim2);margin-top:-6px}
.shots img{cursor:zoom-in}
/* ---------- LIGHTBOX ---------- */
#lightbox{position:fixed;inset:0;z-index:300;background:var(--overlay);
display:flex;flex-direction:column;backdrop-filter:blur(5px)}
#lightbox[hidden]{display:none}
.lbbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;padding:9px 14px;
border-bottom:1px solid var(--line);background:color-mix(in srgb,var(--panel) 92%,transparent)}
#lbCap{font:12px var(--mono);color:var(--txt);flex:1 1 260px;min-width:0;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#lbStage{flex:1;overflow:auto;display:flex;align-items:center;justify-content:center;
padding:16px;scrollbar-width:thin}
#lbStage.zoomed{align-items:flex-start;justify-content:flex-start;cursor:grab}
#lbStage.zoomed.dragging{cursor:grabbing}
#lbImg{max-width:100%;max-height:100%;display:block;border-radius:4px;
box-shadow:0 14px 50px rgba(0,0,0,.75);background:var(--field)}
#lbStage.zoomed #lbImg{max-width:none;max-height:none;cursor:zoom-out}
.lbnav{position:absolute;top:50%;transform:translateY(-50%);z-index:2;
font:600 22px/1 var(--mono);padding:12px 15px;background:color-mix(in srgb,var(--panel2) 92%,transparent);
border:1px solid var(--line2);border-radius:8px}
.lbnav.prev{left:12px}.lbnav.next{right:12px}
#lbStage img:not([src]){display:none}
/* ---------- ROADMAP / WALKTHROUGH COLOUR CODE ---------- */
.sbody b,.achbody b{font-weight:650}
.k-tag{color:var(--k-tag);background:color-mix(in srgb,var(--k-tag) 13%,transparent);padding:0 3px;border-radius:3px}
.k-item{color:var(--k-item);background:color-mix(in srgb,var(--k-item) 13%,transparent);padding:0 3px;border-radius:3px}
.k-miss{color:var(--k-miss);background:color-mix(in srgb,var(--k-miss) 14%,transparent);border-bottom:1px solid color-mix(in srgb,var(--k-miss) 50%,transparent);padding:0 3px;border-radius:3px}
.k-ach{color:var(--k-ach)}
.k-boss{color:var(--k-boss);background:color-mix(in srgb,var(--k-boss) 13%,transparent);padding:0 3px;border-radius:3px}
.k-area{color:var(--k-area)}
.k-mode{color:var(--k-mode)}
.key{display:flex;flex-wrap:wrap;gap:8px 18px;align-items:center;margin:12px 0 0;font:11.5px var(--mono);color:var(--dim)}
.key b{font-weight:650}
/* ---------- PER-STEP SUB-CHECKLISTS ---------- */
.subs{margin:15px 0 2px;border-top:1px dashed var(--line2);padding-top:11px}
.subhead{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:9px}
.subtitle{font:600 11.5px var(--mono);color:var(--dim);letter-spacing:.06em;text-transform:uppercase}
.sublist{display:grid;gap:5px;grid-template-columns:repeat(auto-fill,minmax(255px,1fr))}
.sub{display:flex;gap:8px;align-items:flex-start;padding:5px 9px;border:1px solid var(--line);
border-radius:6px;background:var(--panel2);cursor:pointer;transition:.13s}
.sub:hover{border-color:var(--line2);background:var(--panel3)}
.sub input{appearance:none;width:16px;height:16px;flex:0 0 16px;margin-top:2px;border:1.5px solid var(--line2);
border-radius:4px;background:var(--field);cursor:pointer;position:relative}
.sub input:checked{background:var(--ok);border-color:var(--ok)}
.sub input:checked::after{content:"✓";position:absolute;inset:0;display:grid;place-items:center;
color:var(--on-acc);font:800 11px/1 sans-serif}
.sub span{display:flex;flex-direction:column;min-width:0}
.sub b{font-size:12.5px;font-weight:600;line-height:1.3}
.sub em{font:10.5px var(--mono);color:var(--dim2);font-style:normal;margin-top:1px}
.sub.on-done{opacity:.55}
.sub.on-done b{text-decoration:line-through;text-decoration-color:var(--ok)}
.sub.tag b{color:var(--k-tag)}
.sub.ach b{color:var(--k-ach)}
.sub.ach.miss b{color:var(--k-miss)}
.sub.item b{color:var(--k-item)}
/* ---------- DOG TAGS ---------- */
.dgrid{display:grid;gap:6px;grid-template-columns:repeat(auto-fill,minmax(320px,1fr))}
.dt{
display:flex;gap:9px;align-items:flex-start;padding:7px 10px;border:1px solid var(--line);
border-radius:7px;background:var(--panel);cursor:pointer;transition:.13s
}
.dt:hover{border-color:var(--line2);background:var(--panel2)}
.dt.done{background:color-mix(in srgb,var(--ok) 8%,transparent);border-color:color-mix(in srgb,var(--ok) 40%,transparent);opacity:.65}
.dt.done .dname{text-decoration:line-through;text-decoration-color:var(--ok);color:var(--dim)}
.dt input{appearance:none;width:17px;height:17px;flex:0 0 17px;margin-top:2px;border:1.5px solid var(--line2);border-radius:4px;background:var(--field);cursor:pointer;position:relative}
.dt input:checked{background:var(--ok);border-color:var(--ok)}
.dt input:checked::after{content:"✓";position:absolute;inset:0;display:grid;place-items:center;color:var(--on-acc);font:800 11px/1 sans-serif}
.dname{font-size:13.2px;font-weight:600;line-height:1.3}
.darea{font:11px var(--mono);color:var(--acc2);margin-top:1px}
.dnote{font:11px var(--mono);color:var(--warn);margin-top:3px;line-height:1.35}
.cond{font:9px var(--mono);color:var(--on-acc);background:var(--warn);padding:1px 5px;border-radius:3px;margin-left:6px;vertical-align:1px}
/* threshold meter */
.thr{display:flex;flex-wrap:wrap;gap:10px;margin-top:10px}
.thrcard{flex:1 1 190px;background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:9px 11px}
.thrcard .lbl{font:11px var(--mono);color:var(--dim);display:flex;justify-content:space-between}
.thrcard .val{font:700 19px var(--mono);color:var(--txt);margin:2px 0 4px}
.thrcard .val small{font-size:12px;color:var(--dim);font-weight:400}
.thrcard.hit{border-color:var(--ok);background:color-mix(in srgb,var(--ok) 12%,transparent)}
.thrcard.hit .val{color:var(--ok)}
/* ---------- MAPS ---------- */
.mapctl{display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin:0 0 10px}
.mapctl button{padding:6px 9px}
.mapwrap{
overflow:auto;max-height:72vh;border:1px solid var(--line2);border-radius:9px;
background:var(--map-bg);position:relative;box-shadow:inset 0 0 0 1px rgba(0,0,0,.2)
}
#mapCanvas{display:block}
.legend{display:flex;flex-wrap:wrap;gap:9px 16px;margin-top:12px;font:11.5px var(--mono);color:var(--dim)}
.legend span{display:flex;align-items:center;gap:6px}
.swatch{width:13px;height:13px;border-radius:3px;border:1px solid rgba(255,255,255,.28);display:inline-block;flex:0 0 13px}
.maptip{font:11.5px var(--mono);color:var(--dim2);margin-top:8px}
/* ---------- ROADMAP ---------- */
.stop{border:1px solid var(--line);border-radius:9px;background:var(--panel);margin-bottom:10px;overflow:hidden}
.stop .sh{display:flex;gap:12px;align-items:flex-start;padding:11px 13px;cursor:pointer}
.stop.done{opacity:.62;border-color:color-mix(in srgb,var(--ok) 40%,transparent);background:color-mix(in srgb,var(--ok) 8%,transparent)}
.stop.done .stitle{text-decoration:line-through;text-decoration-color:var(--ok)}
.snum{
flex:0 0 30px;height:30px;border-radius:7px;background:var(--panel3);border:1px solid var(--line2);
display:grid;place-items:center;font:700 12px var(--mono);color:var(--acc)
}
.stop.done .snum{background:var(--ok);color:var(--on-acc);border-color:var(--ok)}
.stitle{font-weight:650;font-size:14px}
.sarea{font:11px var(--mono);color:var(--acc2);margin-top:1px}
.sbody{display:none;padding:2px 14px 14px 55px;border-top:1px solid var(--line)}
.stop.open .sbody{display:block}
.stop .sh input{appearance:none;width:20px;height:20px;flex:0 0 20px;margin-top:4px;border:1.5px solid var(--line2);border-radius:5px;background:var(--field);cursor:pointer;position:relative}
.stop .sh input:checked{background:var(--ok);border-color:var(--ok)}
.stop .sh input:checked::after{content:"✓";position:absolute;inset:0;display:grid;place-items:center;color:var(--on-acc);font:800 13px/1 sans-serif}
.phase{margin:22px 0 10px;display:flex;align-items:center;gap:12px}
.phase h2{margin:0}
.phase:after{content:"";flex:1;height:1px;background:linear-gradient(90deg,var(--line2),transparent)}
.kv{display:grid;grid-template-columns:auto 1fr;gap:5px 14px;font-size:13.5px;margin:.5em 0}
.kv dt{font:600 11.5px var(--mono);color:var(--dim);text-transform:uppercase;letter-spacing:.05em;padding-top:2px}
.kv dd{margin:0}
footer{max-width:1400px;margin:0 auto;padding:0 16px 50px;color:var(--dim2);font-size:12px}
.srcline{font-size:12.5px;color:var(--dim);margin:3px 0}
.toast{
position:fixed;left:50%;bottom:26px;transform:translate(-50%,20px);opacity:0;pointer-events:none;
background:var(--acc);color:var(--on-acc);font:600 12.5px var(--mono);padding:9px 16px;border-radius:8px;
transition:.25s;z-index:200;box-shadow:0 6px 24px rgba(0,0,0,.5)
}
.toast.on{opacity:1;transform:translate(-50%,0)}
::selection{background:var(--acc-d);color:var(--on-acc)}
</style>
</head>
<body>
<header>
<div class="hwrap">
<div class="htop">
<div class="brand">
<span class="tag">la-li-lu-le-lo</span>
<div style="min-width:0">
<h1>MGS2 Master Collection — 100% Companion</h1>
<small>METAL GEAR SOLID 2: Sons of Liberty — Master Collection &nbsp;·&nbsp; 51 achievements</small>
</div>
</div>
<div class="hbtns">
<button id="btnSteam"><span id="steamdot"></span>Steam</button>
<select id="themeSel" title="Colour theme" aria-label="Colour theme"></select>
<button id="btnHide">Hide done: off</button>
<button id="btnExport">Export</button>
<button id="btnImport">Import</button>
<button id="btnReset" class="danger">Reset</button>
<input type="file" id="fileIn" accept=".json" hidden>
</div>
</div>
<div class="stats">
<span class="stat">ACH <b id="sAch">0/51</b><span class="bar"><i id="bAch"></i></span></span>
<span class="stat">TAGS <b id="sTag">0/394</b><span class="bar blue"><i id="bTag"></i></span></span>
<span class="stat">ROADMAP <b id="sRoad">0/0</b><span class="bar gold"><i id="bRoad"></i></span></span>
<span id="saveflag">saved locally</span>
</div>
<nav class="tabs" id="tabs"></nav>
</div>
</header>
<main>
<section class="tab" id="tab-roadmap"></section>
<section class="tab" id="tab-ach"></section>
<section class="tab" id="tab-tags"></section>
<section class="tab" id="tab-maps"></section>
<section class="tab" id="tab-boss"></section>
<section class="tab" id="tab-steam"></section>
<section class="tab" id="tab-miss"></section>
<section class="tab" id="tab-vr"></section>
<section class="tab" id="tab-src"></section>
</main>
<footer>
<p>Unofficial fan-made companion for personal use. Metal Gear Solid 2: Sons of Liberty — Master Collection Version © Konami.
Achievement icons and screenshots are hot-linked from their original sources (Steam Community, Video Chums, Dayngl's Guides);
progress is stored only in this browser's local storage.</p>
</footer>
<div class="toast" id="toast"></div>
<div id="lightbox" hidden>
<div class="lbbar">
<span id="lbCap"></span>
<button id="lbZoom" title="toggle between fit-to-screen and full resolution">zoom 100%</button>
<a id="lbOpen" class="btn" href="#" target="_blank" rel="noopener">open original ↗</a>
<button id="lbClose">✕ close (Esc)</button>
</div>
<div id="lbStage"><img id="lbImg" alt="" referrerpolicy="no-referrer"></div>
<button class="lbnav prev" id="lbPrev" aria-label="previous image"></button>
<button class="lbnav next" id="lbNext" aria-label="next image"></button>
</div>
<script>
/* ============================================================
DATA
============================================================ */
const ACHIEVEMENTS = /*__ACHIEVEMENTS__*/[];
const ROADMAP = /*__ROADMAP__*/[];
const DOGTAGS = /*__DOGTAGS__*/[];
const MAPS = /*__MAPS__*/[];
const TOTALS = /*__TOTALS__*/{};
const BIGBOSS = /*__BIGBOSS__*/{};
/* ============================================================
STATE
============================================================ */
const KEY = 'mgs2mc-guide-v1';
/* Optional: a Steam vanity name or SteamID64 to sync on first load.
Left empty in the public repo - type your own on the Steam Sync tab instead.
(Anyone still running a local copy keeps their saved profile in localStorage.) */
const DEFAULT_PROFILE = '';
/* Live Steam sync needs a server (Steam sends no CORS headers). Leave this empty when running
locally next to steam-sync.py. If you host this page on GitHub Pages / Netlify / Cloudflare,
deploy steam-proxy-worker.js and paste its URL here to get sync back. */
const STEAM_PROXY = '';
const IS_LOCAL = /^(localhost|127\.0\.0\.1|\[::1\])$/.test(location.hostname);
let S = {ach:{}, tags:{}, road:{}, items:{}, hideDone:false, tab:'roadmap', roadDiff:'Very Easy',
steam:{profile:'',id64:'',persona:'',unlocked:{},lastSync:0}};
function load(){
try{
const raw = localStorage.getItem(KEY);
if(raw){
const p = JSON.parse(raw);
S = Object.assign(S, p);
S.ach = S.ach||{}; S.tags = S.tags||{}; S.road = S.road||{}; S.items = S.items||{};
S.steam = Object.assign({profile:'',id64:'',persona:'',unlocked:{},lastSync:0}, S.steam||{});
S.tab = S.tab||'roadmap';
S.roadDiff = S.roadDiff||'Very Easy';
}
}catch(e){ console.warn('load failed', e); }
}
let saveTimer=null;
function save(){
try{
localStorage.setItem(KEY, JSON.stringify(S));
const f=document.getElementById('saveflag');
f.textContent='saved ✓'; f.style.color='var(--ok)';
clearTimeout(saveTimer);
saveTimer=setTimeout(()=>{f.textContent='saved locally'; f.style.color='';},1400);
}catch(e){
const f=document.getElementById('saveflag');
f.textContent='⚠ storage blocked — use Export'; f.style.color='var(--miss)';
}
}
function toast(msg){
const t=document.getElementById('toast'); t.textContent=msg; t.classList.add('on');
clearTimeout(t._t); t._t=setTimeout(()=>t.classList.remove('on'),1600);
}
/* ============================================================
PROGRESS
============================================================ */
const ACH_TOTAL = ACHIEVEMENTS.length;
const TAG_TOTAL = DOGTAGS.reduce((a,l)=>a+l.tags.length,0);
const ROAD_TOTAL= ROADMAP.reduce((a,p)=>a+p.steps.length,0);
const achDone = ()=>ACHIEVEMENTS.filter(a=>S.ach[a.id]).length;
const tagDone = ()=>DOGTAGS.reduce((a,l)=>a+l.tags.filter((_,i)=>S.tags[l.id+':'+i]).length,0);
const roadDone= ()=>ROADMAP.reduce((a,p)=>a+p.steps.filter(s=>S.road[s.id]).length,0);
function paintProgress(){
const a=achDone(), t=tagDone(), r=roadDone();
document.getElementById('sAch').textContent = a+'/'+ACH_TOTAL;
document.getElementById('bAch').style.width = (a/ACH_TOTAL*100)+'%';
document.getElementById('sTag').textContent = t+'/'+TAG_TOTAL;
document.getElementById('bTag').style.width = (t/TAG_TOTAL*100)+'%';
document.getElementById('sRoad').textContent = r+'/'+ROAD_TOTAL;
document.getElementById('bRoad').style.width = (r/ROAD_TOTAL*100)+'%';
}
/* ============================================================
TABS
============================================================ */
const TABS=[
['roadmap','🗺️ Roadmap'],
['ach','🏆 Achievements'],
['tags','🏷️ Dog Tags'],
['maps','🗺️ Area Maps'],
['steam','🔄 Steam Sync'],
['boss','💀 Big Boss Run'],
['miss','⚠️ Missables'],
['vr','🕹️ VR / Extra Modes'],
['src','📚 Sources & Notes'],
];
function buildTabs(){
const n=document.getElementById('tabs');
n.innerHTML = TABS.map(([id,label])=>`<button data-tab="${id}">${label}</button>`).join('');
n.addEventListener('click',e=>{
const b=e.target.closest('button[data-tab]'); if(!b) return;
go(b.dataset.tab);
});
}
function go(id){
S.tab=id; save();
document.querySelectorAll('section.tab').forEach(s=>s.classList.toggle('on', s.id==='tab-'+id));
document.querySelectorAll('#tabs button').forEach(b=>b.classList.toggle('on', b.dataset.tab===id));
if(id==='maps') setTimeout(drawMap,0); // canvas is only painted while visible
window.scrollTo({top:0,behavior:'instant'});
}
const esc = s => String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const normName = s => String(s).toLowerCase().replace(/[^a-z0-9]/g,'');
/* In-game screenshots, attached to the achievement whose location they show.
Roadmap steps reference them by achievement name; they are rendered inline
in the step so the pictures sit next to the instructions for that spot. */
function stepShots(photos){
if(!photos || !photos.length) return '';
const thumb = x => x.s==='dayngl' ? x.u+'?w=480' : x.u+'?imw=480&amp;ima=fit&amp;impolicy=Letterbox';
const src = x => x.s==='dayngl' ? "Dayngl's Guides" : 'Steam guide';
return `<div class="shots">${photos.map(x=>
`<a class="lb" href="${x.u}" target="_blank" rel="noopener"`+
` data-full="${x.u}" data-cap="${esc(x.cap)}${src(x)}"`+
` title="${esc(x.cap)} — click to zoom">`+
`<img src="${thumb(x)}" loading="lazy" referrerpolicy="no-referrer"`+
` alt="${esc(x.cap)} location screenshot" onerror="this.closest('a').remove()"></a>`
).join('')}</div>`;
}
/* ---------- lightbox ---------- */
let lbList=[], lbIdx=0, lbZoom=false;
function lbCollect(anchor){
const box=anchor.closest('.shots');
const all=box ? Array.from(box.querySelectorAll('a.lb')) : [anchor];
return {all, idx: Math.max(0, all.indexOf(anchor))};
}
function lbOpen(anchor){
const c=lbCollect(anchor);
lbList=c.all.map(a=>({full:a.dataset.full, cap:a.dataset.cap}));
lbIdx=c.idx;
document.getElementById('lightbox').hidden=false;
document.body.style.overflow='hidden';
lbShow();
}
function lbShow(){
const it=lbList[lbIdx]; if(!it) return;
const img=document.getElementById('lbImg');
img.src=it.full; img.alt=it.cap||'';
document.getElementById('lbCap').textContent=(lbList.length>1 ? (lbIdx+1)+' / '+lbList.length+' · ' : '')+(it.cap||'');
document.getElementById('lbOpen').href=it.full;
lbZoom=false;
const st=document.getElementById('lbStage');
st.classList.remove('zoomed'); st.scrollTop=0; st.scrollLeft=0;
document.getElementById('lbZoom').textContent='zoom 100%';
const many=lbList.length>1;
document.getElementById('lbPrev').style.display = many?'':'none';
document.getElementById('lbNext').style.display = many?'':'none';
}
function lbClose(){
document.getElementById('lightbox').hidden=true;
document.body.style.overflow='';
}
function lbToggleZoom(ev){
const st=document.getElementById('lbStage'), img=document.getElementById('lbImg');
lbZoom=!lbZoom;
if(lbZoom){
// keep whatever part of the shot you clicked on centred once it grows
const r=img.getBoundingClientRect();
const fx=ev ? (ev.clientX-r.left)/Math.max(1,r.width) : 0.5;
const fy=ev ? (ev.clientY-r.top)/Math.max(1,r.height) : 0.5;
st.classList.add('zoomed');
document.getElementById('lbZoom').textContent='zoom fit';
requestAnimationFrame(()=>{
st.scrollLeft = img.offsetWidth*fx - st.clientWidth/2;
st.scrollTop = img.offsetHeight*fy - st.clientHeight/2;
});
}else{
st.classList.remove('zoomed'); st.scrollTop=0; st.scrollLeft=0;
document.getElementById('lbZoom').textContent='zoom 100%';
}
}
function lbStep(d){
if(lbList.length<2) return;
lbIdx=(lbIdx+d+lbList.length)%lbList.length;
lbShow();
}
/* ============================================================
ROADMAP TAB
============================================================ */
const openSteps=new Set(); // survives re-renders (diff switch, hide-done, etc.)
/* area -> chapter, so a step can find its own dog-tag list */
const AREA2CH={};
DOGTAGS.forEach(l=>l.tags.forEach(t=>{ if(!AREA2CH[t.area]) AREA2CH[t.area]=l.chapter; }));
const slug = s => String(s).toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');
/* The chips, tick boxes and item labels for one walkthrough step. Dog tags reuse the exact
same keys as the Dog Tags tab (listId:index) so the two views are one checklist. */
function subsHTML(s){
const ach=(s.gets&&s.gets.ach)||[];
const items=(s.gets&&s.gets.item)||[];
const areas=s.areas||[];
const chapter=areas.length ? AREA2CH[areas[0]] : '';
const listId=chapter ? DOGTAGS.findIndex(l=>l.chapter===chapter && l.difficulty===S.roadDiff) : -1;
let tags=[];
if(listId>=0){
DOGTAGS[listId].tags.forEach((t,i)=>{
if(areas.indexOf(t.area)>=0) tags.push({t, i, key:DOGTAGS[listId].id+':'+i});
});
}
if(!ach.length && !items.length && !tags.length) return '';
const row=(kind,cls,key,attr,checked,label,sub)=>`<label class="sub ${cls}">
<input type="checkbox" ${attr}="${key}" data-sub="${kind}" ${checked?'checked':''}>
<span><b>${esc(label)}</b><em>${sub}</em></span></label>`;
return `<div class="subs" data-subs="${s.id}">
<div class="subhead">
<span class="subtitle">what to get here</span>
<span class="pill" data-subcount="${s.id}">0/0</span>
${tags.length?`<span class="pill">🏷️ ${tags.length} ${esc(S.roadDiff)} tags</span>`:''}
</div>
<div class="sublist">
${ach.map(id=>{const a=ACHIEVEMENTS.find(x=>x.id===id)||{name:id};
return row('ach','ach'+(a.miss?' miss':''), id, 'data-achbox', !!S.ach[id], a.name,
'achievement'+(a.miss?' · missable':''));}).join('')}
${tags.map(o=>row('tag','tag', o.key, 'data-tagbox', !!S.tags[o.key], o.t.name,
'dog tag · '+esc(o.t.area)+(o.t.note?' · ⚠ conditional':''))).join('')}
${items.map(l=>{const k=s.id+':'+slug(l);
return row('item','item', k, 'data-item', !!S.items[k], l, 'pick up');}).join('')}
</div>
</div>`;
}
/* recompute every visible "n/m" chip and strike through finished rows */
function subsRefresh(){
document.querySelectorAll('.subs').forEach(box=>{
const ins=box.querySelectorAll('input[type=checkbox]');
let done=0;
ins.forEach(i=>{
if(i.checked) done++;
const lab=i.closest('.sub');
if(lab) lab.classList.toggle('on-done', i.checked);
});
const pill=box.querySelector('[data-subcount]');
if(pill) pill.textContent=done+'/'+ins.length;
});
}
function renderRoad(){
const el=document.getElementById('tab-roadmap');
let h = `
<div class="panel">
<h2>How to 100% MGS2 Master Collection</h2>
<p>You need <b>51 achievements</b>. The smart order is <b>five passes</b> — three cheap dog-tag runs that also
mop up every miscellaneous/missable achievement, then two challenge runs. Do <b>not</b> attempt Big Boss first:
you want the Bandana, Stealth Camo and the Brown Wig unlocked before you try it.</p>
<div class="infobox"><b>The single most important thing:</b> after finishing a chapter you must make a
<b>completed save file</b> (save on the results screen / after the credits). Special items only carry over from a
<b>cleared</b> save, and several achievements (Yorkie, Poodle, Beagle, Shiba Inu) only pop once the chapter is finished.</div>
<table>
<tr><th>#</th><th>Run</th><th>Difficulty</th><th>Dog tags</th><th>Main purpose</th></tr>
<tr><td class="n">1</td><td>Everything Run</td><td>Very Easy</td><td>Tanker 24 + Plant 43 = 67</td><td>All misc/missable achievements, weapons, boxes, camera photos</td></tr>
<tr><td class="n">2</td><td>Tag Run II</td><td>Easy</td><td>Tanker 26 + Plant 44 = 70</td><td>Push Tanker to 50 (Bandana) &amp; Plant to 80 (Brown Wig)</td></tr>
<tr><td class="n">3</td><td>Tag Run III</td><td>Normal</td><td>Tanker 33 + Plant 49 = 82</td><td>Push Tanker to 76 (Stealth) &amp; Plant to 120+ (Plant Stealth)</td></tr>
<tr><td class="n">4</td><td>Clean Run</td><td>Very Easy + Stealth</td><td>—</td><td><b>Pacifist Run</b> + <b>Complete Stealth</b> together, trivial with camo</td></tr>
<tr><td class="n">5</td><td>Big Boss Run</td><td>Extreme</td><td>—</td><td><b>Big Boss</b> — save it for last</td></tr>
<tr><td class="n">+</td><td>Off-story</td><td>any</td><td>—</td><td>Snake Tales, Boss Survival, VR/Alternative missions, Casting Theater, Screenplay Book</td></tr>
</table>
<div class="warnbox"><b>29 of the 51 are missable.</b> The ones that most often get missed on a first run:
<b>Hurt Locker</b>, <b>Kissing Booth</b>, <b>Snake Beater</b>, <b>Down in Smoke</b>, <b>Vamp Eyer</b> (you must NOT
skip the final cutscene), <b>Sexting</b> &amp; <b>Who Ya Gonna Call?</b> (Tanker Holds, one-shot areas),
<b>Johnny on the Spot</b> (two Directional Mic moments), and <b>To Catch a Predator</b>.</div>
<p class="muted">Tick the steps below as you go — the counters feed the ROADMAP bar at the top.
Steps are in story order for each chapter.</p>
<div class="key">
<span><b class="k-tag">guard name</b> whose dog tag it is</span>
<span><b class="k-item">weapon / box</b> something you pick up</span>
<span><b class="k-ach">achievement</b></span>
<span><b class="k-miss">missable achievement</b> do not walk past it</span>
<span><b class="k-boss">boss</b></span>
<span><b class="k-area">area</b></span>
</div>
<div class="controls" style="margin:14px 0 0">
<span class="muted" style="font:11.5px var(--mono)">Dog-tag checklist shown in each step for:</span>
<div class="chips">
${['Very Easy','Easy','Normal','Hard','Extreme'].map(d=>
`<span class="chip ${S.roadDiff===d?'on':''}" data-roaddiff="${d}">${d}</span>`).join('')}
</div>
</div>
<p class="muted" style="margin-top:8px">Open a step and its <b>sub-checkboxes</b> list the dog tags, achievements
and items for that spot. <b>Ticking a dog tag here ticks the same tag in the Dog Tags tab</b> (and vice versa) —
it is the same checklist, and items and achievements sync to their own tabs too.</p>
</div>`;
ROADMAP.forEach(ph=>{
const done = ph.steps.filter(s=>S.road[s.id]).length;
h += `<div class="phase"><h2>${esc(ph.phase)}</h2><span class="pill">${done}/${ph.steps.length}</span></div>`;
if(ph.intro) h += `<div class="panel tight"><p style="margin:0">${ph.intro}</p></div>`;
ph.steps.forEach((s,i)=>{
const on=!!S.road[s.id];
h += `<div class="stop ${on?'done':''} ${openSteps.has(s.id)?'open':''}" data-road="${s.id}">
<div class="sh">
<input type="checkbox" ${on?'checked':''} data-roadbox="${s.id}">
<div class="snum">${i+1}</div>
<div style="flex:1;min-width:0">
<div class="stitle">${esc(s.title)}</div>
${s.area?`<div class="sarea">${esc(s.area)}</div>`:''}
</div>
<span class="caret">▾</span>
</div>
<div class="sbody">${stepShots(s.photos)}${s.body}${subsHTML(s)}</div>
</div>`;
});
});
el.innerHTML=h;
subsRefresh();
}
/* ============================================================
ACHIEVEMENTS TAB
============================================================ */
const CATS=[['all','All'],['story','Story'],['coll','Collectible'],['misc','Misc / Missable'],['mode','Extra Modes'],['chal','Challenge'],['extra','MC Extras'],['missing','⬇ Missing on Steam']];
let achFilter='all', achQuery='', achOpenOnly=false;
function renderAch(){
const el=document.getElementById('tab-ach');
const counts={all:ACHIEVEMENTS.length};
CATS.forEach(([c])=>{ if(c!=='all'&&c!=='missing') counts[c]=ACHIEVEMENTS.filter(a=>a.cat===c).length; });
counts['missing']=ACHIEVEMENTS.length;
const doneC={all:achDone()};
CATS.forEach(([c])=>{ if(c!=='all'&&c!=='missing') doneC[c]=ACHIEVEMENTS.filter(a=>a.cat===c&&S.ach[a.id]).length; });
doneC['missing']=steamCount();
let list = ACHIEVEMENTS.filter(a=>{
if(achFilter==='missing') return !(S.steam.unlocked||{})[a.api];
return achFilter==='all'||a.cat===achFilter;
});
if(achQuery){
const q=achQuery.toLowerCase();
list=list.filter(a=>(a.name+' '+a.desc+' '+a.guide).toLowerCase().includes(q));
}
if(S.hideDone) list=list.filter(a=>!S.ach[a.id]);
let h=`<div class="panel">
<h2>Achievement checklist <span class="pill">${achDone()}/${ACH_TOTAL}</span>${steamCount()?`<span class="pill steam">${steamCount()} on Steam</span>`:''}</h2>
<p class="muted">Click a card to open the full walkthrough for that achievement. Click the box (or the card) to tick it.
${S.steam.profile?`Syncing with <b>${esc(S.steam.persona||S.steam.profile)}</b>. `:''}Cards with a blue edge are already unlocked on your Steam profile.</p>
<div class="controls">
<div class="chips">
${CATS.map(([c,l])=>`<span class="chip ${achFilter===c?'on':''}" data-cat="${c}">${l} <span class="c">${doneC[c]}/${counts[c]}</span></span>`).join('')}
</div>
<input type="search" id="achQ" placeholder="search achievements…" value="${esc(achQuery)}">
</div>
${list.length?'':'<div class="warnbox">Nothing matches that filter.</div>'}
</div>`;
list.forEach(a=>{
const on=!!S.ach[a.id];
const st=!!(S.steam.unlocked||{})[a.api];
const catLabel=(CATS.find(c=>c[0]===a.cat)||['',''])[1];
h+=`<div class="ach ${on?'done':''} ${st?'steam':''}" data-ach="${a.id}">
<div class="achhead">
<input type="checkbox" ${on?'checked':''} data-achbox="${a.id}">
${a.icon?`<img class="icon" src="${a.icon}" alt="" loading="lazy" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'icon ph',textContent:'${esc(a.name[0])}'}))">`
:`<div class="icon ph">${esc(a.name[0])}</div>`}
<div class="ameta">
<div class="aname">${esc(a.name)} <span class="pill ${a.cat}">${catLabel}</span>${a.miss?'<span class="pill miss">missable</span>':''}${a.cum?'<span class="pill">cumulative</span>':''}${st?'<span class="pill steam">STEAM ✓</span>':''}</div>
<div class="adesc">${esc(a.desc)}</div>
</div>
<div class="aright"><span class="caret">▾ details</span></div>
</div>
<div class="achbody">${a.guide}</div>
</div>`;
});
el.innerHTML=h;
const q=document.getElementById('achQ');
if(q){
q.addEventListener('input',e=>{achQuery=e.target.value; const p=e.target.selectionStart; renderAch();
const n=document.getElementById('achQ'); if(n){n.focus(); n.setSelectionRange(p,p);} });
}
}
/* ============================================================
DOG TAGS TAB
============================================================ */
let tagList=0, tagQuery='';
const listDone = l => l.tags.filter((_,i)=>S.tags[l.id+':'+i]).length;
function renderTags(){
const el=document.getElementById('tab-tags');
const L=DOGTAGS[tagList];
const tDone=tagDone();
// thresholds
const tankerLists=DOGTAGS.filter(l=>l.chapter==='Tanker');
const plantLists =DOGTAGS.filter(l=>l.chapter==='Plant');
// Only Very Easy→Normal count toward the achievements per the guides, but we count all collected.
const tankerEasy3 = tankerLists.slice(0,3).reduce((a,l)=>a+listDone(l),0);
const plantEasy3 = plantLists.slice(0,3).reduce((a,l)=>a+listDone(l),0);
const plantAll = plantLists.reduce((a,l)=>a+listDone(l),0);
const tankerAll = tankerLists.reduce((a,l)=>a+listDone(l),0);
const thr=(label,val,need,extra)=>`<div class="thrcard ${val>=need?'hit':''}">
<div class="lbl"><span>${label}</span><span>${need}</span></div>
<div class="val">${val}<small> / ${need}</small></div>
<div class="bar" style="width:100%"><i style="width:${Math.min(100,val/need*100)}%"></i></div>
${extra?`<div class="lbl" style="margin-top:5px">${extra}</div>`:''}
</div>`;
let h=`<div class="panel">
<h2>Dog tags <span class="pill">${tDone}/${TAG_TOTAL}</span></h2>
<p>Hold up a guard (aim at their back, run in front, aim at their face), grab the tag, then tranquillise them.
If they refuse, shoot their hand or foot with a <b>lethal</b> weapon to scare them. Tags are saved per-difficulty,
but you must <b>finish the chapter</b> for a missed tag to be added to your total.</p>
<div class="thr">
${thr('Tanker — Bandana (Yorkie)',tankerEasy3,50,'Very Easy + Easy + Normal')}
${thr('Tanker — Stealth (Poodle)',tankerEasy3,76,'Very Easy + Easy + Normal')}
${thr('Plant — Brown Wig (Beagle)',plantEasy3,80,'Very Easy + Easy + Normal')}
${thr('Plant — Stealth (Shiba Inu)',plantEasy3,120,'Very Easy + Easy + Normal')}
${thr('Plant — Orange Wig',plantAll,160,'all difficulties')}
${thr('Plant — Blue Wig (O₂)',plantAll,200,'all difficulties')}
</div>
<div class="infobox" style="margin-top:12px">
<b>Tanker total: ${tankerAll}/${tankerLists.reduce((a,l)=>a+l.tags.length,0)}</b> &nbsp;·&nbsp;
<b>Plant total: ${plantAll}/${plantLists.reduce((a,l)=>a+l.tags.length,0)}</b> &nbsp;·&nbsp;
Grand total ${TAG_TOTAL}. The achievement only needs Very Easy, Easy and Normal tags (67 + 140).
Tags you tick here are remembered per list.
</div>
</div>`;
h+=`<div class="panel tight">
<div class="controls" style="margin:0">
<div class="chips">
${DOGTAGS.map((l,i)=>{const d=listDone(l);return `<span class="chip ${i===tagList?'on':''}" data-list="${i}">
${l.chapter==='Tanker'?'🚢':'🏭'} ${esc(l.difficulty)} <span class="c">${d}/${l.tags.length}</span></span>`}).join('')}
</div>
<input type="search" id="tagQ" placeholder="find a name / area…" value="${esc(tagQuery)}">
<button data-gomap="1">🗺️ Open ${esc(L.chapter)} ${esc(L.difficulty)} area map</button>
</div>
</div>`;
let tags=L.tags.map((t,i)=>({t,i}));
if(tagQuery){
const q=tagQuery.toLowerCase();
tags=tags.filter(o=>(o.t.name+' '+o.t.area+' '+(o.t.note||'')).toLowerCase().includes(q));
}
if(S.hideDone) tags=tags.filter(o=>!S.tags[L.id+':'+o.i]);
h+=`<div class="panel">
<h2>${L.chapter}${esc(L.difficulty)} <span class="pill">${listDone(L)}/${L.tags.length}</span></h2>
<p class="muted">${esc(L.blurb||'')}</p>
<div class="dgrid">
${tags.map(o=>{
const on=!!S.tags[L.id+':'+o.i];
return `<div class="dt ${on?'done':''}" data-tag="${L.id}:${o.i}">
<input type="checkbox" ${on?'checked':''} data-tagbox="${L.id}:${o.i}">
<div style="flex:1;min-width:0">
<div class="dname">${esc(o.t.name)}${o.t.note?'<span class="cond">CONDITIONAL</span>':''}</div>
<div class="darea">${esc(o.t.area)}</div>
${o.t.note?`<div class="dnote">⚠ ${esc(o.t.note)}</div>`:''}
</div>
</div>`;
}).join('')}
</div>
</div>`;
el.innerHTML=h;
const q=document.getElementById('tagQ');
if(q) q.addEventListener('input',e=>{tagQuery=e.target.value;const p=e.target.selectionStart;renderTags();
const n=document.getElementById('tagQ'); if(n){n.focus(); n.setSelectionRange(p,p);}});
}
/* ============================================================
AREA MAPS (rebuilt cell-by-cell from the tracking spreadsheet)
============================================================ */
let mapIdx=0, mapZoom=1.8, mapShowText=true, mapTextScale=1.35;
const MAPLEGEND=[
['#D9D9D9','walkway — level 1'],['#CCCCCC','walkway — level 2'],['#999999','walkway — level 3'],
['#76A5AF','trap / hazard'],
['#4A86E8','door / area change'],['#FFFF00','locked door'],['#434343','blocked entry'],['#00FFFF','ladder / lift'],
['#FF9900','guard (tag)'],['#FF00FF','tough guard'],['#FF0000','boss with tag'],['#00FF00','conditional guard'],
['#D9EAD3','Shell 1 area'],['#FFF2CC','Shell 2 area'],['#CFE2F3','Shell 1 core'],['#D9D2E9','Shell 2 core'],
];
function mapColOffsets(M){
const off=[], n=M.c1-M.c0+2;
let x=0, keys=Object.keys(M.widths).map(Number).sort((a,b)=>a-b);
for(let c=M.c0;c<=M.c1;c++){
off[c-M.c0]=x;
let w=null;
for(const k of keys){ const mm=M.widths[k]; if(c>=k && c<=mm[0]){ w=mm[1]; break; } }
if(w==null) w=12.63;
x += w*7+5;
}
off[n-1]=x;
return off;
}
function mapRowOffsets(M){
const off=[], n=M.r1-M.r0+2;
let y=0;
for(let r=M.r0;r<=M.r1;r++){
off[r-M.r0]=y;
const h=(M.heights && M.heights[r]) ? M.heights[r] : 15.75;
y += h*1.3333;
}
off[n-1]=y;
return off;
}
function mapRowH(M,r){ return ((M.heights && M.heights[r]) ? M.heights[r] : 15.75)*1.3333; }
function drawMap(){
const M=MAPS[mapIdx], cv=document.getElementById('mapCanvas');
if(!M || !cv) return;
const offX=mapColOffsets(M), offY=mapRowOffsets(M);
const W=offX[M.c1-M.c0+1], H=offY[M.r1-M.r0+1];
const z=mapZoom, cssW=Math.round(W*z), cssH=Math.round(H*z);
let dpr=Math.min(window.devicePixelRatio||1, 2);
const budget=10e6; // keep the backing store ~40 MB even on huge maps
if(cssW*cssH*dpr*dpr>budget) dpr=Math.max(1, Math.sqrt(budget/(cssW*cssH)));
cv.style.width=cssW+'px'; cv.style.height=cssH+'px';
cv.width=Math.round(cssW*dpr); cv.height=Math.round(cssH*dpr);
const g=cv.getContext('2d');
g.setTransform(dpr*z,0,0,dpr*z,0,0);
g.fillStyle='#ffffff'; g.fillRect(0,0,W,H);
const pal=M.palette;
for(const rowstr of M.fills){
const i=rowstr.indexOf(':'), r=+rowstr.slice(0,i);
const y=offY[r-M.r0], h=mapRowH(M,r);
for(const run of rowstr.slice(i+1).split(',')){
const p=run.split('+'), c=+p[0], len=+p[1], idx=+p[2];
const x=offX[c-M.c0], w=offX[c-M.c0+len]-x;
g.fillStyle='#'+pal[idx].slice(2);
g.fillRect(x,y,w,h);
}
}
if(mapShowText){
// Excel only clips overflowing text at the next cell that actually HAS content.
// A background fill alone does not stop it, so clip against text cells only.
const textCols={};
for(const t of M.texts){ (textCols[t[0]] || (textCols[t[0]]=[])).push(t[1]); }
for(const r in textCols) textCols[r].sort((a,b)=>a-b);
const mstart={};
for(const mm of M.merges) mstart[mm[0]+','+mm[1]]=mm[3];
const FS=Math.round(11*mapTextScale);
const FONT=px=>px+'px "Segoe UI",system-ui,-apple-system,sans-serif';
let curFont=FS;
const setF=f=>{ if(f!==curFont){ g.font=FONT(f); curFont=f; } };
g.font=FONT(FS);
g.textBaseline='middle';
for(const [r,c,t] of M.texts){
const rh=mapRowH(M,r);
const y=offY[r-M.r0]+rh/2;
const x0=offX[c-M.c0];
const mk=mstart[r+','+c];
let endC;
if(mk!=null) endC=mk+1;
else{ const arr=textCols[r], i=arr.indexOf(c); endC=(i>=0 && i<arr.length-1) ? arr[i+1] : M.c1+1; }
const endX=offX[Math.min(endC, M.c1+1)-M.c0];
let maxW=endX-x0-3;
if(maxW<4) continue; // genuinely overlapped by the next label
// Canvas condenses text horizontally to honour maxWidth, which turns long labels into
// an unreadable smear. Prefer shrinking the font a little, and if even that will not
// fit, let the label overflow its cell rather than crushing it.
let drawW=maxW;
setF(FS);
let w=g.measureText(t).width;
if(w>maxW){
let f=FS;
while(w>maxW && f>7){ f-=1; setF(f); w=g.measureText(t).width; }
if(w>maxW) drawW=undefined;
}
g.fillStyle='#000';
g.fillText(t, x0+2, y, drawW);
}
setF(FS);
}
const lbl=document.getElementById('zoomLbl');
if(lbl) lbl.textContent=Math.round(z*100)+'%';
const info=document.getElementById('mapInfo');
if(info) info.textContent=`${M.chapter} · ${M.difficulty} · ${M.r1-M.r0+1} rows × ${M.c1-M.c0+1} cols`;
}
function renderMaps(){
const el=document.getElementById('tab-maps');
const M=MAPS[mapIdx]||{};
el.innerHTML=`
<div class="panel">
<h2>Area maps <span class="pill">rebuilt from the dog-tag spreadsheet</span></h2>
<p>These are <b>u/Spikeyroxas' guard-position maps</b>, redrawn cell-by-cell from the original spreadsheet —
every coloured block, guard marker, number and label is the original data. Scroll the panel to pan;
nothing here needs an internet connection.</p>
<div class="controls" style="margin-bottom:10px">
<div class="chips">
${MAPS.map((m,i)=>`<span class="chip ${i===mapIdx?'on':''}" data-map="${i}">${m.chapter==='Tanker'?'🚢':'🏭'} ${esc(m.difficulty)}</span>`).join('')}
</div>
</div>
<div class="mapctl">
<button data-zoom="-"> zoom out</button>
<span class="pill" id="zoomLbl">150%</span>
<button data-zoom="+">+ zoom in</button>
<button data-zoom="1">100%</button>
<button data-zoom="2">200%</button>
<button data-zoom="fit">fit width</button>
<label class="sw"><input type="checkbox" id="mapText" ${mapShowText?'checked':''}> show labels</label>
<span class="pill">label size</span>
<button data-tscale="-">A</button>
<span class="pill" id="tscaleLbl">${Math.round(mapTextScale*100)}%</span>
<button data-tscale="+">A+</button>
<span class="pill" id="mapInfo"></span>
</div>
<div class="mapwrap" id="mapWrap"><canvas id="mapCanvas"></canvas></div>
<div class="legend">
${MAPLEGEND.map(([c,n])=>`<span><i class="swatch" style="background:${c}"></i>${n}</span>`).join('')}
</div>
<p class="maptip">The original sheets also carry their own <b>Key</b>, <b>Hazard Key</b> and numbered
<b>Guards Key</b> in the top-left of each map — including the numbered markers (1.0, 2.0, 3.0 …) that identify
each guard by name. Those are rendered in the image itself, so scroll to the top of a map to read them.</p>
</div>`;
document.getElementById('mapText').addEventListener('change',e=>{ mapShowText=e.target.checked; drawMap(); });
if(document.getElementById('tab-maps').classList.contains('on')) drawMap();
}
/* ============================================================
BIG BOSS TAB (static)
============================================================ */
function renderBoss(){
const b=BIGBOSS;
let h=`<div class="panel">
<h2>Big Boss run — Extreme, Tanker→Plant <span class="pill chal">hardest achievement</span></h2>
<p>${b.intro}</p>
<div class="grid g2">
<div><h3>Requirements</h3><ul class="tight">${b.reqs.map(r=>`<li>${r}</li>`).join('')}</ul></div>
<div><h3>Where the game refills your health</h3><ul class="tight">${b.heals.map(r=>`<li>${r}</li>`).join('')}</ul></div>
</div>
</div>`;
h+=`<div class="panel"><h2>Time checkpoints</h2>
<p class="muted">Be at roughly these times when the fight <b>starts</b>. If you're more than a couple of minutes behind, restart.</p>
<table><tr><th>Checkpoint</th><th>Target</th></tr>
${b.times.map(t=>`<tr><td>${t[0]}</td><td class="n">${t[1]}</td></tr>`).join('')}
</table></div>`;
h+=`<div class="panel"><h2>Recommended save points</h2>
<p class="muted">Saving is free (it does not cost you anything on the codename) but <b>reloading restarts you at the beginning of the area you saved in</b>, so save right after entering a new area.</p>
<ul class="tight">${b.saves.map(s=>`<li>${s}</li>`).join('')}</ul></div>`;
h+=`<div class="panel"><h2>Time-saving rules</h2><ul class="tight">${b.tips.map(s=>`<li>${s}</li>`).join('')}</ul></div>`;
b.bosses.forEach(x=>{
h+=`<div class="panel"><h2>${esc(x.name)}</h2>${x.body}</div>`;
});
document.getElementById('tab-boss').innerHTML=h;
}
/* ============================================================
STEAM SYNC
============================================================ */
const steamCount = ()=>ACHIEVEMENTS.filter(a=>(S.steam.unlocked||{})[a.api]).length;
const HELPER = 'http://127.0.0.1:8765/api/steam';
function syncEndpoint(){
// served locally by steam-sync.py -> same origin
if(IS_LOCAL && location.protocol.startsWith('http')) return '/api/steam';
// a deployed proxy Worker, if one is configured
if(STEAM_PROXY) return STEAM_PROXY.replace(/\/$/,'') + '/api/steam';
// hosted or opened off the disk -> use the local helper when it is running.
// An https page may reach 127.0.0.1 (it counts as a trustworthy origin), and the
// helper answers the Private Network Access preflight Chrome asks for.
return HELPER;
}
async function syncSteam(profile){
const el=document.getElementById('syncStatus');
const setStatus=(msg,cls)=>{ if(el){ el.className='status '+(cls||''); el.innerHTML=msg; } };
profile=(profile||S.steam.profile||'').trim().replace(/^https?:\/\/steamcommunity\.com\//,'').replace(/\/.*$/,'');
profile=profile.replace(/^(profiles|id)\//,'');
if(!profile){ setStatus('Enter a Steam profile first — a vanity name (e.g. <code>gaben</code>) or a 17-digit SteamID64.','bad'); return; }
S.steam.profile=profile; save();
const url=syncEndpoint();
setStatus('⏳ Contacting Steam for <b>'+esc(profile)+'</b>…');
try{
const r = await fetch(url+'?profile='+encodeURIComponent(profile), {cache:'no-store'});
const j = await r.json();
if(!j.ok){
setStatus('❌ '+esc(j.error||'Sync failed.')+(j.hint?'<br><span class="muted">'+esc(j.hint)+'</span>':''),'bad');
document.getElementById('steamdot').className='err';
return;
}
// mark what Steam says is unlocked; never un-tick manual ticks
let added=0;
(j.achievements||[]).forEach(a=>{
if(a.unlocked){ if(!S.steam.unlocked[a.api]) added++; S.steam.unlocked[a.api]=1; S.ach[a.api]=true; }
});
S.steam.id64=j.steamid64||''; S.steam.persona=j.persona||profile; S.steam.lastSync=Date.now();
save();
const total=(j.achievements||[]).filter(a=>a.unlocked).length;
setStatus('✅ Synced <b>'+esc(S.steam.persona)+'</b> — <b>'+total+'/'+((j.achievements||[]).length||51)+
'</b> unlocked on Steam'+(added?' ('+added+' newly ticked here)':'')+'.','ok');
document.getElementById('steamdot').className='on';
}catch(e){
setStatus('❌ Could not reach the sync helper.<br><span class="muted">Steam sends no CORS headers, so the page needs something to fetch your profile through. '+
'Run <code>python3 steam-sync.py</code> on this machine (it listens on <b>127.0.0.1:8765</b>) and try again — that is the easiest option, and it is what this page just tried. '+
'Alternatively deploy <code>steam-proxy-worker.js</code> and paste its URL into <code>STEAM_PROXY</code>.</span>','bad');
document.getElementById('steamdot').className='err';
} renderSteam(); renderAch(); paintProgress();
}
function renderSteam(){
const el=document.getElementById('tab-steam');
const unl=S.steam.unlocked||{};
const have=steamCount();
const when=S.steam.lastSync? new Date(S.steam.lastSync).toLocaleString() : 'never';
el.innerHTML=`
<div class="panel">
<h2>Steam sync <span class="pill">${have}/${ACH_TOTAL} owned</span></h2>
<p>Point this at any public Steam profile and it will tick off everything that profile has already unlocked —
those achievements get a <span class="pill steam">STEAM ✓</span> badge and a blue edge so you can tell them apart from
things you ticked by hand. Use the <b>⬇ Missing on Steam</b> filter on the Achievements tab to see only what is left.</p>
<div class="syncrow">
<input type="text" id="syncProfile" placeholder="vanity name or SteamID64" value="${esc(S.steam.profile||'')}">
<button class="primary" id="btnSync">Sync now</button>
<button id="btnSyncClear">Forget profile</button>
</div>
<div class="status ${have?'ok':''}" id="syncStatus">
${have?`Last synced <b>${esc(S.steam.persona||'')}</b> — ${when}`:'Not synced yet. Enter a profile and hit Sync.'}
</div>
<div class="steamgrid">
${ACHIEVEMENTS.map(a=>`<div class="smini ${unl[a.api]?'have':'no'}">
${a.icon?`<img src="${a.icon}" alt="" loading="lazy">`:''}
<span class="nm" title="${esc(a.name)}">${esc(a.name)}</span></div>`).join('')}
</div>
</div>
<div class="panel">
<h2>How the sync works</h2>
<p>Steam has no browser-accessible achievement API: the public XML endpoint
(<code>steamcommunity.com/&lt;profile&gt;/stats/2131640/?tab=achievements&amp;xml=1</code>) sends no CORS headers, so a web page
cannot read it directly. <code>steam-sync.py</code> is a 60-line stdlib-only helper that serves this folder
<b>and</b> proxies that request for you.</p>
<ol class="tight">
<li>Keep <code>steam-sync.py</code> in the same folder as this page.</li>
<li>Run <code>python3 steam-sync.py</code> (no dependencies, no API key, nothing to install).</li>
<li>Open <b>http://127.0.0.1:8765/</b> instead of double-clicking the file — that also gives you reliable
<code>localStorage</code> saving.</li>
<li>Enter your vanity name or SteamID64 in the box above and hit <b>Sync now</b>. It re-syncs automatically every time you load the page.</li>
</ol>
<h3>If it fails</h3>
<table>
<tr><th>Message</th><th>Fix</th></tr>
<tr><td>Could not reach the sync helper</td><td>You have not started <code>steam-sync.py</code>, or you opened the page from a different folder. Start it and try again.</td></tr>
<tr><td>"This page is hosted…"</td><td>Static hosting has no server, and Steam blocks direct browser requests. Either run the page locally on <code>127.0.0.1:8765</code>, or deploy <code>steam-proxy-worker.js</code> and set <code>STEAM_PROXY</code>. Your ticks move between the two with <b>Export</b> / <b>Import</b>.</td></tr>
<tr><td>Profile could not be found</td><td>Check the spelling of the vanity name, or paste the full profile URL.</td></tr>
<tr><td>Profile is private</td><td>Steam → Profile → Edit Profile → Privacy Settings → set <b>Game details</b> to <b>Public</b>.</td></tr>
<tr><td>Game not in library / no stats</td><td>That account does not own MGS2 Master Collection (appid 2131640), or has never launched it.</td></tr>
</table>
<div class="infobox">The helper only ever makes one outbound request, to the public Steam Community page for the profile you name.
Nothing is uploaded anywhere, and your profile name is stored only in this page's local storage.</div>
</div>`;
const b=document.getElementById('btnSync');
if(b) b.addEventListener('click',()=>syncSteam(document.getElementById('syncProfile').value));
const c=document.getElementById('btnSyncClear');
if(c) c.addEventListener('click',()=>{
S.steam={profile:'',id64:'',persona:'',unlocked:{},lastSync:0}; save();
document.getElementById('steamdot').className='';
renderSteam(); renderAch(); paintProgress(); toast('Steam profile forgotten');
});
const inp=document.getElementById('syncProfile');
if(inp) inp.addEventListener('keydown',e=>{ if(e.key==='Enter') syncSteam(inp.value); });
document.getElementById('steamdot').className = have?'on':(S.steam.profile?'':'');
}
/* ============================================================
MISSABLES TAB
============================================================ */
function renderMiss(){
const miss=ACHIEVEMENTS.filter(a=>a.miss);
let h=`<div class="panel">
<h2>Every missable achievement, in story order</h2>
<p>${miss.length} of the 51 can be permanently missed inside a single playthrough — but <b>nothing is missable
across the whole save</b>: you will be replaying the game several times anyway, so a missed misc achievement just
means "do it on the next pass". Tick them here as you grab them.</p>
<div class="warnbox">The four that most often force an extra playthrough:
<b>Vamp Eyer</b> (don't skip the final cutscene), <b>Who Ya Gonna Call?</b> and <b>Sexting</b> (Tanker Holds — one-shot areas),
and <b>Kissing Booth → Snake Beater</b> (Deck-A lockers, easy to walk past).</div>
</div>
<div class="panel">
<table><tr><th>✓</th><th>Achievement</th><th>Where / when</th><th>Window</th></tr>
${miss.map(a=>`<tr>
<td><input type="checkbox" data-achbox="${a.id}" ${S.ach[a.id]?'checked':''} style="appearance:none;width:17px;height:17px;border:1.5px solid var(--line2);border-radius:4px;background:var(--field);cursor:pointer;position:relative" class="mchk ${S.ach[a.id]?'on':''}"></td>
<td><b>${esc(a.name)}</b><div class="muted" style="font-size:12px">${esc(a.desc)}</div></td>
<td>${a.where||'—'}</td>
<td class="muted" style="font-size:12.5px">${a.window||'—'}</td>
</tr>`).join('')}
</table></div>`;
document.getElementById('tab-miss').innerHTML=h;
document.querySelectorAll('#tab-miss input[data-achbox]').forEach(cb=>{
cb.addEventListener('change',()=>toggleAch(cb.dataset.achbox));
});
}
/* ============================================================
VR / EXTRA MODES TAB (static)
============================================================ */
function renderVR(){
document.getElementById('tab-vr').innerHTML=`
<div class="panel">
<h2>Off-story achievements</h2>
<p>These are all done from the main menu and are the easiest part of the 100%. Knock them out in a single sitting.</p>
<table>
<tr><th>Achievement</th><th>Mode</th><th>What to do</th></tr>
<tr><td><b>Piece of Cake</b></td><td>VR / Alternative</td><td>Complete any single VR or Alternative mission. One and done.</td></tr>
<tr><td><b>In It to Win It</b></td><td>VR / Alternative</td><td>Place 1st in 50 different missions (beat the target time). There are 500+, so pick the shortest Sneaking/Weapon missions. Do this while clearing missions for variety — <b>you do NOT need to clear them all</b> on Master Collection.</td></tr>
<tr><td><b>Tell Me a Tale</b></td><td>Snake Tales</td><td>Complete <b>any one</b> of the five Snake Tales: A Wrongdoing, Big Shell Evil, Confidential Legacy, Dead Man, External Gazer.</td></tr>
<tr><td><b>No Boss of Mine</b></td><td>Boss Survival</td><td>Beat Olga → Tanker ambush → Fatman → Harrier → Vamp → Tengu → Metal Gear RAYs → Solidus. <b>Pick Raiden on Easy</b> — he has the HF Blade for Solidus; Snake only gets melee.</td></tr>
<tr><td><b>Casting Theater</b></td><td>Casting Theater</td><td>Watch any scene. Pops together with Love Hurts.</td></tr>
<tr><td><b>Love Hurts</b></td><td>Casting Theater</td><td>After beating the game: Main Menu → Special → Casting Theater → <b>Demo 8</b> → swap <b>Raiden → Rose</b> and <b>Solidus → Raiden</b>, then watch.</td></tr>
<tr><td><b>Cover-to-Cover Solid 2</b></td><td>Screenplay Book</td><td>From the Master Collection launcher, open the MGS2 Screenplay Book and page all the way to the end.</td></tr>
</table>
<div class="okbox"><b>Fastest 100% path for the off-story stuff:</b> finish the game once (unlocks Boss Survival + Casting Theater) →
do the two Casting Theater clicks → do one Snake Tale → do 50 VR/Alt first places → read the Screenplay Book.</div>
</div>
<div class="panel">
<h2>Special items you unlock (and what they do)</h2>
<table>
<tr><th>Item</th><th>Chapter</th><th>Requirement</th><th>Effect</th></tr>
<tr><td>Bandana</td><td>Tanker</td><td>50 Tanker tags</td><td>Infinite ammo</td></tr>
<tr><td>Stealth Camo</td><td>Tanker</td><td>76 Tanker tags</td><td>Invisible to enemies/cameras</td></tr>
<tr><td>Brown Wig</td><td>Plant</td><td>80 Plant tags</td><td>Infinite ammo</td></tr>
<tr><td>Stealth Camo</td><td>Plant</td><td>120 Plant tags</td><td>Invisible to enemies/cameras</td></tr>
<tr><td>Orange Wig</td><td>Plant</td><td>160 Plant tags</td><td>Infinite grip</td></tr>
<tr><td>Blue Wig</td><td>Plant</td><td>200 Plant tags</td><td>Infinite oxygen</td></tr>
</table>
<div class="warnbox">Special items only exist on a <b>cleared save</b>, and the game tracks Tanker and Plant unlocks separately
when you start a <b>Tanker-Plant</b> run. For the two stealth items to be available in the same playthrough you must have
earned them in a completed <b>Tanker-Plant</b> file and then started a new game loading that cleared data.</div>
</div>`;
}
/* ============================================================
SOURCES TAB
============================================================ */
function renderSrc(){
document.getElementById('tab-src').innerHTML=`
<div class="panel">
<h2>Sources this companion is built from</h2>
<p>Everything here is compiled from the following community work. All credit to the original authors.</p>
<p class="srcline">🏆 <a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3065634746" target="_blank" rel="noopener">"🏆 Metal Gear Solid 2 Achievement Guide" by Cole ヴ Viper</a> — Steam Community guide. Source of the achievement write-ups, boss strategies, Big Boss criteria, save points, time checkpoints and achievement icons.</p>
<p class="srcline">🏷️ <a href="https://videochums.com/article/metal-gear-solid-2-all-dog-tags-checklist" target="_blank" rel="noopener">"Metal Gear Solid 2 - All Dog Tags Checklist" by Video Chums</a> — the complete 394-tag list with guard names and areas.</p>
<p class="srcline">📊 <a href="https://docs.google.com/spreadsheets/d/1tDpHI_G7du0fnnNdc8--zyoULuoneiBNJByxXrmixWU/edit" target="_blank" rel="noopener">Dog Tag Checklist spreadsheet by u/Spikeyroxas</a> — source of the per-guard <b>conditional/missable</b> notes (spawn windows, who replaces whom, who only appears after Fatman, etc.).</p>
<p class="srcline">📖 <a href="https://daynglsgameguides.com/2023/10/23/metal-gear-solid-2-trophy-achievement-guide-master-collection/" target="_blank" rel="noopener">Dayngl's Guides — MGS2 Trophy &amp; Achievement Guide</a> — cross-check of unlock conditions, and the source of the second set of location screenshots used in the Roadmap.</p>
<p class="srcline">📷 <b>Location screenshots</b> in the Roadmap come from those two guides and are hot-linked from their CDNs (Steam Community and Dayngl's Guides). Click any thumbnail to open the full-size original on the source site. If you are the author of either guide and would rather they were not linked, say so and they can be dropped.</p>
<p class="srcline">🏅 <a href="https://truesteamachievements.com/game/METAL-GEAR-SOLID-2-Sons-of-Liberty-Master-Collection-Version/achievements" target="_blank" rel="noopener">TrueSteamAchievements — MGS2 MC achievement list</a> — authoritative list of all <b>51</b> achievements (the Steam guide's numbering skips one).</p>
<h3>Notes on version differences</h3>
<ul class="tight">
<li><b>Silence is Golden</b> is "shoot an enemy radio" (the 2011 HD version required 10).</li>
<li><b>My Rations…</b> requires getting sea lice all over your rations, not just finding one.</li>
<li><b>Tell Me a Tale</b> only needs <b>one</b> Snake Tale (the HD version required all five).</li>
<li><b>Virtually Impossible</b> (clear every VR mission) was <b>removed</b> from the Master Collection.</li>
<li>There is no platinum on Steam — 51 achievements, and <b>Big Boss</b> is the completion blocker.</li>
<li>The dog-tag achievements no longer require every tag on every difficulty — Very Easy + Easy + Normal is enough.</li>
</ul>
</div>
<div class="panel">
<h2>About your saved progress</h2>
<p>Your ticks are stored in this browser's <code>localStorage</code> under the key <code>mgs2mc-guide-v1</code>.
They persist between sessions on the same browser, but are <b>per-browser and per-origin</b> — if you open the file from a
different path or a different browser you'll see a fresh checklist.</p>
<p>Use <b>Export</b> to download a <code>mgs2-progress.json</code> backup, and <b>Import</b> to restore it — handy for moving
from your desktop to the machine next to your console, or for backing up before a big run.</p>
<div class="infobox">Tip: keep this page open on a second screen and use the tabs. The header counters update live and the
page auto-saves on every tick.</div>
</div>`;
}
/* ============================================================
EVENTS
============================================================ */
function toggleAch(id){
S.ach[id]=!S.ach[id];
if(!S.ach[id]) delete S.ach[id];
save(); syncAch(id); subsRefresh();
}
function toggleItem(key){
S.items[key]=!S.items[key];
if(!S.items[key]) delete S.items[key];
save();
document.querySelectorAll(`[data-item="${key}"]`).forEach(cb=>{ cb.checked=!!S.items[key]; });
subsRefresh();
}
function syncAch(id){
// checkbox states in every tab that shows this achievement
document.querySelectorAll(`[data-achbox="${id}"]`).forEach(cb=>{cb.checked=!!S.ach[id]; cb.classList.toggle('on',!!S.ach[id]);});
document.querySelectorAll(`.ach[data-ach="${id}"]`).forEach(c=>c.classList.toggle('done',!!S.ach[id]));
document.querySelectorAll('.mchk').forEach(cb=>{ if(cb.dataset.achbox===id){cb.checked=!!S.ach[id];} });
paintProgress();
}
function toggleRoad(id){
S.road[id]=!S.road[id]; if(!S.road[id]) delete S.road[id];
save();
document.querySelectorAll(`[data-roadbox="${id}"]`).forEach(cb=>cb.checked=!!S.road[id]);
document.querySelectorAll(`.stop[data-road="${id}"]`).forEach(c=>c.classList.toggle('done',!!S.road[id]));
paintProgress();
}
function toggleTag(key){
S.tags[key]=!S.tags[key]; if(!S.tags[key]) delete S.tags[key];
save();
document.querySelectorAll(`[data-tagbox="${key}"]`).forEach(cb=>cb.checked=!!S.tags[key]);
document.querySelectorAll(`.dt[data-tag="${key}"]`).forEach(c=>c.classList.toggle('done',!!S.tags[key]));
paintProgress();
// refresh chips + threshold cards without losing scroll
const y=window.scrollY;
const chips=document.querySelectorAll('.chip[data-list]');
chips.forEach(ch=>{
const l=DOGTAGS[+ch.dataset.list];
ch.innerHTML=`${l.chapter==='Tanker'?'🚢':'🏭'} ${esc(l.difficulty)} <span class="c">${listDone(l)}/${l.tags.length}</span>`;
});
document.querySelectorAll('.thrcard').forEach(()=>{});
subsRefresh();
window.scrollTo({top:y,behavior:'instant'});
}
document.addEventListener('click',e=>{
// achievement card / checkbox
const ab=e.target.closest('[data-achbox]');
if(ab){ if(ab.closest('#tab-miss')||ab.closest('.subs')) return; e.stopPropagation(); toggleAch(ab.dataset.achbox); return; }
const ac=e.target.closest('.ach .achhead');
if(ac){ ac.parentElement.classList.toggle('open'); return; }
// roadmap
const rb=e.target.closest('[data-roadbox]');
if(rb){ e.stopPropagation(); toggleRoad(rb.dataset.roadbox); return; }
const rh=e.target.closest('.stop .sh');
if(rh){ const st=rh.parentElement; st.classList.toggle('open');
if(st.classList.contains('open')) openSteps.add(st.dataset.road); else openSteps.delete(st.dataset.road);
return; }
const rd=e.target.closest('[data-roaddiff]');
if(rd){ S.roadDiff=rd.dataset.roaddiff; save(); renderRoad(); return; }
// tags
const tb=e.target.closest('[data-tagbox]');
if(tb){ if(tb.closest('.subs')) return; e.stopPropagation(); toggleTag(tb.dataset.tagbox); return; }
const td=e.target.closest('.dt');
if(td){ toggleTag(td.dataset.tag); return; }
// chips
const c=e.target.closest('.chip[data-cat]');
if(c){ achFilter=c.dataset.cat; renderAch(); return; }
const cl=e.target.closest('.chip[data-list]');
if(cl){ tagList=+cl.dataset.list; tagQuery=''; renderTags(); return; }
// maps
const gm=e.target.closest('[data-gomap]');
if(gm){ mapIdx=tagList; renderMaps(); go('maps'); return; }
const cm=e.target.closest('.chip[data-map]');
if(cm){ mapIdx=+cm.dataset.map; renderMaps(); return; }
const zb=e.target.closest('[data-zoom]');
if(zb){
const v=zb.dataset.zoom;
if(v==='+') mapZoom=Math.min(4, Math.round((mapZoom+0.25)*100)/100);
else if(v==='-') mapZoom=Math.max(0.4, Math.round((mapZoom-0.25)*100)/100);
else if(v==='fit'){
const wrap=document.getElementById('mapWrap');
const M=MAPS[mapIdx], off=mapColOffsets(M);
if(wrap) mapZoom=Math.max(0.3, Math.min(4, (wrap.clientWidth-6)/off[M.c1-M.c0+1]));
}
else mapZoom=+v;
drawMap(); return;
}
const ts=e.target.closest('[data-tscale]');
if(ts){
mapTextScale = ts.dataset.tscale==='+' ? Math.min(3, mapTextScale+0.15)
: Math.max(0.6, mapTextScale-0.15);
mapTextScale=Math.round(mapTextScale*100)/100;
const l=document.getElementById('tscaleLbl');
if(l) l.textContent=Math.round(mapTextScale*100)+'%';
drawMap(); return;
}
});
document.getElementById('btnHide').addEventListener('click',()=>{
S.hideDone=!S.hideDone; save();
document.getElementById('btnHide').textContent='Hide done: '+(S.hideDone?'on':'off');
renderAch(); renderTags();
});
document.getElementById('btnExport').addEventListener('click',()=>{
const blob=new Blob([JSON.stringify(S,null,1)],{type:'application/json'});
const a=document.createElement('a');
a.href=URL.createObjectURL(blob); a.download='mgs2-progress.json'; a.click();
toast('Progress exported');
});
document.getElementById('btnImport').addEventListener('click',()=>document.getElementById('fileIn').click());
document.getElementById('fileIn').addEventListener('change',ev=>{
const f=ev.target.files[0]; if(!f) return;
const r=new FileReader();
r.onload=()=>{ try{
const p=JSON.parse(r.result);
S=Object.assign({ach:{},tags:{},road:{},hideDone:false,tab:S.tab},p);
save(); boot(true); toast('Progress imported');
}catch(e){ toast('That file could not be read'); } };
r.readAsText(f);
});
document.getElementById('btnReset').addEventListener('click',()=>{
if(!confirm('Erase every tick (achievements, dog tags and roadmap)? This cannot be undone.')) return;
S={ach:{},tags:{},road:{},hideDone:S.hideDone,tab:S.tab};
save(); boot(true); toast('Checklist reset');
});
document.getElementById('btnSteam').addEventListener('click',()=>go('steam'));
/* sub-checklist tick boxes: let the label toggle natively, then mirror the state */
document.addEventListener('change', e=>{
const t=e.target;
if(!t || t.tagName!=='INPUT') return;
const kind=t.dataset.sub;
if(!kind) return;
if(kind==='ach') toggleAch(t.dataset.achbox);
else if(kind==='tag') toggleTag(t.dataset.tagbox);
else if(kind==='item') toggleItem(t.dataset.item);
});
/* ---------- lightbox wiring ---------- */
let lbDragged=false;
document.addEventListener('click', e=>{
const a=e.target.closest('a.lb');
if(a){ e.preventDefault(); lbOpen(a); return; }
if(e.target.closest('#lbClose')){ lbClose(); return; }
if(e.target.closest('#lbZoom')){ lbToggleZoom(null); return; }
if(e.target.closest('#lbPrev')){ lbStep(-1); return; }
if(e.target.closest('#lbNext')){ lbStep(1); return; }
const stage=e.target.closest('#lbStage');
if(stage){
if(e.target.id==='lbImg'){
if(lbDragged){ lbDragged=false; return; } // that was a pan, not a click
lbToggleZoom(e);
} else if(!lbZoom){
lbClose(); // click the backdrop to close
}
}
});
document.addEventListener('keydown', e=>{
if(document.getElementById('lightbox').hidden) return;
if(e.key==='Escape'){ lbClose(); }
else if(e.key==='ArrowLeft'){ lbStep(-1); }
else if(e.key==='ArrowRight'){ lbStep(1); }
else if(e.key===' '){ e.preventDefault(); lbToggleZoom(null); }
});
(function lbDrag(){
const st=document.getElementById('lbStage');
let down=false, sx=0, sy=0, sl=0, sp=0;
st.addEventListener('mousedown', e=>{
if(!lbZoom || e.target.id!=='lbImg') return;
down=true; lbDragged=false; sx=e.clientX; sy=e.clientY;
sl=st.scrollLeft; sp=st.scrollTop;
st.classList.add('dragging'); e.preventDefault();
});
window.addEventListener('mousemove', e=>{
if(!down) return;
if(Math.abs(e.clientX-sx)+Math.abs(e.clientY-sy) > 3) lbDragged=true;
st.scrollLeft=sl-(e.clientX-sx);
st.scrollTop =sp-(e.clientY-sy);
});
window.addEventListener('mouseup', ()=>{ down=false; st.classList.remove('dragging'); });
})();
/* ============================================================
THEMES
============================================================ */
const THEMES=[
['codec','Codec (default)'],
['gruvbox','Gruvbox Dark'],
['monokai','Monokai'],
['solarized-dark','Solarized Dark'],
['dracula','Dracula'],
['nord','Nord'],
['tokyo-night','Tokyo Night'],
['catppuccin','Catppuccin Mocha'],
['one-dark','One Dark'],
['rose-pine','Rosé Pine'],
];
function setTheme(k){
if(!k || k==='codec') document.documentElement.removeAttribute('data-theme');
else document.documentElement.setAttribute('data-theme',k);
try{ localStorage.setItem('mgs2-theme', k||'codec'); }catch(e){}
}
function buildThemes(){
const sel=document.getElementById('themeSel');
if(!sel) return;
sel.innerHTML=THEMES.map(([k,l])=>`<option value="${k}">${l}</option>`).join('');
let cur='codec';
try{ cur=localStorage.getItem('mgs2-theme')||'codec'; }catch(e){}
if(!THEMES.some(t=>t[0]===cur)){ cur='codec'; setTheme('codec'); } // e.g. a theme that was removed
sel.value=cur;
sel.addEventListener('change',()=>setTheme(sel.value));
}
/* ============================================================
BOOT
============================================================ */
function boot(skipLoad){
if(!skipLoad) load();
if(!S.steam.profile && DEFAULT_PROFILE) S.steam.profile = DEFAULT_PROFILE;
document.getElementById('btnHide').textContent='Hide done: '+(S.hideDone?'on':'off');
buildThemes();
buildTabs();
renderRoad(); renderAch(); renderTags(); renderMaps(); renderSteam(); renderBoss(); renderMiss(); renderVR(); renderSrc();
go(S.tab||'roadmap');
paintProgress();
if(S.steam.profile) setTimeout(()=>syncSteam(S.steam.profile),350);
}
boot();
</script>
</body>
</html>