Sync works on the deployed site; add reproducible build and deploy script

The Pages deployment is now the primary copy, so two things had to change:

- Hosted pages fall back to steam-sync.py on 127.0.0.1:8765 instead of
  refusing to sync. steam-sync.py now answers Chrome's Private Network
  Access preflight (Access-Control-Allow-Private-Network), which is what a
  public https page needs before it may call into localhost.
- The build sources lived in /tmp and would have been lost. They now live in
  build/, and build.py outputs to the repo root. deploy.sh rebuilds,
  commits and pushes in one step.

Editing index.html directly is no longer the workflow - edit build/ and run
./deploy.sh.
This commit is contained in:
Rain 2026-09-15 17:40:06 -04:00
parent 4d5545e6c5
commit a63606b5c8
17 changed files with 6120 additions and 18 deletions

467
build/build.py Normal file
View file

@ -0,0 +1,467 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Assembles the single-file MGS2 companion from the collected sources."""
import json, os, sys, re, html, shutil
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.dirname(HERE) # repo root, one level above build/
sys.path.insert(0, HERE)
from achievements_src import ACH
from roadmap_src import ROADMAP, BIGBOSS
official = json.load(open(os.path.join(HERE, "steam_official.json")))
vc = json.load(open(os.path.join(HERE, "dogtags_vc.json")))
notes = json.load(open(os.path.join(HERE, "dogtag_notes.json")))
maps = json.load(open(os.path.join(HERE, "maps.json")))
# ---------------------------------------------------------------- achievements
ach_list = []
missing_guide = []
for o in official:
api = o["api"]
src = ACH.get(api)
if not src:
missing_guide.append(api)
src = dict(cat="misc", guide="<p>No notes yet.</p>")
entry = {
"id": api,
"api": api,
"name": o["name"],
"desc": o["desc"],
"icon": o["icon"],
"icoLocked": o["iconLocked"],
"cat": src.get("cat", "misc"),
}
if src.get("miss"): entry["miss"] = True
if src.get("cum"): entry["cum"] = True
if src.get("where"): entry["where"] = src["where"]
if src.get("window"): entry["window"] = src["window"]
entry["guide"] = src.get("guide", "<p>—</p>")
ach_list.append(entry)
if missing_guide:
print("!! achievements without hand-written notes:", missing_guide)
# ------------------------------------------------- in-game location screenshots
# Two sources, both hot-linked (their CDNs serve any referrer):
# steam - the Steam Community achievement guide
# dayngl - Dayngl's Guides MGS2 trophy guide (descriptive filenames, close-up shots)
# Button-prompt glyphs, trophy icons, the section divider and box-art are excluded.
guide_imgs = json.load(open(os.path.join(HERE, "steam_imgs_by_ach.json")))
guide_meta = json.load(open(os.path.join(HERE, "ach_icons.json")))
dayngl_imgs = json.load(open(os.path.join(HERE, "dayngl_real.json")))
def norm(s):
return "".join(ch for ch in s.lower() if ch.isalnum())
name_to_api = {norm(o["name"]): o["api"] for o in official}
TINY = {
"03A5065DF1B5316CC3828CACFE80ED88308EE8D7","30A3C27578B65038D6A638BD24B09419F47519BF",
"3E2512E8BE55F3A14A9C2C96819B2DB46D8B3674","4E29A232BE1405A43384DCE389E660672860E038",
"539C603F19D06579B9DD671E39E313B9E8494525","675A6525F9933AEBBA3596F7288F01A1A36A46BB",
"7B48927D0A89321440B0D9B9B32E46D9D331828B","A810C37F28E4C51A9EE589511B9614070046B9FB",
"B31F84B091C9C019AA9F35EEA761092D81A4B028","BFEE9C0D8F085A3F91285784B09666747771931A",
"D3886DAA8B8B4108625D7AC48FA19663E3823408","E2CDBD01848B7ADBEA4CE42C54199F1385E13FD6",
"FC08EA78D73A06C58AA304C6DAA576D3AC56C483"}
DIVIDER = "DB3FB14642EB5F120A00CB0AF5FC3EBA45B219D4"
shots = {}
for gname, info in guide_meta.items():
api = name_to_api.get(norm(gname))
if not api:
continue
keep = []
for u in guide_imgs.get(str(info["num"]), []):
h = u.rstrip("/").split("/")[-1]
if h in TINY or h == DIVIDER or u == info["icon"]:
continue
keep.append({"u": u, "s": "steam"})
if keep:
shots.setdefault(api, []).extend(keep)
unmatched = set()
for x in dayngl_imgs:
api = name_to_api.get(norm(x["ach"]))
if not api:
unmatched.add(x["ach"]); continue
shots.setdefault(api, []).append({"u": x["url"], "s": "dayngl"})
# (per-step cap below keeps any single step from becoming a photo wall)
## --- photos that are the SAME SHOT as a Steam one, detected by perceptual hash
## (dHash on 16x16 greyscale, Hamming distance <= 14/240). Dayngl's guide reuses the
## same screenshots as the Steam guide, so these showed the same picture twice.
DUP_DAYNGL = {
"Screenshot-metal-gear-solid-2-book-2.png", "Screenshot-metal-gear-solid-2-book-3.png",
"Screenshot-metal-gear-solid-2-book.png", "Screenshot-metal-gear-solid-2-box-3-2.png",
"Screenshot-metal-gear-solid-2-box-3-3.png", "Screenshot-metal-gear-solid-2-box-3.png",
"Screenshot-metal-gear-solid-2-camera.png", "Screenshot-metal-gear-solid-2-ghost.png",
"Screenshot-metal-gear-solid-2-locker-poster.png", "Screenshot-metal-gear-solid-2-orange-box-2.png",
"Screenshot-metal-gear-solid-2-orange-box-4.png", "Screenshot-metal-gear-solid-2-snake-beater-2.png",
"Screenshot-mgs2-sea-louse-2.png", "Screenshot-mgs2-sea-louse.png",
"Snapshot_192.png", "Snapshot_194.png", "Snapshot_195.png", "Snapshot_196.png", "Snapshot_197.png",
"Snapshot_203.png", "Snapshot_204.png", "Snapshot_208.png",
}
# --- achievements whose pictures span SEVERAL locations: each photo is pinned to one step.
# Order taken from the sequence the guides present them in (text, then its screenshot).
PHOTO_STEP = {
# Moving Day - the seven boxes live all over both chapters; each shot shows one box
"5B7C3B4E8BF3379814E8C185D356BED511BFE81D": "r1-2", # Box 1, Tanker Deck-D
"orange-box.png": "r1-2",
"1A140671AAFEBEF9A706AACA000D1756E09B5225": "r1-9", # Wet Box, Nav Deck
"0155C94B62BD63D9892146E0255F7F685202E9E5": "r1-9",
"orange-box-3.png": "r1-9",
"420301D5428D7150DAD254BB90EFF9637DBDAA07": "r2-2", # Box 1, Strut A
"strut-a-box-2": "r2-2",
"7D577E665B40A901FCCA3326640653EB5C4C6E65": "r2-8", # Box 2, Strut F
"strut-a-box-3": "r2-8",
"2E0EB1DD4C769654138A04B9DE6DC58804D1B735": "r2-9", # Box 5, Strut E conveyor
"D5931E8F90958DE3CA75693F016767AAC02C9E34": "r2-9", # Box 3, Strut E rooftop
"box-4.png": "r2-9",
"ACA684EB5386F44862EF3A34DA0056C7D8ED509B": "r2-10", # Box 4, Shell 1 Core B2
"box-4-2.png": "r2-10",
# Photo Finish - the conveyor method (Strut E) vs the Level 5 door pickup
"A0B74949CFCC8D0058DD0FF5ED6E362DBC65D94B": "r2-9",
"F446514EA80167FC12D5816BA2D7854D6628E4F5": "r2-9",
"ACC84EFADCDAE266D93B6B6FD5CEF82A8B66E22E": "r2-9",
"225FFD74A14296925890B40193A5578B1299DFB6": "r2-15", # Level 5 door
# Johnny on the Spot - mic location 1 (Ames) vs location 2 (oil fence)
"F99FE3B53E49D8AD5837E7842ACE632A2F0051D0": "r2-11",
"Snapshot_205.png": "r2-11",
"06754D9C8F7CF65B9D0C74F17FBC54E6FA8071CE": "r2-15",
"Snapshot_206.png": "r2-15",
}
SPLIT_APIS = {"ach_002_027", "ach_002_035", "ach_002_038"} # Johnny / Photo Finish / Moving Day
# strip the perceptual duplicates
before = sum(len(v) for v in shots.values())
for api in shots:
shots[api] = [x for x in shots[api] if x["u"].split("/")[-1] not in DUP_DAYNGL]
after = sum(len(v) for v in shots.values())
print("dropped %d duplicate photos (%d -> %d)" % (before - after, before, after))
for e in ach_list:
if e["api"] in shots:
e["shots"] = shots[e["api"]]
n_steam = sum(1 for v in shots.values() for x in v if x["s"] == "steam")
n_dayngl = sum(1 for v in shots.values() for x in v if x["s"] == "dayngl")
print("with screenshots: %d achievements (%d images: %d steam + %d dayngl)"
% (len(shots), n_steam + n_dayngl, n_steam, n_dayngl))
if unmatched:
print("!! dayngl images with no matching achievement:", sorted(unmatched))
# ------------------------------------------------- road-map colour coding
# The walkthrough is written by hand with <b> for "the thing you interact with" and
# <i> for guard names. Here we classify each of those and attach a semantic class so
# dog tags, items, achievements (missable especially) and bosses are visually distinct.
TAGNAMES = set()
for blk in vc:
for t in blk["tags"]:
TAGNAMES.add(norm(t["name"]))
ACH_MISS = {norm(e["name"]) for e in ach_list if e.get("miss")}
ACH_ANY = {norm(e["name"]) for e in ach_list}
BOSSES = ["olga gurlukovich","olga","fortune","fatman","harrier","vamp","solidus snake",
"solidus","metal gear ray","metal gear rays","rays","tengu","tengu soldiers"]
BOSS_NORM = {norm(b) for b in BOSSES}
ITEMS = {norm(x) for x in [
"stun grenades","chaff grenades","hand grenades","grenades","claymore mines","c4","m9","socom",
"psg-1","psg1","psg1t","rgb6","aks-74u","aks74u","m4","nikita","stinger","hf blade","coolant",
"directional mic","book","magazine","digital camera","camera","cardboard box","box","box 1","box 2",
"box 3","box 4","box 5","wet box","lv. 1 card","lv. 5 card","lv. 5 door","level 5 door","ration",
"rations","bandana","stealth camo","brown wig","orange wig","blue wig","sensor a","disguise",
"ak-47","ak","body armor","thermal goggles","mine detector","scope","night vision","cigarettes",
"radar","radar node","nikita","psg-1t","coolant spray","sensor","card","tag",
]}
AREAS = {norm(x) for x in [
"aft deck","deck-a","deck-b","deck-c","deck-d","deck-e","deck 2","deck-2","hold no. 1","hold no. 2",
"hold no. 3","holds","strut a","strut b","strut c","strut d","strut e","strut f","strut l",
"shell 1 core","shell 2 core","shell 1 core 1f","shell 1 core b1","shell 1 core b2",
"shell 2 core 1f","shell 2 core b1","arsenal gear","ascending colon","engine room",
"navigational deck","oil fence","big shell","tanker","plant","heliport","sediment pool",
"pump room","transformer room","dining hall","parcel room","warehouse","computer room",
"deep sea dock","connecting bridge","sewage treatment facility","air purification room","rooftop",
]}
def core_of(txt):
t = re.sub(r"\s*\(.*?\)\s*$", "", txt) # trailing "(Moving Day)"
t = re.sub(r"#\s*\d+\s*\.?$", "", t) # trailing "#1"
t = re.sub(r"[\s:.!]+$", "", t)
return t.strip()
def _ach_class(n):
if n in ACH_MISS: return "k-miss"
if n in ACH_ANY: return "k-ach"
if len(n) >= 12: # "Cover-to-Cover Solid 2" -> full title
for full in ACH_ANY:
if full.startswith(n):
return "k-miss" if full in ACH_MISS else "k-ach"
return None
def _item_class(n):
if n in ITEMS: return "k-item"
if len(n) >= 5 and any(i.startswith(n) for i in ITEMS if len(i) >= 5):
return "k-item" # "Bandana 50", "Brown Wig 80"
return None
def colourise_b(txt):
core = core_of(txt)
# compound labels like "disguise + AK-47" or "Bandana / Brown Wig"
if re.search(r"[+/]", core):
parts = [core_of(p) for p in re.split(r"[+/]", core)]
parts = [norm(p) for p in parts if p.strip()]
if parts and all(_item_class(p) == "k-item" for p in parts):
return "k-item"
n = norm(core)
for fn in (_ach_class, _item_class):
r = fn(n)
if r: return r
if n in BOSS_NORM or any(n.startswith(b) for b in BOSS_NORM if len(b) >= 6):
return "k-boss" # "Olga's body", "Fatman's body"
if n in AREAS:
return "k-area"
return None
def colourise_i(txt):
parts = [norm(p) for p in re.split(r"[,/]", txt)]
parts = [p for p in parts if p]
if not parts:
return None
hits = sum(1 for p in parts if p in TAGNAMES)
return "k-tag" if hits >= max(1, len(parts) * 0.6) else None
def colourise(html):
def rep_b(m):
cls = colourise_b(m.group(1))
return '<b class="%s">%s</b>' % (cls, m.group(1)) if cls else m.group(0)
def rep_i(m):
cls = colourise_i(m.group(1))
return '<i class="%s">%s</i>' % (cls, m.group(1)) if cls else m.group(0)
html = re.sub(r"<b>(.*?)</b>", rep_b, html, flags=re.S)
html = re.sub(r"<i>(.*?)</i>", rep_i, html, flags=re.S)
return html
n_col = 0
for ph in ROADMAP:
for st in ph["steps"]:
before = st["body"]
st["body"] = colourise(before)
n_col += len(re.findall(r'class="k-', st["body"]))
for bx in BIGBOSS["bosses"]:
bx["body"] = colourise(bx["body"])
print("colour-coded spans in roadmap: %d" % n_col)
# ------------------------------------------------- step sub-checklists
# Which dog-tag areas belong to which walkthrough step. Every area must appear
# exactly once or the build complains, so nothing can silently go missing.
TAGMAP = {
"r1-1": ["Aft deck"],
"r1-2": ["Deck-D, crew's quarters"],
"r1-3": ["Deck-A, crew's quarters"],
"r1-4": ["Deck-A, crew's lounge"],
"r1-5": ["Deck-B, crew's quarters"],
"r1-6": ["Deck-C, crew's quarters"],
"r1-7": ["Deck-2, port"],
"r1-8": ["Engine room"],
"r1-9": ["Navigational deck, wing"],
"r1-10": ["Hold No.1", "Hold No.2", "Hold No.3"],
"r2-1": ["Strut A Deep Sea Dock"],
"r2-2": ["Strut A roof", "Strut A Pump Room"],
"r2-3": ["AB connecting bridge", "Strut B Transformer Room"],
"r2-4": ["BC connecting bridge", "Strut C Dining Hall"],
"r2-5": ["CD connecting bridge", "Strut D Sediment Pool"],
"r2-6": ["DE connecting bridge", "Strut E Parcel Room"],
"r2-7": ["Strut E heliport"],
"r2-8": ["Strut F warehouse", "FA connecting bridge"],
"r2-9": ["Shell 1 Core, 1F"],
"r2-10": ["Shell 1 Core, B2 Computer Room"],
"r2-11": ["Shell 1 Core, B1"],
"r2-12": ["Strut L Sewage Treatment Facility"],
"r2-14": ["Shell 2 Core, 1F Air Purification Room"],
"r2-15": ["KL connecting bridge"],
"r2-16": ["Arsenal Gear-Ascending Colon"],
}
# sanity: every tag area is claimed exactly once, and no step claims a bogus area
all_areas = {}
for blk in vc:
for t in blk["tags"]:
all_areas.setdefault((blk["chapter"], t["area"]), 0)
all_areas[(blk["chapter"], t["area"])] += 0
claimed = {}
for sid, areas in TAGMAP.items():
for a in areas:
claimed[a] = claimed.get(a, 0) + 1
known = {a for (_, a) in all_areas}
missing = sorted(known - set(claimed))
dupes = sorted(a for a, n in claimed.items() if n > 1)
bogus = sorted(set(claimed) - known)
assert not missing, "tag areas not mapped to any step: %s" % missing
assert not dupes, "tag areas mapped to more than one step: %s" % dupes
assert not bogus, "TAGMAP references unknown areas: %s" % bogus
print("tag areas mapped: %d (all covered, no duplicates)" % len(known))
api_by_norm = {norm(e["name"]): e["api"] for e in ach_list}
canon = {e["api"]: e["name"] for e in ach_list}
n_sub = 0
for ph in ROADMAP:
for st in ph["steps"]:
ach, items, seen = [], [], set()
for cls, txt in re.findall(r'<b class="k-(ach|miss|item)">(.*?)</b>', st["body"], re.S):
label = re.sub(r"\s+", " ", txt).strip()
if cls in ("ach", "miss"):
api = api_by_norm.get(norm(core_of(label)))
if api and api not in seen:
seen.add(api); ach.append(api)
else:
if label not in seen:
seen.add(label); items.append(label)
st["gets"] = {"ach": ach, "item": items}
if st["id"] in TAGMAP:
st["areas"] = TAGMAP[st["id"]]
n_sub += len(ach) + len(items)
print("sub-items extracted: %d achievements + %d item labels"
% (sum(len(ph2["gets"]["ach"]) for ph2 in
[s for ph in ROADMAP for s in ph["steps"]]),
n_sub - sum(len(ph2["gets"]["ach"]) for ph2 in
[s for ph in ROADMAP for s in ph["steps"]])))
needs_split = [s["id"] for ph in ROADMAP for s in ph["steps"]
if "areas" not in s and s["gets"]["ach"] == [] and s["gets"]["item"] == []]
if needs_split:
print("steps with no sub-items at all:", ", ".join(needs_split))
# every pin must match at least one real photo, and each photo may match only one pin
_all_photo_urls = [x["u"] for v in shots.values() for x in v]
for k in PHOTO_STEP:
hits = [u for u in _all_photo_urls if k in u]
assert hits, "PHOTO_STEP key matches no photo: %s" % k
def photo_step(url):
best = None
for k, sid in PHOTO_STEP.items():
if k in url and (best is None or len(k) > len(best[0])):
best = (k, sid)
return best[1] if best else None
# every photo of a split achievement must be pinned, or it would land in the wrong place
for api in SPLIT_APIS:
for x in shots.get(api, []):
assert photo_step(x["u"]), "unpinned photo for %s: %s" % (api, x["u"])
# any achievement referenced by more than one step must be split explicitly
refs = {}
for ph in ROADMAP:
for st in ph["steps"]:
for api in st["gets"]["ach"]:
refs.setdefault(api, set()).add(st["id"])
unsplit = sorted(a for a, s in refs.items() if len(s) > 1 and shots.get(a) and a not in SPLIT_APIS)
assert not unsplit, "achievement photos span several steps but are not pinned: %s" % unsplit
# attach the photos to the steps, not the achievements
placed = set()
for ph in ROADMAP:
for st in ph["steps"]:
out, seen = [], set()
# (a) everything belonging to an achievement this step talks about
for api in st["gets"]["ach"]:
if api in SPLIT_APIS:
continue # these are pinned per-photo below
for x in shots.get(api, []):
if x["u"] in seen: continue
seen.add(x["u"]); out.append({"u": x["u"], "s": x["s"], "cap": canon[api]})
# (b) photos pinned to this step by PHOTO_STEP, even if the step never names the achievement
for api in SPLIT_APIS:
for x in shots.get(api, []):
if photo_step(x["u"]) == st["id"] and x["u"] not in seen:
seen.add(x["u"]); out.append({"u": x["u"], "s": x["s"], "cap": canon[api]})
st["photos"] = out[:8]
for x in st["photos"]: placed.add(x["u"])
if len(out) > 8:
print(" note: %s had %d photos, capped to 8" % (st["id"], len(out)))
all_used = {x["u"] for v in shots.values() for x in v}
left = all_used - placed
print("photos placed in steps: %d of %d" % (len(placed), len(all_used)))
if left:
print(" !! unplaced photos:")
for u in sorted(left): print(" ", u)
# ---------------------------------------------------------------- dog tags
BLURB = {
("Tanker","Very Easy"): "Your first pass. Guards are relaxed and there are only 24 tags — a gentle introduction to hold-ups.",
("Tanker","Easy"): "Same route, two more tags. The last two Tanker lists are what push you past the 50 and 76 thresholds.",
("Tanker","Normal"): "33 tags. Reaching 76 total Tanker tags happens partway through this list — that is the Tanker Stealth unlock.",
("Tanker","Hard"): "35 tags. Not needed for any achievement — only chase these for the Orange/Blue wigs on the Plant side or for completion's sake.",
("Tanker","Extreme"): "34 tags, and the guards hit far harder. Purely optional for achievement purposes.",
("Plant","Very Easy"): "43 tags. Watch for the conditional spawns — several guards only appear after the Fatman fight or after the Emma escort.",
("Plant","Easy"): "44 tags. This list alone pushes the Plant total past 80 for the Brown Wig.",
("Plant","Normal"): "49 tags. Crossing 120 Plant tags happens partway through here — that is the Plant Stealth unlock and the end of the required tag grind.",
("Plant","Hard"): "52 tags. Optional — needed only for the 160/200 wig thresholds.",
("Plant","Extreme"): "54 tags, including Hideo Kojima himself. Optional for achievements.",
}
tags_out = []
for i, blk in enumerate(vc):
key = (blk["chapter"], blk["difficulty"])
n = notes.get("|".join(key), {})
tags = []
for t in blk["tags"]:
e = {"name": t["name"], "area": t["area"]}
if n.get(t["name"]):
e["note"] = n[t["name"]]
tags.append(e)
tags_out.append({
"id": f"{blk['chapter'][:1].lower()}{i}",
"chapter": blk["chapter"],
"difficulty": blk["difficulty"],
"blurb": BLURB.get(key, ""),
"tags": tags,
})
TOTALS = {
"tanker": sum(len(l["tags"]) for l in tags_out if l["chapter"] == "Tanker"),
"plant": sum(len(l["tags"]) for l in tags_out if l["chapter"] == "Plant"),
"grand": sum(len(l["tags"]) for l in tags_out),
}
# ---------------------------------------------------------------- inject
def js(o):
s = json.dumps(o, ensure_ascii=False, separators=(",", ":"))
return s.replace("</", "<\\/")
tpl = open(os.path.join(HERE, "template.html"), encoding="utf-8").read()
subs = {
"/*__ACHIEVEMENTS__*/[]": js(ach_list),
"/*__ROADMAP__*/[]": js(ROADMAP),
"/*__DOGTAGS__*/[]": js(tags_out),
"/*__MAPS__*/[]": js(maps),
"/*__TOTALS__*/{}": js(TOTALS),
"/*__BIGBOSS__*/{}": js(BIGBOSS),
}
for k, v in subs.items():
if k not in tpl:
raise SystemExit("placeholder missing from template: " + k)
tpl = tpl.replace(k, v)
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "index.html"), "w", encoding="utf-8") as f:
f.write(tpl)
sync_src = os.path.join(HERE, "steam-sync.py")
if os.path.exists(sync_src):
shutil.copy2(sync_src, os.path.join(OUT, "steam-sync.py"))
for extra in ("steam-proxy-worker.js",):
p = os.path.join(HERE, extra)
if os.path.exists(p):
shutil.copy2(p, os.path.join(OUT, extra))
print("achievements :", len(ach_list))
print("roadmap steps:", sum(len(p["steps"]) for p in ROADMAP), "in", len(ROADMAP), "phases")
print("dog tag lists:", len(tags_out), "tags:", TOTALS)
print("with notes :", sum(1 for l in tags_out for t in l["tags"] if t.get("note")))
print("written :", os.path.join(OUT, "index.html"),
f"{os.path.getsize(os.path.join(OUT, 'index.html'))/1024:.1f} KB")