"""UNWRITTEN - scripted full playthrough. Owner: Agent D. THE proof that the three acts wire into one game. Runs headless, deterministic, driving the REAL systems end to end: - the REAL dialogue runner (choices selected by index via dialogue.select_choice / dialogue.advance) - the REAL battle loop (gw_bats won through the actual command MenuList + target-picker via key injection; bosses won through BattleScreen's autopilot seam, which drives the same _commit/_resolve path the menu uses, using real Talk options) - REAL area transitions (overworld.enter_area via the goto_area hook / ferry dialogue), the REAL shop and party menu, the REAL epilogue. Twenty story beats, each screenshotted to tests/shots/pt_NN_.png and each followed by GS-state assertions. Prints a beat-by-beat PASS log with the running Story-Point total. Exits 0 only on a full clean run. Run: cd build && ./mcrogueface --headless --exec ../games/unwritten/tests/playthrough.py """ import os import sys import random _ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, _ROOT) import mcrfpy from mcrfpy import automation import main from systems.state import GS from systems import dialogue, overworld, battle, epilogue, shop, party_menu from data import characters as chardata _SHOTS = os.path.join(_ROOT, "tests", "shots") K = mcrfpy.Key P = mcrfpy.InputState.PRESSED _beat_no = [0] # ------------------------------------------------------------------- utilities def pump(n): for _ in range(n): mcrfpy.step(1.0 / 60.0) def shot(name): os.makedirs(_SHOTS, exist_ok=True) automation.screenshot(os.path.join(_SHOTS, name)) def beat(title): _beat_no[0] += 1 print(" [beat %02d] %s | points=%d gold=%d party=%s" % (_beat_no[0], title, GS.points, GS.gold, GS.party), flush=True) def fail(msg): print("FAIL playthrough: %s" % msg, flush=True) sys.exit(1) def need(cond, msg): if not cond: fail(msg) # ---- dialogue driving (REAL runner) ---- def reveal(): """Force the open dialogue's typewriter to complete so its text/choices are on screen for a screenshot (presentation only; progression does not need it).""" c = dialogue.CURRENT if c is not None and getattr(c, "box", None) is not None: try: if c.box._typing: c.box._skip() except Exception: pass pump(3) def advance_to_choices(limit=40): for _ in range(limit): if dialogue.CURRENT is None: return False if dialogue.current_choice_labels() is not None: return True dialogue.advance() pump(2) return dialogue.current_choice_labels() is not None def choose_label(substr): labels = dialogue.current_choice_labels() need(labels is not None, "choose_label(%r): no choice node open" % substr) for i, (lab, enabled) in enumerate(labels): if substr in lab: need(enabled, "choose_label(%r): choice is disabled" % substr) dialogue.select_choice(i) pump(2) return fail("choose_label(%r): not found in %r" % (substr, [l for l, _ in labels])) def finish_dialogue(default=-1, guard=80): """Advance/auto-choose (last enabled by default) until the dialogue closes or transfers.""" for _ in range(guard): if dialogue.CURRENT is None: return labels = dialogue.current_choice_labels() if labels: en = [i for i, (l, e) in enumerate(labels) if e] idx = en[default] if en else 0 dialogue.select_choice(idx) else: dialogue.advance() pump(2) def open_dialogue(scene_id): overworld.WORLD.run_dialogue(scene_id) pump(12) # ---- battle driving ---- def _center_enemies(core): return core.alive_enemies() def boss_policy(screen, actor): """Autopilot: fire an enabled boss Talk once, else heal a badly hurt ally, else an offensive skill / basic attack. Drives the REAL _commit path.""" core = screen.core skills = chardata.learned_skills(actor.char_id, actor.cstate.level) allies = core.alive_party() enemies = core.alive_enemies() for o in core.boss_talk_options(): if o["enabled"]: return {"type": "talk", "talk": o["id"]} hurt = [a for a in allies if a.hp < a.max_hp * 0.45] if hurt: tgt = min(hurt, key=lambda c: c.hp / float(c.max_hp)) for s in skills: if s["special"] == "kindle" and actor.sp >= s["sp"]: return {"type": "skill", "skill": s, "target": tgt} if s["special"] == "tonic_toss" and actor.sp >= s["sp"] \ and tgt.char_id != "NYX": return {"type": "skill", "skill": s, "target": tgt} for iid in ("red_tonic", "bread"): if GS.inventory.get(iid, 0) > 0: return {"type": "item", "item": iid, "target": tgt} dmg = [s for s in skills if s["kind"] in ("phys", "magic") and actor.sp >= s["sp"]] if dmg and enemies and random.random() < 0.7: aoe = [s for s in dmg if s["target"] == "all_enemies"] s = random.choice(aoe) if (aoe and len(enemies) >= 2) else random.choice(dmg) tgt = min(enemies, key=lambda e: e.hp) if enemies else None return {"type": "skill", "skill": s, "target": tgt} if enemies: return {"type": "attack", "target": min(enemies, key=lambda e: e.hp)} return {"type": "guard"} def battle_ui_win(scr, shot_name=None, max_iter=2000): """Drive a battle to victory through the ACTUAL on-screen command menu + target picker, by injecting keys (auto: Attack -> first target).""" shot_done = False for _ in range(max_iter): if scr._ended: break pump(3) if not shot_done and shot_name and scr.state == "command": shot(shot_name) shot_done = True if scr.stack.has("victory") or scr.stack.has("defeat"): scr.scene.on_key(K.ENTER, P) pump(3) continue if scr.stack.has("target"): scr.scene.on_key(K.ENTER, P) pump(2) continue if scr.stack.has("cmd"): scr.scene.on_key(K.ENTER, P) # Attack is the first enabled row pump(2) continue if not shot_done and shot_name: shot(shot_name) def battle_auto_win(scr, shot_name=None, max_iter=8000): """Drive a boss battle to victory via the autopilot seam (real command dispatch), dismissing the victory/defeat panels with a keypress. The autopilot is installed at battle creation via battle.DEFAULT_AUTOPILOT.""" if scr._auto is None: scr.set_autopilot(boss_policy) shot_done = False for _ in range(max_iter): if scr._ended: break pump(3) if not shot_done and shot_name and scr.state in ("resolving", "command"): shot(shot_name) shot_done = True if scr.stack.has("victory") or scr.stack.has("defeat"): scr.scene.on_key(K.ENTER, P) pump(3) continue if not shot_done and shot_name: shot(shot_name) def level_party_to(level): """Grant the whole roster enough XP to reach `level` and top everyone up. Stands in for the encounters a real player fights between story beats.""" from systems.state import xp_for_level target = xp_for_level(level) for cs in GS.roster.values(): if cs.xp < target: cs.add_xp(target - cs.xp) cs.full_heal() # =========================================================================== # the playthrough # =========================================================================== def run(): random.seed(20260703) os.makedirs(_SHOTS, exist_ok=True) print("=== UNWRITTEN full playthrough ===", flush=True) # ------------------------------------------------------------ 1. TITLE main.build_title() title_scene = mcrfpy.current_scene pump(20) shot("pt_01_title.png") need(title_scene.name == "title", "title scene not active") beat("title screen") # ---------------------------------------- 2. intro dialogue mid-typewriter # press ENTER on the real title handler: starts a new game -> intro monologue title_scene.on_key(K.ENTER, P) pump(10) # entrance + a few characters of the typewriter need(dialogue.CURRENT is not None, "intro dialogue did not start") need(dialogue.CURRENT.box._typing, "intro should be mid-typewriter") shot("pt_02_intro_typewriter.png") beat("intro monologue (mid-typewriter)") # advance the whole intro; its terminal 'end' hands off to the overworld finish_dialogue(guard=40) pump(30) # enter_area("hollowbrook", "start") # ---------------------------------------------- 3. Hollowbrook, Act 1 w = overworld.WORLD need(w is not None and w.area_id == "hollowbrook", "not in hollowbrook") need(abs(w.zoom - 2.0) < 1e-6 and abs(w.grid.zoom - 2.0) < 1e-6, "overworld must render at zoom 2.0 (got %r)" % w.grid.zoom) need(GS.act == 1 and GS.party == ["PIP"], "act1 start state wrong") pump(10) shot("pt_03_hollowbrook_act1.png") beat("Hollowbrook Act 1 (zoom 2)") # -------------------------------------------- 4. quill_first choice list open_dialogue("quill_first") need(advance_to_choices(), "quill_first reached no choice node") reveal() shot("pt_04_quill_choices.png") need(len(dialogue.current_choice_labels()) == 2, "quill choices missing") choose_label("What do YOU think") # -> quill_confident (+1) need(advance_to_choices(), "quill_first second choice missing") choose_label("Did everyone wake up") # -> asked_unwoken (+1) finish_dialogue() need(GS.has("quill_confident"), "quill_confident not set") need(GS.has("asked_unwoken"), "asked_unwoken not set") need(not w.frozen, "overworld should unfreeze after quill dialogue") beat("Quill: confident + asked about the unwoken") # ---------------------------------- 5. Bramble recruit + North Door opens open_dialogue("bramble_door") need(advance_to_choices(), "bramble_door reached no choices") choose_label("[OATH]") # graceful recruit finish_dialogue() pump(6) shot("pt_05_bramble_door_open.png") need("BRAMBLE" in GS.party, "Bramble not recruited") need(GS.has("bramble_joined") and GS.has("door_open"), "bramble_joined/door_open not set") beat("Ser Bramble joins; North Door opens (door_open)") # --------------------------------------- 6. shop dispute (side with Vera) open_dialogue("shop_dispute") need(advance_to_choices(), "shop_dispute reached no choices") reveal() shot("pt_06_shop_dispute.png") labels = dialogue.current_choice_labels() # [ALCHEMY] choice must be shown-but-disabled (no ALCHEMY in party) need(any(("ALCHEMY" in l and not e) for l, e in labels), "the [ALCHEMY] choice should be visible but locked") choose_label("Come with me and prove it") # side with Vera finish_dialogue() need("VERA" in GS.party, "Vera not recruited") need(GS.has("vera_joined_early") and GS.has("griselda_grudge"), "vera_joined_early/griselda_grudge not set") beat("Shop dispute resolved siding with Vera") # ------------------------------------------- 7. Griselda's shop (buy bread) GS.gold = 80 bread0 = GS.inventory.get("bread", 0) open_dialogue("griselda_shop") choose_label("see your wares") # -> n_shop (text node) dialogue.advance() # fire its 'shop' action pump(10) need(shop._MENU is not None, "shop did not open") shot("pt_07_shop_open.png") gold0 = GS.gold shop._MENU._buy("bread") # real buy path need(GS.inventory.get("bread", 0) == bread0 + 1, "bread not bought") need(GS.gold < gold0, "cogs not spent on bread") shop._MENU._leave() pump(6) need(not w.frozen, "overworld should resume after leaving shop") beat("Griselda's shop open; bought Bread") # ------------------------------------------------------- 8. ferry ride node open_dialogue("odd_ferry_1") reveal() shot("pt_08_ferry.png") need(advance_to_choices(), "ferry reached no choices") choose_label("Take the ferry") # req door_open (met) # advance to Odd's mid-ride question, answer it, ride triggers goto_area need(advance_to_choices(), "ferry ride question missing") choose_label("For the town") finish_dialogue() # n6 -> goto_area:gearwood pump(40) # fade + enter_area(gearwood) beat("Ferry ride with Odd -> Gearwood") # --------------------------------------------- 9. Gearwood arrival banner w = overworld.WORLD need(w.area_id == "gearwood", "did not arrive in gearwood") pump(8) shot("pt_09_gearwood_arrival.png") beat("Gearwood arrival banner") # -------------------------------- 10. real battle vs gw_bats via UI loop level_party_to(3) # stand in for early encounters GS.current_area = "gearwood" won = {"v": False} scr = battle.start_battle("gw_bats", on_victory=lambda: won.__setitem__("v", True)) battle_ui_win(scr, shot_name="pt_10_battle_gw_bats.png") need(scr.core.result == "victory", "gw_bats battle not won (%r)" % scr.core.result) need(won["v"], "gw_bats on_victory callback never fired") need(mcrfpy.current_scene is w.scene, "battle did not restore the overworld") beat("gw_bats battle WON through the command menu + target picker") # ------------------------------------ 11. Moth shrine (name 'small things') open_dialogue("moth_shrine") need(advance_to_choices(), "moth_shrine reached no choices") reveal() shot("pt_11_moth_shrine.png") choose_label("small things") # moth_light_name_small finish_dialogue() need(GS.has("moth_light_name_small"), "moth light-name not recorded") need("MOTH" in GS.roster, "Moth not recruited") beat("Moth's light named for small things; Moth joins the roster") # -------------------- (bridge) open the shrine chest + descend the stair open_dialogue("shrine_chest") finish_dialogue() need(GS.has("shrine_chest_open") and GS.inventory.get("brass_key", 0) >= 1, "shrine chest did not yield the brass key") open_dialogue("gearwood_stair") need(advance_to_choices(), "stair reached no choices") choose_label("Unlock it with the brass key") finish_dialogue() # -> act 2, goto_area:undercroft pump(40) # ---------------------------------------------- 12. Undercroft with FOV w = overworld.WORLD need(w.area_id == "undercroft", "did not descend into the undercroft") need(w.fov is not None, "undercroft should build an FOV overlay") need(GS.act == 2, "act should be 2 in the undercroft") pump(10) shot("pt_12_undercroft_fov.png") beat("Undercroft with fog-of-war (FOV)") # --------------------------------------------- 13. Cantor wake (write CANTOR) open_dialogue("cantor_wake") need(advance_to_choices(), "cantor_wake reached no choices") reveal() shot("pt_13_cantor_wake.png") choose_label("Write CANTOR") finish_dialogue() need("CANTOR" in GS.roster, "Cantor not recruited") need(GS.has("cantor_joined"), "cantor_joined not set") beat("Cantor woken by writing his name") # ---------------------- 14. Gatekeeper boss WON incl. the [OATH] Talk option need("BRAMBLE" in GS.party, "Bramble must be active for the OATH talk") level_party_to(6) GS.grant("red_tonic", 3) GS.current_area = "undercroft" battle.DEFAULT_AUTOPILOT = boss_policy # auto-drive the dialogue-started boss open_dialogue("gatekeeper_pre") need(advance_to_choices(), "gatekeeper_pre reached no choices") choose_label("going through") # -> battle:boss_gatekeeper finish_dialogue() # advance n6 -> starts the battle pump(4) scr = _active_battle() need(scr is not None, "gatekeeper battle did not start") battle_auto_win(scr, shot_name="pt_14_gatekeeper_battle.png") battle.DEFAULT_AUTOPILOT = None need(scr.core.result == "victory", "gatekeeper not defeated (%r)" % scr.core.result) need(GS.has("gatekeeper_relieved"), "the [OATH] Talk option was not used") need(GS.has("gatekeeper_slain"), "gatekeeper_post did not run") beat("Gatekeeper WON using [OATH] Relieve; the vale reverts") # gatekeeper_post is now open on the overworld stack; it ends by fading to # the greyed Hollowbrook (act 3), which auto-runs the Bell #3 arrival scene. finish_dialogue() pump(45) # ---------------------------------------- 15. grey Hollowbrook (Bell #3) w = overworld.WORLD need(w.area_id == "hollowbrook" and GS.act == 3, "did not revert to hollowbrook") need(w._grey_factor() is not None, "grey wash should be active on arrival") need(dialogue.CURRENT is not None, "the Bell #3 arrival scene did not auto-run") reveal() shot("pt_15_grey_hollowbrook.png") finish_dialogue() need(GS.has("bell_3_done"), "bell_3 arrival did not complete") beat("Reverted Hollowbrook; Bell's apology (bell_3_done)") # -------------------------------- 16. one rewake (Quill) -> partial color full_wash = w._grey_factor() open_dialogue("rewake_quill") need(advance_to_choices(), "rewake_quill reached no choices") choose_label("What is a door FOR") # req quill_confident finish_dialogue() pump(10) need(GS.has("rewoke_quill"), "rewoke_quill not set") eased = w._grey_factor() need(eased is not None and eased < full_wash - 0.1, "color should return as Quill rewakes (%.2f -> %.2f)" % (full_wash, eased)) shot("pt_16_rewake_quill_partial.png") beat("Quill rewoken; color begins to return") # ---------------------- 17. all three rewoken + Nyx recruited at the plinth open_dialogue("rewake_griselda") need(advance_to_choices(), "rewake_griselda reached no choices") choose_label("put me in your LEDGER") # req party VERA (active) finish_dialogue() # -> hold the line need(GS.has("rewoke_griselda"), "rewoke_griselda not set") open_dialogue("rewake_odd") need(advance_to_choices(), "rewake_odd reached no choices") choose_label("NEW route into his hand") # press the fare need(advance_to_choices(), "rewake_odd follow-up choice missing") choose_label("I'll write you one") # odd_promised (+1) finish_dialogue() need(GS.has("rewoke_odd") and GS.has("odd_promised"), "odd rewake incomplete") need(GS.has("town_rewoken"), "town_rewoken should set after all 3 rewakes") pump(10) # Nyx stands on the empty plinth now; we asked about the unwoken in beat 4 open_dialogue("nyx_plinth") need(advance_to_choices(), "nyx_plinth reached no choices") choose_label("I've been looking for you") # req asked_unwoken -> warm join finish_dialogue() need("NYX" in GS.roster and GS.has("nyx_joined"), "Nyx not recruited") pump(6) shot("pt_17_all_rewoken_nyx.png") beat("All three rewoken + Nyx joins at the plinth (town_rewoken)") # -------------------------------------------------- 18. Study arrival open_dialogue("odd_ferry_study") need(advance_to_choices(), "odd_ferry_study reached no choices") choose_label("pale channel to the Study") finish_dialogue() # goto_area:study pump(45) w = overworld.WORLD need(w.area_id == "study", "did not reach the Study") pump(8) shot("pt_18_study_arrival.png") beat("The Maker's Study (paper palette)") # ----- put the recommended team forward for the finale (REAL party swap) - pm = party_menu.open_menu(w.scene, w.stack, on_close=lambda: None) if "MOTH" not in GS.party: pm._do_swap("BRAMBLE", "MOTH") if "CANTOR" not in GS.party: pm._do_swap("VERA", "CANTOR") pm.destroy() pump(4) need("MOTH" in GS.party and "CANTOR" in GS.party, "party swap for the finale failed: %s" % GS.party) # --------------------- 19. Custodian boss WON using a Talk option need(GS.points >= 8, "need >= 8 Story Points for the finale talk options") level_party_to(8) GS.grant("red_tonic", 4) GS.grant("blue_draught", 3) GS.current_area = "study" battle.DEFAULT_AUTOPILOT = boss_policy open_dialogue("custodian_pre") need(advance_to_choices(), "custodian_pre reached no choices") choose_label("Pending isn't perfect") # -> battle:boss_custodian finish_dialogue() pump(4) scr = _active_battle() need(scr is not None, "custodian battle did not start") battle_auto_win(scr, shot_name="pt_19_custodian_battle.png") battle.DEFAULT_AUTOPILOT = None need(scr.core.result == "victory", "custodian not defeated (%r)" % scr.core.result) need(len(scr.core.talk_used) >= 1, "no Talk option was used vs the Custodian") print(" custodian talk options used: %s" % sorted(scr.core.talk_used), flush=True) beat("Custodian WON using Talk (%s)" % ",".join(sorted(scr.core.talk_used))) # --------------------------------------- 20. epilogue + final WRITTEN card # custodian_post is open on the overworld stack; choose to read it the Book need(dialogue.CURRENT is not None, "custodian_post did not run") need(advance_to_choices(), "custodian_post reached no choices") choose_label("let it read") # req points >= 8 finish_dialogue() # -> action epilogue pump(30) ep = epilogue.CURRENT need(ep is not None, "epilogue did not start") need(ep.pages, "epilogue collected no pages") shot("pt_20a_epilogue_page.png") # page through to the final card, injecting a keypress per page for _ in range(len(ep.pages) + 4): if ep.final: break ep.scene.on_key(K.ENTER, P) pump(20) need(ep.final, "epilogue never reached the final card") pump(120) # stamp + WRITTEN thunk timers shot("pt_20b_written.png") need(GS.has("book_read_custodian"), "book_read_custodian not set") beat("Epilogue read; UNWRITTEN stamped WRITTEN") # ------------------------------------------------------------- summary print("", flush=True) print("=== PLAYTHROUGH COMPLETE ===", flush=True) print("Story Points: %d" % GS.points, flush=True) print("Roster: %s" % sorted(GS.roster), flush=True) print("Active party: %s" % GS.party, flush=True) key_flags = ["quill_confident", "asked_unwoken", "bramble_joined", "door_open", "vera_joined_early", "griselda_grudge", "moth_light_name_small", "moth_joined", "cantor_joined", "gatekeeper_relieved", "gatekeeper_slain", "bell_3_done", "rewoke_quill", "rewoke_griselda", "rewoke_odd", "odd_promised", "town_rewoken", "nyx_joined", "book_read_custodian"] have = [f for f in key_flags if GS.has(f)] print("Flags set (%d/%d): %s" % (len(have), len(key_flags), have), flush=True) missing = [f for f in key_flags if not GS.has(f)] need(not missing, "expected story flags missing: %s" % missing) need(GS.points >= 12, "story points unexpectedly low: %d" % GS.points) need(len(GS.roster) == 6, "expected all 6 recruited, got %s" % sorted(GS.roster)) print("PASS playthrough", flush=True) sys.exit(0) def _active_battle(): """The BattleScreen that start_battle just created is referenced by the dialogue hook only transiently; grab it via current_scene's owner.""" return battle._LAST_SCREEN if __name__ == "__main__": run()