578 lines
24 KiB
Python
578 lines
24 KiB
Python
|
|
"""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_<beat>.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()
|