#!/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="
No notes yet.
") 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", "—
") 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 for "the thing you interact with" and # 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 '%s' % (cls, m.group(1)) if cls else m.group(0) def rep_i(m): cls = colourise_i(m.group(1)) return '%s' % (cls, m.group(1)) if cls else m.group(0) html = re.sub(r"(.*?)", rep_b, html, flags=re.S) html = re.sub(r"(.*?)", 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'(.*?)', 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")