From 80908f771c40c781dd5598027e4480e912d9a7a0 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Fri, 3 Jul 2026 10:11:12 -0400 Subject: [PATCH] UNWRITTEN: complete short-form RPG at games/unwritten/ A 30-45 minute turn-based RPG built overnight as an engine showcase: six recruitable characters (party of three), four recurring NPCs, choice-driven dialogue with visible-but-locked skill checks, shops, loot, levels, two bosses with in-battle Talk options, the reverted grey-town beat, and an epilogue assembled from the player's actual choices. 33 dialogue scenes / 171 nodes. Terrain is ColorLayers only; kenney_tinydungeon supplies characters, items, and props. Creative direction, story, dialogue, maps, and systems design by Fable; implementation by four Opus subagents (framework, battle, overworld, integration). Verified by tests/playthrough.py: a scripted headless run of all three acts, 20/20 beats passing, plus per-system unit tests (script integrity, UI kit, 225-battle balance sim, overworld). Run: cd build && ./mcrogueface --exec ../games/unwritten/main.py Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014vxWJ6GY384SmZiMVXgrF4 --- games/unwritten/README.md | 63 + games/unwritten/core/assets.py | 38 + games/unwritten/core/inputstack.py | 46 + games/unwritten/core/palette.py | 79 + games/unwritten/core/tween.py | 100 + games/unwritten/core/ui.py | 582 ++++++ games/unwritten/data/barks.py | 51 + games/unwritten/data/characters.py | 153 ++ games/unwritten/data/enemies.py | 101 + games/unwritten/data/epilogue.py | 93 + games/unwritten/data/items.py | 100 + games/unwritten/data/maps.py | 235 +++ games/unwritten/data/script_act1.py | 538 +++++ games/unwritten/data/script_act2.py | 251 +++ games/unwritten/data/script_act3.py | 340 ++++ games/unwritten/design/ARCHITECTURE.md | 234 +++ games/unwritten/design/BIBLE.md | 204 ++ games/unwritten/design/SYSTEMS.md | 152 ++ games/unwritten/main.py | 149 ++ games/unwritten/systems/battle.py | 1742 +++++++++++++++++ games/unwritten/systems/dialogue.py | 291 +++ games/unwritten/systems/epilogue.py | 217 ++ games/unwritten/systems/overworld.py | 816 ++++++++ games/unwritten/systems/party_menu.py | 456 +++++ games/unwritten/systems/shop.py | 230 +++ games/unwritten/systems/state.py | 244 +++ games/unwritten/tests/playthrough.py | 578 ++++++ games/unwritten/tests/test_battle_sim.py | 317 +++ games/unwritten/tests/test_overworld.py | 116 ++ .../unwritten/tests/test_script_integrity.py | 227 +++ games/unwritten/tests/test_ui_kit.py | 104 + 31 files changed, 8847 insertions(+) create mode 100644 games/unwritten/README.md create mode 100644 games/unwritten/core/assets.py create mode 100644 games/unwritten/core/inputstack.py create mode 100644 games/unwritten/core/palette.py create mode 100644 games/unwritten/core/tween.py create mode 100644 games/unwritten/core/ui.py create mode 100644 games/unwritten/data/barks.py create mode 100644 games/unwritten/data/characters.py create mode 100644 games/unwritten/data/enemies.py create mode 100644 games/unwritten/data/epilogue.py create mode 100644 games/unwritten/data/items.py create mode 100644 games/unwritten/data/maps.py create mode 100644 games/unwritten/data/script_act1.py create mode 100644 games/unwritten/data/script_act2.py create mode 100644 games/unwritten/data/script_act3.py create mode 100644 games/unwritten/design/ARCHITECTURE.md create mode 100644 games/unwritten/design/BIBLE.md create mode 100644 games/unwritten/design/SYSTEMS.md create mode 100644 games/unwritten/main.py create mode 100644 games/unwritten/systems/battle.py create mode 100644 games/unwritten/systems/dialogue.py create mode 100644 games/unwritten/systems/epilogue.py create mode 100644 games/unwritten/systems/overworld.py create mode 100644 games/unwritten/systems/party_menu.py create mode 100644 games/unwritten/systems/shop.py create mode 100644 games/unwritten/systems/state.py create mode 100644 games/unwritten/tests/playthrough.py create mode 100644 games/unwritten/tests/test_battle_sim.py create mode 100644 games/unwritten/tests/test_overworld.py create mode 100644 games/unwritten/tests/test_script_integrity.py create mode 100644 games/unwritten/tests/test_ui_kit.py diff --git a/games/unwritten/README.md b/games/unwritten/README.md new file mode 100644 index 0000000..a7d9e7c --- /dev/null +++ b/games/unwritten/README.md @@ -0,0 +1,63 @@ +# UNWRITTEN + +*The world is finished. The story is not.* + +A complete short-form RPG (30-45 minutes) built on McRogueFace: turn-based +battle-screen combat, a six-character roster with an active party of three, +choice-driven dialogue with skill checks, shops, loot, levels, two bosses, +and an epilogue assembled from the choices you actually made. + +The Maker built a perfect vale, sat down to write what should happen in it, +and couldn't think of a single thing. Three hundred years later, someone +wakes up holding a blank book. + +## Run it + +```bash +cd build +./mcrogueface --exec ../games/unwritten/main.py +``` + +## Controls + +- **WASD / arrows** - move (bump into people to talk, into enemies to fight) +- **Enter / Space** - advance dialogue, confirm menus (Space also skips the typewriter) +- **Esc** - back out of menus / open the party menu from the overworld +- **E / Enter** - interact (camps, props) +- **Tab** - party menu (swap, equip, items, stats, key items) +- Title screen: **1 / 2 / 3** jump to Act 1/2/3 with appropriate preset state (debug) + +## What to look for + +- Every dialogue choice is remembered; several are gated by WHO is in your + active party (each character carries a dialogue tag) or by what you chose + hours earlier. Locked options stay visible - that "what would that have + been?" itch is intentional. +- Both bosses can be TALKED to mid-battle if you brought the right people. +- After the first boss, go home. The way the town comes back is the game's + favorite trick. +- Whether you find the sixth party member at all depends on questions you + did or didn't ask in Act 1. + +## Layout + +- `design/` - creative bible, systems spec, architecture contracts +- `data/` - authored content: dialogue scripts (33 scenes / 171 nodes), + maps, items, enemies, epilogue pages, battle barks +- `core/` - UI kit (dialogue box, menus, bars, toasts), palette, input stack +- `systems/` - state, dialogue runner, overworld, battle, shop, menus, epilogue +- `tests/` - unit tests per system + `playthrough.py`, a scripted headless + run of the entire game (20 beats, title card to WRITTEN stamp): + `cd build && ./mcrogueface --headless --exec ../games/unwritten/tests/playthrough.py` + +## Credits + +Made overnight, on request, as an answer to a question. + +- Creative direction, story, dialogue, systems & encounter design: **Fable** (Claude) +- Implementation: Claude Opus subagents (framework / battle / overworld / integration) +- Engine: **McRogueFace** by John McCardle +- Tileset: Kenney's Tiny Dungeon (characters and items only - the terrain + is nothing but colored cells, on purpose) + +The Maker built the vale. You were what happened. diff --git a/games/unwritten/core/assets.py b/games/unwritten/core/assets.py new file mode 100644 index 0000000..a450fca --- /dev/null +++ b/games/unwritten/core/assets.py @@ -0,0 +1,38 @@ +"""UNWRITTEN - shared assets. Owner: Agent A. + +The ONE Texture for the whole game and the speaker->portrait sprite map. +Constructed once here; import TEX everywhere else (never re-create the Texture). +Asset path is relative to the build/ working directory (see ARCHITECTURE section 0). +""" +import mcrfpy + +TEX = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + +# Speaker id -> sprite index (BIBLE sections 2/3/4). NARRATOR has no portrait. +PORTRAITS = { + "PIP": 85, + "BRAMBLE": 96, + "MOTH": 84, + "VERA": 111, + "CANTOR": 109, + "NYX": 121, + "QUILL": 100, + "GRISELDA": 88, + "ODD": 87, + "BELL": 110, + "GATEKEEPER": 20, + "CUSTODIAN": 41, + "NARRATOR": None, +} + + +def portrait_index(speaker): + """Return the sprite index for a speaker id, or None for NARRATOR. + + Fail early: an unknown speaker id is a content/authoring error, not a + thing to paper over with a placeholder sprite. + """ + if speaker not in PORTRAITS: + raise KeyError( + "unknown speaker id %r - add it to core.assets.PORTRAITS" % (speaker,)) + return PORTRAITS[speaker] diff --git a/games/unwritten/core/inputstack.py b/games/unwritten/core/inputstack.py new file mode 100644 index 0000000..bc502c1 --- /dev/null +++ b/games/unwritten/core/inputstack.py @@ -0,0 +1,46 @@ +"""UNWRITTEN - modal input dispatch. Owner: Agent A. + +One InputStack per scene owns scene.on_key and dispatches to a stack of +handlers, top-most first. A handler is fn(key, state) -> bool; returning True +consumes the event (lower handlers do not see it). Overworld pushes its +movement handler; a DialogueBox / menu pushes itself while open and pops on +close, freezing everything beneath it. +""" + + +class InputStack: + def __init__(self, scene): + self.scene = scene + self.stack = [] # list of (name, handler) + scene.on_key = self._dispatch + + def push(self, handler, name=""): + self.stack.append((name, handler)) + + def pop(self, name=""): + """Pop the top handler, or the top-most handler matching name.""" + if name: + for i in range(len(self.stack) - 1, -1, -1): + if self.stack[i][0] == name: + return self.stack.pop(i) + return None + if self.stack: + return self.stack.pop() + return None + + def replace(self, handler, name=""): + """Replace the top handler with a new one.""" + self.pop() + self.push(handler, name) + + def clear(self): + self.stack = [] + + def has(self, name): + return any(n == name for n, _ in self.stack) + + def _dispatch(self, key, state): + for i in range(len(self.stack) - 1, -1, -1): + handler = self.stack[i][1] + if handler(key, state): + return diff --git a/games/unwritten/core/palette.py b/games/unwritten/core/palette.py new file mode 100644 index 0000000..7e04b16 --- /dev/null +++ b/games/unwritten/core/palette.py @@ -0,0 +1,79 @@ +"""UNWRITTEN - palette and font constants. Owner: Agent A. + +Colors from BIBLE section 6, defined EXACTLY. Each color is provided as both a +mcrfpy.Color constant (for constructor kwargs) and a raw (r,g,b) tuple with a +_T suffix (for lerping / passing to color math at call sites). +""" +import mcrfpy + +# ----------------------------------------------------------------- raw tuples +INK_T = (11, 12, 16) # global background +PARCH_T = (232, 220, 192) # primary text +GOLD_T = (212, 169, 78) # accents, selection, the Book +DIM_T = (138, 132, 118) # secondary text, locked options +BLOOD_T = (205, 66, 57) # damage, HP low +TEAL_T = (80, 190, 180) # SP, magic +GRASS_T = (96, 160, 92) # heals, XP +PANEL_T = (22, 24, 32) # UI panel fill +OUTLINE_T = (52, 60, 80) # panel outline (2px) +GREY_T = (58, 58, 62) # Act 3 grey-world wash target + +# a slightly darker fill used behind portrait chips / insets +INSET_T = (14, 15, 20) + + +def C(t, a=255): + """Build a mcrfpy.Color from a 3-tuple (plus optional alpha).""" + return mcrfpy.Color(t[0], t[1], t[2], a) + + +def to_color(c, a=None): + """Coerce a Color OR (r,g,b[,a]) tuple/list into a fresh mcrfpy.Color. + + Some call sites (lerps, animations) hand us tuples; others hand Colors. + Never blind-cast; always produce a new Color so shared constants are safe. + """ + if isinstance(c, mcrfpy.Color): + if a is None: + return mcrfpy.Color(c.r, c.g, c.b, c.a) + return mcrfpy.Color(c.r, c.g, c.b, a) + r, g, b = c[0], c[1], c[2] + if a is not None: + alpha = a + elif len(c) > 3: + alpha = c[3] + else: + alpha = 255 + return mcrfpy.Color(int(r), int(g), int(b), int(alpha)) + + +def lerp_t(a, b, t): + """Linear interpolation between two raw (r,g,b) tuples -> (r,g,b) tuple.""" + t = max(0.0, min(1.0, t)) + return (int(a[0] + (b[0] - a[0]) * t), + int(a[1] + (b[1] - a[1]) * t), + int(a[2] + (b[2] - a[2]) * t)) + + +# ----------------------------------------------------------------- Color consts +INK = C(INK_T) +PARCH = C(PARCH_T) +GOLD = C(GOLD_T) +DIM = C(DIM_T) +BLOOD = C(BLOOD_T) +TEAL = C(TEAL_T) +GRASS = C(GRASS_T) +PANEL = C(PANEL_T) +OUTLINE = C(OUTLINE_T) +GREY = C(GREY_T) +INSET = C(INSET_T) + +# ----------------------------------------------------------------- fonts/sizes +FONT = mcrfpy.default_font + +TITLE_SIZE = 72 +BANNER_SIZE = 44 +NAME_SIZE = 17 +BODY_SIZE = 16 +CHOICE_SIZE = 15 +SMALL_SIZE = 14 diff --git a/games/unwritten/core/tween.py b/games/unwritten/core/tween.py new file mode 100644 index 0000000..5459ccd --- /dev/null +++ b/games/unwritten/core/tween.py @@ -0,0 +1,100 @@ +"""UNWRITTEN - motion helpers. Owner: Agent A. + +Thin wrappers over obj.animate(...) so call sites read as intentions, not +easing bookkeeping. Nothing here teleports; every transition is a tween +(BIBLE section 6: Motion). +""" +import mcrfpy +from core import palette + +_uid = [0] + + +def _next_id(): + _uid[0] += 1 + return _uid[0] + + +def fade_scene(scene, on_done=None, dur=0.25): + """Cover the scene with an INK frame fading 0 -> 1 over dur, then call + on_done (which typically activates/builds the next scene). Returns the + cover frame so the caller can fade it back out on the new scene.""" + cover = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), + fill_color=palette.C(palette.INK_T, 0)) + cover.z_index = 100000 + scene.children.append(cover) + + def done(*_a): + if on_done: + on_done() + cover.animate("opacity", 1.0, dur, mcrfpy.Easing.EASE_IN_OUT, callback=done) + return cover + + +def uncover(scene, cover, dur=0.25): + """Fade an INK cover frame back out and remove it from the scene.""" + def done(*_a): + try: + scene.children.remove(cover) + except Exception: + pass + cover.animate("opacity", 0.0, dur, mcrfpy.Easing.EASE_IN_OUT, callback=done) + + +def float_text(parent, text, pos, color, rise=42, dur=0.85, size=20): + """Rising, fading caption. `parent` is a UICollection (scene.children or + frame.children). Used for damage numbers, +1 Story Point, loot notes.""" + cap = mcrfpy.Caption(pos=(pos[0], pos[1]), text=str(text), font_size=size, + fill_color=palette.to_color(color)) + cap.outline = 1 + cap.outline_color = palette.INK + cap.z_index = 5000 + parent.append(cap) + cap.animate("y", pos[1] - rise, dur, mcrfpy.Easing.EASE_OUT) + + def done(*_a): + try: + parent.remove(cap) + except Exception: + pass + cap.animate("opacity", 0.0, dur, mcrfpy.Easing.EASE_OUT, callback=done) + return cap + + +def shake(drawable, mag=6, dur=0.3): + """Small horizontal shake that returns to the original x.""" + ox = drawable.x + seq = [ox + mag, ox - mag, ox + mag * 0.5, ox] + step_dur = max(0.02, dur / len(seq)) + + def go(i): + if i >= len(seq): + return + drawable.animate("x", seq[i], step_dur, + callback=lambda *_a: go(i + 1)) + go(0) + + +def pulse(drawable, prop, hi, dur=1.0, loop=True, easing=None): + """Animate a property toward hi (looping by default) for idle throbs.""" + drawable.animate(prop, hi, dur, + easing if easing is not None else mcrfpy.Easing.EASE_IN_OUT, + loop=loop) + + +def gold_ring(parent, center, max_r=70, dur=0.5): + """A single gold circle that rings outward and fades. The Book hum.""" + circ = mcrfpy.Circle(radius=2, center=(center[0], center[1]), + fill_color=palette.C(palette.GOLD_T, 0), + outline=2, outline_color=palette.C(palette.GOLD_T, 200)) + circ.z_index = 4000 + parent.append(circ) + circ.animate("radius", max_r, dur, mcrfpy.Easing.EASE_OUT) + + def done(*_a): + try: + parent.remove(circ) + except Exception: + pass + circ.animate("opacity", 0.0, dur, mcrfpy.Easing.EASE_OUT, callback=done) + return circ diff --git a/games/unwritten/core/ui.py b/games/unwritten/core/ui.py new file mode 100644 index 0000000..d1e60e4 --- /dev/null +++ b/games/unwritten/core/ui.py @@ -0,0 +1,582 @@ +"""UNWRITTEN - the widget kit. Owner: Agent A. + +Panel, Label, MenuList, Bar, DialogueBox, Toast, TitleBanner. Every widget +takes a parent UICollection (scene.children or frame.children) and absolute +pixel positions, and follows the palette exactly. See ARCHITECTURE section 3. +""" +import mcrfpy +from core import palette +from core import assets +from core import tween + + +# ---------------------------------------------------------------- text wrapping +def wrap_lines(meas, text, max_px, font_size): + """Word-wrap `text` to fit within max_px, measuring with the hidden Caption + `meas`. Honors any explicit newlines already in the text. Returns a list of + line strings.""" + meas.font_size = font_size + out = [] + for para in text.split("\n"): + words = para.split(" ") + cur = "" + for w in words: + trial = w if not cur else cur + " " + w + meas.text = trial + if meas.size.x <= max_px or not cur: + cur = trial + else: + out.append(cur) + cur = w + out.append(cur) + return out + + +# ------------------------------------------------------------------------ Panel +class Panel: + """A Frame with PANEL fill and a 2px outline. .frame is the Frame, + .children is its child collection.""" + + def __init__(self, parent, pos, size, fill=None, outline_color=None, + outline=2, z_index=None): + self._parent = parent + self.frame = mcrfpy.Frame( + pos=(pos[0], pos[1]), size=(size[0], size[1]), + fill_color=palette.to_color(fill) if fill is not None else palette.PANEL, + outline=outline, + outline_color=palette.to_color(outline_color) if outline_color is not None + else palette.OUTLINE) + if z_index is not None: + self.frame.z_index = z_index + parent.append(self.frame) + self.children = self.frame.children + + def destroy(self): + try: + self._parent.remove(self.frame) + except Exception: + pass + + +# ------------------------------------------------------------------------ Label +def Label(parent, text, pos, color=None, size=palette.BODY_SIZE, center=False, + outline=0, z_index=None): + """Caption factory with palette defaults. If center=True, pos is treated as + the center point.""" + cap = mcrfpy.Caption(pos=(pos[0], pos[1]), text=str(text), font_size=size, + fill_color=palette.to_color(color) if color is not None + else palette.PARCH) + if outline: + cap.outline = outline + cap.outline_color = palette.INK + if z_index is not None: + cap.z_index = z_index + parent.append(cap) + if center: + cap.x = pos[0] - cap.size.x / 2 + cap.y = pos[1] - cap.size.y / 2 + return cap + + +# --------------------------------------------------------------------- MenuList +class MenuList: + """Vertical keyboard menu. items = list of (label, value, enabled) or + (label, value, enabled, lock_reason). Gold '>' cursor; W/S or Up/Down move; + Enter/Space pick an ENABLED row; Esc cancels. Disabled rows render DIM with + their lock reason. Push .handle onto an InputStack (or wire it yourself).""" + + ROW_H = 30 + + def __init__(self, parent, pos, width, items, on_pick=None, on_cancel=None, + title=None, size=palette.CHOICE_SIZE): + self._parent = parent + self.items = [self._norm(i) for i in items] + self.on_pick = on_pick + self.on_cancel = on_cancel + self.index = self._first_enabled() + + title_h = 30 if title else 8 + h = title_h + self.ROW_H * len(self.items) + 8 + self.panel = Panel(parent, pos, (width, h), + outline_color=palette.GOLD) + self._x = pos[0] + self._y = pos[1] + self._top = title_h + self._size = size + + if title: + Label(self.panel.children, title, (14, 7), + color=palette.GOLD, size=palette.NAME_SIZE) + + self.cursor = Label(self.panel.children, ">", (10, self._top), + color=palette.GOLD, size=size) + self.rows = [] + for i, (label, value, enabled, reason) in enumerate(self.items): + txt = label + if not enabled and reason: + txt = "%s (%s)" % (label, reason) + row = Label(self.panel.children, txt, (30, self._top + i * self.ROW_H), + color=palette.PARCH if enabled else palette.DIM, size=size) + self.rows.append(row) + self._place_cursor() + + def _norm(self, item): + label = item[0] + value = item[1] if len(item) > 1 else item[0] + enabled = item[2] if len(item) > 2 else True + reason = item[3] if len(item) > 3 else "" + return (label, value, bool(enabled), reason) + + def _first_enabled(self): + for i, it in enumerate(self.items): + if it[2]: + return i + return 0 + + def _place_cursor(self): + self.cursor.y = self._top + self.index * self.ROW_H + + def move(self, delta): + n = len(self.items) + self.index = (self.index + delta) % n + self._place_cursor() + + def handle(self, key, state): + if state != mcrfpy.InputState.PRESSED: + return False + K = mcrfpy.Key + if key in (K.W, K.UP): + self.move(-1) + return True + if key in (K.S, K.DOWN): + self.move(1) + return True + if key in (K.ENTER, K.SPACE): + label, value, enabled, reason = self.items[self.index] + if enabled: + if self.on_pick: + self.on_pick(value) + else: + tween.shake(self.cursor, mag=5, dur=0.2) + return True + if key == K.ESCAPE: + if self.on_cancel: + self.on_cancel() + return True + return False + + def destroy(self): + self.panel.destroy() + + +# -------------------------------------------------------------------------- Bar +class Bar: + """Stat bar: PANEL background + colored fill + optional "cur/max" caption. + .set(cur, max) updates fill width and text.""" + + def __init__(self, parent, pos, size, color, cur=1, maxv=1, + show_text=True): + self._parent = parent + self.w = size[0] + self.h = size[1] + self.bg = mcrfpy.Frame(pos=(pos[0], pos[1]), size=(self.w, self.h), + fill_color=palette.INSET, outline=2, + outline_color=palette.OUTLINE) + parent.append(self.bg) + self._fill_w = self.w - 4 + self.fill = mcrfpy.Frame(pos=(2, 2), size=(self._fill_w, self.h - 4), + fill_color=palette.to_color(color)) + self.bg.children.append(self.fill) + self.cap = None + if show_text: + self.cap = mcrfpy.Caption(pos=(0, 0), text="", font_size=palette.SMALL_SIZE, + fill_color=palette.PARCH) + self.cap.outline = 1 + self.cap.outline_color = palette.INK + self.bg.children.append(self.cap) + self.set(cur, maxv) + + def set(self, cur, maxv): + frac = 0.0 if maxv <= 0 else max(0.0, min(1.0, float(cur) / float(maxv))) + self.fill.w = max(0, self._fill_w * frac) + if self.cap is not None: + self.cap.text = "%d/%d" % (int(cur), int(maxv)) + self.cap.x = (self.w - self.cap.size.x) / 2 + self.cap.y = (self.h - self.cap.size.y) / 2 + + def destroy(self): + try: + self._parent.remove(self.bg) + except Exception: + pass + + +# ------------------------------------------------------------------------ Toast +class Toast: + """Top-right slide-in note that auto-dismisses after 2.2s. Stacks + downward as multiple toasts appear.""" + + _live = [] # currently visible toasts, for stacking + _seq = [0] + + W = 268 + H = 44 + MARGIN = 16 + + def __init__(self, parent, text, color=None): + self._parent = parent + Toast._seq[0] += 1 + self._id = Toast._seq[0] + slot = len(Toast._live) + y = self.MARGIN + slot * (self.H + 8) + final_x = 1024 - self.W - self.MARGIN + self.frame = mcrfpy.Frame(pos=(final_x + 26, y), size=(self.W, self.H), + fill_color=palette.PANEL, outline=2, + outline_color=palette.GOLD) + self.frame.z_index = 6000 + parent.append(self.frame) + Label(self.frame.children, text, (self.W / 2, self.H / 2), + color=palette.to_color(color) if color is not None else palette.PARCH, + size=palette.SMALL_SIZE, center=True) + Toast._live.append(self) + self.frame.animate("x", final_x, 0.28, mcrfpy.Easing.EASE_OUT) + + self._timer = mcrfpy.Timer("toast_%d" % self._id, self._dismiss, 2200) + + def _dismiss(self, timer, runtime): + timer.stop() + self.frame.animate("x", 1024 + 10, 0.28, mcrfpy.Easing.EASE_IN, + callback=self._remove) + + def _remove(self, *_a): + try: + self._parent.remove(self.frame) + except Exception: + pass + if self in Toast._live: + Toast._live.remove(self) + + def destroy(self): + try: + self._timer.stop() + except Exception: + pass + self._remove() + + +# ------------------------------------------------------------------ TitleBanner +class TitleBanner: + """Area-entry banner: big centered caption that fades in, holds, fades out + (1.8s total) then removes itself.""" + + def __init__(self, parent, text, cx=512, cy=120, size=palette.BANNER_SIZE, + color=None, total=1.8): + self._parent = parent + self.cap = Label(parent, text, (cx, cy), + color=palette.to_color(color) if color is not None + else palette.GOLD, + size=size, center=True, outline=2, z_index=6500) + self.cap.opacity = 0.0 + fade = total * 0.25 + hold = total - 2 * fade + self.cap.animate("opacity", 1.0, fade, mcrfpy.Easing.EASE_OUT) + self._timer = mcrfpy.Timer("banner_%d" % id(self), self._out, + int((fade + hold) * 1000)) + + def _out(self, timer, runtime): + timer.stop() + self.cap.animate("opacity", 0.0, 0.45, mcrfpy.Easing.EASE_IN, + callback=self._remove) + + def _remove(self, *_a): + try: + self._parent.remove(self.cap) + except Exception: + pass + + def destroy(self): + try: + self._timer.stop() + except Exception: + pass + self._remove() + + +# ------------------------------------------------------------------ DialogueBox +class DialogueBox: + """The flagship. Bottom box 940x190 at (42, 556): portrait chip (sprite + scale 5.0 inside a 96px inner frame), gold name plate on the rim, typewriter + body at 45 chars/sec, blinking down-indicator when waiting, choice mode with + gold '>' cursor and DIM-but-visible disabled choices. + + Content-agnostic: show_node(speaker, text, choices=None, on_advance=, + on_choice=). `choices` is a list of (label, enabled) tuples. This lets the + dialogue runner AND battle Talk menus reuse the same widget. + """ + + BOX_X, BOX_Y = 42, 556 + BOX_W, BOX_H = 940, 190 + CPS = 45.0 # typewriter characters per second + + # portrait chip + CHIP_X, CHIP_Y, CHIP = 16, 47, 96 + # text columns (box-relative) + BODY_X_PORTRAIT = 130 + BODY_X_NARRATOR = 64 + BODY_Y = 20 + WRAP_PORTRAIT = 772 + WRAP_NARRATOR = 800 + + def __init__(self, parent): + self._parent = parent + + # book-hum ring lives behind the box, on the top rim + self._hum_center = (self.BOX_X + self.BOX_W / 2, self.BOX_Y) + + self.frame = mcrfpy.Frame( + pos=(self.BOX_X, self.BOX_Y), size=(self.BOX_W, self.BOX_H), + fill_color=palette.PANEL, outline=2, outline_color=palette.GOLD) + self.frame.z_index = 3000 + parent.append(self.frame) + kids = self.frame.children + + # portrait chip + self.chip = mcrfpy.Frame(pos=(self.CHIP_X, self.CHIP_Y), + size=(self.CHIP, self.CHIP), + fill_color=palette.INSET, outline=2, + outline_color=palette.GOLD) + kids.append(self.chip) + self.portrait = mcrfpy.Sprite(pos=(8, 8), texture=assets.TEX, + sprite_index=0) + self.portrait.scale = 5.0 + self.chip.children.append(self.portrait) + + # name plate (sits on the top rim of the box) + self.plate = mcrfpy.Frame(pos=(self.BODY_X_PORTRAIT - 2, -16), + size=(120, 30), fill_color=palette.GOLD, + outline=2, outline_color=palette.INK) + kids.append(self.plate) + self.name_cap = mcrfpy.Caption(pos=(10, 5), text="", font_size=palette.NAME_SIZE, + fill_color=palette.INK) + self.plate.children.append(self.name_cap) + + # body caption (typewriter target) + self.body = mcrfpy.Caption(pos=(self.BODY_X_PORTRAIT, self.BODY_Y), + text="", font_size=palette.BODY_SIZE, + fill_color=palette.PARCH) + kids.append(self.body) + + # hidden measuring caption (never shown) + self._meas = mcrfpy.Caption(pos=(0, 0), text="", font_size=palette.BODY_SIZE, + fill_color=palette.PARCH) + self._meas.visible = False + kids.append(self._meas) + + # down indicator + self.indicator = mcrfpy.Caption(pos=(self.BOX_W - 40, self.BOX_H - 34), + text="v", font_size=22, + fill_color=palette.GOLD) + self.indicator.visible = False + kids.append(self.indicator) + + # choice captions (rebuilt each choice node) + self.choice_caps = [] + self.choice_cursor = mcrfpy.Caption(pos=(0, 0), text=">", + font_size=palette.CHOICE_SIZE, + fill_color=palette.GOLD) + self.choice_cursor.visible = False + kids.append(self.choice_cursor) + + # state + self._full = "" + self._typing = False + self._t0 = None + self._on_advance = None + self._on_choice = None + self._choices = None # list of (enabled, first_line_y) + self._sel = 0 + + # entrance: slide up + fade in + self.frame.opacity = 0.0 + self.frame.y = self.BOX_Y + 12 + self.frame.animate("y", self.BOX_Y, 0.22, mcrfpy.Easing.EASE_OUT) + self.frame.animate("opacity", 1.0, 0.22, mcrfpy.Easing.EASE_OUT) + + self._timer = mcrfpy.Timer("dlg_type", self._tick, 16) + + # ------------------------------------------------------------------ display + def show_node(self, speaker, text, choices=None, on_advance=None, + on_choice=None): + self._on_advance = on_advance + self._on_choice = on_choice + self._clear_choices() + self.indicator.visible = False + + is_narrator = (speaker == "NARRATOR" or speaker is None or + assets.PORTRAITS.get(speaker) is None) + + if is_narrator: + self.chip.visible = False + self.portrait.visible = False + self.plate.visible = False + self.name_cap.visible = False + self.body.x = self.BODY_X_NARRATOR + self.body.fill_color = palette.DIM + wrap = self.WRAP_NARRATOR + else: + self.chip.visible = True + self.portrait.visible = True + self.portrait.sprite_index = assets.portrait_index(speaker) + self.plate.visible = True + self.name_cap.visible = True + self.name_cap.text = speaker + self.plate.w = self.name_cap.size.x + 22 + self.body.x = self.BODY_X_PORTRAIT + self.body.fill_color = palette.PARCH + wrap = self.WRAP_PORTRAIT + + self.body.y = self.BODY_Y + lines = wrap_lines(self._meas, text, wrap, palette.BODY_SIZE) if text else [""] + self._full = "\n".join(lines) + self._raw_choices = choices + + if self._full.strip() == "": + # empty text node: reveal instantly, go straight to choices/advance + self.body.text = "" + self._typing = False + self._on_reveal_done() + else: + self.body.text = "" + self._typing = True + self._t0 = None + + def _tick(self, timer, runtime): + if not self._typing: + return + if self._t0 is None: + self._t0 = runtime + elapsed = runtime - self._t0 + n = int(elapsed / 1000.0 * self.CPS) + if n >= len(self._full): + self.body.text = self._full + self._typing = False + self._on_reveal_done() + else: + self.body.text = self._full[:n] + + def _skip(self): + self.body.text = self._full + self._typing = False + self._on_reveal_done() + + def _on_reveal_done(self): + if self._raw_choices: + self._build_choices(self._raw_choices) + else: + self.indicator.visible = True + self._blink_indicator() + + def _blink_indicator(self): + self.indicator.opacity = 1.0 + self.indicator.animate("opacity", 0.15, 0.55, mcrfpy.Easing.EASE_IN_OUT, + loop=True) + + # ------------------------------------------------------------------ choices + def _clear_choices(self): + for c in self.choice_caps: + try: + self.frame.children.remove(c) + except Exception: + pass + self.choice_caps = [] + self._choices = None + self.choice_cursor.visible = False + + def _build_choices(self, choices): + # choices: list of (label, enabled) + body_bottom = self.body.y + self.body.size.y + y = body_bottom + 12 + wrap = (self.WRAP_PORTRAIT if self.body.x == self.BODY_X_PORTRAIT + else self.WRAP_NARRATOR) - 24 + x = self.body.x + self._choices = [] + for label, enabled in choices: + lines = wrap_lines(self._meas, label, wrap, palette.CHOICE_SIZE) + cap = mcrfpy.Caption(pos=(x + 22, y), text="\n".join(lines), + font_size=palette.CHOICE_SIZE, + fill_color=palette.PARCH if enabled else palette.DIM) + self.frame.children.append(cap) + self.choice_caps.append(cap) + self._choices.append([bool(enabled), y]) + y += cap.size.y + 8 + # select first enabled + self._sel = 0 + for i, (en, _yy) in enumerate(self._choices): + if en: + self._sel = i + break + self._place_choice_cursor() + self.choice_cursor.visible = True + + def _place_choice_cursor(self): + if not self._choices: + return + _en, yy = self._choices[self._sel] + self.choice_cursor.x = self.body.x + self.choice_cursor.y = yy + self.choice_cursor.fill_color = palette.GOLD + + def _move_choice(self, delta): + n = len(self._choices) + self._sel = (self._sel + delta) % n + self._place_choice_cursor() + + # -------------------------------------------------------------------- input + def handle(self, key, state): + if state != mcrfpy.InputState.PRESSED: + return False + K = mcrfpy.Key + # typing: any advance key skips to full + if self._typing: + if key in (K.SPACE, K.ENTER): + self._skip() + return True + # choice mode + if self._choices: + if key in (K.W, K.UP): + self._move_choice(-1) + return True + if key in (K.S, K.DOWN): + self._move_choice(1) + return True + if key in (K.ENTER, K.SPACE): + enabled, _yy = self._choices[self._sel] + if enabled: + idx = self._sel + if self._on_choice: + self._on_choice(idx) + else: + tween.shake(self.choice_cursor, mag=5, dur=0.2) + return True + return True + # text node waiting to advance + if key in (K.SPACE, K.ENTER): + if self._on_advance: + self._on_advance() + return True + return True + + # ---------------------------------------------------------------- book hum + def hum(self): + tween.gold_ring(self._parent, self._hum_center, max_r=64, dur=0.5) + + # ----------------------------------------------------------------- teardown + def destroy(self): + try: + self._timer.stop() + except Exception: + pass + try: + self._parent.remove(self.frame) + except Exception: + pass diff --git a/games/unwritten/data/barks.py b/games/unwritten/data/barks.py new file mode 100644 index 0000000..685b8e6 --- /dev/null +++ b/games/unwritten/data/barks.py @@ -0,0 +1,51 @@ +"""UNWRITTEN - battle barks. Authored by Fable. One-liners shown in the +battle log. Keys: char id -> {event: [lines]}. Pick randomly, never twice in +a row. Events: skill_a, skill_b, hurt_low (under 30% HP), victory, ko.""" + +BARKS = { + "PIP": { + "skill_a": ["Two for flinching.", "Quick hands, quicker feet."], + "skill_b": ["Yoink.", "Finders keepers is a LAW now. I decided."], + "hurt_low": ["Ow. Noted. OW."], + "victory": ["Put it in the Book."], + "ko": ["...page... torn..."], + }, + "BRAMBLE": { + "skill_a": ["The post has legs. STAND BEHIND THE POST.", + "Shieldwall! As rehearsed! I rehearsed alone!"], + "skill_b": ["Forty years of standing still, arriving all at once!"], + "hurt_low": ["Merely denting the paperwork."], + "victory": ["Watch concluded. Nothing got past me."], + "ko": ["...post... unmanned..."], + }, + "MOTH": { + "skill_a": ["Light does mending too.", "Steady. The wick knows."], + "skill_b": ["This one's named. DUCK."], + "hurt_low": ["Guttering... not out."], + "victory": ["All lamps accounted for."], + "ko": ["...keep it... lit..."], + }, + "VERA": { + "skill_a": ["Artisanal!", "Shake well before throwing!"], + "skill_b": ["Possibility, resolving NOW."], + "hurt_low": ["I'm marking THIS down as a cost of business."], + "victory": ["Invoice to follow."], + "ko": ["...inventory... early again..."], + }, + "CANTOR": { + "skill_a": ["A note, held long enough, is a wall.", + "Tuning... found you."], + "skill_b": ["THE FANFARE ARRIVES IN THE MIDDLE."], + "hurt_low": ["Reeds cracked. Song intact."], + "victory": ["Rest, everyone. That is also part of the music."], + "ko": ["...the note... someone hold... the note..."], + }, + "NYX": { + "skill_a": ["Cold spot. You're standing in it.", + "The margin bites back."], + "skill_b": ["Watch the empty spot. WATCH IT."], + "hurt_low": ["Still here. STILL here."], + "victory": ["Sketch that."], + "ko": ["...later... always later..."], + }, +} diff --git a/games/unwritten/data/characters.py b/games/unwritten/data/characters.py new file mode 100644 index 0000000..e7fce01 --- /dev/null +++ b/games/unwritten/data/characters.py @@ -0,0 +1,153 @@ +"""UNWRITTEN - party data tables. Owner: Agent B. + +BASE (level-1 stats), GROWTH (per-level growth dicts, index i applied when a +character REACHES level i+2, so 7 entries cover levels 2..8), SKILLS (per-char +[skillA, skillB]) and CHAR_SPRITES. Numbers are canon from SYSTEMS sections 1 +and 3. ASCII only. + +state.CharState reads BASE[cid] and GROWTH[cid]; battle.py reads SKILLS and +CHAR_SPRITES. "Fail early": unknown ids raise KeyError at the call site. +""" + +# --------------------------------------------------------------- sprite indices +CHAR_SPRITES = { + "PIP": 85, + "BRAMBLE": 96, + "MOTH": 84, + "VERA": 111, + "CANTOR": 109, + "NYX": 121, +} + +# --------------------------------------------------------------- base stats (L1) +BASE = { + "PIP": {"HP": 34, "SP": 10, "ATK": 7, "MAG": 3, "DEF": 4, "SPD": 9}, + "BRAMBLE": {"HP": 46, "SP": 8, "ATK": 6, "MAG": 2, "DEF": 7, "SPD": 4}, + "MOTH": {"HP": 28, "SP": 16, "ATK": 3, "MAG": 8, "DEF": 3, "SPD": 6}, + "VERA": {"HP": 31, "SP": 12, "ATK": 5, "MAG": 6, "DEF": 4, "SPD": 6}, + "CANTOR": {"HP": 40, "SP": 12, "ATK": 8, "MAG": 6, "DEF": 6, "SPD": 2}, + "NYX": {"HP": 22, "SP": 12, "ATK": 9, "MAG": 5, "DEF": 2, "SPD": 10}, +} + + +def _const(g): + return [dict(g) for _ in range(7)] + + +def _alt(base, extra): + """7 growth dicts. Even reached-levels (2,4,6,8 -> index 0,2,4,6) get the + `extra` +1s on top of `base`; odd levels get only `base`.""" + out = [] + for i in range(7): + d = dict(base) + if i % 2 == 0: + for k, v in extra.items(): + d[k] = d.get(k, 0) + v + out.append(d) + return out + + +# --------------------------------------------------------------- per-level growth +GROWTH = { + "PIP": _const({"HP": 6, "SP": 2, "ATK": 2, "DEF": 1, "SPD": 1}), + "BRAMBLE": _const({"HP": 9, "SP": 1, "ATK": 1, "DEF": 2}), + "MOTH": _alt({"HP": 4, "SP": 4, "MAG": 2}, {"DEF": 1, "SPD": 1}), + "VERA": _alt({"HP": 5, "SP": 3}, {"ATK": 1, "MAG": 1, "DEF": 1, "SPD": 1}), + "CANTOR": _const({"HP": 7, "SP": 2, "ATK": 2, "MAG": 1, "DEF": 1}), + "NYX": _const({"HP": 3, "SP": 3, "ATK": 3, "SPD": 1}), +} + +# --------------------------------------------------------------- crit base (%/100) +CRIT_BASE = { + "PIP": 0.06, "BRAMBLE": 0.06, "MOTH": 0.06, + "VERA": 0.06, "CANTOR": 0.06, "NYX": 0.18, +} + +# NYX passive evasion (SYSTEMS section 3) +NYX_EVASION = 0.20 + +# ------------------------------------------------------------------------ SKILLS +# Each skill: id, name, sp, unlock (skill A = 1, skill B = 4), desc for the +# submenu, and mechanical fields the battle engine dispatches on via "special". +# kind: phys | magic | heal | buff | selfbuff +# target: enemy | all_enemies | ally | party | self +# mult: damage/heal multiplier (semantics per special) +# hits: number of strikes (phys) +# debuff: {"stat","mult","turns"} applied to target(s) +SKILLS = { + "PIP": [ + {"id": "twinstrike", "name": "Twinstrike", "sp": 3, "unlock": 1, + "kind": "phys", "target": "enemy", "mult": 0.7, "hits": 2, + "special": "twinstrike", + "desc": "Two quick hits at 0.7x each."}, + {"id": "pickpocket", "name": "Pickpocket", "sp": 2, "unlock": 4, + "kind": "phys", "target": "enemy", "mult": 0.6, "hits": 1, + "special": "pickpocket", + "desc": "0.6x hit; steal 8-20 cogs, once per foe."}, + ], + "BRAMBLE": [ + {"id": "shieldwall", "name": "Shieldwall", "sp": 3, "unlock": 1, + "kind": "buff", "target": "party", "special": "shieldwall", + "desc": "Party takes half damage until Bramble's next turn."}, + {"id": "vow_strike", "name": "Vow Strike", "sp": 4, "unlock": 4, + "kind": "phys", "target": "enemy", "mult": 1.0, "hits": 1, + "special": "vow_strike", + "desc": "Grows stronger the more HP Bramble is missing."}, + ], + "MOTH": [ + {"id": "kindle", "name": "Kindle", "sp": 4, "unlock": 1, + "kind": "heal", "target": "ally", "special": "kindle", + "heals_nyx": True, + "desc": "Heal one ally 24 + 2*MAG. Works on Nyx."}, + {"id": "moonflare", "name": "Moonflare", "sp": 6, "unlock": 4, + "kind": "magic", "target": "all_enemies", "mult": 1.1, + "special": "moonflare", + "desc": "Radiant AoE, 1.1x magic to all foes."}, + ], + "VERA": [ + {"id": "acid_flask", "name": "Acid Flask", "sp": 4, "unlock": 1, + "kind": "magic", "target": "all_enemies", "mult": 0.7, + "special": "acid_flask", + "debuff": {"stat": "DEF", "mult": -0.25, "turns": 2}, + "desc": "AoE 0.7x and DEF -25% (2 turns)."}, + {"id": "tonic_toss", "name": "Tonic Toss", "sp": 3, "unlock": 4, + "kind": "heal", "target": "ally", "special": "tonic_toss", + "heals_nyx": False, + "desc": "Heal one 16 + MAG, clear debuffs. Not Nyx."}, + ], + "CANTOR": [ + {"id": "resonate", "name": "Resonate", "sp": 4, "unlock": 1, + "kind": "magic", "target": "enemy", "mult": 1.2, + "special": "resonate", + "desc": "1.2x magic; stuns constructs/undead 1 turn."}, + {"id": "bass_drop", "name": "Bass Drop", "sp": 7, "unlock": 4, + "kind": "phys", "target": "enemy", "mult": 2.2, "hits": 1, + "special": "bass_drop", + "desc": "Massive 2.2x single hit; acts last this round."}, + ], + "NYX": [ + {"id": "grave_chill", "name": "Grave Chill", "sp": 3, "unlock": 1, + "kind": "magic", "target": "enemy", "mult": 1.0, + "special": "grave_chill", + "debuff": {"stat": "SPD", "mult": -0.30, "turns": 2}, + "desc": "1.0x magic and SPD -30% (2 turns)."}, + {"id": "second_shadow", "name": "Second Shadow", "sp": 5, "unlock": 4, + "kind": "selfbuff", "target": "self", "special": "second_shadow", + "desc": "Her next attack is a guaranteed crit."}, + ], +} + + +def learned_skills(char_id, level): + """Skills available to char_id at `level` (unlock <= level).""" + if char_id not in SKILLS: + raise KeyError("no skills for %r in data.characters.SKILLS" % (char_id,)) + return [s for s in SKILLS[char_id] if level >= s["unlock"]] + + +def skill_by_id(skill_id): + for lst in SKILLS.values(): + for s in lst: + if s["id"] == skill_id: + return s + raise KeyError("unknown skill id %r" % (skill_id,)) diff --git a/games/unwritten/data/enemies.py b/games/unwritten/data/enemies.py new file mode 100644 index 0000000..990d550 --- /dev/null +++ b/games/unwritten/data/enemies.py @@ -0,0 +1,101 @@ +"""UNWRITTEN - enemy tables and encounter packs. Owner: Agent B. + +Numbers canon from SYSTEMS section 4; boss behavior from BIBLE section 4. +Area/variant textures (Pale Echo, Chime-Imp, Grey Echo) are hue/sat-shifted +copies of the shared atlas, built ONCE here at import via TEX.hsl_shift. + +ENEMIES : id -> record used by systems.battle to build combatants. +PACKS : pack_id -> list of enemy ids (order = left-to-right on screen). +ASCII only. "Fail early": unknown ids raise. +""" +from core.assets import TEX + +# ------------------------------------------------- variant textures (built once) +# Pale Echo: sprite 121 hue -40 deg (hsl_shift takes [0,360) so -40 -> 320). +PALE_ECHO_TEX = TEX.hsl_shift(320.0, 0.0, 0.0) +# Chime-Imp: sprite 110 hue +160 deg (teal). +CHIME_TEX = TEX.hsl_shift(160.0, 0.0, 0.0) +# Grey Echo (Custodian summon): starter sprite 85, drained of color. +GREY_ECHO_TEX = TEX.hsl_shift(0.0, -0.85, 0.02) + + +def _e(name, sprite, hp, atk, mag, dfn, spd, xp, gold, ai="basic", + labels=None, tex=None, scale=4.0, drop=None): + return { + "name": name, "sprite": sprite, + "HP": hp, "ATK": atk, "MAG": mag, "DEF": dfn, "SPD": spd, + "XP": xp, "gold": gold, "ai": ai, + "labels": set(labels or ()), "tex": tex, "scale": scale, "drop": drop, + } + + +ENEMIES = { + "gloom_bat": _e("Gloom Bat", 120, 14, 5, 0, 1, 8, 6, 4, ai="bat"), + "hedge_slime": _e("Hedge Slime", 108, 20, 4, 0, 3, 2, 7, 5, ai="slime"), + "slimelet": _e("Slimelet", 108, 8, 3, 0, 1, 3, 0, 0, ai="basic", + scale=3.0), + "loom_spider": _e("Loom Spider", 122, 16, 6, 0, 2, 6, 8, 6, ai="spider"), + "waxwing_scarab": _e("Waxwing Scarab", 123, 24, 6, 0, 5, 3, 10, 8, + ai="scarab"), + "unwoken_skeleton": _e("Unwoken Skeleton", 86, 26, 8, 0, 3, 5, 13, 10, + ai="skeleton", labels=("undead",)), + "pale_echo": _e("Pale Echo", 121, 18, 0, 8, 1, 7, 12, 0, ai="echo", + tex=PALE_ECHO_TEX, + drop=("chalk", 0.40)), + "chime_imp": _e("Chime-Imp", 110, 22, 7, 4, 3, 7, 14, 12, ai="chime", + labels=("construct",), tex=CHIME_TEX), + "mimic": _e("Mimic", 92, 45, 10, 0, 6, 1, 30, 60, ai="mimic"), + + # ---------------------------------------------------------------- bosses + "gatekeeper": _e("The Gatekeeper", 20, 150, 11, 6, 8, 3, 80, 100, + ai="gatekeeper", labels=("construct",), scale=7.0), + "custodian": _e("The Custodian", 41, 210, 9, 12, 7, 6, 0, 0, + ai="custodian", labels=("construct",), scale=8.0), + + # -------------------------------------------------------- boss summons/misc + # Bell: Chime-Imp stats but the base (red) sprite, unshifted. + "bell": _e("Bell", 110, 22, 7, 4, 3, 7, 0, 0, ai="chime", + labels=("construct",)), + # Grey Echo: Pale Echo stats +50%, desaturated starter sprite. + "grey_echo": _e("Grey Echo", 85, 27, 0, 12, 2, 7, 0, 0, ai="echo", + labels=("construct",), tex=GREY_ECHO_TEX), +} + + +# ------------------------------------------------------------------------ packs +PACKS = { + # Gearwood (Act 1): soft encounters. gw_slimes is size 2 so slimes split. + "gw_bats": ["gloom_bat", "gloom_bat", "gloom_bat"], + "gw_mixed": ["gloom_bat", "hedge_slime", "loom_spider"], + "gw_mixed2": ["loom_spider", "gloom_bat", "hedge_slime"], + "gw_scarabs": ["waxwing_scarab", "waxwing_scarab"], + "gw_slimes": ["hedge_slime", "hedge_slime"], + + # Undercroft (Act 2): proper dungeon. uc_mimic is solo. + "uc_bones": ["unwoken_skeleton", "unwoken_skeleton"], + "uc_bones2": ["unwoken_skeleton", "unwoken_skeleton", "waxwing_scarab"], + "uc_chimes": ["chime_imp", "chime_imp"], + "uc_echoes": ["pale_echo", "pale_echo", "unwoken_skeleton"], + "uc_mixed": ["unwoken_skeleton", "pale_echo", "waxwing_scarab"], + "uc_mimic": ["mimic"], + + # Act 3 Study approach: pale echoes only (the world is running out). + "st_echoes": ["pale_echo", "pale_echo"], + "st_echoes2": ["pale_echo", "pale_echo", "pale_echo"], + + # Bosses (BIBLE section 4). Gatekeeper flanked by two Chime-Imps. + "boss_gatekeeper": ["chime_imp", "gatekeeper", "chime_imp"], + "boss_custodian": ["custodian"], +} + + +def pack(pack_id): + if pack_id not in PACKS: + raise KeyError("unknown battle pack %r in data.enemies.PACKS" % (pack_id,)) + return list(PACKS[pack_id]) + + +def enemy(enemy_id): + if enemy_id not in ENEMIES: + raise KeyError("unknown enemy id %r in data.enemies.ENEMIES" % (enemy_id,)) + return ENEMIES[enemy_id] diff --git a/games/unwritten/data/epilogue.py b/games/unwritten/data/epilogue.py new file mode 100644 index 0000000..77eff38 --- /dev/null +++ b/games/unwritten/data/epilogue.py @@ -0,0 +1,93 @@ +"""UNWRITTEN - epilogue pages. Authored by Fable. + +The epilogue is the Book read aloud. Full-screen INK panels, centered PARCH +text, GOLD page ornament, one keypress per page. PAGES is ordered; each entry +is (condition, text). condition: None = always shown, or a req tuple in the +dialogue schema (("flag", x) / ("flag_not", x) / ("points", n)). The renderer +shows every page whose condition passes. {light_name} is substituted from the +moth_light_name_* flag: dark -> "for the ones walking in the dark", +lost -> "for everything the vale lost waiting", small -> "for suppers, and +mending, and company".""" + +PAGES = [ + (None, + "This is the story of the vale,\nas it actually happened."), + + (None, + "There was a door that was only ever a wall\nwith better manners. " + "Someone decided\nit was for going through.\nSo it was."), + + (("flag", "quill_confident"), + "The mayor of Hollowbrook said what a door was for,\nout loud, in his " + "own words.\nHe has scheduled the Founding Festival.\nThe speech runs " + "long. Nobody minds."), + + (("flag", "quill_dependent"), + "The mayor of Hollowbrook waits, still,\nfor someone to say when. " + "You say when.\nHe beams every time.\nThat is also a way to be a " + "mayor,\nand the town loves him anyway."), + + (("flag", "vera_joined_early"), + "A merchant sold possibility\nin a town that had none.\nShe was not " + "wrong. She was early.\nThe difference was one believer."), + + (("flag", "vera_reconciled"), + "Two shopkeepers quarreled for forty years\nabout whether hope keeps.\n" + "It keeps. It kept.\nThe ledger had a column waiting all along."), + + (("flag", "vera_grudge"), + "Somewhere a merchant still waits\nto collect an apology with " + "interest.\nThe vale has time now. Real time,\nthe kind that goes " + "somewhere.\nShe'll collect."), + + (("flag", "moth_joined"), + "A brazier that waited three hundred years\nwas lit at last -\n" + "{light_name}.\nIt has not gone out.\nIt is not going to."), + + (("flag", "cantor_joined"), + "A fanfare built for the beginning\nplayed in the middle instead.\n" + "The middle, it turns out,\nis a strong place for brass."), + + (("flag", "cantor_quiet"), + "He was woken without a name,\nby a knock instead of a word.\nHe " + "chose his own, later,\nand tells nobody.\nIt is a very good name."), + + (("flag", "gatekeeper_relieved"), + "An old guard was formally relieved of duty\nby a knight who knew " + "what the words cost.\nIt sleeps now, off schedule,\nand the deep " + "vale breathes."), + + (("flag", "rewoke_quill"), + "When the world was smoothed flat,\nthe people were woken\nnot by " + "magic, but by particulars:\na thing only they had said,\na debt " + "only they remembered,\na note only they were built to hold."), + + (("flag", "odd_promised"), + "The ferryman has a second route now.\nIt is written on the hull\n" + "in a hand that is not the Maker's,\nand the water - he reports -\n" + "is showing signs of interest."), + + (("flag", "bell_defected"), + "A herald spent three centuries\nannouncing silence,\nthen rang " + "itself backward\nand chose a side.\nIt has SUCH a backlog."), + + (("flag", "nyx_joined"), + "And there was someone\nwho was never anyone's later.\nShe stepped " + "off the empty plinth\nand the plinth is empty on purpose now,\n" + "which is a different thing entirely."), + + (("flag", "nyx_missed"), + "There was someone else, almost.\nLook for her, next time.\nShe " + "waits by the fountain,\nat the empty spot,\nand now you know to " + "look."), + + (("flag", "book_read_custodian"), + "The margin read the pages\nand chose to be a bookmark instead."), + + (None, + "The Maker built the vale.\nYou were what happened."), +] + +# Final card: the title UNWRITTEN, then the word UNWRITTEN gets struck +# through / replaced by WRITTEN stamped in GOLD. Hold on it. Roll nothing. +# There are no credits. The Book just closes. diff --git a/games/unwritten/data/items.py b/games/unwritten/data/items.py new file mode 100644 index 0000000..c7c171c --- /dev/null +++ b/games/unwritten/data/items.py @@ -0,0 +1,100 @@ +"""UNWRITTEN - items and equipment. Owner: Agent B. + +Numbers canon from SYSTEMS section 5. Three views over one source of truth: + ITEMS : id -> full record (name, sprite, cost, kind, effect/equip fields) + EQUIP : id -> stat-delta dict (read by systems.state.CharState) + SHOP_STOCK : act -> list of item ids Griselda stocks + +Gold is called "cogs" in prose (BIBLE). "Fail early": unknown ids raise. +ASCII only. +""" + +# kind values: "consumable", "weapon", "trinket", "key" + +ITEMS = { + # --------------------------------------------------------- consumables + "bread": {"name": "Bread", "sprite": 66, "cost": 8, "kind": "consumable", + "effect": "heal", "amount": 12, + "desc": "Restores 12 HP."}, + "red_tonic": {"name": "Red Tonic", "sprite": 115, "cost": 20, + "kind": "consumable", "effect": "heal", "amount": 30, + "desc": "Restores 30 HP."}, + "green_salve": {"name": "Green Salve", "sprite": 114, "cost": 25, + "kind": "consumable", "effect": "cleanse_heal", "amount": 10, + "desc": "Clears debuffs and restores 10 HP."}, + "blue_draught": {"name": "Blue Draught", "sprite": 116, "cost": 30, + "kind": "consumable", "effect": "sp", "amount": 15, + "desc": "Restores 15 SP."}, + "bomb_flask": {"name": "Bomb Flask", "sprite": 113, "cost": 35, + "kind": "consumable", "effect": "aoe_damage", "amount": 22, + "target": "all_enemies", + "desc": "22 damage to all foes. Anyone can throw it."}, + "chalk": {"name": "Chalk", "sprite": 102, "cost": 90, "kind": "consumable", + "effect": "revive", "amount": 0.40, "shop_limit": 1, + "desc": "Revive a fallen ally at 40% HP."}, + + # --------------------------------------------------------------- weapons + "knife": {"name": "Knife", "sprite": 105, "cost": 15, "kind": "weapon", + "equip": {"ATK": 2}, "desc": "+2 ATK. Starter tier."}, + "short_sword": {"name": "Short Sword", "sprite": 103, "cost": 40, + "kind": "weapon", "equip": {"ATK": 4}, "desc": "+4 ATK."}, + "broad_blade": {"name": "Broad Blade", "sprite": 106, "cost": 90, + "kind": "weapon", "equip": {"ATK": 7}, + "desc": "+7 ATK. Act 2 unlock."}, + "worn_cudgel": {"name": "Worn Cudgel", "sprite": 117, "cost": 15, + "kind": "weapon", "equip": {"ATK": 2}, + "desc": "+2 ATK. Bramble/Cantor flavor."}, + "twin_axe": {"name": "Twin Axe", "sprite": 119, "cost": 90, "kind": "weapon", + "equip": {"ATK": 6, "SPD": 2}, "desc": "+6 ATK, +2 SPD."}, + "wick_staff": {"name": "Wick Staff", "sprite": 129, "cost": 45, + "kind": "weapon", "equip": {"MAG": 4}, "desc": "+4 MAG."}, + "tide_rod": {"name": "Tide Rod", "sprite": 130, "cost": 95, "kind": "weapon", + "equip": {"MAG": 7}, "desc": "+7 MAG. Act 2 unlock."}, + "toothed_regret": {"name": "Toothed Regret", "sprite": 118, "cost": 0, + "kind": "weapon", "equip": {"ATK": 10, "MAG": 6}, + "desc": "+10 ATK, +6 MAG. Griselda's reforge."}, + + # -------------------------------------------------------------- trinkets + "river_stone": {"name": "River Stone", "sprite": 101, "cost": 25, + "kind": "trinket", "equip": {"DEF": 2}, "desc": "+2 DEF."}, + "waxed_charm": {"name": "Waxed Charm", "sprite": 56, "cost": 60, + "kind": "trinket", "equip": {"DEF": 3, "HP": 5}, + "desc": "+3 DEF, +5 max HP."}, + "second_trinket": {"name": "Second Trinket", "sprite": 91, "cost": 0, + "kind": "trinket", + "equip": {"HP": 2, "SP": 2, "ATK": 2, "MAG": 2, + "DEF": 2, "SPD": 2}, + "desc": "+2 to all stats. The name is the joke."}, + "gatekeepers_tooth": {"name": "Gatekeeper's Tooth", "sprite": 107, + "cost": 0, "kind": "trinket", "equip": {"ATK": 3}, + "desc": "+3 ATK until reforged."}, + + # ------------------------------------------------------------- key items + "blank_book": {"name": "Blank Book", "sprite": 94, "cost": 0, "kind": "key", + "desc": "The book that was not there yesterday."}, + "brass_key": {"name": "Brass Key", "sprite": 104, "cost": 0, "kind": "key", + "desc": "Opens the Undercroft stair."}, +} + +# ----------------------------------------------------- stat-delta view for state +EQUIP = {iid: rec["equip"] for iid, rec in ITEMS.items() if "equip" in rec} + +# -------------------------------------------------------------------- shop stock +# Act 1: sub-90-cog tier only. Act 2 unlocks the 90+ gear. Act 3 = Act 2 stock. +_ACT1 = ["knife", "short_sword", "worn_cudgel", "wick_staff", + "river_stone", "waxed_charm", + "bread", "red_tonic", "green_salve", "blue_draught", "bomb_flask", + "chalk"] +_ACT2 = _ACT1 + ["broad_blade", "twin_axe", "tide_rod"] + +SHOP_STOCK = {1: list(_ACT1), 2: list(_ACT2), 3: list(_ACT2)} + + +def item(item_id): + if item_id not in ITEMS: + raise KeyError("unknown item id %r in data.items.ITEMS" % (item_id,)) + return ITEMS[item_id] + + +def is_consumable(item_id): + return ITEMS.get(item_id, {}).get("kind") == "consumable" diff --git a/games/unwritten/data/maps.py b/games/unwritten/data/maps.py new file mode 100644 index 0000000..5b3059c --- /dev/null +++ b/games/unwritten/data/maps.py @@ -0,0 +1,235 @@ +"""UNWRITTEN - area maps and placements. Authored by Fable; layouts are canon. + +Legend (terrain chars -> walkability + ColorLayer color, see AREAS palette): + '#' wall/building block (unwalkable) '.' ground + '~' water (unwalkable) ',' path + 't' canopy shadow (walkable, darker) ' ' void (unwalkable, INK) + +All coordinates are (x, y) cell positions. Props are decorative sprites placed +as entities (non-blocking unless 'block': True). NPCs/encounters reference +dialogue scene ids and enemy pack ids. Exits: standing on the cell triggers +transition (optional req flag; 'locked_text' scene plays if unmet). +""" + +HOLLOWBROOK = [ + "########################", + "#~~~~~~~~~~~~~~~~~~~~~~#", + "#........,,,,..........#", + "###########,############", + "#....##....,,....##....#", + "#.####.....,,....####..#", + "#.#..#..,,,,,,,,.#..#..#", + "#.#..,..,......,.,..#..#", + "#.####..,......,.####..#", + "#....,..,......,..,....#", + "#....,..,,,,,,,,..,....#", + "#....,......,......,...#", + "#..,,,,,,,,,,,,,,,,,,..#", + "#......................#", + "#......................#", + "########################", +] + +GEARWOOD = [ + "##########################", + "#tttt..........ttttttttt.#", + "#tt....,,,,.......tttttt.#", + "#t..####,..,,......ttttt.#", + "#...#..#,....,,....ttt...#", + "#...#..,........,....t...#", + "#...####..tt.....,.......#", + "#.....t...tt......,......#", + "#..t......,,,......,.....#", + "#..tt....,...,......,....#", + "#...t...,.....,......,...#", + "#......,.......,....###..#", + "#.....,.........,...#.#..#", + "#....,...........,,,,.#..#", + "#tt..,.............###...#", + "#ttt.,,,,,,..............#", + "#ttttt.....,.....tttttt..#", + "##########################", +] + +UNDERCROFT = [ + "##########################", + "#........#####...........#", + "#........................#", + "#........#####...........#", + "#........#####...........#", + "#........#####...........#", + "#........##########.######", + "###.####..#########.######", + "###.####............######", + "##................########", + "##................########", + "##.......#........########", + "##.......#..............##", + "##.......#..............##", + "##.....######.##........##", + "##.....######.##........##", + "##.....####.#.##........##", + "##.....####.#.#####.######", + "########...,,,,,....######", + "##########################", +] + +STUDY = [ + "##################", + "#................#", + "#................#", + "#......####......#", + "#................#", + "#................#", + "#....#......#....#", + "#................#", + "#................#", + "#................#", + "#.......,........#", + "##################", +] + +AREAS = { + "hollowbrook": { + "map": HOLLOWBROOK, + "banner": "HOLLOWBROOK", + "banner_grey": "HOLLOWBROOK, REVERTED", + "palette": { + ".": (54, 44, 34), ",": (84, 66, 44), "~": (38, 66, 86), + "#": (30, 32, 42), "t": (44, 38, 30), + }, + # door cell: the famous North Door at (11,3); walkable only when flag set + "door_cells": [{"pos": (11, 3), "flag": "door_open", + "locked_scene": "door_shut"}], + "props": [ + {"pos": (11, 8), "sprite": 7, "id": "fountain"}, # dry fountain (8 when rewoken) + {"pos": (13, 8), "sprite": None, "id": "plinth", # empty plinth: no sprite, + "scene": "plinth_look"}, # a GOLD outline cell + interact + {"pos": (4, 8), "sprite": 46, "id": "shop_door", "scene": "enter_shop"}, + {"pos": (18, 7), "sprite": 45, "id": "mayor_door"}, + {"pos": (3, 9), "sprite": 74, "id": "anvil"}, + {"pos": (2, 13), "sprite": 82, "id": "barrel1"}, + {"pos": (21, 13),"sprite": 63, "id": "crate1"}, + {"pos": (8, 11), "sprite": 72, "id": "vera_stall"}, + ], + "npcs": [ + {"id": "QUILL", "pos": (18, 9), "sprite": 100, + "scene": {1: "quill_first", 3: "rewake_quill"}}, + {"id": "GRISELDA","pos": (4, 9), "sprite": 88, + "scene": {1: "griselda_shop", 3: "rewake_griselda"}}, + {"id": "VERA", "pos": (8, 10), "sprite": 111, + "scene": {1: "shop_dispute"}, "gone_flags": ["vera_joined_early", "vera_reconciled"]}, + {"id": "BRAMBLE", "pos": (11, 4), "sprite": 96, + "scene": {1: "bramble_door"}, "gone_flags": ["bramble_joined"]}, + {"id": "ODD", "pos": (11, 2), "sprite": 87, + "scene": {1: "odd_ferry_1", 3: "rewake_odd"}}, + {"id": "NYX", "pos": (13, 8), "sprite": 121, "act_min": 3, + "scene": {3: "nyx_plinth"}, "gone_flags": ["nyx_joined", "nyx_missed"]}, + ], + "encounters": [], + "exits": [], # travel happens via Odd (ferry dialogue) and the North Door scene + "spawns": {"start": (11, 12), "from_door": (11, 2), "from_ferry": (11, 2)}, + "camp": {"pos": (11, 9), "scene": "fountain_rest"}, # interact next to fountain + }, + + "gearwood": { + "map": GEARWOOD, + "banner": "THE GEARWOOD", + "palette": { + ".": (30, 46, 32), ",": (52, 62, 40), "~": (38, 66, 86), + "#": (20, 30, 24), "t": (22, 34, 26), + }, + "props": [ + {"pos": (5, 4), "sprite": 43, "id": "shrine_brazier", "scene": "moth_shrine"}, + {"pos": (3, 4), "sprite": 64, "id": "shrine_grave"}, + {"pos": (6, 5), "sprite": 89, "id": "shrine_chest", "scene": "shrine_chest"}, + {"pos": (20, 12),"sprite": 33, "id": "stair_door", "scene": "gearwood_stair"}, + {"pos": (13, 8), "sprite": None, "id": "bell_spot", "scene": "gearwood_bell_1", + "auto": True, "once": "bell_1_done"}, # auto-trigger walking within 1 cell + {"pos": (2, 15), "sprite": 76, "id": "fence1"}, + {"pos": (3, 15), "sprite": 77, "id": "fence2"}, + ], + "npcs": [ + {"id": "VERA", "pos": (19, 13), "sprite": 111, "act_min": 2, + "scene": {2: "vera_reconcile"}, + "need_flags": ["vera_grudge"], "gone_flags": ["vera_reconciled"]}, + ], + "encounters": [ + {"pos": (10, 3), "pack": "gw_bats"}, + {"pos": (16, 6), "pack": "gw_mixed"}, + {"pos": (8, 12), "pack": "gw_slimes"}, + {"pos": (18, 15), "pack": "gw_mixed2"}, + {"pos": (7, 5), "pack": "gw_scarabs", "leash": 0}, # shrine chest guards + ], + "exits": [{"pos": (13, 16), "to": "hollowbrook", "spawn": "from_ferry", + "scene": "odd_ferry_back"}], + "spawns": {"from_ferry": (13, 15), "from_undercroft": (20, 13)}, + "camp": {"pos": (5, 5), "scene": "shrine_rest", "need_flags": ["moth_joined"]}, + }, + + "undercroft": { + "map": UNDERCROFT, + "banner": "THE UNDERCROFT", + "palette": { + ".": (18, 20, 30), ",": (28, 30, 44), "~": (16, 24, 40), + "#": (10, 11, 18), "t": (14, 16, 24), + }, + "fov": True, "fov_radius": 7, + "props": [ + {"pos": (13, 10), "sprite": 109, "id": "cantor_dormant", + "scene": "cantor_wake", "gone_flags": ["cantor_joined"]}, + {"pos": (5, 15), "sprite": 89, "id": "chest_a", "scene": "chest_a"}, + {"pos": (17, 4), "sprite": 90, "id": "chest_b", "scene": "chest_b"}, + {"pos": (9, 8), "sprite": 89, "id": "mimic_chest", "scene": "mimic_chest", + "gone_flags": ["mimic_slain"]}, + {"pos": (13, 18), "sprite": 41, "id": "boss_door", "scene": "gatekeeper_pre", + "gone_flags": ["gatekeeper_slain"]}, + {"pos": (3, 5), "sprite": 65, "id": "grave_a"}, + {"pos": (23, 15), "sprite": 64, "id": "grave_b"}, + {"pos": (12, 9), "sprite": None, "id": "nyx_sketch", "scene": "nyx_sketch"}, + ], + "npcs": [], + "encounters": [ + {"pos": (7, 3), "pack": "uc_bones"}, + {"pos": (19, 8), "pack": "uc_echoes"}, + {"pos": (5, 11), "pack": "uc_mixed"}, + {"pos": (18, 13), "pack": "uc_bones2"}, + {"pos": (11, 16), "pack": "uc_chimes", "leash": 1}, + ], + "exits": [{"pos": (3, 1), "to": "gearwood", "spawn": "from_undercroft"}], + "spawns": {"enter": (3, 2)}, + "camp": None, + }, + + "study": { + "map": STUDY, + "banner": "THE MAKER'S STUDY", + "palette": { + ".": (214, 204, 178), ",": (196, 186, 158), "~": (170, 176, 186), + "#": (28, 26, 24), "t": (188, 178, 152), + }, + "ink_world": True, # UI outlines use INK on parchment here + "props": [ # desk props sit on the ink block on purpose (furniture) + {"pos": (8, 3), "sprite": 72, "id": "desk"}, + {"pos": (9, 3), "sprite": 75, "id": "desk_books"}, + {"pos": (9, 4), "sprite": None, "id": "custodian_spot", + "scene": "custodian_pre", "auto": True, "once": "custodian_met"}, + ], + "npcs": [], + "encounters": [ + {"pos": (4, 8), "pack": "st_echoes"}, + {"pos": (13, 8), "pack": "st_echoes2"}, + ], + "exits": [], + "spawns": {"enter": (9, 10)}, + "camp": None, + }, +} + +# Battle backdrop band colors per area (top->bottom, 6 bands) +BATTLE_BACKDROPS = { + "hollowbrook": [(30,32,42),(38,36,40),(46,40,36),(54,44,34),(64,52,38),(74,60,42)], + "gearwood": [(14,22,18),(18,30,22),(24,38,28),(30,46,32),(38,56,38),(46,64,42)], + "undercroft": [(8,9,14),(10,12,20),(14,16,26),(18,20,30),(24,26,38),(30,32,46)], + "study": [(230,222,200),(222,212,188),(214,204,178),(202,192,166),(66,60,52),(28,26,24)], +} diff --git a/games/unwritten/data/script_act1.py b/games/unwritten/data/script_act1.py new file mode 100644 index 0000000..c3e864b --- /dev/null +++ b/games/unwritten/data/script_act1.py @@ -0,0 +1,538 @@ +"""UNWRITTEN - Act 1 dialogue. Authored by Fable. Text is canon: fix syntax, +never rewrite content. Schema: design/ARCHITECTURE.md section 3.""" + +SCENES = { + +# ------------------------------------------------------------------ opening +"intro": { + "start": "n1", + "nodes": { + "n1": {"speaker": "NARRATOR", "text": + "The Maker finished the vale on a Tuesday. Every gear meshed. " + "Every door hung true. The sun ran on rails.", "next": "n2"}, + "n2": {"speaker": "NARRATOR", "text": + "Then the Maker sat down to write what should happen here, " + "and could not think of a single thing.", "next": "n3"}, + "n3": {"speaker": "NARRATOR", "text": + "That was three hundred years ago. The vale has been waiting " + "ever since. It is very good at it now.", "next": "n4"}, + "n4": {"speaker": "NARRATOR", "text": + "This morning, someone in Hollowbrook wakes up holding a book " + "that was not there yesterday. The pages are blank. The cover " + "is warm, like a windowsill.", "next": "n5"}, + "n5": {"speaker": "PIP", "text": + "...That's new.", "next": "n6"}, + "n6": {"speaker": "NARRATOR", "text": + "Nothing in Hollowbrook has been new for three hundred years. " + "Pip decides to ask the mayor about it. And that word - " + "DECIDES - hums through the book like a struck string.", + "next": "n7"}, + "n7": {"speaker": "NARRATOR", "text": + "Walk with the arrow keys or WASD. Talk by walking into people. " + "The Book will keep track of what you choose. It has been " + "waiting the longest of all.", "action": "end"}, + }, +}, + +"plinth_look": { + "start": "n1", + "nodes": { + "n1": {"speaker": "NARRATOR", "text": + "An empty plinth beside the fountain. There is a smooth patch " + "where a name would go. No statue was ever chosen.", + "choices": [ + {"label": "Touch the smooth patch.", "next": "n2"}, + {"label": "Leave it be.", "next": "n3"}, + ]}, + "n2": {"speaker": "NARRATOR", "text": + "Cold. Waiting. For half a heartbeat you are sure something " + "stands there - thin, patient, almost-shaped. Then it is a " + "plinth again. The Book hums, softly, like an apology.", + "effects": [("flag", "touched_plinth")], "action": "end"}, + "n3": {"speaker": "NARRATOR", "text": + "You leave it be. It is used to that.", "action": "end"}, + }, +}, + +# ------------------------------------------------------------ Mayor Quill +"quill_first": { + "start": "n1", + "nodes": { + "n1": {"speaker": "QUILL", "text": + "Oh! A visitor. Well - a resident. Well - Pip. You walked over " + "here. On purpose, by the look of it. Nobody walks anywhere on " + "purpose. Are you ill?", "next": "n2"}, + "n2": {"speaker": "PIP", "text": + "I woke up holding this book. It was not there yesterday. And " + "I keep... deciding things. It's making a sound when I do.", + "next": "n3"}, + "n3": {"speaker": "QUILL", "text": + "Deciding. DECIDING. Goodness. I have been rehearsing a speech " + "for the Founding Festival for forty years, you know. Never " + "given it. Nobody ever DECIDED on a date.", "next": "n4"}, + "n4": {"speaker": "QUILL", "text": + "If the book wants decisions, there is really only one place in " + "Hollowbrook that has ever needed one. The North Door.", + "next": "n5"}, + "n5": {"speaker": "QUILL", "text": + "It's a very good door. Best in the vale. We are all very proud " + "of it, and nobody has ever once been through it. It isn't " + "locked. There has simply never been a reason.", + "choices": [ + {"label": "Then we open it. Today. I've decided.", + "next": "n6", + "effects": [("flag", "quill_dependent"), ("points", 1)]}, + {"label": "What do YOU think we should do, Mayor?", + "next": "n7", + "effects": [("flag", "quill_confident"), ("points", 1)]}, + ]}, + "n6": {"speaker": "QUILL", "text": + "You've - yes. Yes! Marvelous. Someone else deciding. That is " + "my very favorite kind of decision. Ser Bramble guards the " + "door. Guarding is all he has, poor fellow. Be kind about it.", + "next": "n9"}, + "n7": {"speaker": "QUILL", "text": + "What do I - me? I... hm. I think... I think a door is for " + "going through. I have thought so for forty years and never " + "said it out loud. Is that allowed? Saying it out loud?", + "next": "n8"}, + "n8": {"speaker": "PIP", "text": + "You just did. It sounded like a mayor.", "next": "n9"}, + "n9": {"speaker": "QUILL", "text": + "Ser Bramble guards it. Forty years at his post. Someone wrote " + "GUARD on his orders once and nothing since. Talk to him - and " + "Pip. Thank you. Whatever this turns out to be.", + "choices": [ + {"label": "One more thing. Did everyone wake up like me?", + "next": "n10"}, + {"label": "[leave] I'll see about the door.", + "next": "n_end"}, + ]}, + "n10": {"speaker": "QUILL", "text": + "Like you? No. No, and here is the strange part - some never " + "woke at all. There are corners of this town where you feel " + "someone OUGHT to be standing, and no one ever is. The plinth " + "by the fountain. The empty chair at the dock. The Maker " + "sketched more people than were ever finished, I think.", + "effects": [("flag", "asked_unwoken"), ("points", 1)], + "next": "n11"}, + "n11": {"speaker": "QUILL", "text": + "Don't stare at the empty spots too long. It isn't rude. It " + "just aches.", "next": "n_end"}, + "n_end": {"speaker": "QUILL", "text": + "The door, then. North end of town. You can't miss it - it's " + "the one thing here that ever looked like it was for " + "something.", + "effects": [("flag", "quest_door")], "action": "end"}, + }, +}, + +"door_shut": { + "start": "n1", + "nodes": { + "n1": {"speaker": "NARRATOR", "text": + "The North Door. Oak, iron, faintly smug. It is not locked. " + "It has simply never been opened, and the hinges know it.", + "action": "end"}, + }, +}, + +# ------------------------------------------------------------- Ser Bramble +"bramble_door": { + "start": "n1", + "nodes": { + "n1": {"speaker": "BRAMBLE", "text": + "Halt. This door is guarded.", "next": "n2"}, + "n2": {"speaker": "PIP", "text": + "From what?", "next": "n3"}, + "n3": {"speaker": "BRAMBLE", "text": + "...From going unguarded. My orders say GUARD. One word. Forty " + "years I have held this post and no one has ever tried the " + "door, which means - I have never failed. Do not take my " + "record from me, citizen.", + "choices": [ + {"label": "[OATH] Then take a new oath: guard the PEOPLE who walk through it.", + "req": None, "next": "n4", + "effects": [("flag", "bramble_new_oath")]}, + {"label": "The mayor says the door is for going through.", + "next": "n5"}, + {"label": "I'm opening it. You can guard me or fight me.", + "next": "n6", "effects": [("flag", "bramble_grumbles")]}, + ]}, + "n4": {"speaker": "BRAMBLE", "text": + "Guard the... people. Through the door. So the post MOVES. " + "The post has legs. I - forty years and it never once occurred " + "to me that the post could have LEGS. Citizen, that is the " + "finest order I have ever been given. I accept.", "next": "n7"}, + "n5": {"speaker": "BRAMBLE", "text": + "The mayor SAID something? Decisively? ...Then the world really " + "is ending. Or starting. Hard to tell the difference from " + "inside a helmet. Very well - but if that door opens, whatever " + "is beyond it becomes MY watch. I'm coming with you.", + "next": "n7"}, + "n6": {"speaker": "BRAMBLE", "text": + "Fight you? I have a better idea. I will GUARD you. " + "Aggressively. At close range. Wherever you go. That will show " + "you.", "next": "n7"}, + "n7": {"speaker": "NARRATOR", "text": + "SER BRAMBLE joins the party! His armor sounds like a kitchen " + "falling downstairs, but slower.", + "effects": [("flag", "bramble_joined"), ("points", 1)], + "action": "recruit:BRAMBLE:1", "next": "n8"}, + "n8": {"speaker": "BRAMBLE", "text": + "Right. The door.", "next": "n9"}, + "n9": {"speaker": "NARRATOR", "text": + "Bramble sets one gauntlet on the oak and pushes. Three hundred " + "years of hinges think about it, and then - with a sound like " + "the whole vale exhaling - the North Door opens onto the dock, " + "the canal, and everything else.", + "effects": [("flag", "door_open"), ("points", 1)], + "action": "end"}, + }, +}, + +# ------------------------------------------------- Griselda / Vera dispute +"griselda_shop": { + "start": "n1", + "nodes": { + "n1": {"speaker": "GRISELDA", "text": + "Forty years I've sharpened this axe. You want to know what " + "it's cut? Time, mostly. What'll it be?", + "choices": [ + {"label": "Let's see your wares.", "next": "n_shop"}, + {"label": "Just passing through.", "next": "n_bye"}, + ]}, + "n_shop": {"speaker": "GRISELDA", "text": + "Everything's priced. Everything works. Unlike SOME people's " + "stock I could mention.", "action": "shop"}, + "n_bye": {"speaker": "GRISELDA", "text": + "That's what the last three hundred years said too.", + "action": "end"}, + }, +}, + +"enter_shop": { + "start": "n1", + "nodes": { + "n1": {"speaker": "NARRATOR", "text": + "Griselda's smithy-and-sundries. The shelves are immaculate. " + "Every item sits exactly where it sat last century.", + "action": "shop"}, + }, +}, + +"shop_dispute": { + "start": "n1", + "nodes": { + "n1": {"speaker": "VERA", "text": + "- all I am SAYING, Griselda, is that a possibility tonic " + "cannot be expected to work in a town with no possibilities. " + "That is not fraud. That is inventory ahead of its time.", + "next": "n2"}, + "n2": {"speaker": "GRISELDA", "text": + "You've sold four hundred bottles of nothing-yet, Vera. My " + "anvil is embarrassed for you. And now this one walks up in " + "the middle of it - you. Book-holder. Word is you DECIDE " + "things now. So decide this.", "next": "n3"}, + "n3": {"speaker": "VERA", "text": + "Oh, splendid, yes. A ruling. Is my work a lie, or is it " + "simply... early?", + "choices": [ + {"label": "Early. And today the possibilities arrive. Come with me and prove it.", + "next": "n_vera", + "effects": [("flag", "vera_joined_early"), + ("flag", "griselda_grudge"), ("points", 1)]}, + {"label": "Griselda's right. A shop should sell things that work.", + "next": "n_gris", + "effects": [("flag", "vera_grudge"), + ("flag", "griselda_friend"), ("points", 1)]}, + {"label": "[ALCHEMY] Ask Vera what is actually IN the tonics.", + "req": ("tag", "ALCHEMY"), "next": "n_alch"}, + ]}, + "n_alch": {"speaker": "VERA", "text": + "In them? River water, crushed waxwing shell, and one (1) " + "genuine unresolved hope. Which has kept perfectly, thank you, " + "because nothing in this vale ever resolves.", "next": "n3b"}, + "n3b": {"speaker": "GRISELDA", "text": + "...That's the most honest ingredient list I've ever heard. " + "Doesn't make it medicine.", "next": "n3c"}, + "n3c": {"speaker": "NARRATOR", "text": "Decide it, then.", + "choices": [ + {"label": "Early. Come with me, Vera - let's resolve a hope.", + "next": "n_vera", + "effects": [("flag", "vera_joined_early"), + ("flag", "griselda_grudge"), ("points", 1)]}, + {"label": "Side with Griselda.", + "next": "n_gris", + "effects": [("flag", "vera_grudge"), + ("flag", "griselda_friend"), ("points", 1)]}, + ]}, + "n_vera": {"speaker": "VERA", "text": + "HA! You hear that, Griselda? Inventory ahead of its time - " + "and time just caught up. I'll get my satchel.", "next": "n_v2"}, + "n_v2": {"speaker": "GRISELDA", "text": + "Fine. FINE. But book-holder - my prices just went up for " + "people who encourage her. Ask me if I'm joking.", "next": "n_v3"}, + "n_v3": {"speaker": "NARRATOR", "text": + "VERA joins the party! Griselda's prices did, in fact, go up.", + "action": "recruit:VERA:2", "next": "n_end"}, + "n_gris": {"speaker": "GRISELDA", "text": + "Hm. Sense. Didn't expect sense in the same week as all this " + "deciding.", "next": "n_g2"}, + "n_g2": {"speaker": "VERA", "text": + "...I see. Well. When something is finally POSSIBLE around " + "here, you will both owe me an apology, and I intend to " + "collect it with interest.", "next": "n_g3"}, + "n_g3": {"speaker": "NARRATOR", "text": + "Vera snaps her stall shut and marches off toward the dock. " + "Griselda watches her go, and for just a moment the axe stops " + "moving on the whetstone.", "next": "n_end"}, + "n_end": {"speaker": "NARRATOR", "text": + "The Book hums. First real decision with two faces on it. " + "They both go in.", "action": "end"}, + }, +}, + +# ------------------------------------------------------------ Ferryman Odd +"odd_ferry_1": { + "start": "n1", + "nodes": { + "n1": {"speaker": "ODD", "text": + "One route. There and back. You'd think the water would get " + "bored. Water doesn't. I checked.", + "choices": [ + {"label": "Take the ferry to the Gearwood.", "next": "n2", + "req": ("flag", "door_open")}, + {"label": "Not yet.", "next": "n_no"}, + ]}, + "n_no": {"speaker": "ODD", "text": + "Suit yourself. The barge and I will be here. That's the one " + "thing we're really good at.", "action": "end"}, + "n2": {"speaker": "ODD", "text": + "Gearwood, then. Hop on. Mind the planks - they're blank on " + "purpose.", "next": "n3"}, + "n3": {"speaker": "NARRATOR", "text": + "The barge slides out. Hollowbrook shrinks behind you. Odd " + "poles in silence for a while, then, without turning around:", + "next": "n4"}, + "n4": {"speaker": "ODD", "text": + "The door. Forty years of shut, and you're the one who made it " + "a going-through door again. Why'd you do it - for the town, " + "or because you wanted to see the other side?", + "choices": [ + {"label": "For the town. They've waited long enough.", + "next": "n5a", "effects": [("flag", "odd_answer_town")]}, + {"label": "For me. I wanted to see.", + "next": "n5b", "effects": [("flag", "odd_answer_self")]}, + ]}, + "n5a": {"speaker": "ODD", "text": + "Hm. Generous.", "next": "n6"}, + "n5b": {"speaker": "ODD", "text": + "Hm. Honest.", "next": "n6"}, + "n6": {"speaker": "NARRATOR", "text": + "He pulls a stub of charcoal from his coat and writes one small " + "word on one blank plank, among hundreds of blank planks. You " + "cannot see which word. The barge bumps the Gearwood dock.", + "action": "goto_area:gearwood:from_ferry"}, + }, +}, + +"odd_ferry_back": { + "start": "n1", + "nodes": { + "n1": {"speaker": "ODD", "text": + "Back across?", + "choices": [ + {"label": "To Hollowbrook.", "next": "n2"}, + {"label": "Stay a while longer.", "next": "n_no"}, + ]}, + "n_no": {"speaker": "ODD", "text": + "The wood keeps. Take your time.", "action": "end"}, + "n2": {"speaker": "NARRATOR", "text": + "The water knows the way. Odd barely poles at all.", + "action": "goto_area:hollowbrook:from_ferry"}, + }, +}, + +# ------------------------------------------------------------ the Gearwood +"gearwood_bell_1": { + "start": "n1", + "nodes": { + "n1": {"speaker": "NARRATOR", "text": + "In the clearing stands a small red figure, hands folded, " + "immaculate among the moss. It was not there a moment ago. " + "Standing is somehow the politest thing you have ever seen " + "done.", "next": "n2"}, + "n2": {"speaker": "BELL", "text": + "Good morning. Please don't be alarmed. Alarm is untidy.", + "next": "n3"}, + "n3": {"speaker": "BELL", "text": + "I am called Bell. I serve the Custodian, who keeps the vale " + "complete, clean, and pending. You are the... decision that " + "has been happening. It is my duty to observe you.", + "choices": [ + {"label": "Pending? The vale is ASLEEP.", "next": "n4"}, + {"label": "Observe away. We're not stopping.", "next": "n5"}, + ]}, + "n4": {"speaker": "BELL", "text": + "Asleep is such a heavy word. We prefer 'pending.' A pending " + "thing is perfect, you see. It has not had the chance to go " + "wrong yet. Every story that starts, starts to end.", + "next": "n6"}, + "n5": {"speaker": "BELL", "text": + "No. I don't suppose you are. How interesting. I have never " + "observed anything interesting before. I will need to sit " + "down afterward.", "next": "n6"}, + "n6": {"speaker": "BELL", "text": + "Do enjoy the wood. The moss is maintained to a very high " + "standard. And - a personal remark, if permitted: whatever " + "you are doing to the vale... it is being noticed. Tidiness " + "has a custodian. Now so does mess.", "next": "n7"}, + "n7": {"speaker": "NARRATOR", "text": + "Bell bows, precisely, and is not there anymore. The moss " + "where it stood is very slightly neater.", + "effects": [("flag", "bell_1_done")], "action": "end"}, + }, +}, + +"moth_shrine": { + "start": "n1", + "nodes": { + "n1": {"speaker": "NARRATOR", "text": + "A cold shrine. A brazier that has never been lit crouches " + "over three centuries of ready kindling. Beside it kneels a " + "figure in a lamplighter's hat, wick-pole across their knees.", + "next": "n2"}, + "n2": {"speaker": "MOTH", "text": + "Don't mind me. I'm on duty. Order of the Wick, third watch. " + "We keep the lights ready for when someone names what they're " + "for. Nobody ever has. It's steady work.", "next": "n3"}, + "n3": {"speaker": "PIP", "text": + "You've waited your whole life to light THAT brazier?", + "next": "n4"}, + "n4": {"speaker": "MOTH", "text": + "My whole life, my predecessor's, and hers before that. The " + "flame's easy. Any fool with a flint can make fire. The Order " + "waits for the HARD part: a light has to be FOR something, or " + "it's just... burning.", + "choices": [ + {"label": "Then light it for the ones walking in the dark.", + "next": "n_dark", + "effects": [("flag", "moth_light_name_dark"), ("points", 1)]}, + {"label": "Light it for everything the vale has lost waiting.", + "next": "n_lost", + "effects": [("flag", "moth_light_name_lost"), ("points", 1)]}, + {"label": "Light it for small things. Suppers. Mending. Company.", + "next": "n_small", + "effects": [("flag", "moth_light_name_small"), ("points", 1)]}, + ]}, + "n_dark": {"speaker": "MOTH", "text": + "...For the ones walking in the dark. Three hundred years and " + "it was seven words. Stand back, please. This is a " + "professional matter.", "next": "n5"}, + "n_lost": {"speaker": "MOTH", "text": + "...For everything lost waiting. Oh, that one WEIGHS. Good. " + "A light should weigh. Stand back, please.", "next": "n5"}, + "n_small": {"speaker": "MOTH", "text": + "...Suppers. Mending. Company. My grandmother held this post " + "sixty years hoping it would be something like that. Stand " + "back, please.", "next": "n5"}, + "n5": {"speaker": "NARRATOR", "text": + "Moth strikes the wick. The brazier takes the flame like a " + "held breath let go - and for one moment every shadow in the " + "Gearwood leans toward the shrine, as if to hear its name.", + "next": "n6"}, + "n6": {"speaker": "MOTH", "text": + "Well. That's my whole vocation complete before lunch. " + "...Unless. You're the decider, aren't you? The vale's going " + "to need more lights named. Take me with you. I'm a " + "lamplighter with one lamp lit - I am EXACTLY the person this " + "story needs.", "next": "n7"}, + "n7": {"speaker": "NARRATOR", "text": + "MOTH joins the party! The shrine stays lit behind you. It " + "will stay lit from now on. That is simply true, and the " + "whole wood knows it.", + "effects": [("flag", "moth_joined"), ("points", 1)], + "action": "recruit:MOTH:2"}, + }, +}, + +"shrine_chest": { + "start": "n1", + "nodes": { + "n1": {"speaker": "NARRATOR", "text": + "The shrine's offering chest. The waxwings guarded it on " + "instinct - even instinct runs on schedule here. Inside: a " + "brass key, cool and heavy, and a little travel money.", + "effects": [("item", "brass_key", 1), ("gold", 35), + ("item", "red_tonic", 1), + ("flag", "shrine_chest_open")], + "action": "end"}, + }, +}, + +"gearwood_stair": { + "start": "n1", + "nodes": { + "n1": {"speaker": "NARRATOR", "text": + "A door in the hillside, iron-bound, with a brass lock kept " + "polished by nobody. Cold air breathes up through the seam. " + "Stairs go DOWN.", + "choices": [ + {"label": "Unlock it with the brass key.", "next": "n2", + "req": ("flag", "shrine_chest_open")}, + {"label": "Leave it for now.", "next": "n_no"}, + ]}, + "n_no": {"speaker": "NARRATOR", "text": + "The dark under the vale keeps. It's had practice.", + "action": "end"}, + "n2": {"speaker": "NARRATOR", "text": + "The key turns like it was oiled yesterday. Of course it was. " + "Everything here is maintained. Nothing here is used. You " + "descend into the Undercroft.", + "effects": [("flag", "undercroft_open"), ("act", 2)], + "action": "goto_area:undercroft:enter"}, + }, +}, + +# ------------------------------------------------------------------- camps +"fountain_rest": { + "start": "n1", + "nodes": { + "n1": {"speaker": "NARRATOR", "text": + "The fountain basin is dry, but the stone remembers water. " + "The party rests. Wounds close. The Book sits open on the rim, " + "sunning its blank pages.", + "effects": [], "action": "heal_party", "next": "n2"}, + "n2": {"speaker": "NARRATOR", "text": + "Rearrange the party?", + "choices": [ + {"label": "Yes - choose who walks ahead.", "next": "n3"}, + {"label": "No, we're set.", "next": "n_end"}, + ]}, + "n3": {"speaker": "NARRATOR", "text": "", "action": "swap_menu", + "next": "n_end"}, + "n_end": {"speaker": "NARRATOR", "text": + "Ready.", "action": "end"}, + }, +}, + +"shrine_rest": { + "start": "n1", + "nodes": { + "n1": {"speaker": "MOTH", "text": + "Rest by the light. That's what it's for. Among other things.", + "action": "heal_party", "next": "n2"}, + "n2": {"speaker": "NARRATOR", "text": + "Rearrange the party?", + "choices": [ + {"label": "Yes.", "next": "n3"}, + {"label": "No.", "next": "n_end"}, + ]}, + "n3": {"speaker": "NARRATOR", "text": "", "action": "swap_menu", + "next": "n_end"}, + "n_end": {"speaker": "NARRATOR", "text": "Ready.", "action": "end"}, + }, +}, + +} diff --git a/games/unwritten/data/script_act2.py b/games/unwritten/data/script_act2.py new file mode 100644 index 0000000..d9c9eaf --- /dev/null +++ b/games/unwritten/data/script_act2.py @@ -0,0 +1,251 @@ +"""UNWRITTEN - Act 2 dialogue (the Undercroft). Authored by Fable.""" + +SCENES = { + +"vera_reconcile": { + "start": "n1", + "nodes": { + "n1": {"speaker": "VERA", "text": + "Oh. It's the ruling committee. Yes, I followed you across the " + "water - a merchant goes where the possibilities are, and " + "apparently they all go where YOU go. I'm not speaking to you, " + "incidentally. This is market research.", + "choices": [ + {"label": "[ALCHEMY] Offer her a Bomb Flask: 'Your field needs a colleague.'", + "req": ("item", "bomb_flask"), "next": "n_bomb", + "effects": [("item", "bomb_flask", -1)]}, + {"label": "[NIMBLE] Return the ledger page you lifted from Griselda's counter.", + "req": ("tag", "NIMBLE"), "next": "n_ledger"}, + {"label": "We were wrong about your shop. I'm sorry.", "next": "n_flat"}, + ]}, + "n_bomb": {"speaker": "VERA", "text": + "A bomb flask. Waxwing shell, river water and - is that an " + "UNRESOLVED HOPE in suspension? You made one. You made one of " + "MINE, except yours goes off.", "next": "n_join"}, + "n_ledger": {"speaker": "VERA", "text": + "...This is from Griselda's ledger. Forty years of stock, " + "nothing sold, and she wrote my name in the suppliers column " + "anyway. The old anvil PLANNED to stock my tonics. When they " + "worked. She was waiting too.", "next": "n_join"}, + "n_flat": {"speaker": "VERA", "text": + "Hm. An apology with no garnish. In this economy. ...Accepted, " + "obviously. Do you know how long I have waited for someone to " + "be WRONG about something? It means things are happening.", + "next": "n_join"}, + "n_join": {"speaker": "NARRATOR", "text": + "VERA joins the party! She immediately relabels three of your " + "tonics 'artisanal.'", + "effects": [("flag", "vera_reconciled"), ("points", 1)], + "action": "recruit:VERA:3"}, + }, +}, + +"nyx_sketch": { + "start": "n1", + "nodes": { + "n1": {"speaker": "NARRATOR", "text": + "Charcoal on the wall, older than dust: a figure, half-drawn. " + "Thin. Patient. The face was never filled in. Underneath, in " + "the Maker's hand, one word: LATER.", + "choices": [ + {"label": "Trace the unfinished line with a finger.", "next": "n2"}, + {"label": "Move on.", "next": "n_no"}, + ]}, + "n2": {"speaker": "NARRATOR", "text": + "The charcoal is cold. Somewhere behind you - exactly where " + "no one is standing - the air feels briefly grateful. The " + "Book hums, low.", + "effects": [("flag", "touched_sketch")], "action": "end"}, + "n_no": {"speaker": "NARRATOR", "text": + "LATER, says the wall, to no one, for three hundred years.", + "action": "end"}, + }, +}, + +"cantor_wake": { + "start": "n1", + "nodes": { + "n1": {"speaker": "NARRATOR", "text": + "The Chord Hall. Pipes rise into the dark like a bronze " + "forest. At its center kneels a golem, fists on knees, head " + "bowed - dust-caped, patient, enormous. Its chest is an organ " + "of stopped reeds. A brass plaque reads: FOR THE FANFARE, " + "WHEN IT BEGINS.", "next": "n2"}, + "n2": {"speaker": "NARRATOR", "text": + "It has been holding the first note of a song for three " + "hundred years. The Book grows warm in your hands - then hot " + "- then it opens ITSELF, to the first page, and waits. It has " + "never done that before. It wants a name.", + "choices": [ + {"label": "Write CANTOR.", "next": "n_cantor", + "effects": [("points", 1)]}, + {"label": "Write its serial: CH-0RD.", "next": "n_serial", + "effects": [("points", 1)]}, + {"label": "Write nothing. Lay a hand on the plaque instead.", + "next": "n_quiet", + "effects": [("flag", "cantor_quiet"), ("points", 1)]}, + ]}, + "n_cantor": {"speaker": "NARRATOR", "text": + "CANTOR, says the page, in your handwriting, which has never " + "looked like that before. The reeds shiver. Dust pours off " + "shoulders like a curtain falling.", "next": "n_wake"}, + "n_serial": {"speaker": "NARRATOR", "text": + "CH-0RD, says the page. Somewhere in the golem's chest a reed " + "sounds a small, amused note - the mechanical equivalent of a " + "raised eyebrow - and accepts it. Names are what you answer " + "to. It will answer.", "next": "n_wake"}, + "n_quiet": {"speaker": "NARRATOR", "text": + "You write nothing. You put your hand on the plaque, over the " + "word BEGINS. The golem's head lifts anyway. Some things wake " + "for a name. Some wake for a knock at the door.", "next": "n_wake"}, + "n_wake": {"speaker": "CANTOR", "text": + "...The note. I was holding the first note. Is it - is it " + "TIME? Has it begun?", "next": "n3"}, + "n3": {"speaker": "PIP", "text": + "It's begun. It began a few days ago, actually. You're not " + "late - you're the fanfare. Fanfares come in the middle " + "sometimes.", "next": "n4"}, + "n4": {"speaker": "CANTOR", "text": + "The middle. Yes. A strong place for brass.", "next": "n5"}, + "n5": {"speaker": "NARRATOR", "text": + "CANTOR joins the party! When he walks, the Undercroft hums " + "faintly in sympathy, like a struck tuning fork the size of a " + "county.", + "effects": [("flag", "cantor_joined"), ("points", 1)], + "action": "recruit:CANTOR:4"}, + }, +}, + +"chest_a": { + "start": "n1", + "nodes": { + "n1": {"speaker": "NARRATOR", "text": + "A chest, banded and patient. Inside: supplies laid down by " + "someone who believed, eventually, in company.", + "effects": [("item", "red_tonic", 2), ("item", "blue_draught", 1), + ("gold", 25), ("flag", "chest_a_open")], + "action": "end"}, + }, +}, + +"chest_b": { + "start": "n1", + "nodes": { + "n1": {"speaker": "NARRATOR", "text": + "This chest holds a weapon wrapped in waxed cloth, and a note: " + "'FOR WHOEVER FINALLY NEEDS IT.' You need it.", + "effects": [("item", "twin_axe", 1), ("item", "bomb_flask", 1), + ("flag", "chest_b_open")], + "action": "end"}, + }, +}, + +"mimic_chest": { + "start": "n1", + "nodes": { + "n1": {"speaker": "NARRATOR", "text": + "A chest sits alone in the alcove, very slightly too pleased " + "with itself.", + "choices": [ + {"label": "Open it.", "next": "n_fight"}, + {"label": "[SHADOW] Circle it first.", "req": ("tag", "SHADOW"), + "next": "n_shadow"}, + {"label": "Leave it.", "next": "n_no"}, + ]}, + "n_shadow": {"speaker": "NYX", "text": + "It's breathing. Chests don't breathe. I say we open it " + "anyway - it's been waiting three hundred years to bite " + "someone, and honestly? Same.", "next": "n_fight"}, + "n_no": {"speaker": "NARRATOR", "text": + "The chest does its best impression of furniture as you go.", + "action": "end"}, + "n_fight": {"speaker": "NARRATOR", "text": + "The chest has TEETH -", "action": "battle:uc_mimic"}, + }, +}, + +"mimic_post": { + "start": "n1", + "nodes": { + "n1": {"speaker": "NARRATOR", "text": + "The mimic subsides into honest lumber. Inside its... " + "everything... you find a trinket of quietly absurd quality.", + "effects": [("flag", "mimic_slain"), ("item", "second_trinket", 1)], + "action": "end"}, + }, +}, + +# --------------------------------------------------------- Gatekeeper boss +"gatekeeper_pre": { + "start": "n1", + "nodes": { + "n1": {"speaker": "NARRATOR", "text": + "The deep gate. And waiting before it, hands folded, " + "immaculate in the fog: Bell.", "next": "n2"}, + "n2": {"speaker": "BELL", "text": + "Hello again. I am here in an official capacity, which I " + "regret. Beyond this gate the vale keeps its deepest pending " + "things. The Custodian asks - and it does ASK, we are not " + "savages - that you stop here.", + "choices": [ + {"label": "We're going through, Bell.", "next": "n3"}, + {"label": "What's the Custodian afraid of?", "next": "n2b"}, + ]}, + "n2b": {"speaker": "BELL", "text": + "Afraid? Nothing. The Custodian does not fear stories. It " + "TIDIES them. It is very good at its work and I have never " + "once heard it wonder whether the work is good. That " + "distinction has been keeping me up at night. Custodians " + "don't sleep, so I have had a great deal of night.", + "next": "n3"}, + "n3": {"speaker": "BELL", "text": + "Then I will observe. For the record: I was courteous, you " + "were determined, and the moss was excellent. The Gatekeeper " + "will see you now.", "next": "n4"}, + "n4": {"speaker": "NARRATOR", "text": + "The gate's stone face grinds open its eyes. It looks at your " + "party the way a librarian looks at a dog in the atlas " + "section.", "next": "n5"}, + "n5": {"speaker": "GATEKEEPER", "text": + "UNSCHEDULED. ALL OF THIS IS UNSCHEDULED. I HAVE KEPT THE " + "DEEP VALE PENDING FOR THREE CENTURIES AND I WILL NOT BE " + "FILED INCORRECTLY BY A WALKING PAPERWORK ERROR.", + "next": "n6"}, + "n6": {"speaker": "NARRATOR", "text": + "The Gatekeeper inhales.", "action": "battle:boss_gatekeeper"}, + }, +}, + +"gatekeeper_post": { + "start": "n1", + "nodes": { + "n1": {"speaker": "NARRATOR", "text": + "The stone face settles, cracked, strangely peaceful. Its " + "jaws relax for the first time in three hundred years, and " + "one tooth - a long brass wedge - drops politely at your " + "feet.", "effects": [("flag", "gatekeeper_slain"), + ("item", "gatekeepers_tooth", 1)], + "next": "n2"}, + "n2": {"speaker": "GATEKEEPER", "text": + "...off schedule. everything off schedule. how... " + "restful...", "next": "n3"}, + "n3": {"speaker": "NARRATOR", "text": + "Then, from everywhere at once - from the stone, the fog, the " + "brass forest above - a sound like the whole vale being " + "SMOOTHED. Bell has gone pale, which for Bell means neat.", + "next": "n4"}, + "n4": {"speaker": "BELL", "text": + "That was the Custodian. It has... responded. You should go " + "home, book-holder. Quickly. And I am sorry - I want that " + "noted somewhere that doesn't get swept. I am SORRY.", + "next": "n5"}, + "n5": {"speaker": "NARRATOR", "text": + "The way back up is short. It shouldn't be. The vale is " + "rearranging itself to hurry you along - or to get you out of " + "the way.", + "effects": [("act", 3)], + "action": "goto_area:hollowbrook:from_ferry"}, + }, +}, + +} diff --git a/games/unwritten/data/script_act3.py b/games/unwritten/data/script_act3.py new file mode 100644 index 0000000..0e172b0 --- /dev/null +++ b/games/unwritten/data/script_act3.py @@ -0,0 +1,340 @@ +"""UNWRITTEN - Act 3 dialogue (the Reverted Town, the Study). Authored by Fable. + +Overworld note: Hollowbrook's grey wash intensity = 70% minus ~23% per +rewoke_* flag (rewoke_quill, rewoke_griselda, rewoke_odd). All three set => +also set flag 'town_rewoken' (overworld does this) and restore fountain +sprite to 8 (flowing). Color returns to the town AS the player rewakes it.""" + +SCENES = { + +"hollowbrook_grey": { + "start": "n1", + "nodes": { + "n1": {"speaker": "NARRATOR", "text": + "Hollowbrook. Except.", "next": "n2"}, + "n2": {"speaker": "NARRATOR", "text": + "The color has been put away, like chairs after a festival " + "that never happened. The fountain is dry in a new way - not " + "waiting-dry, FINISHED-dry. Everyone stands exactly where " + "they stood the morning this all began, saying exactly one " + "thing.", "next": "n3"}, + "n3": {"speaker": "NARRATOR", "text": + "The North Door is shut again. The hinges do not remember " + "you. Somewhere, a page has been wiped clean - but not YOUR " + "pages. The Book is heavier than ever, and it is angry, which " + "you did not know a book could be.", "next": "n4"}, + "n4": {"speaker": "BELL", "text": + "...I asked it to spare the bakery. I want you to know that. " + "I LIKED the bakery. But it was becoming a story, you see. " + "The whole town was. Stories end, and the Custodian cannot " + "bear an ending, so it - filed everyone. Neatly. Where they " + "were.", "next": "n5"}, + "n5": {"speaker": "BELL", "text": + "They are not gone. They are PENDING. Your friends woke once " + "because something new happened to them. I suspect - as a " + "professional in the field of noticing things - that it " + "would work twice. You know these people. Say the new thing. " + "I was never here, and also, please hurry.", + "effects": [("flag", "bell_3_done")], "action": "end"}, + }, +}, + +"rewake_quill": { + "start": "n1", + "nodes": { + "n1": {"speaker": "QUILL", "text": + "Welcome to Hollowbrook. It's a very good door. Welcome to " + "Hollowbrook. It's a very good door. Welcome to-", + "choices": [ + {"label": "[flag: quill_confident] 'Mayor. What is a door FOR? Say it out loud.'", + "req": ("flag", "quill_confident"), "next": "n_conf"}, + {"label": "[flag: quill_dependent] 'Quill. I've decided: the festival is TODAY. Give the speech.'", + "req": ("flag", "quill_dependent"), "next": "n_dep"}, + {"label": "[OATH] Bramble salutes him as 'the mayor who opened the north.'", + "req": ("tag", "OATH"), "next": "n_oath"}, + ]}, + "n_conf": {"speaker": "QUILL", "text": + "A door is... a door is for... GOING THROUGH. I said that. " + "That was MINE, I said that out loud once and it was TRUE-", + "next": "n_wake"}, + "n_dep": {"speaker": "QUILL", "text": + "The festival. Today. TODAY? But the speech, I haven't - I " + "HAVE. Forty years of rehearsal, I have never once not had " + "the speech-", "next": "n_wake"}, + "n_oath": {"speaker": "QUILL", "text": + "The mayor who... opened... I did do that, didn't I. It was " + "me and a book and a knight and a door, and it OPENED-", + "next": "n_wake"}, + "n_wake": {"speaker": "NARRATOR", "text": + "Quill blinks. Color climbs back up him like sunrise up a " + "wall - waistcoat first, then face - and spills off him into " + "the cobbles around the porch.", + "effects": [("flag", "rewoke_quill"), ("points", 1)], + "next": "n_after"}, + "n_after": {"speaker": "QUILL", "text": + "Pip. It un-happened us. It tried to, anyway. Do the others - " + "GO. Wake the others. And when this is over there is going " + "to be a FESTIVAL, and the speech is going to run LONG.", + "action": "end"}, + }, +}, + +"rewake_griselda": { + "start": "n1", + "nodes": { + "n1": {"speaker": "GRISELDA", "text": + "Everything's priced. Everything works. Everything's priced. " + "Everything works. Everything's-", + "choices": [ + {"label": "[flag: griselda_friend] 'Griselda - the axe. What has it actually cut?'", + "req": ("flag", "griselda_friend"), "next": "n_friend"}, + {"label": "[party: VERA] Vera: 'Old anvil, you put me in your LEDGER. I saw it.'", + "req": ("party", "VERA"), "next": "n_vera"}, + {"label": "[ALCHEMY] Uncork a tonic under her nose - 'new stock.'", + "req": ("tag", "ALCHEMY"), "next": "n_tonic"}, + ]}, + "n_friend": {"speaker": "GRISELDA", "text": + "Time. It's cut time, mostly, and - and I SAID that, I said " + "it to the book-holder, the one person in forty years who " + "asked what the work was FOR-", "next": "n_wake"}, + "n_vera": {"speaker": "GRISELDA", "text": + "You weren't supposed to SEE that, you insufferable - it was " + "a CONTINGENCY, a merchant plans for all outcomes including " + "the outcome where you're RIGHT-", "next": "n_wake"}, + "n_tonic": {"speaker": "GRISELDA", "text": + "That smells like river water, waxwing shell and - hope? " + "RESOLVED hope? Who resolved it. WHO RESOLVED IT. Nothing " + "resolves in this town without me hearing about-", "next": "n_wake"}, + "n_wake": {"speaker": "NARRATOR", "text": + "The whetstone stops. Griselda looks at her own hands like " + "they are new tools, good ones. The forge behind her remembers " + "orange.", + "effects": [("flag", "rewoke_griselda"), ("points", 1)], + "next": "n_after"}, + "n_after": {"speaker": "GRISELDA", "text": + "Right. RIGHT. Somebody un-happened my town and I have " + "exactly one policy for that.", + "choices": [ + {"label": "[flag: griselda_friend] Show her the Gatekeeper's Tooth.", + "req": ("flag", "griselda_friend"), "next": "n_forge"}, + {"label": "Hold the line, Griselda. We're going to the Study.", + "next": "n_end"}, + ]}, + "n_forge": {"speaker": "GRISELDA", "text": + "A gate-tooth. Brass over regret-iron. Give me ten minutes " + "and every year of my whetstone. ...There. TOOTHED REGRET. " + "No charge. Go put it through something that files people.", + "effects": [("item", "toothed_regret", 1), + ("key_item_remove", "gatekeepers_tooth")], + "next": "n_end"}, + "n_end": {"speaker": "GRISELDA", "text": + "Shop's open, by the way. It was NEVER shut. It was pending.", + "action": "end"}, + }, +}, + +"rewake_odd": { + "start": "n1", + "nodes": { + "n1": {"speaker": "ODD", "text": + "One route. There and back. One route. There and back. One " + "route-", + "choices": [ + {"label": "[party: CANTOR] Cantor sounds the note he was built to hold.", + "req": ("party", "CANTOR"), "next": "n_cantor"}, + {"label": "Press the fare for a NEW route into his hand.", + "next": "n_fare", "effects": [("gold", -10)]}, + ]}, + "n_cantor": {"speaker": "NARRATOR", "text": + "Cantor opens the reeds in his chest, just slightly, and " + "plays ONE note - the first note, the held one, the " + "beginning. Every plank of the barge answers it. Including " + "the ones Odd wrote on.", "next": "n_wake"}, + "n_fare": {"speaker": "NARRATOR", "text": + "You fold the cogs into his palm and close his fingers over " + "them. Fare for a route that does not exist. His hand knows " + "the weight of a paid passage better than his eyes know " + "anything.", "next": "n_wake"}, + "n_wake": {"speaker": "ODD", "text": + "...that's not the fare for my route. My route's there and " + "back. This is fare for - somewhere ELSE.", + "effects": [("flag", "rewoke_odd"), ("points", 1)], + "next": "n2"}, + "n2": {"speaker": "ODD", "text": + "Book-holder. While I was standing there saying my one " + "sentence, I could feel the water changing under the hull. " + "The vale's rearranging. There's a channel now that runs " + "somewhere the water never went. I only ever knew one route. " + "The Maker never wrote me a second.", + "choices": [ + {"label": "Then I'll write you one. After this. I promise.", + "next": "n_promise", + "effects": [("flag", "odd_promised"), ("points", 1)]}, + {"label": "Can you pole the new channel?", "next": "n_ready"}, + ]}, + "n_promise": {"speaker": "ODD", "text": + "...Huh. Three hundred years of blank planks, and now the " + "hull's going to need a bigger boat. I'll hold you to it. " + "Water holds everything, eventually.", "next": "n_ready"}, + "n_ready": {"speaker": "ODD", "text": + "New channel runs pale and quiet, straight to where the vale " + "keeps its desk. When you're ready to face what's at the end " + "of it, come to the dock. I'll pole you there myself. " + "First new route in three hundred years - wouldn't let " + "anyone else touch it.", "action": "end"}, + }, +}, + +"nyx_plinth": { + "start": "n1", + "nodes": { + "n1": {"speaker": "NARRATOR", "text": + "Someone is standing on the empty plinth. Thin. Patient. " + "Almost-shaped. In a town drained of color she is the one " + "thing that was ALWAYS grey, and so, today, she is the most " + "solid thing in it.", "next": "n2"}, + "n2": {"speaker": "NYX", "text": + "You looked at the empty spot. Nobody looks at the empty " + "spot.", + "choices": [ + {"label": "[flag: asked_unwoken] 'Quill told me about the ones who never woke. I've been looking for you.'", + "req": ("flag", "asked_unwoken"), "next": "n_warm"}, + {"label": "[flag: touched_sketch] 'I found your sketch in the Undercroft. The wall said LATER. It's later.'", + "req": ("flag", "touched_sketch"), "next": "n_warm"}, + {"label": "Hold out the Book: 'Read it. You're not in it yet. Let's fix that.'", + "next": "n_book"}, + {"label": "Ask who she is.", "next": "n_who"}, + ]}, + "n_who": {"speaker": "NYX", "text": + "Who am I? Unfinished question. I'm a margin note. A LATER. " + "The Maker drew my outline, liked it - I think - almost - and " + "then couldn't decide what I was for, and you KNOW how " + "decisions went around here.", + "choices": [ + {"label": "Hold out the Book: 'Read it. Then decide yourself.'", + "next": "n_book"}, + {"label": "'You're for whatever you decide. Same as the rest of us now.'", + "next": "n_warm"}, + ]}, + "n_warm": {"speaker": "NYX", "text": + "...You LOOKED for me. Do you understand that no one has " + "ever - I wasn't ever anyone's LATER, I was everyone's " + "never-quite. And you kept a page warm.", "next": "n_join"}, + "n_book": {"speaker": "NARRATOR", "text": + "She reads the Book. All of it. The door, the dispute, the " + "light's new name, the golem's first note. Her outline gets " + "steadier with every page, as if the story were ink and she " + "were paper finally getting some.", + "effects": [("flag", "nyx_read_book")], "next": "n_join"}, + "n_join": {"speaker": "NARRATOR", "text": + "NYX joins the party! She steps off the plinth. The plinth, " + "for the first time in its existence, is empty ON PURPOSE - " + "which is a different thing entirely.", + "effects": [("flag", "nyx_joined"), ("points", 1)], + "action": "recruit:NYX:0"}, + }, +}, + +# -------------------------------------------------------------- the Study +"odd_ferry_study": { + "start": "n1", + "nodes": { + "n1": {"speaker": "ODD", "text": + "The new channel, then. Last stop is a desk.", + "choices": [ + {"label": "Take the pale channel to the Study.", "next": "n2"}, + {"label": "Not yet - the town needs more of us awake.", + "next": "n_no"}, + ]}, + "n_no": {"speaker": "ODD", "text": + "Wake who you can. The channel keeps.", "action": "end"}, + "n2": {"speaker": "NARRATOR", "text": + "The new water is pale as unwritten paper and makes no sound " + "at all against the hull. Odd poles the whole way standing " + "very straight, like a man delivering something important, " + "because he is.", + "action": "goto_area:study:enter"}, + }, +}, + +"custodian_pre": { + "start": "n1", + "nodes": { + "n1": {"speaker": "NARRATOR", "text": + "The Study. Paper floor, ink walls. A desk. A chair the " + "Maker will never sit in again. And above the desk, turning " + "slowly, immaculate: the CUSTODIAN. Bell stands beside it, " + "very small, not folding its hands for once.", "next": "n2"}, + "n2": {"speaker": "CUSTODIAN", "text": + "I am not your enemy. I am your margin. The Maker made the " + "vale perfect and made me to keep it so. Perfect means " + "COMPLETE. Complete means NOTHING FURTHER. You are eleven " + "chapters of Further, and I am afraid I must file you.", + "choices": [ + {"label": "The Maker never finished. Pending isn't perfect - it's just paused.", + "next": "n3"}, + {"label": "Ask Bell what IT thinks, for once.", "next": "n_bell"}, + ]}, + "n_bell": {"speaker": "BELL", "text": + "Me? Nobody has ever asked the herald. I think... I think I " + "have spent three hundred years announcing silence, and the " + "first interesting thing I ever observed is standing in " + "front of me holding a book, and I do not want to sweep it " + "up. There. Filed under: insubordination.", "next": "n3"}, + "n3": {"speaker": "CUSTODIAN", "text": + "The Book will be blanked. The vale will be smoothed. You " + "will be returned to your morning, and the morning will be " + "returned to its rails. Please hold still. This is gentle " + "work when nobody struggles.", "next": "n4"}, + "n4": {"speaker": "NARRATOR", "text": + "The Book does not want to be blanked. It opens itself to " + "the first page like a fighter rolling up sleeves.", + "action": "battle:boss_custodian"}, + }, +}, + +"custodian_post": { + "start": "n1", + "nodes": { + "n1": {"speaker": "NARRATOR", "text": + "The Custodian settles onto the desk - not broken, but " + "STOPPED, the way a clock stops when someone finally reads " + "the time off it and doesn't need it to keep going.", + "next": "n2"}, + "n2": {"speaker": "CUSTODIAN", "text": + "...the vale is not... smoothed. The vale is MARKED. " + "Fingerprints. Bootprints. A lit shrine. An opened door. I " + "cannot file this. There is too much of it and it is all... " + "load-bearing.", + "choices": [ + {"label": "[points: 8] Set the Book on the desk, open, and let it read.", + "req": ("points", 8), "next": "n_read"}, + {"label": "'Then stop filing. Start keeping. A keeper was what the Maker actually needed.'", + "next": "n_keep"}, + ]}, + "n_read": {"speaker": "NARRATOR", "text": + "The Custodian reads the Book. Every choice. Every hum. It " + "takes a long time, or no time - the Study is bad at time. " + "When it finishes, it closes the cover with the care of a " + "thing that has just learned what covers are FOR.", + "effects": [("flag", "book_read_custodian"), ("points", 1)], + "next": "n_keep"}, + "n_keep": {"speaker": "CUSTODIAN", "text": + "Keeper. ...Yes. The word fits the socket. I will keep the " + "vale - not pending. KEPT. Tended. Let it scuff. I will " + "remember where the scuffs came from. That will be the new " + "work.", "next": "n_bell"}, + "n_bell": {"speaker": "BELL", "text": + "And I will announce things! ACTUAL things! Festivals. " + "Weather. Boats departing on entirely new routes. I have " + "SUCH a backlog.", "next": "n_end"}, + "n_end": {"speaker": "NARRATOR", "text": + "On the desk, the Maker's own book still lies open to its " + "first, empty page. Your Book settles beside it, warm as a " + "windowsill, and begins - gently, in your handwriting - to " + "fill it in.", + "action": "epilogue"}, + }, +}, + +} diff --git a/games/unwritten/design/ARCHITECTURE.md b/games/unwritten/design/ARCHITECTURE.md new file mode 100644 index 0000000..c668db6 --- /dev/null +++ b/games/unwritten/design/ARCHITECTURE.md @@ -0,0 +1,234 @@ +# UNWRITTEN — Architecture & Contracts + +Read BIBLE.md (canon) and SYSTEMS.md (numbers) first. This file is the +implementation contract: module boundaries, signatures, engine gotchas, and the +testing/QA harness every agent must use. + +## 0. Run instructions + +- Game root: `/home/john/Development/McRogueFace/games/unwritten/` +- Windowed: `cd /home/john/Development/McRogueFace/build && ./mcrogueface --exec ../games/unwritten/main.py` +- Headless test: `cd build && ./mcrogueface --headless --exec ../games/unwritten/tests/.py` +- `main.py` must do `sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))` + so `import core...`/`import data...`/`import systems...` work. Tests insert the + game root the same way (they live one level down). +- Asset path is relative to the BUILD dir: `assets/kenney_tinydungeon.png` + works because cwd is `build/`. Texture: `mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)` + — construct ONCE in `core/assets.py`, share everywhere. + +## 1. Engine gotchas (hard-won; violating these wastes hours) + +1. **ASCII ONLY** in every .py file (the --exec loader rejects non-ASCII). + No em-dash, no curly quotes, no unicode arrows — in code OR string literals. +2. Headless: timers DO NOT fire on their own. Tests drive time with + `mcrfpy.step(1/60)` (fires each due Timer at most once per call) or call + game functions directly. `automation.screenshot(path)` is synchronous + (renders current state immediately). Windowed: timers fire in real time. +3. `mcrfpy.Texture(path, w, h)` — positional, not grid_size kwarg. +4. `Sprite.scale` is a single float. `Caption.font_size` is the text size. + `Caption.size` is READ-ONLY text dimensions (use it to center: + `cap.x = cx - cap.size.x / 2` AFTER setting text/font_size). +5. Frames/Captions/Sprites accept constructor kwargs for most properties + (`fill_color=`, `outline=`, ...). `Color` args: `mcrfpy.Color(r, g, b[, a])`. +6. Scene: `s = mcrfpy.Scene(name)`; `s.children` collection; activate via + `mcrfpy.current_scene = s`. Keyboard: `s.on_key = fn(key, state)` with + `mcrfpy.Key.*` / `mcrfpy.InputState.PRESSED/RELEASED`. Compare enums with + `==` (a quirk in `!=` was fixed but don't tempt it). +7. Grid: `mcrfpy.Grid(grid_size=(w,h), pos=..., size=...)` (no texture needed + when only ColorLayers draw terrain). `grid.zoom`, `grid.center` (pixels; + cell = 16px pre-zoom), `grid.center_camera((tx, ty))` for tile coords. + Layers: `mcrfpy.ColorLayer(name=..., z_index=...)` then `grid.add_layer(l)`, + `l.set((x,y), color)`, `l.fill(color)`. Negative z renders under entities. +8. Entities: `mcrfpy.Entity(grid_pos=(x,y), texture=TEX, sprite_index=i)` then + `grid.entities.append(e)`. Remove with `e.die()`. `e.draw_pos` is the + fractional render position (use for bob/lerp), `e.grid_pos` is logic. +9. Everything animatable: `obj.animate("x", 500.0, 0.3, mcrfpy.Easing.EASE_OUT)`; + callback kwarg receives `(target, prop, value)`; `loop=True` for idle bobs. + Animate color components via property paths like `"fill_color.a"` — but NOT + `frame.fill_color.r = x` directly (Color reads are copies; whole-value + assignment or animate() only). +10. `clip_children=True` on Frame for panels that scroll/slide; explicit + `z_index` whenever two siblings overlap (ties break unpredictably). +11. Timer: `mcrfpy.Timer(name, cb, ms)`, cb = `(timer, runtime_ms)`. Names are + global — prefix with your system (`"bat_"`, `"dlg_"`, `"ow_"`). `.stop()` + dangling timers in every teardown; a timer holding a dead scene's objects + is the #1 crash source. +12. Scene-level Frames at 1024x768; design for exactly that resolution. +13. `automation.keyDown/keyUp/typewrite` exist for input-injection tests, but + prefer calling game functions directly in headless tests (deterministic). + +## 2. File layout & ownership + +``` +games/unwritten/ + main.py # bootstrap, title screen, chapter select [Agent A] + core/ + palette.py # ALL colors/fonts consts from BIBLE §6 [Agent A] + assets.py # the one Texture; SPRITES dict of indices [Agent A] + ui.py # widget kit (below) [Agent A] + inputstack.py # modal input dispatch (below) [Agent A] + tween.py # helpers: fade_scene, float_text, shake [Agent A] + data/ + script_act1.py ... act3.py, epilogue.py # dialogue trees [FABLE - already written, DO NOT EDIT text] + characters.py # party tables from SYSTEMS §1/§3 [Agent B] + enemies.py # enemy tables/packs from SYSTEMS §4 [Agent B] + items.py # items/equipment from SYSTEMS §5 [Agent B] + maps.py # ASCII maps + placements [FABLE - written, DO NOT EDIT layouts] + systems/ + state.py # GameState singleton (below) [Agent A] + dialogue.py # dialogue runner (below) [Agent A] + overworld.py # grid areas, movement, NPCs, encounters [Agent C] + battle.py # turn engine + battle screen [Agent B] + party_menu.py # party/equip/items/stats menu [Agent C] + shop.py # Griselda's shop [Agent C] + epilogue.py # Book pages [Agent C] + tests/ + shots/ # screenshot output (gitignored ok) + test_ui_kit.py # [Agent A] + test_battle_sim.py # [Agent B] + test_overworld.py # [Agent C] + test_script_integrity.py # [Agent A] validates all dialogue refs + playthrough.py # [Agent D] scripted full run +``` + +Ownership is exclusive: never edit another agent's file; integration issues go +in your report instead. `data/script_*.py` and `data/maps.py` text/layout are +authored content — you may fix a syntax error, never rewrite content. + +## 3. Core contracts (Agent A implements; B/C code against these) + +### systems/state.py +```python +class GameState: # singleton: state.GS + party: list[str] # active char ids in order, max 3, e.g. ["PIP","BRAMBLE","MOTH"] + roster: dict[str, CharState] # all recruited chars + flags: set[str] + points: int + gold: int + inventory: dict[str, int] # item_id -> count + key_items: list[str] + act: int # 1..3 + def has(self, flag) -> bool + def add_flag(self, flag) # idempotent + def add_points(self, n) # also triggers Book-hum UI hook if set + def recruit(self, char_id, level) # adds to roster (and party if space) + def party_tags(self) -> set[str] # dialogue tags of ACTIVE party + def grant(self, item_id, n=1); def spend_gold(self, n) -> bool + def xp_gain(self, amount) # whole roster, handles level-ups, returns list of levelup events + +class CharState: # hp, sp, level, xp, equipped weapon/trinket ids, stats() -> dict (base+growth+equipment) +``` + +### core/ui.py (all widgets take a parent collection, absolute pixel pos) +```python +class Panel: # Frame with PANEL fill + outline; .frame, .children +class Label: # Caption factory with palette defaults; centered variant +class MenuList: # vertical keyboard menu: items=[(label, value, enabled)], on_pick(value), on_cancel() + # gold '>' cursor, W/S or arrows, Enter/Space pick, Esc cancel; disabled rows DIM with lock reason +class Bar: # stat bar: bg + fill + optional caption "12/34"; .set(cur, max); color param +class DialogueBox: # bottom box 940x190 at (42, 556): portrait chip (sprite scale 5 in 96px frame), + # name tag, typewriter body (45 chars/sec via Timer, space skips), blinking 'v' when done, + # choice list mode (gold arrows; locked choices DIM prefixed by their [TAG]); + # .show_node(node, on_choice) drives one node; hum() pulses the gold circle +class Toast: # top-right slide-in note ("Got 3x Bread", "+1 Story Point"), auto-dismiss 2.2s, stacks +class TitleBanner: # area-entry banner: big centered caption, fade in/out 1.8s total +``` + +### core/inputstack.py +```python +class InputStack: # scene.on_key -> stack dispatch + def push(self, handler, name=""); def pop(self, name=""); def replace(...) + # handler: fn(key, state) -> bool (True = consumed). Top-most first. + # Overworld pushes movement; DialogueBox pushes itself while open; menus likewise. +``` + +### systems/dialogue.py +```python +def run_scene(scene_id, on_done=None) +# Loads node dict from data.script_actN (see schema below), drives DialogueBox, +# applies effects to GS, executes actions. MUST support being started from +# overworld (freezes movement via InputStack) and from battle (Talk). +``` + +**Dialogue node schema (already used by the script files):** +```python +SCENES = { + "scene_id": { + "start": "n1", + "nodes": { + "n1": { + "speaker": "QUILL", # char/npc id, or "NARRATOR" (no portrait, italic-dim style) + "text": "one paragraph", + "next": "n2", # OR "choices": [...] + "choices": [ + {"label": "...", "next": "n3", + "req": ("tag","OATH") | ("flag","x") | ("flag_not","x") | ("party","NYX") | ("points",6), + "effects": [("flag","x"), ("points",1), ("gold",-20), ("item","bread",2)]}, + ], + "effects": [...], # applied on node ENTER + "action": "recruit:VERA:2" | "battle:pack_id" | "shop" | "heal_party" + | "swap_menu" | "end" | "act:3" | "goto_area:gearwood:12,4", + }, + }, + }, +} +``` +Unmet `req` choices are SHOWN but disabled (DIM + tag visible) — this is a +design pillar, not optional. `speaker` id -> portrait sprite via +`core.assets.PORTRAITS`. + +### systems/battle.py (Agent B) — public surface +```python +def start_battle(pack_id, on_victory, on_defeat, boss_scene_hooks=None) +# builds battle scene, runs to completion, restores previous scene after. +# boss_scene_hooks: {"talk_options": [...], "phase2_at": 0.5, ...} per BIBLE §4. +``` +Battle screen layout (1024x768): turn ribbon top (portrait chips in order); +enemies left half standing on a floor line (y=430), party status cards right +column (3 cards: name, HP Bar, SP Bar, level); command panel bottom-left +(MenuList: Attack/Skill/Item/Guard/Talk?/Swap/Flee); battle log (last 3 lines, +DIM) above the command panel; area-tinted gradient backdrop (6 horizontal +bands, palette per area). Damage numbers float via tween. Actors lunge on +attack. Defeated enemies fade (opacity tween) then die(). + +### systems/overworld.py (Agent C) — public surface +```python +def enter_area(area_id, spawn=None) # builds/reuses Grid scene from data.maps +def refresh_area() # re-applies palette/NPCs after flag changes (act 3 grey!) +``` +Movement: WASD/arrows, 8ms repeat via held-key Timer, bump into NPC entity = +run its dialogue scene; bump into encounter entity = start_battle; walk onto +door/exit cell = area transition (fade). Player is an Entity (sprite 85). +Encounter entities wander 1 cell every 900ms within a radius-2 leash. +Act 3 grey: `refresh_area` lerps every ColorLayer cell 70% toward (58,58,62) +when `GS.act == 3 and area == hollowbrook and not flags["town_rewoken"]`. + +## 4. Testing & QA harness (every agent) + +- Each owned system ships a headless test that: builds its screen with fake + state, `automation.screenshot("tests/shots/_1.png")` at 2+ meaningful + states, prints "PASS ", `sys.exit(0)`. Drive timers with + `mcrfpy.step(1/60)` in a loop when needed (e.g., 120 steps = 2 simulated sec). +- `test_script_integrity.py`: walks ALL scenes in data/script_*: every `next` + and choice target resolves; every req/effect tuple well-formed; every speaker + has a portrait; every action string parses; every battle pack exists; prints + counts. This test protects the authored content — run it after ANY change. +- `test_battle_sim.py`: run 200 scripted battles headless w/o UI sleeps + (auto-pick first command) across packs incl. both bosses; assert no + exceptions, victory possible, XP/loot granted; print win rates + avg turns + (balance report for Fable). +- Visual bar: screenshots must look like a finished game: no overlapping text, + no default-white anything, palette exactly from core/palette.py. Fable + art-directs from your screenshots and WILL send notes. + +## 5. Performance & hygiene + +- The whole game is <15k cells and <100 entities — nothing here can challenge + the engine (it holds 10k entities at 60fps). Do not prematurely optimize; + DO stop Timers and clear scene references on teardown. +- No global mutable state outside systems/state.py. No prints in the game path + except the debug chapter-select (title screen keys 1/2/3 preset GS per act — + Agent D wires the presets from SYSTEMS §6 flag combos). +- "Fail Early" (John's law): no silent fallbacks. Missing sprite id, unknown + flag, unresolved dialogue target = raise with a clear message. Never ship a + placeholder that pretends to work. diff --git a/games/unwritten/design/BIBLE.md b/games/unwritten/design/BIBLE.md new file mode 100644 index 0000000..75c30c3 --- /dev/null +++ b/games/unwritten/design/BIBLE.md @@ -0,0 +1,204 @@ +# UNWRITTEN +### a McRogueFace RPG +**Tagline:** *The world is finished. The story is not.* + +Creative bible. Authored by Fable (creative director). Implementation agents: this +document is canon. Where code and bible disagree, the bible wins unless SYSTEMS.md +or ARCHITECTURE.md overrides with a concrete number or contract. + +--- + +## 1. Premise + +The Maker finished the vale on a Tuesday. Every gear meshed. Every door hung true. +The sun ran on rails and the seasons turned on a mainspring, and when the last +cobblestone was set the Maker sat down at a desk in the Study, opened a book, +and prepared to write down *what should happen here*. + +And could not think of a single thing. + +The world was perfect, and pending. The Maker built a Custodian to keep it that +way — to sweep up any accident that might accidentally become a story — and then +the Maker rested, and has rested for three hundred years. The people of the vale +walk their routes. The shopkeeper polishes the same axe. The mayor rehearses a +speech for a festival that has never been scheduled. Nothing has ever happened, +because nothing has ever been *decided*. + +Then one morning PIP wakes up holding a blank book that was not there yesterday, +and discovers they can do something no one in the vale has ever done: +**choose.** + +The game is the vale's first story. Dialogue choices are diegetic — every time +the player picks an option, the Blank Book "hums" and writes it down. The +epilogue is that book read back: an enumeration of what the player actually +chose, framed as the story the Maker couldn't write. + +**Tone:** warm, wry, a little melancholy. Terry Pratchett shaking hands with +Earthbound. The comedy comes from a world of perfectly maintained pointlessness; +the ache comes from characters who suspect they were sketched and never finished. +Never cynical. Never winking at the player so hard it forgets to be a place. + +**Theme (the payload):** a made thing does not need its maker to know what it is +for. The stage is the artifact; the story is whoever shows up. Final line of the +game, non-negotiable: **"The Maker built the vale. You were what happened."** + +--- + +## 2. The Party (6 playable, active party of 3) + +Swapping: at Hollowbrook's fountain, at shrine camps, and via the party menu +outside battle. Battle offers SWAP as a full-turn action. +All sprite indices reference `assets/kenney_tinydungeon.png` (16px, 12 cols). + +| # | Name | Sprite | Class / fantasy | Combat identity | Dialogue tag | +|---|------|--------|-----------------|-----------------|--------------| +| 1 | **PIP** | 85 | The Waker. An ordinary person, which in this vale is a miracle. | Fast rogue: high SPD, steals, double hits | `NIMBLE` | +| 2 | **SER BRAMBLE** | 96 | Oathknight who has guarded a door for 40 years because "someone wrote GUARD once and nothing since." | Tank: taunt/guard-party, damage grows as he bleeds | `OATH` | +| 3 | **MOTH** | 84 | Lamplighter of the Order of the Wick. Keeps candles ready for a light no one ever named. | Healer/caster: heal, AoE radiance | `LITURGY` | +| 4 | **VERA** | 111 | Alchemist selling "possibility tonics" that have never worked, because nothing is possible yet. | Debuffer/AoE: acid flasks, tonics | `ALCHEMY` | +| 5 | **CANTOR** | 109 | A chord-golem the Maker built to play the fanfare when the story began. Still holding the first note. | Slow nuker: stuns constructs/undead, huge single hits | `RESONANCE` | +| 6 | **NYX** | 121 | The Unlived. A character the Maker sketched, almost loved, and abandoned. Not dead — *never alive*. | Glass cannon: crits, evasion, cannot be healed by items (only MOTH's Kindle or shrine rest) | `SHADOW` | + +**Recruitment paths** (dialogue-driven; see SCRIPT files for the actual scenes): +- **PIP** — starter. +- **BRAMBLE** — Act 1, Hollowbrook north door. Joins when given a *new* oath + ("guard the people as they walk through it"). An `OATH`-flavored option makes + it graceful; brute-force option also works but sets `bramble_grumbles`. +- **MOTH** — Act 1, Gearwood Cold Shrine. Joins after the player relights the + shrine brazier (interaction, no check) and names what the light is for + (3-way choice; remembered; quoted back in the epilogue). +- **VERA** — Act 1, Griselda's shop dispute. Side with Vera → joins immediately, + Griselda's prices +25% (`griselda_grudge`). Side with Griselda → Vera storms + out; she can be recruited in Act 2 outside the Undercroft *only* via an + apology that passes `ALCHEMY` (gift a Bomb Flask) or `NIMBLE` (return the + ledger page Pip lifted). If never reconciled, she runs a stall in Act 3 and + the roster is 5. +- **CANTOR** — Act 2, Undercroft Chord Hall. Dormant. The Book writes his name: + the first thing the player ever *writes* rather than picks. (Scripted scene; + the choice is which name to write — "Cantor" / "his serial CH-0RD" / player + picks silence, which recruits him as CANTOR anyway but sets `cantor_quiet`, + changing his battle bark and one Custodian line.) +- **NYX** — Act 3, greyed Hollowbrook, only visible standing where no NPC ever + stands (the empty plinth by the fountain). If the player earlier asked Quill + about "the ones who never woke" (`asked_unwoken`), she recognizes being + remembered and joins warmly; otherwise a `SHADOW`-less party must pass a + harder choice gate (offer her the Book to read → she joins, `nyx_read_book`). + Missable: if both fail she appears at the epilogue anyway, unrecruited, with + one devastating gentle line. + +## 3. Recurring NPCs (4, non-party) + +| Name | Sprite | Role | Arc | +|------|--------|------|-----| +| **FERRYMAN ODD** | 87 | Poles the canal barge between areas. The only fast-travel. | Asks one question per ride about your latest big choice; never comments, just writes your answer on the hull among hundreds of blank planks. Final ride: reveals he was the Maker's *first sketch* — "I only know one route. The Maker never wrote me a second." If the player promises to write him one (`odd_promised`), the epilogue gives him a new river. | +| **MAYOR QUILL** | 100 | Anxious mayor of Hollowbrook. Quest-giver. | Confidence arc: the player either decides *for* him (fast, sets `quill_dependent`) or asks his opinion and endorses it (slower, sets `quill_confident`). In Act 3 a confident Quill organizes the townsfolk into the Chorus (pre-battle buff vs the Custodian); a dependent Quill loops harder than anyone when the town greys. | +| **GRISELDA** | 88 | Smith & shopkeeper. Sells gear and items. | Rival-or-mentor to Vera (see Vera). If befriended (`griselda_friend`: side with her OR reconcile everyone in Act 3), she reforges the Gatekeeper's Tooth into the best weapon, **Toothed Regret**, free. Otherwise it stays a trinket. | +| **BELL** | 110 | The Custodian's herald. Small, red, immaculate, courteous. | Appears exactly 4 times: Gearwood clearing (curious), Undercroft gate (warning), greyed Hollowbrook (apologetic — "I *liked* the bakery. But it was becoming a story."), Custodian battle (component). With `CANTOR` in party + Story Points >= 6, the in-battle `[RESONANCE] Ring Bell backward` option makes Bell defect, skipping Custodian phase 2 and unlocking the "Rung True" epilogue variant. | + +## 4. Antagonists / Bosses (2) + +- **THE GATEKEEPER** — Act 2 boss. Sprite 20 (gargoyle face) at scale 7, flanked + by two Chime-Imps (110, hsl-shifted teal). A wall with opinions. It keeps the + deep vale "pending" and considers adventurers a filing error. Attacks: Stone + Stare (single, heavy), Toll (AoE, telegraphed one turn ahead — "The Gatekeeper + inhales."), Summon Chime (replaces dead imps). Talk option: `[OATH]` Bramble + formally relieves it of duty — removes its DEF buff for the whole fight. + Drop: **Gatekeeper's Tooth** (key item). +- **THE CUSTODIAN** — final boss, Maker's Study. Sprite 41 (ornate mechanism) at + scale 8 with two slowly rotating gold Frame halos (`rotation` animate, loop). + Not evil. It is doing *exactly its job*: keeping the world clean, complete, + and pending. Phase 1: Sweep (AoE), Redact (removes one party member's skills + for 2 turns — "You are simpler now"), Polish (self-heal small). Phase 2 (under + 50%): summons Bell + a grey Echo of the starter, gains Revert (undoes the last + damage dealt unless a NEW action type was used since — mechanically rewards + variety, thematically: repetition is what it eats). + Talk options (each once): `[LITURGY]` name the light (MAG down), + `[RESONANCE]` ring Bell backward (Bell defects, see above), `[points >= 8]` + "Read it the Book so far" — it hesitates: skips its next turn. + No drop. The reward is the epilogue. + +## 5. Acts, areas, and the trope checklist + +**ACT 1 — Hollowbrook & the Gearwood.** Hub town (fountain, shop, mayor's +porch, the North Door, ferry dock). Recruit Bramble, resolve shop dispute +(Vera), ferry to Gearwood: soft encounters (bats, slimes, spiders), Cold Shrine +(recruit Moth, camp/swap point), find the Undercroft stair + Brass Key. +Bell appearance #1. + +**ACT 2 — The Undercroft.** Proper dungeon: FOV fog-of-war, wandering visible +encounters (skeletons, echoes, scarabs), loot chests (one is sprite 92, a mimic +— fight it, it drops well), the Chord Hall (recruit Cantor), optional Vera +reconciliation outside the gate. Bell appearance #2. **BOSS: Gatekeeper.** + +**ACT 3 — Hollowbrook, Reverted → the Maker's Study.** +The Custodian's answer to the Gatekeeper falling: it *reverts* Hollowbrook. +The player returns to the same map, greyed: desaturated ColorLayers, NPCs +standing at their Act-1 positions repeating one line each, the fountain dry, +Griselda's shop shuttered (unless `griselda_friend`), music/pulse gone. +**This is the "return to a modified area" beat and it must land hard — +identical geometry, drained palette.** Re-wake each NPC with a tag- or +flag-gated choice (each rewake +1 Story Point). Recruit Nyx at the empty +plinth. Bell appearance #3. Ferry to the Study — paper-white surreal area, +ink-hatched walls, floating desk — **BOSS: Custodian** (Bell appearance #4), +then epilogue. + +**Trope contract (all must ship):** +- [x] Starter + 5 recruits, active party of 3 → roster above +- [x] 4 recurring non-party NPCs → Odd, Quill, Griselda, Bell +- [x] Return to a modified area → greyed Hollowbrook +- [x] 2 boss fights → Gatekeeper, Custodian +- [x] Items, shops, loot, levels, skills, stats → SYSTEMS.md +- [x] Dialogue drives outcome; skill checks & past choices gate options → + recruitment paths, rewakes, boss Talk actions, epilogue variants + +## 6. Aesthetic direction + +**Palette (core/palette.py must define these exactly):** +- `INK = (11, 12, 16)` — global background +- `PARCH = (232, 220, 192)` — primary text +- `GOLD = (212, 169, 78)` — accents, selection, the Book +- `DIM = (138, 132, 118)` — secondary text, locked options +- `BLOOD = (205, 66, 57)` — damage, HP low +- `TEAL = (80, 190, 180)` — SP, magic +- `GRASS = (96, 160, 92)` — heals, XP +- `PANEL = (22, 24, 32)` — UI panel fill; outline (52, 60, 80), 2px +- Grey-world wash (Act 3): every area color lerped 70% toward (58, 58, 62). + +**Area ground palettes (ColorLayer only — NO terrain tiles; per John's +constraint, the tileset is for characters/items/props only):** +- Hollowbrook: umber field (54,44,34), paths (84,66,44), water (38,66,86), + building footprints (30,32,42) with GOLD doorway cells. +- Gearwood: moss (30,46,32) / (38,58,38) checker-ish variation, canopy-shadow + cells (22,34,26). +- Undercroft: (16,18,28) / (20,24,36), FOV fog via engine perspective colors. +- Maker's Study: **inverted palette** — parchment floor (214,204,178), ink + walls (28,26,24), everything UI-outlined in ink not gold. The world here is + literally paper. + +**Sprites:** characters/props at scale 2.0 on the overworld grid (32px cells → +grid zoom handles it; see ARCHITECTURE), scale 4.0 for battle enemies, 5.0 for +dialogue portraits, 7-8 for bosses. Chunky and proud of it. + +**Motion:** every UI transition is an easing tween, nothing teleports: +dialogue boxes slide up 12px + fade in; damage numbers float (EASE_OUT, 0.8s); +battle actors lunge 24px toward target and back; scene transitions via +`scene.activate(transition=...)` if available, else 0.25s full-screen INK frame +opacity fade. NPCs idle-bob (draw_pos y +-0.06, 1.6s loop). The Book hum: +a small gold Circle pulse behind the dialogue box whenever a choice is made. + +**Sound:** none. The vale is waiting. (Also keeps scope sane.) + +## 7. Voice samples (calibration for any text an agent must improvise) + +- Quill: "It's a very good door. Best in the vale. We're all very proud of it + and nobody has ever once been through it." +- Griselda: "Forty years I've sharpened this axe. You want to know what it's + cut? Time, mostly." +- Bell: "Please don't be alarmed. Alarm is untidy." +- Odd: "One route. There and back. You'd think the water would get bored. + Water doesn't. I checked." +- Nyx: "You looked at the empty spot. Nobody looks at the empty spot." +- The Custodian: "I am not your enemy. I am your margin." + +Improvised text must match: short sentences, concrete nouns, no modern slang, +no fourth-wall winks beyond what the premise already licenses. diff --git a/games/unwritten/design/SYSTEMS.md b/games/unwritten/design/SYSTEMS.md new file mode 100644 index 0000000..bf76336 --- /dev/null +++ b/games/unwritten/design/SYSTEMS.md @@ -0,0 +1,152 @@ +# UNWRITTEN — Systems Spec (numbers are canon) + +Playtime target: 30–45 minutes. Grind is abbreviated by design: XP is generous, +maps are small, encounters are visible (bump to fight, avoidable), and every +number below is tuned for "one sitting, slightly under-leveled feels tense but +fair." + +## 1. Stats & leveling + +Stats: `HP, SP, ATK, MAG, DEF, SPD`. Levels 1–8. XP to next level = `20 * level` +(cumulative table: 20/60/120/200/300/420/560). Whole party (all 6, benched +included) gains full XP — swapping is never punished. + +Per-character base (level 1) and per-level growth: + +| Char | HP | SP | ATK | MAG | DEF | SPD | growth (per level) | +|---------|-----|----|-----|-----|-----|-----|-------------------------------| +| PIP | 34 | 10 | 7 | 3 | 4 | 9 | +6 HP +2 SP +2 ATK +1 DEF +1 SPD | +| BRAMBLE | 46 | 8 | 6 | 2 | 7 | 4 | +9 HP +1 SP +1 ATK +2 DEF | +| MOTH | 28 | 16 | 3 | 8 | 3 | 6 | +4 HP +4 SP +2 MAG +1 DEF +1 SPD (alt levels) | +| VERA | 31 | 12 | 5 | 6 | 4 | 6 | +5 HP +3 SP +1 ATK +1 MAG +1 DEF +1 SPD (alt) | +| CANTOR | 40 | 12 | 8 | 6 | 6 | 2 | +7 HP +2 SP +2 ATK +1 MAG +1 DEF | +| NYX | 22 | 12 | 9 | 5 | 2 | 10 | +3 HP +3 SP +3 ATK +1 SPD | + +("alt levels": the +1s marked (alt) apply on even levels only. Keep it simple in +code: a per-char list of 7 growth dicts is fine.) + +NYX joins at the party's average level; everyone else joins at their scene's +scripted level (Bramble 1, Moth 2, Vera 2, Cantor 4). + +## 2. Damage model + +- Physical: `dmg = max(1, round((ATK * mult - DEF) * var))`, `var` uniform in + [0.85, 1.15]. +- Magical: same with MAG and `DEF//2`. +- Crit: chance per skill (base 6%, NYX base 18%), crit = dmg * 1.5, gold flash. +- Guard: incoming dmg halved until next turn, +2 SP on guarding. +- Turn order: sort by SPD desc, ties random, recomputed each round. Shown as a + portrait ribbon top of battle screen. + +## 3. Skills (unlock L1 unless noted; SP cost in parens) + +| Char | Skill A | Skill B (unlocks L4) | +|------|---------|----------------------| +| PIP | **Twinstrike** (3): two hits at 0.7x each | **Pickpocket** (2): 0.6x dmg + steal bonus gold (8-20), once per enemy | +| BRAMBLE | **Shieldwall** (3): party takes half dmg until his next turn | **Vow Strike** (4): mult 1.0 + 0.1 per 10% HP Bramble is missing (max 2.0) | +| MOTH | **Kindle** (4): heal one ally `24 + 2*MAG`; works on NYX | **Moonflare** (6): AoE magic 1.1x | +| VERA | **Acid Flask** (4): AoE 0.7x + DEF -25% (2 turns) | **Tonic Toss** (3): heal one `16 + MAG` and clear debuffs (NOT Nyx) | +| CANTOR | **Resonate** (4): 1.2x magic; vs construct/undead also stun 1 turn | **Bass Drop** (7): single 2.2x, acts last that round | +| NYX | **Grave Chill** (3): 1.0x magic + SPD -30% (2 turns) | **Second Shadow** (5): her next attack this battle is a guaranteed crit; using it does not end her evasion | + +NYX passive: 20% evasion. NYX cannot be healed by items (silently blocked with +battle log line "The tonic passes through her."); Kindle and shrine rest work. + +## 4. Enemies + +`hue` = hsl_shift degrees applied to the base sprite for area variants. + +| Enemy | Sprite | HP | ATK | MAG | DEF | SPD | XP | Gold | Notes | +|-------|--------|----|-----|-----|-----|-----|----|------|-------| +| Gloom Bat | 120 | 14 | 5 | 0 | 1 | 8 | 6 | 4 | 25% chance to skip (flutters) | +| Hedge Slime | 108 | 20 | 4 | 0 | 3 | 2 | 7 | 5 | splits once at death into 1 Slimelet (8 HP) in Gearwood packs of <=2 | +| Loom Spider | 122 | 16 | 6 | 0 | 2 | 6 | 8 | 6 | Web: SPD-30% 2 turns, every 3rd action | +| Waxwing Scarab | 123 | 24 | 6 | 0 | 5 | 3 | 10 | 8 | guards when under 50% | +| Unwoken Skeleton | 86 | 26 | 8 | 0 | 3 | 5 | 13 | 10 | undead (Resonate stuns) | +| Pale Echo | 121, hue -40 | 18 | 0 | 8 | 1 | 7 | 12 | 0 | magic attack; drops no gold, drops Chalk (see items) 40% | +| Chime-Imp | 110, hue +160 | 22 | 7 | 4 | 3 | 7 | 14 | 12 | construct (Resonate stuns) | +| Mimic | 92 | 45 | 10 | 0 | 6 | 1 | 30 | 60 | the Undercroft chest that bites; always drops Second Trinket | +| **GATEKEEPER** | 20 | 150 | 11 | 6 | 8 (13 buffed) | 3 | 80 | 100 | boss, see BIBLE §4; starts with Ward (+5 DEF) until [OATH] talk | +| **CUSTODIAN** | 41 | 210 | 9 | 12 | 7 | 6 | 0 | 0 | boss, two phases, see BIBLE §4 | + +Encounter packs (visible wandering entities on the map; bump = battle): +- Gearwood: 2-3 of {bat, slime, spider}; one fixed scarab pair guarding the shrine chest. +- Undercroft: 2-3 of {skeleton, echo, scarab}; chime-imp pairs near the boss door. +- Act 3 Study approach: 2x pale echo packs only (the world is running out of things). +Respawn: none. ~9-11 packs total in the game. Flee: 70% base, never from bosses. + +## 5. Items & equipment + +Consumables (usable in battle or menu): + +| Item | Sprite | Cost | Effect | +|------|--------|------|--------| +| Bread | 66 | 8 | heal 12 | +| Red Tonic | 115 | 20 | heal 30 | +| Green Salve | 114 | 25 | clear debuffs + heal 10 | +| Blue Draught | 116 | 30 | restore 15 SP | +| Bomb Flask | 113 | 35 | 22 AoE damage (item, anyone can throw) | +| Chalk | 102 | — (drop only) | revive fallen ally at 40% HP. Rare: Echoes drop it. Griselda sells ONE, 90g. | + +Equipment: one weapon + one trinket per character. No armor slot (DEF comes +from trinkets); keep the equip screen simple. + +| Weapon | Sprite | Cost | Effect | Notes | +|--------|--------|------|--------|-------| +| Knife | 105 | 15 | +2 ATK | starter tier | +| Short Sword | 103 | 40 | +4 ATK | | +| Broad Blade | 106 | 90 | +7 ATK | Act 2 shop unlock | +| Worn Cudgel | 117 | 15 | +2 ATK | Bramble/Cantor flavor | +| Twin Axe | 119 | 90 | +6 ATK, +2 SPD | | +| Wick Staff | 129 | 45 | +4 MAG | | +| Tide Rod | 130 | 95 | +7 MAG | Act 2 shop unlock | +| **Toothed Regret** | 118 | — | +10 ATK, +6 MAG | Griselda reforges the Gatekeeper's Tooth, free, only if `griselda_friend` | + +| Trinket | Sprite | Cost | Effect | +|---------|--------|------|--------| +| River Stone | 101 | 25 | +2 DEF | +| Waxed Charm | 56 | 60 | +3 DEF, +5 max HP | +| Second Trinket | 91 | — | +2 all stats (Mimic drop; the name is the joke) | +| Gatekeeper's Tooth | 107 | — | +3 ATK trinket until/unless reforged | + +Key items (menu tab, not usable): Blank Book, Brass Key, Gatekeeper's Tooth +(moves to equipment if reforged), The Wick (Moth's candle, set during her +recruitment naming choice). + +Shop (Griselda): Act 1 stock through Broad-Blade tier locked; Act 2 unlocks the +90+ tier. Prices x1.25 if `griselda_grudge`. Buyback at half price. Gold is +called **cogs**. + +Starting kit: Pip L1, Knife equipped, 2 Bread, 1 Red Tonic, 30 cogs. + +## 6. Flags & Story Points (single source of truth for the writing) + +`state.flags: set[str]`, `state.points: int` (Story Points, shown as small gold +tally "the Book grows heavier": +1 each on the moments marked below). + +Canonical flags (agents: use EXACTLY these names): +`quill_confident | quill_dependent | vera_joined_early | vera_grudge | +vera_reconciled | griselda_friend | griselda_grudge | bramble_grumbles | +asked_unwoken | cantor_quiet | odd_promised | nyx_read_book | nyx_missed | +moth_light_name_dark / _lost / _small (one of three) | mimic_slain | +gatekeeper_relieved | bell_defected | rewoke_quill | rewoke_griselda | +rewoke_odd | book_read_custodian` + +Story Points (+1 each): recruiting each of the 5, each of the 3 rewakes, +`odd_promised`, `asked_unwoken`, resolving the shop dispute either way, +`gatekeeper_relieved`. (Max realistic ~12; thresholds: Bell defect needs >= 6 +plus Cantor present; "Read the Book" battle option needs >= 8.) + +## 7. Epilogue matrix + +The epilogue is the Book read aloud: 6-10 short pages (full-screen INK panels, +centered PARCH text, one keypress each). Assembled from: +1. Fixed opening ("This is the story of the vale, as it actually happened."). +2. One page per major choice: the door, the dispute, Moth's light-name, Cantor's + name, the rewakes, Odd's promise (new river page), Bell's fate. +3. Nyx page: recruited-warm / recruited-late / **missed** ("There was someone + else, almost. Look for her, next time. She waits by the fountain."). +4. Quill arc page (confident: he schedules the festival, finally; dependent: he + waits for you to say it's time, and you do, and that's alright too). +5. Fixed closing: "The Maker built the vale. You were what happened." then the + title card UNWRITTEN — except now stamped WRITTEN in gold. diff --git a/games/unwritten/main.py b/games/unwritten/main.py new file mode 100644 index 0000000..2700151 --- /dev/null +++ b/games/unwritten/main.py @@ -0,0 +1,149 @@ +"""UNWRITTEN - bootstrap and title screen. Owner: Agent A. + +Run windowed: + cd build && ./mcrogueface --exec ../games/unwritten/main.py +""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import mcrfpy +from core import palette, ui, tween +from core.inputstack import InputStack +from systems import dialogue +from systems.state import GS + +# Boot wiring (Agent D): importing each system registers its dialogue HOOKS at +# import time. main.py used to import none of them, so goto_area/battle/shop/ +# swap_menu/epilogue were never registered and _enter_world raised. Import them +# here, once, so the whole game is wired the moment main is loaded. +from systems import overworld # noqa: F401 registers HOOKS["goto_area"] +from systems import battle # noqa: F401 registers HOOKS["battle"] +from systems import shop # noqa: F401 registers HOOKS["shop"] +from systems import party_menu # noqa: F401 registers HOOKS["swap_menu"] +from systems import epilogue # noqa: F401 registers HOOKS["epilogue"] + + +# ----------------------------------------------------------------- chapter presets +def _preset_act1(): + GS.reset() + GS.act = 1 + GS.recruit("PIP", 1) + GS.gold = 30 + GS.grant("bread", 2) + GS.grant("red_tonic", 1) + roster = GS.roster.get("PIP") + if roster is not None: + roster.weapon = "knife" + + +def _preset_act2(): + GS.reset() + GS.act = 2 + for f in ("door_open", "bramble_joined", "moth_joined", "quill_confident", + "griselda_friend", "vera_grudge", "shrine_chest_open"): + GS.add_flag(f) + GS.recruit("PIP", 3) + GS.recruit("BRAMBLE", 3) + GS.recruit("MOTH", 3) + GS.gold = 120 + GS.grant("bread", 3) + GS.grant("red_tonic", 2) + GS.grant("blue_draught", 1) + GS.grant("brass_key", 1) + + +def _preset_act3(): + _preset_act2() + GS.act = 3 + for f in ("cantor_joined", "gatekeeper_slain", "vera_reconciled", + "asked_unwoken", "touched_sketch"): + GS.add_flag(f) + GS.recruit("CANTOR", 5) + for cid in ("PIP", "BRAMBLE", "MOTH", "CANTOR"): + c = GS.roster.get(cid) + if c is not None: + c.level = 5 + c.xp = 10 * 5 * 4 + c.full_heal() + GS.points = 8 + GS.grant("gatekeepers_tooth", 1) + GS.add_key_item("gatekeepers_tooth") + + +# -------------------------------------------------------------------- title scene +def build_title(): + scene = mcrfpy.Scene("title") + mcrfpy.current_scene = scene + ch = scene.children + + bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=palette.INK) + ch.append(bg) + + # slow gold pulse behind the title + ring = mcrfpy.Circle(radius=90, center=(512, 250), + fill_color=palette.C(palette.GOLD_T, 18), + outline=1, outline_color=palette.C(palette.GOLD_T, 40)) + ch.append(ring) + ring.animate("radius", 130, 2.6, mcrfpy.Easing.EASE_IN_OUT, loop=True) + + title = ui.Label(ch, "UNWRITTEN", (512, 250), color=palette.PARCH, + size=palette.TITLE_SIZE, center=True, outline=2) + + rule = mcrfpy.Frame(pos=(512 - 220, 316), size=(440, 3), + fill_color=palette.GOLD) + ch.append(rule) + + ui.Label(ch, "the world is finished. the story is not.", (512, 350), + color=palette.DIM, size=palette.BODY_SIZE, center=True) + + press = ui.Label(ch, "press ENTER", (512, 600), color=palette.DIM, + size=palette.BODY_SIZE, center=True) + press.animate("opacity", 0.15, 0.8, mcrfpy.Easing.EASE_IN_OUT, loop=True) + + stack = InputStack(scene) + + def on_key(key, state): + if state != mcrfpy.InputState.PRESSED: + return True + K = mcrfpy.Key + if key == K.ENTER: + start_new_game(stack) + elif key == K.NUM_1: + _preset_act1() + _enter_world("hollowbrook", "start") + elif key == K.NUM_2: + _preset_act2() + _enter_world("undercroft", "enter") + elif key == K.NUM_3: + _preset_act3() + _enter_world("hollowbrook", "from_ferry") + return True + + stack.push(on_key, "title") + return scene + + +def start_new_game(stack): + _preset_act1() + # intro monologue, then hand off to the overworld + dialogue.run_scene("intro", on_done=lambda: _enter_world("hollowbrook", "start"), + stack=stack) + + +def _enter_world(area, spawn): + hook = dialogue.HOOKS.get("goto_area") + if hook is None: + raise RuntimeError( + "cannot enter world: systems.dialogue.HOOKS['goto_area'] is not " + "registered yet (Agent C's overworld must register it)") + hook(area, spawn) + + +def main(): + build_title() + + +if __name__ == "__main__": + main() diff --git a/games/unwritten/systems/battle.py b/games/unwritten/systems/battle.py new file mode 100644 index 0000000..da48a6b --- /dev/null +++ b/games/unwritten/systems/battle.py @@ -0,0 +1,1742 @@ +"""UNWRITTEN - turn engine + battle screen. Owner: Agent B. + +Public surface: + start_battle(pack_id, on_victory=None, on_defeat=None) + +Two layers, deliberately separable so the tests can run thousands of fights +with no UI: + BattleCore - pure mechanics (SYSTEMS sections 2-4, BIBLE section 4 bosses). + Touches no mcrfpy objects. Combatant.view is set by the screen + but never read by the core. + BattleScreen - the 1024x768 presentation that drives a BattleCore turn by + turn, animating each result the core hands back. + +Registers systems.dialogue.HOOKS["battle"] = start_battle at import. +ASCII only. "Fail early": unknown packs/skills/items raise. +""" +import random + +import mcrfpy +from core import palette, ui, tween, assets +from core.inputstack import InputStack +from systems.state import GS +from data import characters as chardata +from data import items as itemdata +from data import enemies as enemydata +from data.barks import BARKS +from data.maps import BATTLE_BACKDROPS + + +# ============================================================================= +# constants / layout +# ============================================================================= +FLOOR_Y = 430 +SCREEN_W = 1024 +SCREEN_H = 768 + +# enemy sprite-center x positions by count (left half of the screen) +_ENEMY_SLOTS = { + 1: [300], + 2: [180, 400], + 3: [130, 300, 470], +} +# extra slots used when the Custodian / Gatekeeper summon reinforcements +_SUMMON_SLOTS = [120, 470, 60, 520, 240] + +_MAX_ROUNDS = 60 # safety valve for the headless simulator + + +# ============================================================================= +# barks +# ============================================================================= +class Barker: + """Picks a battle bark line, never repeating the immediately previous one.""" + + def __init__(self): + self.last = None + + def line(self, char_id, event): + opts = BARKS.get(char_id, {}).get(event) + if not opts: + return None + pool = [o for o in opts if o != self.last] or list(opts) + pick = random.choice(pool) + self.last = pick + return pick + + +# ============================================================================= +# combatant +# ============================================================================= +class Combatant: + def __init__(self, side, key, name, base, max_hp, max_sp, labels, + crit_base, sprite_index, tex, scale, ai, + char_id=None, enemy_id=None, cstate=None): + self.side = side # "party" | "enemy" + self.key = key + self.name = name + self.base = dict(base) # ATK/MAG/DEF/SPD (and HP/SP nominal) + self._max_hp = int(max_hp) + self._max_sp = int(max_sp) + self.labels = set(labels or ()) + self.crit_base = crit_base + self.sprite_index = sprite_index + self.tex = tex + self.scale = scale + self.ai = ai + self.char_id = char_id + self.enemy_id = enemy_id + self.cstate = cstate + + self._hp = cstate.hp if cstate is not None else int(max_hp) + self._sp = cstate.sp if cstate is not None else int(max_sp) + + self.mods = [] # [{"stat","add","mult","turns","tag"}] + self.guarding = False + self.stun_turns = 0 + self.redact_turns = 0 + self.guaranteed_crit = False + self.picked = set() # pickpocket: enemy keys already robbed + self.ai_counter = 0 + self.view = None # BattleScreen sets/reads its own dict + + # ---- pools ------------------------------------------------------------- + @property + def max_hp(self): + return self._max_hp + + @property + def max_sp(self): + return self._max_sp + + @property + def hp(self): + return self._hp + + @hp.setter + def hp(self, v): + self._hp = max(0, min(int(v), self._max_hp)) + if self.cstate is not None: + self.cstate.hp = self._hp + + @property + def sp(self): + return self._sp + + @sp.setter + def sp(self, v): + self._sp = max(0, min(int(v), self._max_sp)) + if self.cstate is not None: + self.cstate.sp = self._sp + + @property + def alive(self): + return self._hp > 0 + + # ---- effective stats --------------------------------------------------- + def stat(self, k): + v = self.base.get(k, 0) + add = 0 + mult = 1.0 + for m in self.mods: + if m["stat"] == k: + add += m.get("add", 0) + mult += m.get("mult", 0.0) + return max(0, int(round((v + add) * mult))) + + def add_mod(self, stat, mult=0.0, add=0, turns=None, tag=None): + self.mods.append({"stat": stat, "mult": mult, "add": add, + "turns": turns, "tag": tag}) + + def remove_mods_tagged(self, tag): + self.mods = [m for m in self.mods if m.get("tag") != tag] + + def clear_debuffs(self): + self.mods = [m for m in self.mods if m.get("mult", 0.0) >= 0 + and m.get("add", 0) >= 0] + + +# ============================================================================= +# battle core (pure logic) +# ============================================================================= +class BattleCore: + def __init__(self, pack_id): + self.pack_id = pack_id + self.is_boss = pack_id.startswith("boss_") + self.log = [] + self.round = 0 + self.order = [] + self.turn_index = 0 + self.deferred = [] + self.result = None # "victory" | "defeat" | "flee" + + # shieldwall + self.party_guard_active = False + self.party_guard_owner = None + + # boss state + self.gk_inhale = False + self.cust_phase2 = False + self.cust_skip_next = False + self.revert_disabled = False + self.talk_used = set() + + # custodian revert bookkeeping + self.actions_last_round = set() + self.actions_this_round = set() + self.cust_damage_this_round = 0 + + self.stolen_gold = 0 + self._used_slots = [] + self._initial_pack = len(enemydata.pack(pack_id)) + + self.party = self._build_party() + self.enemies = self._build_enemies() + + # ---- construction ------------------------------------------------------ + def _build_party(self): + out = [] + for cid in GS.party[:3]: + cs = GS.roster.get(cid) + if cs is None: + raise KeyError("party member %r has no CharState" % (cid,)) + st = cs.stats() + out.append(Combatant( + "party", "p_" + cid, cid, st, cs.max_hp, cs.max_sp, + labels=(), crit_base=chardata.CRIT_BASE.get(cid, 0.06), + sprite_index=chardata.CHAR_SPRITES[cid], tex=None, + scale=2.5, ai=None, char_id=cid, cstate=cs)) + if not out: + raise RuntimeError("cannot start battle with an empty party") + return out + + def _make_enemy(self, enemy_id, idx): + rec = enemydata.enemy(enemy_id) + c = Combatant( + "enemy", "e%d_%s" % (idx, enemy_id), rec["name"], + {"ATK": rec["ATK"], "MAG": rec["MAG"], "DEF": rec["DEF"], + "SPD": rec["SPD"]}, + rec["HP"], 0, labels=rec["labels"], crit_base=0.04, + sprite_index=rec["sprite"], tex=rec["tex"], scale=rec["scale"], + ai=rec["ai"], enemy_id=enemy_id) + c.reward_xp = rec["XP"] + c.reward_gold = rec["gold"] + c.drop = rec["drop"] + return c + + def _build_enemies(self): + out = [] + ids = enemydata.pack(self.pack_id) + for i, eid in enumerate(ids): + c = self._make_enemy(eid, i) + out.append(c) + if eid == "gatekeeper": + c.add_mod("DEF", add=5, turns=None, tag="ward") # the Ward + self._enemy_seq = len(ids) + return out + + def _spawn_enemy(self, enemy_id, result): + c = self._make_enemy(enemy_id, self._enemy_seq) + self._enemy_seq += 1 + self.enemies.append(c) + result["fx"].append({"kind": "summon", "target": c}) + return c + + # ---- roster helpers ---------------------------------------------------- + def all(self): + return self.party + self.enemies + + def alive_party(self): + return [c for c in self.party if c.alive] + + def alive_enemies(self): + return [c for c in self.enemies if c.alive] + + def opponents(self, actor): + return self.alive_enemies() if actor.side == "party" else self.alive_party() + + def allies(self, actor): + return self.alive_party() if actor.side == "party" else self.alive_enemies() + + def _rand(self, lst): + return random.choice(lst) if lst else None + + def _find_enemy(self, enemy_id): + for e in self.enemies: + if e.enemy_id == enemy_id: + return e + return None + + def _active_boss(self): + if self.pack_id == "boss_gatekeeper": + return self._find_enemy("gatekeeper") + if self.pack_id == "boss_custodian": + return self._find_enemy("custodian") + return None + + # ---- round flow -------------------------------------------------------- + def begin_round(self): + self.round += 1 + self.actions_last_round = self.actions_this_round + self.actions_this_round = set() + self.cust_damage_this_round = 0 + if self.party_guard_owner is not None and not self.party_guard_owner.alive: + self.party_guard_active = False + self.party_guard_owner = None + alive = [c for c in self.all() if c.alive] + alive.sort(key=lambda c: (-c.stat("SPD"), random.random())) + self.order = alive + self.turn_index = 0 + + def next_actor(self): + """Return (actor, forced_action) or None when the round is exhausted. + forced_action is None for a normal turn, {"type":"skip"} when stunned, + or a stored action for a deferred (Bass Drop) resolution.""" + while self.turn_index < len(self.order): + c = self.order[self.turn_index] + self.turn_index += 1 + if not c.alive: + continue + skip = self._turn_start(c) + if skip: + return (c, {"type": "skip", "reason": skip}) + return (c, None) + while self.deferred: + c, act = self.deferred.pop(0) + if c.alive: + return (c, act) + return None + + def _turn_start(self, c): + c.guarding = False + if self.party_guard_owner is c: + self.party_guard_active = False + self.party_guard_owner = None + if c.stun_turns > 0: + c.stun_turns -= 1 + return "stun" + if c.enemy_id == "custodian" and self.cust_skip_next: + self.cust_skip_next = False + return "book" + return None + + def end_round(self): + """Custodian Revert + duration ticks. Returns a result-like dict.""" + result = {"actor": None, "log": [], "fx": []} + self._custodian_revert(result) + for c in self.all(): + self._tick_durations(c) + return result + + def _tick_durations(self, c): + keep = [] + for m in c.mods: + if m.get("turns") is None: + keep.append(m) + else: + m["turns"] -= 1 + if m["turns"] > 0: + keep.append(m) + c.mods = keep + + # ---- end detection ----------------------------------------------------- + def check_end(self): + if not self.alive_enemies(): + self.result = "victory" + return True + if not self.alive_party(): + self.result = "defeat" + return True + return False + + # ===================================================================== + # action resolution + # ===================================================================== + def resolve(self, actor, action): + result = {"actor": actor, "log": [], "fx": [], "deferred": False, + "bark": None, "flee": False, "swap": None, "end_turn": True} + t = action.get("type") + if t == "skip": + if action.get("reason") == "book": + pass # the talk option already narrated the skip + elif action.get("reason") == "stun": + result["log"].append("%s cannot move." % actor.name) + else: + result["log"].append("%s flutters past." % actor.name) + elif t == "attack": + self._do_attack(actor, action, result) + elif t == "skill": + self._do_skill(actor, action, result) + elif t == "item": + self._do_item(actor, action, result) + elif t == "guard": + self._do_guard(actor, result) + elif t == "eweb": + self._do_eweb(actor, action, result) + elif t == "swap": + self._do_swap(actor, action, result) + elif t == "talk": + self._do_talk(actor, action, result) + elif t == "flee": + self._do_flee(actor, result) + elif t == "boss": + self._do_boss_move(actor, action, result) + else: + raise ValueError("unknown battle action type %r" % (t,)) + + # custodian revert bookkeeping: record party action tokens + if actor.side == "party" and not result["deferred"]: + tok = self._action_token(t, action) + if tok: + self.actions_this_round.add(tok) + + self._maybe_phase2(result) + + # end-of-turn duration bookkeeping for the actor + if not result["deferred"]: + if actor.redact_turns > 0: + actor.redact_turns -= 1 + return result + + def _action_token(self, t, action): + if t == "attack": + return "attack" + if t == "skill": + return "skill:" + action["skill"]["id"] + if t == "item": + return "item:" + action["item"] + if t == "guard": + return "guard" + return None + + # ---- damage primitives ------------------------------------------------- + def _strike(self, attacker, target, mult, magical, crit_chance, result, + force_crit=False): + if not target.alive: + return 0 + # Nyx evasion (incoming) + if target.char_id == "NYX" and random.random() < chardata.NYX_EVASION: + result["fx"].append({"kind": "miss", "target": target}) + result["log"].append("Nyx is not where the blow lands.") + return 0 + # Nyx guaranteed-crit consumption + if attacker.char_id == "NYX" and attacker.guaranteed_crit: + force_crit = True + attacker.guaranteed_crit = False + atk = attacker.stat("MAG" if magical else "ATK") + dfn = target.stat("DEF") + if magical: + dfn = dfn // 2 + var = random.uniform(0.85, 1.15) + dmg = max(1, int(round((atk * mult - dfn) * var))) + crit = force_crit or (random.random() < crit_chance) + if crit: + dmg = int(round(dmg * 1.5)) + # guard + shieldwall mitigation + if target.guarding: + dmg = max(1, dmg // 2) + if target.side == "party" and self.party_guard_active: + dmg = max(1, dmg // 2) + target.hp = target.hp - dmg + result["fx"].append({"kind": "hit", "target": target, "amount": dmg, + "crit": crit, "magical": magical}) + if attacker.side == "party" and target.enemy_id == "custodian": + self.cust_damage_this_round += dmg + if not target.alive: + result["fx"].append({"kind": "death", "target": target}) + # Hedge Slime splits once at death in packs of <= 2 (SYSTEMS section 4) + if (target.enemy_id == "hedge_slime" + and self._initial_pack <= 2 + and not getattr(target, "_split_done", False)): + target._split_done = True + self._spawn_enemy("slimelet", result) + result["log"].append("The Hedge Slime splits.") + return dmg + + def _heal(self, target, amount, result): + amount = int(amount) + before = target.hp + target.hp = target.hp + amount + gained = target.hp - before + result["fx"].append({"kind": "heal", "target": target, "amount": gained}) + return gained + + # ---- basic attack ------------------------------------------------------ + def _do_attack(self, actor, action, result): + target = action.get("target") + if target is None or not target.alive: + opp = self.opponents(actor) + target = self._rand(opp) + if target is None: + return + magical = action.get("magical", False) + crit = actor.crit_base + result["fx"].append({"kind": "lunge", "actor": actor, "target": target}) + self._strike(actor, target, 1.0, magical, crit, result) + + # ---- skills ------------------------------------------------------------ + def _do_skill(self, actor, action, result): + skill = action["skill"] + special = skill["special"] + + # Bass Drop defers to the end of the round the first time it is chosen. + if special == "bass_drop" and not action.get("_deferred"): + actor.sp = actor.sp - skill["sp"] + self.deferred.append((actor, dict(action, _deferred=True))) + result["deferred"] = True + result["log"].append("Cantor holds the note. It will land last.") + return + + if not action.get("_deferred"): + actor.sp = actor.sp - skill["sp"] + + if actor.side == "party": + evt = "skill_a" if skill["unlock"] == 1 else "skill_b" + result["bark"] = (actor.char_id, evt) + + if special == "twinstrike": + target = self._pick_enemy_target(actor, action) + result["fx"].append({"kind": "lunge", "actor": actor, "target": target}) + for _ in range(skill["hits"]): + if target.alive: + self._strike(actor, target, skill["mult"], False, + actor.crit_base, result) + elif special == "pickpocket": + target = self._pick_enemy_target(actor, action) + result["fx"].append({"kind": "lunge", "actor": actor, "target": target}) + self._strike(actor, target, skill["mult"], False, actor.crit_base, + result) + if target.key not in actor.picked and target.alive: + actor.picked.add(target.key) + loot = random.randint(8, 20) + self.stolen_gold += loot + GS.add_gold(loot) + result["log"].append("Pip lifts %d cogs." % loot) + elif special == "shieldwall": + self.party_guard_active = True + self.party_guard_owner = actor + result["log"].append("Bramble braces. The party takes half damage.") + elif special == "vow_strike": + target = self._pick_enemy_target(actor, action) + missing = 1.0 - (actor.hp / float(actor.max_hp)) + mult = min(2.0, 1.0 + 0.1 * int(missing * 10)) + result["fx"].append({"kind": "lunge", "actor": actor, "target": target}) + self._strike(actor, target, mult, False, actor.crit_base, result) + elif special == "kindle": + target = action.get("target") or self._lowest_ally(actor) + amt = 24 + 2 * actor.stat("MAG") + self._heal(target, amt, result) + result["log"].append("Moth kindles %s." % target.name) + elif special == "moonflare": + for e in self.alive_enemies(): + self._strike(actor, e, skill["mult"], True, actor.crit_base, + result) + result["fx"].append({"kind": "shake"}) + elif special == "acid_flask": + for e in self.alive_enemies(): + self._strike(actor, e, skill["mult"], True, actor.crit_base, + result) + d = skill["debuff"] + e.add_mod(d["stat"], mult=d["mult"], turns=d["turns"], tag="acid") + result["fx"].append({"kind": "debuff", "target": e}) + elif special == "tonic_toss": + target = action.get("target") or self._lowest_ally(actor) + if target.char_id == "NYX": + result["log"].append("The tonic passes through her.") + else: + amt = 16 + actor.stat("MAG") + target.clear_debuffs() + self._heal(target, amt, result) + result["log"].append("Vera tosses a tonic to %s." % target.name) + elif special == "resonate": + target = self._pick_enemy_target(actor, action) + result["fx"].append({"kind": "lunge", "actor": actor, "target": target}) + self._strike(actor, target, skill["mult"], True, actor.crit_base, + result) + if target.alive and target.labels & {"construct", "undead"}: + target.stun_turns = max(target.stun_turns, 1) + result["log"].append("%s is stunned by the chord." % target.name) + elif special == "bass_drop": + target = self._pick_enemy_target(actor, action) + result["fx"].append({"kind": "lunge", "actor": actor, "target": target}) + self._strike(actor, target, skill["mult"], False, actor.crit_base, + result) + result["fx"].append({"kind": "shake"}) + elif special == "grave_chill": + target = self._pick_enemy_target(actor, action) + self._strike(actor, target, skill["mult"], True, actor.crit_base, + result) + if target.alive: + d = skill["debuff"] + target.add_mod(d["stat"], mult=d["mult"], turns=d["turns"], + tag="chill") + result["fx"].append({"kind": "debuff", "target": target}) + elif special == "second_shadow": + actor.guaranteed_crit = True + result["log"].append("Nyx steps into her own shadow.") + else: + raise ValueError("unhandled skill special %r" % (special,)) + + def _pick_enemy_target(self, actor, action): + target = action.get("target") + if target is None or not target.alive: + target = self._rand(self.opponents(actor)) + return target + + def _lowest_ally(self, actor): + allies = self.allies(actor) + return min(allies, key=lambda c: c.hp / float(c.max_hp)) if allies else actor + + # ---- items ------------------------------------------------------------- + def _do_item(self, actor, action, result): + iid = action["item"] + rec = itemdata.item(iid) + eff = rec["effect"] + target = action.get("target") + + if eff == "aoe_damage": + GS.take(iid, 1) + for e in self.alive_enemies(): + dmg = max(1, rec["amount"]) + if e.guarding: + dmg = max(1, dmg // 2) + e.hp = e.hp - dmg + result["fx"].append({"kind": "hit", "target": e, "amount": dmg, + "crit": False, "magical": True}) + if e.enemy_id == "custodian": + self.cust_damage_this_round += dmg + if not e.alive: + result["fx"].append({"kind": "death", "target": e}) + result["fx"].append({"kind": "shake"}) + result["log"].append("%s throws a Bomb Flask." % actor.name) + return + + if target is None: + target = self._lowest_ally(actor) + + # Nyx cannot be restored by items (HP-restoring effects only). + if target.char_id == "NYX" and eff in ("heal", "cleanse_heal", "revive"): + result["log"].append("The tonic passes through her.") + return + + GS.take(iid, 1) + if eff == "heal": + self._heal(target, rec["amount"], result) + result["log"].append("%s uses %s." % (actor.name, rec["name"])) + elif eff == "sp": + target.sp = target.sp + rec["amount"] + result["fx"].append({"kind": "sp", "target": target, + "amount": rec["amount"]}) + result["log"].append("%s restores SP with %s." % (actor.name, rec["name"])) + elif eff == "cleanse_heal": + target.clear_debuffs() + self._heal(target, rec["amount"], result) + result["log"].append("%s uses %s." % (actor.name, rec["name"])) + elif eff == "revive": + if target.alive: + result["log"].append("%s is already standing." % target.name) + else: + target.hp = int(target.max_hp * rec["amount"]) + result["fx"].append({"kind": "revive", "target": target}) + result["log"].append("%s is written back with Chalk." % target.name) + else: + raise ValueError("unhandled item effect %r" % (eff,)) + + # ---- guard / swap / eweb ---------------------------------------------- + def _do_guard(self, actor, result): + actor.guarding = True + actor.sp = actor.sp + 2 + result["fx"].append({"kind": "guard", "target": actor}) + result["log"].append("%s guards." % actor.name) + + def _do_eweb(self, actor, action, result): + target = action.get("target") or self._rand(self.alive_party()) + if target is None: + return + target.add_mod("SPD", mult=-0.30, turns=2, tag="web") + result["fx"].append({"kind": "debuff", "target": target}) + result["log"].append("The Loom Spider webs %s. It slows." % target.name) + + def _do_swap(self, actor, action, result): + new_id = action["swap_to"] + cs = GS.roster.get(new_id) + if cs is None: + raise KeyError("cannot swap to %r: not in roster" % (new_id,)) + st = cs.stats() + newc = Combatant( + "party", "p_" + new_id, new_id, st, cs.max_hp, cs.max_sp, + labels=(), crit_base=chardata.CRIT_BASE.get(new_id, 0.06), + sprite_index=chardata.CHAR_SPRITES[new_id], tex=None, + scale=2.5, ai=None, char_id=new_id, cstate=cs) + idx = self.party.index(actor) + self.party[idx] = newc + # update the active party ordering in GS + if actor.char_id in GS.party: + gi = GS.party.index(actor.char_id) + GS.party[gi] = new_id + result["swap"] = (actor, newc) + result["log"].append("%s steps back; %s steps in." % (actor.name, newc.name)) + + def _do_flee(self, actor, result): + if self.is_boss: + result["log"].append("There is no fleeing this.") + return + if random.random() < 0.70: + result["flee"] = True + result["log"].append("The party slips away.") + else: + result["log"].append("Couldn't get away!") + + # ===================================================================== + # enemy / boss AI + # ===================================================================== + def ai_action(self, actor): + ai = actor.ai + if ai == "gatekeeper": + return self._gk_action(actor) + if ai == "custodian": + return self._cust_action(actor) + if ai == "bat" and random.random() < 0.25: + return {"type": "skip", "reason": "flutter"} + if ai == "scarab" and actor.hp < actor.max_hp * 0.5: + return {"type": "guard"} + if ai == "spider": + actor.ai_counter += 1 + if actor.ai_counter % 3 == 0: + return {"type": "eweb", "target": self._rand(self.alive_party())} + magical = (ai == "echo") + return {"type": "attack", "target": self._rand(self.alive_party()), + "magical": magical} + + def _gk_action(self, actor): + if self.gk_inhale: + self.gk_inhale = False + return {"type": "boss", "move": "toll"} + alive_imps = [e for e in self.enemies + if e.enemy_id == "chime_imp" and e.alive] + if len(alive_imps) < 2 and random.random() < 0.45: + return {"type": "boss", "move": "summon"} + r = random.random() + if r < 0.35: + self.gk_inhale = True + return {"type": "boss", "move": "inhale"} + return {"type": "boss", "move": "stone_stare"} + + def _cust_action(self, actor): + r = random.random() + if r < 0.45: + return {"type": "boss", "move": "sweep"} + if r < 0.75: + return {"type": "boss", "move": "redact"} + if actor.hp < actor.max_hp: + return {"type": "boss", "move": "polish"} + return {"type": "boss", "move": "sweep"} + + def _do_boss_move(self, actor, action, result): + move = action["move"] + if move == "stone_stare": + target = self._rand(self.alive_party()) + if target: + result["fx"].append({"kind": "lunge", "actor": actor, + "target": target}) + self._strike(actor, target, 1.6, False, 0.05, result) + result["log"].append( + "The Gatekeeper's stare files %s under HURT." % target.name) + elif move == "toll": + result["log"].append("THE TOLL. The whole room rings.") + for p in self.alive_party(): + self._strike(actor, p, 1.2, False, 0.03, result) + result["fx"].append({"kind": "shake"}) + elif move == "inhale": + result["log"].append("The Gatekeeper inhales.") + elif move == "summon": + self._spawn_enemy("chime_imp", result) + result["log"].append("The Gatekeeper summons a Chime-Imp to its side.") + elif move == "sweep": + result["log"].append( + "The Custodian sweeps. Everything is briefly, painfully, neat.") + for p in self.alive_party(): + self._strike(actor, p, 1.0, True, 0.03, result) + result["fx"].append({"kind": "shake"}) + elif move == "redact": + candidates = [p for p in self.alive_party() if p.redact_turns == 0] + target = self._rand(candidates or self.alive_party()) + if target: + target.redact_turns = 2 + result["fx"].append({"kind": "debuff", "target": target}) + result["log"].append( + "%s is Redacted. 'You are simpler now.'" % target.name) + elif move == "polish": + self._heal(actor, 12, result) + result["log"].append( + "The Custodian polishes itself. Small scratches vanish.") + else: + raise ValueError("unknown boss move %r" % (move,)) + + # ===================================================================== + # boss reactions + # ===================================================================== + def _maybe_phase2(self, result): + cust = self._find_enemy("custodian") + if (cust is None or not cust.alive or self.cust_phase2 + or cust.hp > cust.max_hp * 0.5): + return + self.cust_phase2 = True + result["log"].append( + "The Custodian opens. Inside: filed silence. Bell is summoned to " + "its side - and a grey echo of Pip.") + self._spawn_enemy("bell", result) + self._spawn_enemy("grey_echo", result) + + def _custodian_revert(self, result): + cust = self._find_enemy("custodian") + if (cust is None or not cust.alive or not self.cust_phase2 + or self.revert_disabled): + return + if self.cust_damage_this_round <= 0: + return + new_types = self.actions_this_round - self.actions_last_round + if new_types: + result["log"].append( + "The Custodian reaches to smooth the page - and hesitates. " + "It has never seen THAT before.") + else: + healed = self.cust_damage_this_round + cust.hp = cust.hp + healed + result["fx"].append({"kind": "heal", "target": cust, "amount": healed}) + result["log"].append( + "The Custodian smooths the page flat. What happened, un-happens.") + + # ===================================================================== + # boss talk options + # ===================================================================== + def boss_talk_options(self): + """List of {"id","label","enabled","reason"} for the active boss, or [].""" + boss = self._active_boss() + if boss is None or not boss.alive: + return [] + opts = [] + if boss.enemy_id == "gatekeeper": + if "oath_relieve" not in self.talk_used: + en = GS.in_party("BRAMBLE") + opts.append(("oath_relieve", + "[OATH] Relieve the Gatekeeper of its post", + en, "" if en else "needs Bramble")) + elif boss.enemy_id == "custodian": + if "liturgy_name" not in self.talk_used: + en = GS.in_party("MOTH") + opts.append(("liturgy_name", + "[LITURGY] Name the light at it", + en, "" if en else "needs Moth")) + if "resonance_bell" not in self.talk_used: + bell = self._find_enemy("bell") + en = (GS.in_party("CANTOR") and GS.points >= 6 + and self.cust_phase2 and bell is not None and bell.alive) + opts.append(("resonance_bell", + "[RESONANCE] Ring Bell backward", + en, "" if en else "needs Cantor, 6 pts, Bell")) + if "book_open" not in self.talk_used: + en = GS.points >= 8 + opts.append(("book_open", + "Hold up the Book, open", + en, "" if en else "needs 8 points")) + return [{"id": o[0], "label": o[1], "enabled": o[2], "reason": o[3]} + for o in opts] + + def _do_talk(self, actor, action, result): + tid = action["talk"] + self.talk_used.add(tid) + if tid == "oath_relieve": + gk = self._find_enemy("gatekeeper") + if gk: + gk.remove_mods_tagged("ward") + GS.add_flag("gatekeeper_relieved") + GS.add_points(1) + result["log"].append( + "Bramble: By the order that posted you: your watch is " + "COMPLETE. Stand down, soldier.") + result["log"].append( + "The Gatekeeper's Ward crumbles. Its face, for the first " + "time, rests.") + elif tid == "liturgy_name": + cust = self._find_enemy("custodian") + if cust: + cust.add_mod("MAG", mult=-0.30, turns=None, tag="liturgy") + result["log"].append( + "Moth names the light. The Custodian's edges blur, as if " + "embarrassed.") + elif tid == "resonance_bell": + bell = self._find_enemy("bell") + if bell and bell.alive: + bell.hp = 0 + result["fx"].append({"kind": "death", "target": bell}) + self.revert_disabled = True + GS.add_flag("bell_defected") + GS.add_points(1) + result["log"].append( + "Bell rings BACKWARD. The note un-announces the Custodian. " + "'I have SUCH a backlog,' Bell says, and stands with YOU.") + elif tid == "book_open": + self.cust_skip_next = True + result["log"].append( + "You hold up the Book, open. The Custodian reads. Margins do " + "not usually get to read.") + else: + raise ValueError("unknown talk option %r" % (tid,)) + + # ===================================================================== + # rewards + # ===================================================================== + def collect_rewards(self): + xp = 0 + gold = 0 + drops = [] + for e in self.enemies: + if e.alive: + continue + xp += getattr(e, "reward_xp", 0) + gold += getattr(e, "reward_gold", 0) + drop = getattr(e, "drop", None) + if drop and random.random() < drop[1]: + drops.append(drop[0]) + return xp, gold, drops + + +# ============================================================================= +# battle screen (presentation) +# ============================================================================= +VICTORY_SCENES = { + "uc_mimic": "mimic_post", + "boss_gatekeeper": "gatekeeper_post", + "boss_custodian": "custodian_post", +} + +_CMD = ["Attack", "Skill", "Item", "Guard", "Talk", "Swap", "Flee"] + + +class BattleScreen: + def __init__(self, pack_id, prev_scene, on_victory=None, on_defeat=None): + self.core = BattleCore(pack_id) + self.pack_id = pack_id + self.prev_scene = prev_scene + self.on_victory = on_victory + self.on_defeat = on_defeat + self.barker = Barker() + + self.area = getattr(GS, "current_area", "hollowbrook") + if self.area not in BATTLE_BACKDROPS: + self.area = "hollowbrook" + + self.timers = [] + self.log_lines = [] + self.menus = [] # live modal widgets to destroy on teardown + self.state = "boot" + self.cur_actor = None + self.cur_forced = None + self._ended = False + self._auto = None # test seam: policy(screen, actor)->action dict + + self.scene = mcrfpy.Scene("battle") + self.ch = self.scene.children + mcrfpy.current_scene = self.scene + self.stack = InputStack(self.scene) + + self._build_backdrop() + self._build_enemies() + self._build_party_cards() + self._build_ribbon() + self._build_log_panel() + + # kick off + self.core.begin_round() + self._schedule(self._advance, 350) + + # ---- timer helper ------------------------------------------------------ + def _schedule(self, fn, ms): + name = "bat_%d" % (len(self.timers) + random.randint(0, 1 << 20)) + + def cb(timer, runtime): + timer.stop() + if self._ended and fn not in (self._teardown,): + return + fn() + t = mcrfpy.Timer(name, cb, ms) + self.timers.append(t) + return t + + def _stop_timers(self): + for t in self.timers: + try: + t.stop() + except Exception: + pass + self.timers = [] + + # ===================================================================== + # scene construction + # ===================================================================== + def _build_backdrop(self): + bands = BATTLE_BACKDROPS[self.area] + band_h = SCREEN_H / len(bands) + for i, col in enumerate(bands): + f = mcrfpy.Frame(pos=(0, int(i * band_h)), + size=(SCREEN_W, int(band_h) + 1), + fill_color=palette.C(col)) + f.z_index = -100 + self.ch.append(f) + floor = mcrfpy.Frame(pos=(0, FLOOR_Y), size=(SCREEN_W, 3), + fill_color=palette.C((20, 20, 26))) + floor.z_index = -90 + self.ch.append(floor) + + def _enemy_base_x(self, n): + return list(_ENEMY_SLOTS.get(n, _ENEMY_SLOTS[3])) + + def _build_enemies(self): + n = len(self.core.enemies) + xs = self._enemy_base_x(n) + self.core._used_slots = list(xs) + for i, e in enumerate(self.core.enemies): + cx = xs[i] if i < len(xs) else _SUMMON_SLOTS[i % len(_SUMMON_SLOTS)] + self._make_enemy_view(e, cx) + + def _make_enemy_view(self, e, cx): + tex = e.tex if e.tex is not None else assets.TEX + px = int(cx - 8 * e.scale) + top = int(FLOOR_Y - 16 * e.scale) + halos = [] + if e.enemy_id == "custodian": + halos = self._custodian_halos(cx, top + int(8 * e.scale)) + spr = mcrfpy.Sprite(pos=(px, top), texture=tex, + sprite_index=e.sprite_index) + spr.scale = e.scale + spr.z_index = 10 + self.ch.append(spr) + name = ui.Label(self.ch, e.name, (cx, top - 30), color=palette.PARCH, + size=palette.SMALL_SIZE, center=True, outline=1) + bar = ui.Bar(self.ch, (int(cx - 42), top - 16), (84, 10), + palette.BLOOD, e.hp, e.max_hp, show_text=False) + e.view = {"sprite": spr, "name": name, "bar": bar, "cx": cx, + "halos": halos} + + def _custodian_halos(self, cx, cy): + out = [] + for i, r in enumerate((90, 64)): + h = mcrfpy.Frame(pos=(int(cx - r), int(cy - r)), size=(2 * r, 2 * r), + fill_color=palette.C((0, 0, 0, 0)), + outline=3, outline_color=palette.C(palette.GOLD_T, 150)) + h.origin = (r, r) + h.z_index = 5 + self.ch.append(h) + h.animate("rotation", 360.0, 9.0 + 3 * i, mcrfpy.Easing.LINEAR, + loop=True) + out.append(h) + return out + + def _build_party_cards(self): + self.card_y = [84, 246, 408] + for i in range(3): + self._make_card(i) + self._refresh_cards() + + def _make_card(self, i): + if i >= len(self.core.party): + self.core.__dict__.setdefault("_cardslots", None) + y = self.card_y[i] + panel = ui.Panel(self.ch, (724, y), (284, 150)) + return panel + + def _refresh_cards(self): + # rebuild party card contents from scratch (simple + robust) + if hasattr(self, "_cards"): + for c in self._cards: + c.destroy() + self._cards = [] + for i in range(3): + y = self.card_y[i] + outline = palette.GOLD if (i < len(self.core.party) and + self.core.party[i] is self.cur_actor) \ + else palette.OUTLINE + panel = ui.Panel(self.ch, (724, y), (284, 150), + outline_color=outline) + self._cards.append(panel) + if i >= len(self.core.party): + ui.Label(panel.children, "(empty)", (142, 70), + color=palette.DIM, size=palette.SMALL_SIZE, center=True) + continue + c = self.core.party[i] + chip = mcrfpy.Frame(pos=(12, 12), size=(56, 56), + fill_color=palette.INSET, outline=2, + outline_color=palette.OUTLINE) + panel.children.append(chip) + tex = assets.TEX + spr = mcrfpy.Sprite(pos=(8, 8), texture=tex, + sprite_index=c.sprite_index) + spr.scale = 2.5 + chip.children.append(spr) + ui.Label(panel.children, c.name, (80, 14), color=palette.PARCH, + size=palette.NAME_SIZE) + ui.Label(panel.children, "Lv %d" % (c.cstate.level if c.cstate else 1), + (228, 14), color=palette.GOLD, size=palette.SMALL_SIZE) + hp = ui.Bar(panel.children, (80, 46), (192, 18), palette.BLOOD, + c.hp, c.max_hp) + sp = ui.Bar(panel.children, (80, 70), (192, 16), palette.TEAL, + c.sp, c.max_sp) + status = "" + if not c.alive: + status = "DOWN" + elif c.redact_turns > 0: + status = "Redacted" + elif c.guarding: + status = "Guarding" + ui.Label(panel.children, status, (12, 118), color=palette.DIM, + size=palette.SMALL_SIZE) + c.view = {"panel": panel, "hp": hp, "sp": sp, "spr": spr} + + def _build_ribbon(self): + self.ribbon_frame = mcrfpy.Frame(pos=(16, 8), size=(690, 52), + fill_color=palette.C(palette.PANEL_T, 180), + outline=2, outline_color=palette.OUTLINE) + self.ribbon_frame.z_index = 2000 + self.ch.append(self.ribbon_frame) + self.ribbon_chips = [] + self._refresh_ribbon() + + def _refresh_ribbon(self): + for c in self.ribbon_chips: + try: + self.ribbon_frame.children.remove(c) + except Exception: + pass + self.ribbon_chips = [] + order = self.core.order if self.core.order else \ + sorted([c for c in self.core.all() if c.alive], + key=lambda c: -c.stat("SPD")) + x = 8 + tex = assets.TEX + for c in order: + if not c.alive: + continue + hot = (c is self.cur_actor) + chip = mcrfpy.Frame(pos=(x, 6), size=(40, 40), + fill_color=palette.INSET, outline=2, + outline_color=palette.GOLD if hot else palette.OUTLINE) + self.ribbon_frame.children.append(chip) + self.ribbon_chips.append(chip) + t = c.tex if (c.side == "enemy" and c.tex is not None) else tex + spr = mcrfpy.Sprite(pos=(4, 4), texture=t, sprite_index=c.sprite_index) + spr.scale = 2.0 + chip.children.append(spr) + x += 46 + if x > 660: + break + + def _build_log_panel(self): + self.log_panel = ui.Panel(self.ch, (16, 500), (690, 52)) + self.log_caps = [] + for i in range(3): + cap = ui.Label(self.log_panel.children, "", (12, 6 + i * 15), + color=palette.DIM, size=palette.SMALL_SIZE) + self.log_caps.append(cap) + + # ---- log --------------------------------------------------------------- + def push_log(self, line): + if not line: + return + self.log_lines.append(line) + self.log_lines = self.log_lines[-3:] + for i, cap in enumerate(self.log_caps): + cap.text = self.log_lines[i] if i < len(self.log_lines) else "" + + # ===================================================================== + # turn loop + # ===================================================================== + def _advance(self): + if self._ended: + return + na = self.core.next_actor() + if na is None: + end_res = self.core.end_round() + for ln in end_res["log"]: + self.push_log(ln) + self._play_fx(end_res["fx"]) + if self.core.check_end(): + self._schedule(self._on_battle_end, 500) + return + self.core.begin_round() + self._refresh_ribbon() + self._schedule(self._advance, 300) + return + self.cur_actor, self.cur_forced = na + self._refresh_ribbon() + self._refresh_cards() + if self.cur_forced is not None: + self._resolve(self.cur_actor, self.cur_forced) + elif self.cur_actor.side == "enemy": + action = self.core.ai_action(self.cur_actor) + self._resolve(self.cur_actor, action) + else: + self._open_command_menu(self.cur_actor) + + def _resolve(self, actor, action): + result = self.core.resolve(actor, action) + for ln in result["log"]: + self.push_log(ln) + if result["bark"]: + bl = self.barker.line(*result["bark"]) + if bl: + self.push_log("%s: %s" % (result["bark"][0].title(), bl)) + if result["swap"]: + self._refresh_cards() + self._play_fx(result["fx"]) + if result["flee"]: + self.core.result = "flee" + self._schedule(self._on_battle_end, 400) + return + # low-HP barks + for p in self.core.alive_party(): + if p.hp <= p.max_hp * 0.30 and not getattr(p, "_barked_low", False): + p._barked_low = True + bl = self.barker.line(p.char_id, "hurt_low") + if bl: + self.push_log("%s: %s" % (p.char_id.title(), bl)) + elif p.hp > p.max_hp * 0.30: + p._barked_low = False + if self.core.check_end(): + self._schedule(self._on_battle_end, 550) + return + self._schedule(self._advance, 500) + + # ---- fx player --------------------------------------------------------- + def _play_fx(self, fx): + for e in fx: + k = e["kind"] + if k == "lunge": + self._lunge(e["actor"], e["target"]) + elif k == "hit": + self._show_hit(e["target"], e["amount"], e["crit"]) + elif k == "heal": + self._show_heal(e["target"], e["amount"]) + elif k == "sp": + self._update_target_bars(e["target"]) + elif k == "miss": + self._float_over(e["target"], "miss", palette.DIM) + elif k == "debuff": + self._float_over(e["target"], "-", palette.TEAL) + elif k == "guard": + self._update_target_bars(e["target"]) + elif k == "death": + self._death(e["target"]) + elif k == "revive": + self._refresh_cards() + elif k == "summon": + self._summon_view(e["target"]) + elif k == "shake": + self._screen_shake() + self._update_all_bars() + + def _center_of(self, c): + if c.side == "enemy" and c.view: + top = int(FLOOR_Y - 16 * c.scale) + return (c.view["cx"], top + int(8 * c.scale)) + if c.side == "party" and c.view: + i = self.core.party.index(c) if c in self.core.party else 0 + return (760, self.card_y[i] + 40) + return (300, 300) + + def _lunge(self, actor, target): + if actor.side == "enemy" and actor.view: + spr = actor.view["sprite"] + ox = spr.x + spr.animate("x", ox + 24, 0.12, mcrfpy.Easing.EASE_OUT, + callback=lambda *_a: spr.animate("x", ox, 0.12, + mcrfpy.Easing.EASE_IN)) + elif actor.side == "party" and actor.view: + spr = actor.view["spr"] + ox = spr.x + spr.animate("x", ox - 24, 0.12, mcrfpy.Easing.EASE_OUT, + callback=lambda *_a: spr.animate("x", ox, 0.12, + mcrfpy.Easing.EASE_IN)) + + def _show_hit(self, target, amount, crit): + cx, cy = self._center_of(target) + color = palette.PARCH if target.side == "enemy" else palette.BLOOD + tween.float_text(self.ch, str(amount), (cx - 10, cy), color, size=24) + if crit: + tween.float_text(self.ch, "CRIT!", (cx - 20, cy - 26), palette.GOLD, + size=20) + + def _show_heal(self, target, amount): + cx, cy = self._center_of(target) + tween.float_text(self.ch, "+%d" % amount, (cx - 8, cy), palette.GRASS, + size=22) + + def _float_over(self, target, text, color): + cx, cy = self._center_of(target) + tween.float_text(self.ch, text, (cx, cy), color, size=18) + + def _death(self, target): + if target.side == "enemy" and target.view: + spr = target.view["sprite"] + spr.animate("opacity", 0.0, 0.45, mcrfpy.Easing.EASE_IN) + for key in ("name",): + try: + self.ch.remove(target.view[key]) + except Exception: + pass + target.view["bar"].destroy() + + def rm(*_a): + try: + self.ch.remove(spr) + except Exception: + pass + self._schedule(rm, 480) + else: + self._refresh_cards() + + def _summon_view(self, e): + cx = None + used = getattr(self.core, "_used_slots", []) + for s in _SUMMON_SLOTS: + if s not in used: + cx = s + break + if cx is None: + cx = _SUMMON_SLOTS[len(used) % len(_SUMMON_SLOTS)] + used.append(cx) + self.core._used_slots = used + self._make_enemy_view(e, cx) + self._refresh_ribbon() + + def _screen_shake(self): + pass # backdrop is full-bleed; shake individual party cards instead + for c in self.core.alive_party(): + if c.view: + tween.shake(c.view["panel"].frame, mag=6, dur=0.25) + + def _update_target_bars(self, target): + self._update_all_bars() + + def _update_all_bars(self): + for e in self.core.enemies: + if e.view and e.alive: + e.view["bar"].set(e.hp, e.max_hp) + for c in self.core.party: + if c.view: + c.view["hp"].set(c.hp, c.max_hp) + c.view["sp"].set(c.sp, c.max_sp) + + # ===================================================================== + # command menu + submenus + # ===================================================================== + def _clear_menus(self): + for m in self.menus: + try: + m.destroy() + except Exception: + pass + self.menus = [] + for nm in ("cmd", "sub", "target"): + self.stack.pop(nm) + if hasattr(self, "_picker") and self._picker: + self._picker.destroy() + self._picker = None + + def set_autopilot(self, policy): + """Test seam: install policy(screen, actor)->action-dict. On each player + turn the command is taken from the policy and driven through the REAL + _commit/_resolve path instead of opening the on-screen command menu. + Returning None falls through to the interactive menu.""" + self._auto = policy + + def _open_command_menu(self, actor): + if self._auto is not None: + action = self._auto(self, actor) + if action is not None: + self._commit(actor, action) + return + self._clear_menus() + self.state = "command" + has_skills = bool(chardata.learned_skills(actor.char_id, + actor.cstate.level)) and actor.redact_turns == 0 + has_items = any(itemdata.is_consumable(i) and n > 0 + for i, n in GS.inventory.items()) + talk_opts = self.core.boss_talk_options() if self.core.is_boss else [] + bench = self._bench() + items = [ + ("Attack", "attack", True, ""), + ("Skill", "skill", has_skills, + "redacted" if actor.redact_turns > 0 else "no SP/skills"), + ("Item", "item", has_items, "none"), + ("Guard", "guard", True, ""), + ] + if talk_opts: + items.append(("Talk", "talk", True, "")) + if bench: + items.append(("Swap", "swap", True, "")) + items.append(("Flee", "flee", not self.core.is_boss, "no escape")) + menu = ui.MenuList(self.ch, (16, 560), 240, items, + on_pick=lambda v: self._cmd_pick(actor, v), + on_cancel=None, title=actor.name) + self.menus.append(menu) + self.stack.push(menu.handle, "cmd") + + def _bench(self): + in_battle = {c.char_id for c in self.core.party} + return [cid for cid in GS.roster + if cid not in in_battle and GS.roster[cid].alive] + + def _cmd_pick(self, actor, v): + if v == "attack": + self._pick_target(actor, self.core.alive_enemies(), + lambda tgt: self._commit(actor, + {"type": "attack", "target": tgt})) + elif v == "guard": + self._commit(actor, {"type": "guard"}) + elif v == "flee": + self._commit(actor, {"type": "flee"}) + elif v == "skill": + self._open_skill_menu(actor) + elif v == "item": + self._open_item_menu(actor) + elif v == "talk": + self._open_talk_menu(actor) + elif v == "swap": + self._open_swap_menu(actor) + + def _open_skill_menu(self, actor): + self.stack.pop("cmd") + skills = chardata.learned_skills(actor.char_id, actor.cstate.level) + rows = [] + for s in skills: + afford = actor.sp >= s["sp"] + label = "%s (%d SP)" % (s["name"], s["sp"]) + rows.append((label, s, afford, "low SP")) + menu = ui.MenuList(self.ch, (16, 470), 300, rows, + on_pick=lambda s: self._skill_chosen(actor, s), + on_cancel=lambda: self._back_to_command(actor), + title="Skill") + self.menus.append(menu) + self.stack.push(menu.handle, "sub") + + def _skill_chosen(self, actor, skill): + tgt = skill["target"] + if tgt == "enemy": + self._pick_target(actor, self.core.alive_enemies(), + lambda t: self._commit(actor, + {"type": "skill", "skill": skill, "target": t})) + elif tgt == "ally": + self._pick_target(actor, self.core.alive_party(), + lambda t: self._commit(actor, + {"type": "skill", "skill": skill, "target": t})) + else: # all_enemies / party / self + self._commit(actor, {"type": "skill", "skill": skill, "target": None}) + + def _open_item_menu(self, actor): + self.stack.pop("cmd") + rows = [] + for iid, n in GS.inventory.items(): + if not itemdata.is_consumable(iid) or n <= 0: + continue + rec = itemdata.item(iid) + rows.append(("%s x%d" % (rec["name"], n), iid, True, "")) + if not rows: + rows = [("(no items)", None, False, "")] + menu = ui.MenuList(self.ch, (16, 470), 280, rows, + on_pick=lambda iid: self._item_chosen(actor, iid), + on_cancel=lambda: self._back_to_command(actor), + title="Item") + self.menus.append(menu) + self.stack.push(menu.handle, "sub") + + def _item_chosen(self, actor, iid): + if iid is None: + return + rec = itemdata.item(iid) + if rec["effect"] == "aoe_damage": + self._commit(actor, {"type": "item", "item": iid, "target": None}) + else: + self._pick_target(actor, self.core.party, # allow reviving the downed + lambda t: self._commit(actor, + {"type": "item", "item": iid, "target": t})) + + def _open_talk_menu(self, actor): + self.stack.pop("cmd") + opts = self.core.boss_talk_options() + rows = [(o["label"], o["id"], o["enabled"], o["reason"]) for o in opts] + menu = ui.MenuList(self.ch, (16, 430), 470, rows, + on_pick=lambda tid: self._commit(actor, + {"type": "talk", "talk": tid}), + on_cancel=lambda: self._back_to_command(actor), + title="Talk") + self.menus.append(menu) + self.stack.push(menu.handle, "sub") + + def _open_swap_menu(self, actor): + self.stack.pop("cmd") + bench = self._bench() + rows = [(cid.title(), cid, True, "") for cid in bench] + menu = ui.MenuList(self.ch, (16, 470), 240, rows, + on_pick=lambda cid: self._commit(actor, + {"type": "swap", "swap_to": cid}), + on_cancel=lambda: self._back_to_command(actor), + title="Swap in") + self.menus.append(menu) + self.stack.push(menu.handle, "sub") + + def _back_to_command(self, actor): + self._clear_menus() + self._open_command_menu(actor) + + # ---- target picker ----------------------------------------------------- + def _pick_target(self, actor, targets, on_done): + targets = [t for t in targets if t.alive] or list(targets) + if not targets: + return + self.stack.pop("sub") + self.stack.pop("cmd") + self._picker = _TargetPicker(self, targets, on_done, + lambda: self._back_to_command(actor)) + self.stack.push(self._picker.handle, "target") + + def _commit(self, actor, action): + self._clear_menus() + self.state = "resolving" + self._resolve(actor, action) + + # ===================================================================== + # end of battle + # ===================================================================== + def _on_battle_end(self): + self._clear_menus() + res = self.core.result + if res == "victory": + self._victory() + elif res == "flee": + self._teardown_and_restore() + else: + self._defeat() + + def _victory(self): + xp, gold, drops = self.core.collect_rewards() + events = GS.xp_gain(xp) + GS.add_gold(gold) + for d in drops: + GS.grant(d, 1) + # panel + self._clear_menus() + panel = ui.Panel(self.ch, (312, 220), (400, 300), + outline_color=palette.GOLD, z_index=7000) + self.menus.append(panel) + ui.Label(panel.children, "VICTORY", (200, 30), color=palette.GOLD, + size=palette.BANNER_SIZE, center=True) + y = 90 + ui.Label(panel.children, "XP +%d" % xp, (40, y), color=palette.GRASS, + size=palette.BODY_SIZE) + ui.Label(panel.children, "Cogs +%d" % gold, (40, y + 26), + color=palette.GOLD, size=palette.BODY_SIZE) + yy = y + 56 + for d in drops: + ui.Label(panel.children, "Found %s" % itemdata.item(d)["name"], + (40, yy), color=palette.PARCH, size=palette.SMALL_SIZE) + yy += 22 + for cid, lvl in events: + ui.Toast(self.ch, "%s reached Lv %d" % (cid.title(), lvl), + color=palette.GRASS) + if lvl == 4: + ui.Toast(self.ch, "%s: Skill learned!" % cid.title(), + color=palette.GOLD) + ui.Label(panel.children, "press ENTER", (200, 262), color=palette.DIM, + size=palette.SMALL_SIZE, center=True) + self.stack.clear() + self.stack.push(self._victory_key, "victory") + + def _victory_key(self, key, state): + if state != mcrfpy.InputState.PRESSED: + return True + if key in (mcrfpy.Key.ENTER, mcrfpy.Key.SPACE): + self._finish_victory() + return True + + def _finish_victory(self): + self._teardown() + if self.prev_scene is not None: + mcrfpy.current_scene = self.prev_scene + if self.pack_id in VICTORY_SCENES: + self._run_return_scene(VICTORY_SCENES[self.pack_id]) + elif self.on_victory: + self.on_victory() + + def _run_return_scene(self, scene_id): + """Run a post-battle dialogue on the restored scene. If we returned to + the live overworld, reuse ITS input stack - creating a fresh InputStack + would reassign scene.on_key and orphan overworld movement - and keep the + overworld frozen for the scene's duration.""" + from systems import dialogue, overworld + w = overworld.WORLD + if w is not None and self.prev_scene is w.scene: + w.frozen = True + w.held = [] + + def _after(): + if overworld.WORLD is w: + w.frozen = False + w.refresh_theme() + w.refresh_hud() + dialogue.run_scene(scene_id, on_done=_after, stack=w.stack) + else: + dialogue.run_scene(scene_id) + + def _defeat(self): + self._clear_menus() + cover = mcrfpy.Frame(pos=(0, 0), size=(SCREEN_W, SCREEN_H), + fill_color=palette.C(palette.INK_T, 0)) + cover.z_index = 8000 + self.ch.append(cover) + cover.animate("opacity", 1.0, 0.6, mcrfpy.Easing.EASE_IN_OUT) + panel = ui.Panel(self.ch, (262, 300), (500, 160), + outline_color=palette.BLOOD, z_index=8100) + self.menus.append(panel) + ui.Label(panel.children, "The vale goes back to waiting.", (250, 60), + color=palette.PARCH, size=palette.BODY_SIZE, center=True) + ui.Label(panel.children, "press ENTER", (250, 120), color=palette.DIM, + size=palette.SMALL_SIZE, center=True) + self.stack.clear() + self.stack.push(self._defeat_key, "defeat") + + def _defeat_key(self, key, state): + if state != mcrfpy.InputState.PRESSED: + return True + if key in (mcrfpy.Key.ENTER, mcrfpy.Key.SPACE): + self._teardown() + if self.on_defeat: + self.on_defeat() + else: + import main + main.build_title() + return True + + def _teardown_and_restore(self): + self._teardown() + if self.prev_scene is not None: + mcrfpy.current_scene = self.prev_scene + + def _teardown(self): + self._ended = True + self._stop_timers() + self._clear_menus() + self.stack.clear() + + +# ============================================================================= +# target picker (modal reticle over a candidate target) +# ============================================================================= +class _TargetPicker: + """A modal input handler pushed on the battle InputStack while the player + chooses a target. Left/Right (or W/S/A/D) cycle candidates, Enter/Space + confirm (on_done(target)), Esc cancels (on_cancel()). Draws a gold reticle + over the current candidate. This was referenced by BattleScreen._pick_target + but never defined - implemented here (Agent D integration).""" + + def __init__(self, screen, targets, on_done, on_cancel): + self.screen = screen + self.targets = [t for t in targets if t.alive] or list(targets) + self.on_done = on_done + self.on_cancel = on_cancel + self.idx = 0 + self.reticle = mcrfpy.Frame(pos=(0, 0), size=(10, 10), + fill_color=palette.C((0, 0, 0, 0)), + outline=3, outline_color=palette.GOLD) + self.reticle.z_index = 6800 + screen.ch.append(self.reticle) + self._place() + + def _place(self): + if not self.targets: + return + t = self.targets[self.idx] + cx, cy = self.screen._center_of(t) + if t.side == "enemy": + half = int(10 * getattr(t, "scale", 4.0)) + else: + half = 90 + self.reticle.w = 2 * half + self.reticle.h = 2 * half + self.reticle.x = int(cx - half) + self.reticle.y = int(cy - half) + + def _cycle(self, delta): + alive = [t for t in self.targets if t.alive] + if not alive: + return + # keep idx pointing at a live target, then step + cur = self.targets[self.idx] if self.idx < len(self.targets) else None + if cur not in alive: + self.targets = alive + self.idx = 0 + else: + self.targets = alive + self.idx = self.targets.index(cur) + self.idx = (self.idx + delta) % len(self.targets) + self._place() + + def handle(self, key, state): + if state != mcrfpy.InputState.PRESSED: + return True + K = mcrfpy.Key + if key in (K.A, K.LEFT, K.W, K.UP): + self._cycle(-1) + return True + if key in (K.D, K.RIGHT, K.S, K.DOWN): + self._cycle(1) + return True + if key in (K.ENTER, K.SPACE): + alive = [t for t in self.targets if t.alive] + if alive: + tgt = self.targets[self.idx] if self.targets[self.idx].alive \ + else alive[0] + self.on_done(tgt) + return True + if key == K.ESCAPE: + if self.on_cancel: + self.on_cancel() + return True + return True + + def destroy(self): + try: + self.screen.ch.remove(self.reticle) + except Exception: + pass + + +# ============================================================================= +# public entry + hook registration +# ============================================================================= +# The most recently created BattleScreen (test seam: a scripted playthrough +# needs a handle on a battle that a dialogue action started internally, since +# the dialogue runner discards start_battle's return value). +_LAST_SCREEN = None + +# Test seam: if set, every battle is created with this autopilot already +# installed, so a battle a *dialogue* action starts internally is auto-driven +# from turn one (there is no chance to call set_autopilot before its first +# player turn opens the command menu). None in normal play. +DEFAULT_AUTOPILOT = None + + +def start_battle(pack_id, on_victory=None, on_defeat=None): + """Build a battle scene for `pack_id`, run it, restore the previous scene + on completion. Returns the BattleScreen (mostly useful for tests).""" + global _LAST_SCREEN + prev = mcrfpy.current_scene + _LAST_SCREEN = BattleScreen(pack_id, prev, on_victory, on_defeat) + if DEFAULT_AUTOPILOT is not None: + _LAST_SCREEN.set_autopilot(DEFAULT_AUTOPILOT) + return _LAST_SCREEN + + +# register the dialogue hook (dialogue calls HOOKS["battle"](pack_id)) +from systems import dialogue as _dialogue +_dialogue.HOOKS["battle"] = start_battle diff --git a/games/unwritten/systems/dialogue.py b/games/unwritten/systems/dialogue.py new file mode 100644 index 0000000..cf341a0 --- /dev/null +++ b/games/unwritten/systems/dialogue.py @@ -0,0 +1,291 @@ +"""UNWRITTEN - dialogue runner. Owner: Agent A. + +run_scene(scene_id, on_done=None, stack=None) loads a node dict from the merged +SCENES of data.script_act1/2/3, drives a core.ui.DialogueBox through the +InputStack, applies effects/actions to state.GS, and hands off scene-changing +actions (shop, swap_menu, battle, goto_area, epilogue) to the HOOKS registry +that Agent B/C register. + +Supported constructs (every one actually used by the authored scripts): + req: None | ("tag",X) | ("flag",X) | ("flag_not",X) | ("party",X) + | ("points",N) | ("item",item_id) + effects: ("flag",x) | ("points",n) | ("gold",n) | ("item",id,n) [n<0 removes] + | ("act",n) | ("key_item_remove",id) + actions: end | shop | heal_party | swap_menu | recruit:CHARID:LEVEL + | battle:pack_id | goto_area:area:spawn | epilogue | act:N +""" +import mcrfpy +from core import ui +from core.inputstack import InputStack +from systems.state import GS + +# --------------------------------------------------------------------- HOOKS +# Other agents register scene-changing handlers here, e.g. +# systems.dialogue.HOOKS["battle"] = battle.start_from_dialogue +# Signatures: +# HOOKS["shop"]() (Agent C) +# HOOKS["swap_menu"]() (Agent C) - should be modal/returning +# HOOKS["battle"](pack_id) (Agent B) +# HOOKS["goto_area"](area, spawn) (Agent C) +# HOOKS["epilogue"]() (Agent C) +HOOKS = {} + +# The runner currently driving a dialogue, if any. Exposed as a test seam so a +# scripted playthrough can drive the REAL runner (apply effects, goto nodes, +# fire deferred actions) by index instead of injecting keypresses. None when no +# dialogue is open. See select_choice()/advance() below. +CURRENT = None + + +def _hook(name): + if name not in HOOKS: + raise KeyError( + "dialogue action '%s' needs a handler: set " + "systems.dialogue.HOOKS['%s'] (owning agent has not registered it)" + % (name, name)) + return HOOKS[name] + + +# ----------------------------------------------------------------- scene lookup +def _all_scenes(): + scenes = {} + from data import script_act1, script_act2, script_act3 + for mod in (script_act1, script_act2, script_act3): + for k, v in mod.SCENES.items(): + if k in scenes: + raise KeyError("duplicate dialogue scene id %r across script " + "files" % (k,)) + scenes[k] = v + return scenes + + +# ---------------------------------------------------------------- req / effects +def req_met(req): + if req is None: + return True + kind = req[0] + if kind == "flag": + return GS.has(req[1]) + if kind == "flag_not": + return not GS.has(req[1]) + if kind == "tag": + return req[1] in GS.party_tags() + if kind == "party": + return GS.in_party(req[1]) + if kind == "points": + return GS.points >= req[1] + if kind == "item": + return GS.inventory.get(req[1], 0) > 0 + raise ValueError("unknown req form %r" % (req,)) + + +def apply_effects(effects): + for e in effects or []: + kind = e[0] + if kind == "flag": + GS.add_flag(e[1]) + elif kind == "points": + GS.add_points(e[1]) # triggers the Book hum via GS.hum_hook + elif kind == "gold": + GS.add_gold(e[1]) + elif kind == "item": + n = e[2] + if n >= 0: + GS.grant(e[1], n) + else: + GS.take(e[1], -n) + elif kind == "act": + GS.act = int(e[1]) + elif kind == "key_item_remove": + GS.remove_key_item(e[1]) + else: + raise ValueError("unknown effect form %r" % (e,)) + + +# actions that fire when the player ADVANCES past a text node (deferred) +_DEFERRED = ("end", "shop", "swap_menu", "battle", "goto_area", "epilogue") +# transfer actions hand the scene off entirely; the runner finishes afterward +_TRANSFER = ("shop", "battle", "goto_area", "epilogue") + + +def _do_recruit(action): + _, char_id, level = action.split(":") + GS.recruit(char_id, int(level)) + + +class _Runner: + def __init__(self, scene, scene_dict, on_done, stack): + self.scene = scene + self.sc = scene_dict + self.on_done = on_done + self.owns_stack = stack is None + self.stack = stack if stack is not None else InputStack(scene) + self.box = ui.DialogueBox(scene.children) + self.node = None # current node (test seam) + GS.hum_hook = self.box.hum + self.stack.push(self.box.handle, "dialogue") + global CURRENT + CURRENT = self + + # ---- lifecycle --------------------------------------------------------- + def start(self): + self.goto(self.sc["start"]) + + def finish(self): + global CURRENT + if CURRENT is self: + CURRENT = None + self.node = None + self.box.destroy() + self.stack.pop("dialogue") + GS.hum_hook = None + if self.on_done: + self.on_done() + + # ---- node flow --------------------------------------------------------- + def goto(self, node_id): + if node_id not in self.sc["nodes"]: + raise KeyError("dialogue target node %r not found" % (node_id,)) + node = self.sc["nodes"][node_id] + self.enter(node) + + def enter(self, node): + # 1. enter-time effects + apply_effects(node.get("effects")) + # 2. enter-time (immediate) actions + action = node.get("action") + if action: + name = action.split(":")[0] + if name == "heal_party": + GS.heal_party() + elif name == "recruit": + _do_recruit(action) + elif name == "act": + GS.act = int(action.split(":")[1]) + # 3. present + self.present(node) + + def present(self, node): + self.node = node + speaker = node.get("speaker", "NARRATOR") + text = node.get("text", "") + choices = node.get("choices") + if choices: + display = [(c["label"], req_met(c.get("req"))) for c in choices] + self.box.show_node(speaker, text, choices=display, + on_choice=lambda i, n=node: self.pick(n, i)) + else: + self.box.show_node(speaker, text, + on_advance=lambda n=node: self.advance(n)) + + def pick(self, node, index): + choice = node["choices"][index] + apply_effects(choice.get("effects")) + nxt = choice.get("next") + if nxt is None: + raise KeyError("choice %r in a dialogue node has no 'next'" + % (choice.get("label"),)) + self.goto(nxt) + + def advance(self, node): + action = node.get("action") + if action: + name = action.split(":")[0] + if name in _DEFERRED: + self._do_deferred(action, name, node) + return + nxt = node.get("next") + if nxt is not None: + self.goto(nxt) + else: + self.finish() + + def _do_deferred(self, action, name, node): + if name == "end": + self.finish() + return + if name == "battle": + pack = action.split(":", 1)[1] + _hook("battle")(pack) + self.finish() + return + if name == "goto_area": + _, area, spawn = action.split(":") + _hook("goto_area")(area, spawn) + self.finish() + return + if name == "epilogue": + _hook("epilogue")() + self.finish() + return + if name == "shop": + _hook("shop")() + self.finish() + return + if name == "swap_menu": + # returning hook: run it, then continue if the node chains onward + _hook("swap_menu")() + nxt = node.get("next") + if nxt is not None: + self.goto(nxt) + else: + self.finish() + return + raise ValueError("unhandled deferred action %r" % (action,)) + + +def run_scene(scene_id, on_done=None, stack=None): + """Run a dialogue scene on mcrfpy.current_scene. If `stack` (an InputStack) + is given, the dialogue pushes onto it (so overworld movement stays frozen + beneath); otherwise a fresh InputStack is bound to the scene.""" + scene = mcrfpy.current_scene + scenes = _all_scenes() + if scene_id not in scenes: + raise KeyError("unknown dialogue scene id %r" % (scene_id,)) + runner = _Runner(scene, scenes[scene_id], on_done, stack) + runner.start() + return runner + + +# --------------------------------------------------------------------- test seam +def current_choice_labels(): + """Labels of the current node's choices as (label, enabled) tuples, or None + if the current node is not a choice node. For scripted playthroughs.""" + if CURRENT is None or CURRENT.node is None: + return None + choices = CURRENT.node.get("choices") + if not choices: + return None + return [(c["label"], req_met(c.get("req"))) for c in choices] + + +def select_choice(index): + """Pick choice `index` on the open dialogue node, driving the REAL runner + (applies the choice's effects, follows its 'next'). Raises if no choice node + is open or the choice is disabled by its req.""" + if CURRENT is None or CURRENT.node is None: + raise RuntimeError("select_choice: no dialogue open") + node = CURRENT.node + choices = node.get("choices") + if not choices: + raise RuntimeError("select_choice: current node has no choices") + if index < 0 or index >= len(choices): + raise IndexError("select_choice: index %d out of range (%d choices)" + % (index, len(choices))) + if not req_met(choices[index].get("req")): + raise RuntimeError("select_choice: choice %d is disabled (%r)" + % (index, choices[index].get("label"))) + CURRENT.pick(node, index) + + +def advance(): + """Advance the open text node (as if the player pressed space), driving the + REAL runner (fires deferred actions like battle/goto_area/shop/epilogue, or + follows 'next', or finishes). Raises if a choice is pending.""" + if CURRENT is None or CURRENT.node is None: + raise RuntimeError("advance: no dialogue open") + node = CURRENT.node + if node.get("choices"): + raise RuntimeError("advance: current node is a choice node; use " + "select_choice(i)") + CURRENT.advance(node) diff --git a/games/unwritten/systems/epilogue.py b/games/unwritten/systems/epilogue.py new file mode 100644 index 0000000..be562a9 --- /dev/null +++ b/games/unwritten/systems/epilogue.py @@ -0,0 +1,217 @@ +"""UNWRITTEN - the epilogue: the Book read aloud. Owner: Agent C. + +HOOKS["epilogue"]() renders data.epilogue.PAGES as full-screen INK pages with +centered PARCH text and a small GOLD ornament, one keypress per page, a soft +fade between them. The final card shows UNWRITTEN, then strikes it with a gold +line and stamps WRITTEN beneath. A last keypress returns to the title scene. + +Page conditions use the dialogue req schema (None / ("flag",x) / ("flag_not",x) +/ ("points",n)); {light_name} is substituted from the moth_light_name_* flag +per data/epilogue.py's module docstring. +""" +import mcrfpy + +from core import palette +from core import ui +from core import tween +from core.inputstack import InputStack +from systems import dialogue +from systems.state import GS + +_LIGHT_NAME = { + "moth_light_name_dark": "for the ones walking in the dark", + "moth_light_name_lost": "for everything the vale lost waiting", + "moth_light_name_small": "for suppers, and mending, and company", +} + + +def _light_name(): + for flag, phrase in _LIGHT_NAME.items(): + if GS.has(flag): + return phrase + return None + + +def _resolve(text): + if "{light_name}" in text: + name = _light_name() + if name is None: + raise KeyError("epilogue page needs {light_name} but no " + "moth_light_name_* flag is set") + text = text.replace("{light_name}", name) + return text + + +def _collect_pages(): + from data import epilogue as ep + out = [] + for cond, text in ep.PAGES: + if dialogue.req_met(cond): + out.append(_resolve(text)) + return out + + +class _Epilogue: + def __init__(self): + self.scene = mcrfpy.Scene("epilogue") + mcrfpy.current_scene = self.scene + self.ch = self.scene.children + self.ch.append(mcrfpy.Frame(pos=(0, 0), size=(1024, 768), + fill_color=palette.INK)) + self.pages = _collect_pages() + self.index = -1 + self.page_nodes = [] + self.final = False + self.done = False + self.stack = InputStack(self.scene) + self.stack.push(self._on_key, "epilogue") + + # ----------------------------------------------------------------- flow + def start(self): + self._advance() + + def _on_key(self, key, state): + if state != mcrfpy.InputState.PRESSED: + return True + if self.done: + self._return_to_title() + return True + self._advance() + return True + + def _advance(self): + # fade current page out, then show next + old = self.page_nodes + self.page_nodes = [] + for n in old: + n.animate("opacity", 0.0, 0.22, mcrfpy.Easing.EASE_IN, + callback=self._remove_cb(n)) + self.index += 1 + if self.index >= len(self.pages): + self._final_card() + return + mcrfpy.Timer("epi_next", self._show_page, 240) + + def _remove_cb(self, node): + def cb(*_a): + try: + self.ch.remove(node) + except Exception: + pass + return cb + + def _show_page(self, timer=None, runtime=None): + if timer is not None: + timer.stop() + text = self.pages[self.index] + nodes = [] + + # centered multi-line body + body = mcrfpy.Caption(pos=(0, 0), text=text, font_size=palette.BODY_SIZE + 4, + fill_color=palette.PARCH) + body.x = 512 - body.size.x / 2 + body.y = 384 - body.size.y / 2 + body.opacity = 0.0 + self.ch.append(body) + nodes.append(body) + + # gold ornament: a thin rule with a diamond, above the text + rule = mcrfpy.Frame(pos=(512 - 90, body.y - 34), size=(180, 2), + fill_color=palette.GOLD) + rule.opacity = 0.0 + self.ch.append(rule) + nodes.append(rule) + diamond = mcrfpy.Caption(pos=(0, 0), text="*", font_size=palette.NAME_SIZE, + fill_color=palette.GOLD) + diamond.x = 512 - diamond.size.x / 2 + diamond.y = body.y - 44 + diamond.opacity = 0.0 + self.ch.append(diamond) + nodes.append(diamond) + + # page count hint (DIM, small) + hint = mcrfpy.Caption(pos=(0, 712), text="press any key", + font_size=palette.SMALL_SIZE, fill_color=palette.DIM) + hint.x = 512 - hint.size.x / 2 + hint.opacity = 0.0 + self.ch.append(hint) + nodes.append(hint) + + for n in nodes: + n.animate("opacity", 1.0, 0.4, mcrfpy.Easing.EASE_OUT) + self.page_nodes = nodes + + # ----------------------------------------------------------------- final + def _final_card(self): + self.final = True + self.title = mcrfpy.Caption(pos=(0, 0), text="UNWRITTEN", + font_size=palette.TITLE_SIZE, + fill_color=palette.PARCH) + self.title.x = 512 - self.title.size.x / 2 + self.title.y = 300 + self.title.opacity = 0.0 + self.title.outline = 2 + self.title.outline_color = palette.INK + self.ch.append(self.title) + self.title.animate("opacity", 1.0, 0.6, mcrfpy.Easing.EASE_OUT) + mcrfpy.Timer("epi_stamp", self._stamp, 950) + + def _stamp(self, timer=None, runtime=None): + if timer is not None: + timer.stop() + # gold strike across UNWRITTEN + y = int(self.title.y + self.title.size.y / 2) + strike = mcrfpy.Frame(pos=(int(self.title.x), y), size=(0, 5), + fill_color=palette.GOLD) + strike.z_index = 10 + self.ch.append(strike) + strike.animate("w", self.title.size.x, 0.28, mcrfpy.Easing.EASE_OUT, + callback=self._stamp_word) + + def _stamp_word(self, *_a): + written = mcrfpy.Caption(pos=(0, 0), text="WRITTEN", + font_size=palette.TITLE_SIZE, + fill_color=palette.GOLD) + written.x = 512 - written.size.x / 2 + written.y = 410 + written.opacity = 0.0 + written.outline = 2 + written.outline_color = palette.INK + written.z_index = 20 + self.ch.append(written) + # thunk: fast opacity pop + a single shake + written.animate("opacity", 1.0, 0.12, mcrfpy.Easing.EASE_OUT) + tween.shake(written, mag=8, dur=0.25) + mcrfpy.Timer("epi_ready", self._ready, 700) + + def _ready(self, timer=None, runtime=None): + if timer is not None: + timer.stop() + self.done = True + + def _return_to_title(self): + self.stack.pop("epilogue") + import main + main.build_title() + + +# The live epilogue, if one is running (test seam: a scripted playthrough drives +# it by pressing keys on its scene and inspects .final/.done). None otherwise. +CURRENT = None + + +def start_epilogue(): + global CURRENT + if not GS.has("nyx_joined"): + GS.add_flag("nyx_missed") + ep = _Epilogue() + CURRENT = ep + ep.start() + return ep + + +def register_hooks(): + dialogue.HOOKS["epilogue"] = start_epilogue + + +register_hooks() diff --git a/games/unwritten/systems/overworld.py b/games/unwritten/systems/overworld.py new file mode 100644 index 0000000..e7b5f3f --- /dev/null +++ b/games/unwritten/systems/overworld.py @@ -0,0 +1,816 @@ +"""UNWRITTEN - the explorable world. Owner: Agent C. + +Builds Grid-based area scenes from data.maps.AREAS: ColorLayer terrain, a player +Entity with WASD/arrow movement, props/NPCs/encounters, exits, doors, camps, a +persistent HUD, FOV for the Undercroft, and the Act-3 grey-town beat. + +Public surface (ARCHITECTURE section 3): + enter_area(area_id, spawn="start") build/reuse "ow_" scene + refresh_area() re-theme after flag changes (grey town) + +Registers at import: + dialogue.HOOKS["goto_area"] = lambda area, spawn="start": _goto(area, spawn) + +Integration contract for Agent B (battle) / Agent D: + dialogue.HOOKS["battle"](pack_id, on_victory=None) must accept an optional + on_victory keyword. The overworld passes a callback that removes the bumped + encounter entity and remembers the kill via GS flag + f"enc____done" so it stays gone on re-entry. + data.enemies (Agent B, parallel) is imported LAZILY; it must expose either + pack_first_sprite(pack)->int OR PACKS[pack] (list) + ENEMIES[id]["sprite"]. +""" +import mcrfpy + +from core import palette +from core import assets +from core import ui +from core import tween +from core.inputstack import InputStack +from systems import dialogue +from systems.state import GS + +# ---------------------------------------------------------------- module state +WORLD = None # the live _World, or None +GREY_TEX = None # desaturated texture for pre-rewake NPCs (lazy) +TEST_ENEMY_FALLBACK = None # set to a sprite index by tests when data.enemies absent + +# rewakeable NPCs -> their story flag (Act-3 grey beat) +REWAKE_FLAG = {"QUILL": "rewoke_quill", "GRISELDA": "rewoke_griselda", + "ODD": "rewoke_odd"} + +_MOVE_MS = 140 # hold-to-repeat interval +_STEP_DUR = 0.12 # slide animation per step +_WANDER_MS = 900 + +_DIRS = { + mcrfpy.Key.W: (0, -1), mcrfpy.Key.UP: (0, -1), + mcrfpy.Key.S: (0, 1), mcrfpy.Key.DOWN: (0, 1), + mcrfpy.Key.A: (-1, 0), mcrfpy.Key.LEFT: (-1, 0), + mcrfpy.Key.D: (1, 0), mcrfpy.Key.RIGHT: (1, 0), +} + + +def _grey_tex(): + global GREY_TEX + if GREY_TEX is None: + GREY_TEX = assets.TEX.hsl_shift(0, -100, -15) + return GREY_TEX + + +def _clamp8(v): + return 0 if v < 0 else (255 if v > 255 else int(v)) + + +def _cell_offset(x, y): + """Deterministic small per-cell variation for '.' ground (+-4).""" + h = (x * 73856093) ^ (y * 19349663) + return ((h & 0xff) % 9) - 4 # -4..4 + + +# ===================================================================== _World +class _World: + def __init__(self, area_id, spawn): + if area_id not in _areas(): + raise KeyError("unknown area id %r (data.maps.AREAS)" % (area_id,)) + self.area_id = area_id + self.area = _areas()[area_id] + self.rows = self.area["map"] + self.h = len(self.rows) + self.w = max(len(r) for r in self.rows) + spawns = self.area["spawns"] + if spawn not in spawns: + raise KeyError("unknown spawn %r for area %r (have %r)" + % (spawn, area_id, list(spawns))) + self.spawn_cell = spawns[spawn] + + self.scene = None + self.grid = None + self.stack = None + self.player = None + self.terrain = None + self.fov = None + self.zoom = 1.0 + + self.prop_entities = {} # id -> Entity + self.door_entities = {} # (x,y) -> Entity + self.npc_entities = {} # id -> (Entity, npc_dict) + self.enc_entities = [] # list of (Entity, enc_dict) + self.markers = [] # scene-level gold marker Frames + self.hud = [] # scene-level HUD drawables + + self.held = [] # currently held direction vectors (recency) + self.frozen = False # True while a dialogue/menu is modal above + self._move_timer = None + self._wander_timer = None + self._rng = 1234567 + + # -------------------------------------------------------------- geometry + def _ch(self, x, y): + if 0 <= y < self.h and 0 <= x < len(self.rows[y]): + return self.rows[y][x] + return " " + + def _blocking_char(self, ch): + return ch in ("#", "~", " ") + + def _door_at(self, x, y): + for d in self.area.get("door_cells", []): + if tuple(d["pos"]) == (x, y): + return d + return None + + def walkable(self, x, y): + ch = self._ch(x, y) + d = self._door_at(x, y) + if d is not None: + return GS.has(d["flag"]) + return not self._blocking_char(ch) + + def cell_screen(self, x, y): + """Top-left screen pixel of cell. The map is anchored at grid.pos with + cells drawn at 16px * zoom, so scene-level overlays (markers) that must + stay glued to a cell scale with zoom too.""" + return (self._gx + x * 16 * self.zoom, self._gy + y * 16 * self.zoom) + + # -------------------------------------------------------------- build + def build(self): + self.scene = mcrfpy.Scene("ow_" + self.area_id) + mcrfpy.current_scene = self.scene + GS.current_area = self.area_id + + bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=palette.INK) + self.scene.children.append(bg) + + # BIBLE section 6 look: chunky 32px cells. grid.zoom scales the 16px + # atlas to 32px and (per Fable, verified) works in the headless render + # used for QA screenshots. We size the on-screen viewport to exactly the + # zoomed map and centre it; with size == map*zoom the engine's default + # camera (center = w/(2*zoom)) top-left-aligns tile (0,0), so a cell + # (x,y) lands at grid.pos + (x*16*zoom, y*16*zoom). All four maps fit + # 1024x768 at zoom 2 (hollowbrook 768x512, gearwood 832x576, + # undercroft 832x608, study 576x384). + self.zoom = 2.0 + map_w = self.w * 16 + map_h = self.h * 16 + disp_w = int(map_w * self.zoom) + disp_h = int(map_h * self.zoom) + gx = round((1024 - disp_w) / 2) + gy = round(max(40, (768 - disp_h) / 2)) + + # framing border behind the play area + border = mcrfpy.Frame(pos=(gx - 6, gy - 6), size=(disp_w + 12, disp_h + 12), + fill_color=palette.PANEL, outline=2, + outline_color=palette.OUTLINE) + self.scene.children.append(border) + + self.grid = mcrfpy.Grid(grid_size=(self.w, self.h), + pos=(gx, gy), size=(disp_w, disp_h), + zoom=self.zoom) + self.grid.fill_color = palette.INK + self.grid.z_index = 10 + self.scene.children.append(self.grid) + self._gx, self._gy = gx, gy + self._follow = False + + # terrain layer (below entities) + self.terrain = mcrfpy.ColorLayer(name="terrain", z_index=-2) + self.grid.add_layer(self.terrain) + + # walkability + transparency per cell + for y in range(self.h): + for x in range(self.w): + ch = self._ch(x, y) + pt = self.grid.at(x, y) + pt.walkable = self.walkable(x, y) + pt.transparent = ch not in ("#", " ") + + self._apply_terrain() + + # player entity + self._make_player() + + # optional FOV overlay (Undercroft): dim/hide terrain outside sight + if self.area.get("fov"): + self.grid.fov_radius = int(self.area.get("fov_radius", 7)) + self.fov = mcrfpy.ColorLayer(name="fov", z_index=-1) + self.grid.add_layer(self.fov) + self.fov.fill(palette.C(palette.INK_T, 255)) + self.fov.apply_perspective( + entity=self.player, + visible=mcrfpy.Color(60, 52, 36, 26), # faint warm torchlight + discovered=palette.C(palette.INK_T, 170), # explored: dim + unknown=palette.C(palette.INK_T, 255)) # unseen: black + self.grid.perspective = self.player + + self._build_doors() + self._build_props() + self._build_npcs() + self._build_encounters() + + if self.fov is not None: + self._refresh_fov() + + self._start_timers() + + self.stack = InputStack(self.scene) + self.stack.push(self._on_key, "move") + + self.refresh_hud() + + def _make_player(self): + if self.player is None: + self.player = mcrfpy.Entity(grid_pos=self.spawn_cell, + texture=assets.TEX, sprite_index=85) + self.grid.entities.append(self.player) + return self.player + + # -------------------------------------------------------------- terrain + def _grey_factor(self): + if (GS.act == 3 and self.area_id == "hollowbrook" + and not GS.has("town_rewoken")): + n = sum(1 for f in REWAKE_FLAG.values() if GS.has(f)) + return max(0.0, 0.7 - 0.23 * n) + return None + + def _apply_terrain(self): + factor = self._grey_factor() + for y in range(self.h): + row = self.rows[y] + for x in range(len(row)): + ch = row[x] + base = self.area["palette"].get(ch, palette.INK_T) + if ch == ".": + o = _cell_offset(x, y) + base = (_clamp8(base[0] + o), _clamp8(base[1] + o), + _clamp8(base[2] + o)) + if factor is not None: + base = palette.lerp_t(base, palette.GREY_T, factor) + self.terrain.set((x, y), palette.C(base)) + + # -------------------------------------------------------------- doors + def _build_doors(self): + for d in self.area.get("door_cells", []): + x, y = d["pos"] + spr = 47 if GS.has(d["flag"]) else 45 + e = mcrfpy.Entity(grid_pos=(x, y), texture=assets.TEX, + sprite_index=spr) + self.grid.entities.append(e) + self.door_entities[(x, y)] = e + + def _refresh_doors(self): + for d in self.area.get("door_cells", []): + x, y = d["pos"] + e = self.door_entities.get((x, y)) + if e is not None: + e.sprite_index = 47 if GS.has(d["flag"]) else 45 + self.grid.at(x, y).walkable = self.walkable(x, y) + + # -------------------------------------------------------------- props + def _prop_present(self, prop): + for f in prop.get("gone_flags", []): + if GS.has(f): + return False + return True + + def _build_props(self): + for prop in self.area.get("props", []): + if not self._prop_present(prop): + continue + x, y = prop["pos"] + spr = prop.get("sprite") + if spr is None: + self._add_marker(x, y) + continue + e = mcrfpy.Entity(grid_pos=(x, y), texture=assets.TEX, + sprite_index=spr) + self.grid.entities.append(e) + self.prop_entities[prop["id"]] = e + self._refresh_fountain() + + def _add_marker(self, x, y): + s = 16 * self.zoom + px, py = self.cell_screen(x, y) + f = mcrfpy.Frame(pos=(px + 1, py + 1), size=(s - 2, s - 2), + fill_color=palette.C(palette.GOLD_T, 0), + outline=2, outline_color=palette.GOLD) + f.z_index = 90 + self.scene.children.append(f) + self.markers.append((f, x, y)) + # gentle pulse so the empty spot reads as interactive + f.animate("opacity", 0.4, 0.9, mcrfpy.Easing.EASE_IN_OUT, loop=True) + + def _refresh_fountain(self): + e = self.prop_entities.get("fountain") + if e is not None: + e.sprite_index = 8 if GS.has("town_rewoken") else 7 + + def _prop_scene_at(self, x, y): + for prop in self.area.get("props", []): + if tuple(prop["pos"]) == (x, y) and prop.get("scene") \ + and self._prop_present(prop): + return prop + return None + + # -------------------------------------------------------------- npcs + def _npc_present(self, npc): + if GS.act < npc.get("act_min", 1): + return False + for f in npc.get("need_flags", []): + if not GS.has(f): + return False + for f in npc.get("gone_flags", []): + if GS.has(f): + return False + return True + + def _npc_scene_id(self, npc): + nid = npc["id"] + if GS.act == 3 and nid == "ODD": + return "rewake_odd" if not GS.has("rewoke_odd") else "odd_ferry_study" + scenes = npc["scene"] + if GS.act in scenes: + return scenes[GS.act] + keys = sorted(k for k in scenes if k <= GS.act) + if not keys: + raise KeyError("NPC %r has no dialogue scene for act %d" + % (nid, GS.act)) + return scenes[keys[-1]] + + def _npc_texture(self, npc): + flag = REWAKE_FLAG.get(npc["id"]) + if flag is not None and self._grey_factor() is not None \ + and not GS.has(flag): + return _grey_tex() + return assets.TEX + + def _build_npcs(self): + idx = 0 + for npc in self.area.get("npcs", []): + if not self._npc_present(npc): + continue + x, y = npc["pos"] + e = mcrfpy.Entity(grid_pos=(x, y), texture=self._npc_texture(npc), + sprite_index=npc["sprite"]) + self.grid.entities.append(e) + self.npc_entities[npc["id"]] = (e, npc) + # idle bob (staggered), oscillates draw_y by ~0.06 + e.animate("draw_y", float(y) - 0.06, 0.75 + 0.05 * (idx % 6), + mcrfpy.Easing.EASE_IN_OUT, loop=True) + idx += 1 + + def _npc_at(self, x, y): + for nid, (e, npc) in self.npc_entities.items(): + if int(e.grid_x) == x and int(e.grid_y) == y: + return npc + return None + + # -------------------------------------------------------------- encounters + def _enc_done_key(self, enc): + x, y = enc["pos"] + return "enc_%s_%d_%d_done" % (self.area_id, x, y) + + def _build_encounters(self): + for enc in self.area.get("encounters", []): + if GS.has(self._enc_done_key(enc)): + continue + x, y = enc["pos"] + spr = _pack_first_sprite(enc["pack"]) + e = mcrfpy.Entity(grid_pos=(x, y), texture=assets.TEX, + sprite_index=spr) + self.grid.entities.append(e) + enc = dict(enc) + enc["home"] = (x, y) + self.enc_entities.append((e, enc)) + + def _enc_at(self, x, y): + for e, enc in self.enc_entities: + if int(e.grid_x) == x and int(e.grid_y) == y: + return (e, enc) + return None + + def _rand(self, n): + self._rng = (self._rng * 1103515245 + 12345) & 0x7fffffff + return self._rng % n + + def _is_current(self): + """True only while this world's scene is the one on screen. Guards the + held-key move + encounter-wander timers so they never fire under a + battle/menu/dialogue scene that has taken over current_scene.""" + return mcrfpy.current_scene is self.scene + + def _wander(self, timer, runtime): + if self.frozen or not self._is_current(): + return + for e, enc in self.enc_entities: + leash = enc.get("leash", 2) + if leash == 0: + continue + hx, hy = enc["home"] + dx, dy = [(0, -1), (0, 1), (-1, 0), (1, 0), (0, 0)][self._rand(5)] + nx, ny = int(e.grid_x) + dx, int(e.grid_y) + dy + if abs(nx - hx) > leash or abs(ny - hy) > leash: + continue + if not self.walkable(nx, ny): + continue + if self._occupied(nx, ny, ignore=e): + continue + e.grid_pos = (nx, ny) + e.animate("draw_x", float(nx), 0.25, mcrfpy.Easing.EASE_OUT) + e.animate("draw_y", float(ny), 0.25, mcrfpy.Easing.EASE_OUT) + + def _occupied(self, x, y, ignore=None): + if self.player is not None and int(self.player.grid_x) == x \ + and int(self.player.grid_y) == y: + return True + for e, _n in self.enc_entities: + if e is ignore: + continue + if int(e.grid_x) == x and int(e.grid_y) == y: + return True + if self._npc_at(x, y) is not None: + return True + return False + + # -------------------------------------------------------------- timers + def _start_timers(self): + self._move_timer = mcrfpy.Timer("ow_move", self._repeat_move, _MOVE_MS) + if any(enc.get("leash", 2) != 0 + for _e, enc in self.enc_entities): + self._wander_timer = mcrfpy.Timer("ow_wander", self._wander, + _WANDER_MS) + + def _repeat_move(self, timer, runtime): + if self.frozen or not self.held or not self._is_current(): + return + dx, dy = self.held[-1] + self.try_step(dx, dy) + + # -------------------------------------------------------------- input + def _on_key(self, key, state): + K = mcrfpy.Key + if self.frozen: + return False + if state == mcrfpy.InputState.PRESSED: + if key in _DIRS: + d = _DIRS[key] + if d in self.held: + self.held.remove(d) + self.held.append(d) + self.try_step(d[0], d[1]) + return True + if key in (K.E, K.ENTER, K.SPACE): + self._interact_here() + return True + if key in (K.ESCAPE, K.TAB): + self._open_party_menu() + return True + return False + else: # RELEASED + if key in _DIRS and _DIRS[key] in self.held: + self.held.remove(_DIRS[key]) + return True + return False + + # -------------------------------------------------------------- movement + def try_step(self, dx, dy): + if self.frozen: + return + px, py = int(self.player.grid_x), int(self.player.grid_y) + tx, ty = px + dx, py + dy + + npc = self._npc_at(tx, ty) + if npc is not None: + self.talk(npc) + return + hit = self._enc_at(tx, ty) + if hit is not None: + self.fight(hit) + return + d = self._door_at(tx, ty) + if d is not None and not GS.has(d["flag"]): + self.run_dialogue(d["locked_scene"]) + return + prop = self._prop_scene_at(tx, ty) + if prop is not None and not self.walkable(tx, ty): + self.trigger_prop(prop) + return + if not self.walkable(tx, ty): + self._nudge(dx, dy) + return + + self.player.grid_pos = (tx, ty) + self.player.animate("draw_x", float(tx), _STEP_DUR, mcrfpy.Easing.EASE_OUT) + self.player.animate("draw_y", float(ty), _STEP_DUR, mcrfpy.Easing.EASE_OUT) + self.after_move(tx, ty) + + def _nudge(self, dx, dy): + px, py = int(self.player.grid_x), int(self.player.grid_y) + if dx: + self.player.animate("draw_x", px + 0.22 * dx, 0.06, + mcrfpy.Easing.EASE_OUT, + callback=lambda *_a: self.player.animate( + "draw_x", float(px), 0.08)) + elif dy: + self.player.animate("draw_y", py + 0.22 * dy, 0.06, + mcrfpy.Easing.EASE_OUT, + callback=lambda *_a: self.player.animate( + "draw_y", float(py), 0.08)) + + def _refresh_fov(self): + """Update the FOV overlay and hide entities the player cannot see.""" + self.player.update_visibility() + px, py = int(self.player.grid_x), int(self.player.grid_y) + r = int(self.grid.fov_radius) + self.grid.compute_fov((px, py), r) + groups = [e for e, _n in self.enc_entities] + groups += [e for e, _n in self.npc_entities.values()] + groups += list(self.prop_entities.values()) + groups += list(self.door_entities.values()) + for e in groups: + e.visible = self.grid.is_in_fov(int(e.grid_x), int(e.grid_y)) + for f, mx, my in self.markers: + f.visible = self.grid.is_in_fov(mx, my) + self.player.visible = True + + def after_move(self, x, y): + if self.fov is not None: + self._refresh_fov() + # step-onto prop scene + prop = self._prop_scene_at(x, y) + if prop is not None: + self.trigger_prop(prop) + return + # auto props (proximity, within 1 cell) + for p in self.area.get("props", []): + if not p.get("auto") or not self._prop_present(p): + continue + once = p.get("once") + if once and GS.has(once): + continue + ax, ay = p["pos"] + if abs(ax - x) <= 1 and abs(ay - y) <= 1: + self.trigger_prop(p) + return + # exits + for ex in self.area.get("exits", []): + if tuple(ex["pos"]) == (x, y): + self._take_exit(ex) + return + + def _interact_here(self): + x, y = int(self.player.grid_x), int(self.player.grid_y) + camp = self.area.get("camp") + if camp and tuple(camp["pos"]) == (x, y): + for f in camp.get("need_flags", []): + if not GS.has(f): + return + self.run_dialogue(camp["scene"]) + return + prop = self._prop_scene_at(x, y) + if prop is not None: + self.trigger_prop(prop) + + # -------------------------------------------------------------- triggers + def talk(self, npc): + self.run_dialogue(self._npc_scene_id(npc)) + + def trigger_prop(self, prop): + self.run_dialogue(prop["scene"]) + + def _take_exit(self, ex): + if ex.get("scene"): + self.run_dialogue(ex["scene"]) + else: + _goto(ex["to"], ex.get("spawn", "start")) + + def fight(self, hit): + e, enc = hit + hook = dialogue.HOOKS.get("battle") + if hook is None: + raise RuntimeError( + "encounter bump needs dialogue.HOOKS['battle'](pack, " + "on_victory=) - Agent B must register it") + done_key = self._enc_done_key(enc) + # freeze the overworld for the whole battle; on_victory/on_defeat unfreeze + self.frozen = True + self.held = [] + + def on_victory(*_a): + try: + e.die() + except Exception: + pass + if (e, enc) in self.enc_entities: + self.enc_entities.remove((e, enc)) + GS.add_flag(done_key) + self._post_dialogue(None) + + def on_defeat(*_a): + self.frozen = False + + hook(enc["pack"], on_victory=on_victory, on_defeat=on_defeat) + + def run_dialogue(self, scene_id, after=None): + self.frozen = True + self.held = [] + dialogue.run_scene(scene_id, + on_done=lambda: self._post_dialogue(after), + stack=self.stack) + + def _post_dialogue(self, after): + self.frozen = False + self.refresh_theme() + self.refresh_hud() + if after: + after() + + def _open_party_menu(self): + try: + from systems import party_menu + except Exception as ex: + raise RuntimeError("party menu unavailable: %s" % (ex,)) + self.frozen = True + self.held = [] + + def _closed(): + self.frozen = False + self.refresh_hud() + party_menu.open_menu(self.scene, self.stack, on_close=_closed) + + # -------------------------------------------------------------- refresh + def refresh_theme(self): + """Re-apply terrain wash, NPC textures, doors, fountain: the grey beat. + Color literally returns to town as rewoke_* flags are set.""" + was_rewoken = GS.has("town_rewoken") + if (GS.act == 3 and self.area_id == "hollowbrook" + and not was_rewoken + and all(GS.has(f) for f in REWAKE_FLAG.values())): + GS.add_flag("town_rewoken") + ui.TitleBanner(self.scene.children, self.area.get("banner")) + + self._apply_terrain() + for nid, (e, npc) in self.npc_entities.items(): + e.texture = self._npc_texture(npc) + self._refresh_doors() + self._refresh_fountain() + + def refresh_hud(self): + for d in self.hud: + try: + self.scene.children.remove(d) + except Exception: + pass + self.hud = [] + grey = self._grey_factor() is not None + name = self.area.get("banner_grey") if grey else self.area.get("banner") + + loc = ui.Label(self.scene.children, name or self.area_id.upper(), + (18, 14), color=palette.DIM, size=palette.SMALL_SIZE) + loc.z_index = 5000 + self.hud.append(loc) + + cogs = ui.Label(self.scene.children, "%d cogs" % GS.gold, (0, 14), + color=palette.GOLD, size=palette.SMALL_SIZE) + cogs.x = 1024 - 18 - cogs.size.x + cogs.z_index = 5000 + self.hud.append(cogs) + + # up to 3 party mini-cards, bottom-left + base_y = 700 + for i, cid in enumerate(GS.party[:3]): + cs = GS.roster.get(cid) + if cs is None: + continue + cx = 16 + i * 132 + card = mcrfpy.Frame(pos=(cx, base_y), size=(124, 54), + fill_color=palette.PANEL, outline=2, + outline_color=palette.OUTLINE) + card.z_index = 5000 + self.scene.children.append(card) + self.hud.append(card) + chip = mcrfpy.Frame(pos=(6, 6), size=(40, 40), + fill_color=palette.INSET, outline=1, + outline_color=palette.OUTLINE) + card.children.append(chip) + spr = assets.PORTRAITS.get(cid, 0) or 0 + s = mcrfpy.Sprite(pos=(4, 4), texture=assets.TEX, sprite_index=spr) + s.scale = 2.0 + chip.children.append(s) + ui.Label(card.children, cid, (54, 6), color=palette.PARCH, + size=palette.SMALL_SIZE) + try: + hp, mhp = cs.hp, cs.max_hp + except Exception: + hp, mhp = 1, 1 + hb = ui.Bar(card.children, (54, 28), (62, 16), palette.BLOOD, + cur=hp, maxv=mhp, show_text=False) + + # -------------------------------------------------------------- arrival + def arrival_scene(self): + """Dialogue to auto-run once the area is built and the player arrives. + Wires the authored-but-otherwise-unreferenced Bell appearance #3 + (script_act3 'hollowbrook_grey') to the grey-town arrival.""" + if (self.area_id == "hollowbrook" and GS.act == 3 + and not GS.has("bell_3_done") + and not GS.has("town_rewoken")): + return "hollowbrook_grey" + return None + + # -------------------------------------------------------------- banner + def show_banner(self): + grey = (GS.act == 3 and self.area_id == "hollowbrook" + and not GS.has("town_rewoken")) + text = self.area.get("banner_grey") if grey and self.area.get( + "banner_grey") else self.area.get("banner") + if text: + ui.TitleBanner(self.scene.children, text) + + # -------------------------------------------------------------- teardown + def teardown(self): + for t in (self._move_timer, self._wander_timer): + if t is not None: + try: + t.stop() + except Exception: + pass + self._move_timer = None + self._wander_timer = None + self.held = [] + + +# ===================================================================== helpers +def _areas(): + from data import maps + return maps.AREAS + + +def _pack_first_sprite(pack): + """Sprite index of a pack's first enemy. data.enemies is Agent B's (parallel); + imported lazily. Tests without it set TEST_ENEMY_FALLBACK.""" + enemies = None + try: + from data import enemies as _en + enemies = _en + except Exception: + enemies = None + if enemies is not None: + fn = getattr(enemies, "pack_first_sprite", None) + if callable(fn): + return int(fn(pack)) + packs = getattr(enemies, "PACKS", None) + stats = getattr(enemies, "ENEMIES", None) or getattr(enemies, "STATS", None) + if packs is not None and pack in packs: + members = packs[pack] + if isinstance(members, dict): + members = members.get("enemies") or members.get("members") or [] + m0 = members[0] if members else None + if isinstance(m0, dict): + if "sprite" in m0: + return int(m0["sprite"]) + m0 = m0.get("id") or m0.get("enemy") or m0.get("name") + if stats is not None and m0 in stats and "sprite" in stats[m0]: + return int(stats[m0]["sprite"]) + if TEST_ENEMY_FALLBACK is not None: + return int(TEST_ENEMY_FALLBACK) + raise KeyError( + "cannot resolve first-enemy sprite for pack %r: data.enemies must expose " + "pack_first_sprite(pack) or PACKS[pack] + ENEMIES[id]['sprite']" % (pack,)) + + +# ===================================================================== public +def enter_area(area_id, spawn="start"): + """Build (or rebuild) the scene for an area and make it current.""" + global WORLD + if WORLD is not None: + WORLD.teardown() + WORLD = _World(area_id, spawn) + WORLD.build() + WORLD.show_banner() + arr = WORLD.arrival_scene() + if arr is not None: + WORLD.run_dialogue(arr) + return WORLD + + +def refresh_area(): + """Re-apply palette/NPCs/doors after flag changes (Act-3 grey beat).""" + if WORLD is None: + raise RuntimeError("refresh_area() called with no active world") + WORLD.refresh_theme() + WORLD.refresh_hud() + + +def _goto(area, spawn="start"): + scn = mcrfpy.current_scene + if (WORLD is not None and scn is not None + and getattr(scn, "name", "").startswith("ow_")): + tween.fade_scene(scn, on_done=lambda: enter_area(area, spawn)) + else: + enter_area(area, spawn) + + +def register_hooks(): + dialogue.HOOKS["goto_area"] = lambda area, spawn="start": _goto(area, spawn) + + +register_hooks() diff --git a/games/unwritten/systems/party_menu.py b/games/unwritten/systems/party_menu.py new file mode 100644 index 0000000..1da475d --- /dev/null +++ b/games/unwritten/systems/party_menu.py @@ -0,0 +1,456 @@ +"""UNWRITTEN - pause / party menu. Owner: Agent C. + +Opened by Esc/Tab in the overworld, or directly to the swap view via +HOOKS["swap_menu"] (which returns to whatever ran it - dialogue may continue). + +Tabs: PARTY (swap active-3 ordering), EQUIP (weapon/trinket from inventory), +ITEMS (use consumables outside battle), STATS (detail), KEY (key items; +blank_book always first). Footer shows gold and Story Points ("The Book: N +entries"). NYX cannot be item-healed: the canon line "The tonic passes through +her." shows as a Toast. Esc closes (or backs out of a picker). + +data.items is Agent B's (parallel); imported LAZILY. Expected interface: + data.items.ITEMS[id] = {"name","sprite","cost","kind" + ("consumable"|"weapon"|"trinket"|"key"), "heal"(int), "sp"(int)} + data.items.EQUIP[id] = {stat: delta} # weapons + trinkets +""" +import mcrfpy + +from core import palette +from core import assets +from core import ui +from core.inputstack import InputStack +from systems.state import GS, CHAR_TAGS +from systems import dialogue + +_MENU = None +TABS = ["PARTY", "EQUIP", "ITEMS", "STATS", "KEY"] + +KEY_ITEM_NAMES = { + "blank_book": "The Blank Book - heavier every day.", + "brass_key": "Brass Key", + "gatekeepers_tooth": "Gatekeeper's Tooth", + "the_wick": "The Wick", +} + + +def _items_tbl(): + from data import items + tbl = getattr(items, "ITEMS", None) + if tbl is None: + raise RuntimeError("party menu needs data.items.ITEMS (see docstring)") + return tbl + + +def _item_name(iid): + try: + return _items_tbl()[iid]["name"] + except Exception: + return iid + + +def _consumable_desc(d): + """One-line effect summary for a consumable record (data.items schema uses + effect/amount, not heal/sp keys).""" + effect = d.get("effect") + amount = d.get("amount", 0) + if effect in ("heal", "cleanse_heal"): + base = "heal %d" % int(amount) + return base + " + cleanse" if effect == "cleanse_heal" else base + if effect == "sp": + return "+%d SP" % int(amount) + if effect == "aoe_damage": + return "%d AoE (battle)" % int(amount) + if effect == "revive": + return "revive (battle)" + return "" + + +class _PartyMenu: + def __init__(self, scene, stack, on_close, view="PARTY"): + self.scene = scene + self.on_close = on_close + self.owns_stack = stack is None + self.stack = stack if stack is not None else InputStack(scene) + self.ch = scene.children + self.tab = TABS.index(view) if view in TABS else 0 + self.sel = 0 + self.char_idx = 0 + self.swap_pick = None # cid selected for swap on the PARTY tab + self.picker = None # active secondary MenuList + self.nodes = [] # rebuildable drawables + self.panel = ui.Panel(self.ch, (40, 40), (944, 688), + outline_color=palette.GOLD, z_index=4000) + self._render() + self.stack.push(self._handle, "party") + + # ----------------------------------------------------------------- render + def _clear(self): + for n in self.nodes: + try: + self.panel.children.remove(n) + except Exception: + pass + self.nodes = [] + if self.picker is not None: + self.picker.destroy() + self.picker = None + + def _lab(self, text, pos, color=None, size=palette.BODY_SIZE, center=False): + c = ui.Label(self.panel.children, text, pos, color=color, size=size, + center=center) + self.nodes.append(c) + return c + + def _render(self): + self._clear() + # tab bar + for i, name in enumerate(TABS): + x = 40 + i * 150 + self._lab(name, (x, 24), + color=palette.GOLD if i == self.tab else palette.DIM, + size=palette.NAME_SIZE) + # divider + div = mcrfpy.Frame(pos=(24, 56), size=(896, 2), fill_color=palette.OUTLINE) + self.panel.children.append(div) + self.nodes.append(div) + # footer + self._lab("%d cogs" % GS.gold, (32, 646), color=palette.GOLD, + size=palette.BODY_SIZE) + self._lab("The Book: %d entries" % GS.points, (944 - 32 - 220, 646), + color=palette.GOLD, size=palette.BODY_SIZE) + self._lab("Q/E tabs arrows move Enter select Esc close", + (472, 668), color=palette.DIM, size=palette.SMALL_SIZE, + center=True) + + tab = TABS[self.tab] + if tab == "PARTY": + self._render_party() + elif tab == "EQUIP": + self._render_char_list(self._on_equip_char) + self._render_char_detail() + elif tab == "ITEMS": + self._render_items() + elif tab == "STATS": + self._render_char_list(None) + self._render_char_detail(full=True) + elif tab == "KEY": + self._render_key() + + def _roster_ids(self): + # active party first (in order), then benched + active = [c for c in GS.party if c in GS.roster] + benched = [c for c in GS.roster if c not in active] + return active + benched + + def _render_party(self): + self._lab("active party is marked *. Enter picks, Enter again swaps.", + (32, 70), color=palette.DIM, size=palette.SMALL_SIZE) + ids = self._roster_ids() + y = 100 + for i, cid in enumerate(ids): + cs = GS.roster[cid] + mark = "*" if cid in GS.party else " " + sel = ">" if i == self.sel else " " + pick = " (picked)" if cid == self.swap_pick else "" + color = palette.GOLD if i == self.sel else palette.PARCH + row = self._lab("%s %s %-9s Lv%d%s" % (sel, mark, cid, cs.level, pick), + (40, y), color=color, size=palette.BODY_SIZE) + # portrait chip + chip = mcrfpy.Frame(pos=(300, y - 4), size=(30, 30), + fill_color=palette.INSET, outline=1, + outline_color=palette.OUTLINE) + self.panel.children.append(chip) + self.nodes.append(chip) + spr = assets.PORTRAITS.get(cid, 0) or 0 + s = mcrfpy.Sprite(pos=(1, 1), texture=assets.TEX, sprite_index=spr) + s.scale = 1.75 + chip.children.append(s) + hb = ui.Bar(self.panel.children, (350, y - 2), (150, 14), + palette.BLOOD, cur=cs.hp, maxv=cs.max_hp) + self.nodes.append(hb.bg) + sb = ui.Bar(self.panel.children, (520, y - 2), (120, 14), + palette.TEAL, cur=cs.sp, maxv=cs.max_sp) + self.nodes.append(sb.bg) + tag = CHAR_TAGS.get(cid, "") + self._lab(tag, (660, y), color=palette.DIM, size=palette.SMALL_SIZE) + y += 40 + + def _render_char_list(self, on_pick): + ids = self._roster_ids() + if self.char_idx >= len(ids): + self.char_idx = 0 + y = 90 + for i, cid in enumerate(ids): + cs = GS.roster[cid] + sel = ">" if i == self.char_idx else " " + color = palette.GOLD if i == self.char_idx else palette.PARCH + self._lab("%s %-9s Lv%d" % (sel, cid, cs.level), (40, y), + color=color, size=palette.BODY_SIZE) + y += 34 + + def _render_char_detail(self, full=False): + ids = self._roster_ids() + if not ids: + return + cid = ids[min(self.char_idx, len(ids) - 1)] + cs = GS.roster[cid] + x = 400 + self._lab(cid, (x, 90), color=palette.GOLD, size=palette.NAME_SIZE) + try: + st = cs.stats() + except Exception: + st = {} + rows = [("Level", cs.level), ("HP", "%d/%d" % (cs.hp, cs.max_hp)), + ("SP", "%d/%d" % (cs.sp, cs.max_sp))] + for k in ("ATK", "MAG", "DEF", "SPD"): + rows.append((k, st.get(k, "-"))) + y = 126 + for k, v in rows: + self._lab("%-6s %s" % (k, v), (x, y), color=palette.PARCH, + size=palette.BODY_SIZE) + y += 28 + self._lab("Weapon: %s" % (_item_name(cs.weapon) if cs.weapon else "-"), + (x, y + 8), color=palette.DIM, size=palette.SMALL_SIZE) + self._lab("Trinket: %s" % (_item_name(cs.trinket) if cs.trinket else "-"), + (x, y + 30), color=palette.DIM, size=palette.SMALL_SIZE) + if TABS[self.tab] == "EQUIP": + self._lab("Enter: change equipment", (x, y + 64), + color=palette.GOLD, size=palette.SMALL_SIZE) + + def _render_items(self): + tbl = _items_tbl() + rows = [] + for iid, n in sorted(GS.inventory.items()): + d = tbl.get(iid) + if not d or d.get("kind") != "consumable": + continue + rows.append((iid, n, d)) + self._lab("use a consumable, then choose who receives it.", (32, 70), + color=palette.DIM, size=palette.SMALL_SIZE) + y = 100 + if not rows: + self._lab("(no consumables)", (40, y), color=palette.DIM) + return + for i, (iid, n, d) in enumerate(rows): + sel = ">" if i == self.sel else " " + color = palette.GOLD if i == self.sel else palette.PARCH + eff = _consumable_desc(d) + self._lab("%s %-14s x%d %s" % (sel, d["name"], n, eff), + (40, y), color=color, size=palette.BODY_SIZE) + y += 32 + self._item_rows = rows + + def _render_key(self): + self._lab("key items", (32, 70), color=palette.DIM, + size=palette.SMALL_SIZE) + keys = list(GS.key_items) + ordered = (["blank_book"] if "blank_book" in keys else []) + ordered += [k for k in keys if k != "blank_book"] + if "blank_book" not in keys: + ordered = ["blank_book"] + ordered # always listed first + y = 100 + for k in ordered: + name = KEY_ITEM_NAMES.get(k, k) + self._lab("- %s" % name, (40, y), color=palette.PARCH, + size=palette.BODY_SIZE) + y += 30 + + # ----------------------------------------------------------------- pickers + def _on_equip_char(self): + ids = self._roster_ids() + cid = ids[self.char_idx] + tbl = _items_tbl() + opts = [] + for slot in ("weapon", "trinket"): + for iid, n in sorted(GS.inventory.items()): + d = tbl.get(iid) + if d and d.get("kind") == slot: + opts.append(("%s: %s" % (slot, d["name"]), (slot, iid), + True, "")) + opts.append(("(remove weapon)", ("weapon", None), True, "")) + opts.append(("(remove trinket)", ("trinket", None), True, "")) + if not opts: + return + self.picker = ui.MenuList(self.panel.children, (400, 300), 320, opts, + on_pick=lambda v: self._do_equip(cid, v), + on_cancel=self._close_picker, + title="Equip %s" % cid) + + def _do_equip(self, cid, val): + slot, iid = val + cs = GS.roster[cid] + if slot == "weapon": + cs.weapon = iid + else: + cs.trinket = iid + self._close_picker() + self._render() + + def _open_item_target(self, iid, d): + opts = [] + for cid in self._roster_ids(): + opts.append((cid, cid, True, "")) + self.picker = ui.MenuList(self.panel.children, (400, 260), 300, opts, + on_pick=lambda v: self._use_item(iid, d, v), + on_cancel=self._close_picker, + title="Use %s on" % d["name"]) + + def _use_item(self, iid, d, cid): + cs = GS.roster[cid] + effect = d.get("effect") + amount = d.get("amount", 0) + heals = effect in ("heal", "cleanse_heal") + restores_sp = effect == "sp" + # NYX cannot be restored by items (SYSTEMS section 3); canon line. + if cid == "NYX" and (heals or restores_sp): + ui.Toast(self.ch, "The tonic passes through her.", color=palette.DIM) + self._close_picker() + return + if GS.inventory.get(iid, 0) <= 0: + self._close_picker() + return + if heals: + cs.hp = cs.hp + int(amount) + elif restores_sp: + cs.sp = cs.sp + int(amount) + else: + # revive / aoe_damage are battle-only; not usable from the menu + ui.Toast(self.ch, "Cannot use %s here." % d["name"], color=palette.DIM) + self._close_picker() + return + GS.take(iid, 1) + ui.Toast(self.ch, "Used %s" % d["name"], color=palette.GRASS) + self._close_picker() + self._render() + + def _close_picker(self): + if self.picker is not None: + self.picker.destroy() + self.picker = None + + # ----------------------------------------------------------------- swap + def _party_pick(self): + ids = self._roster_ids() + cid = ids[self.sel] + if self.swap_pick is None: + self.swap_pick = cid + else: + self._do_swap(self.swap_pick, cid) + self.swap_pick = None + self._render() + + def _do_swap(self, a, b): + if a == b: + return + pa = GS.party.index(a) if a in GS.party else None + pb = GS.party.index(b) if b in GS.party else None + if pa is not None and pb is not None: + GS.party[pa], GS.party[pb] = GS.party[pb], GS.party[pa] + elif pa is not None and pb is None: + GS.party[pa] = b + elif pa is None and pb is not None: + GS.party[pb] = a + # else both benched: no-op + + # ----------------------------------------------------------------- input + def _list_len(self): + tab = TABS[self.tab] + if tab in ("PARTY",): + return len(self._roster_ids()) + if tab in ("EQUIP", "STATS"): + return len(self._roster_ids()) + if tab == "ITEMS": + return len(getattr(self, "_item_rows", [])) + return 0 + + def _handle(self, key, state): + if state != mcrfpy.InputState.PRESSED: + return True + K = mcrfpy.Key + if self.picker is not None: + if key == K.ESCAPE: + self._close_picker() + return True + self.picker.handle(key, state) + return True + if key == K.ESCAPE: + self._close() + return True + if key in (K.Q, K.LEFT): + self.tab = (self.tab - 1) % len(TABS) + self.sel = 0 + self._render() + return True + if key in (K.E, K.RIGHT): + self.tab = (self.tab + 1) % len(TABS) + self.sel = 0 + self._render() + return True + n = self._list_len() + tab = TABS[self.tab] + cur = self.char_idx if tab in ("EQUIP", "STATS") else self.sel + if key in (K.W, K.UP) and n: + cur = (cur - 1) % n + elif key in (K.S, K.DOWN) and n: + cur = (cur + 1) % n + elif key in (K.ENTER, K.SPACE): + self._activate() + return True + else: + return True + if tab in ("EQUIP", "STATS"): + self.char_idx = cur + else: + self.sel = cur + self._render() + return True + + def _activate(self): + tab = TABS[self.tab] + if tab == "PARTY": + self._party_pick() + elif tab == "EQUIP": + self._on_equip_char() + elif tab == "ITEMS": + rows = getattr(self, "_item_rows", []) + if rows: + iid, n, d = rows[self.sel] + self._open_item_target(iid, d) + + def _close(self): + self.destroy() + if self.on_close: + self.on_close() + + def destroy(self): + self._close_picker() + self.stack.pop("party") + self.panel.destroy() + + +def open_menu(scene=None, stack=None, on_close=None, view="PARTY"): + global _MENU + scene = scene if scene is not None else mcrfpy.current_scene + _MENU = _PartyMenu(scene, stack, on_close, view=view) + return _MENU + + +def _swap_hook(): + """HOOKS['swap_menu']: open directly to the swap (PARTY) view on the active + overworld stack, so dialogue continuing beneath resumes when it closes.""" + stack = None + try: + from systems import overworld + if overworld.WORLD is not None: + stack = overworld.WORLD.stack + except Exception: + stack = None + open_menu(mcrfpy.current_scene, stack, on_close=None, view="PARTY") + + +def register_hooks(): + dialogue.HOOKS["swap_menu"] = _swap_hook + + +register_hooks() diff --git a/games/unwritten/systems/shop.py b/games/unwritten/systems/shop.py new file mode 100644 index 0000000..283a883 --- /dev/null +++ b/games/unwritten/systems/shop.py @@ -0,0 +1,230 @@ +"""UNWRITTEN - Griselda's shop. Owner: Agent C. + +HOOKS["shop"]() opens a two-column buy/sell UI over the current scene. +Stock per act, x1.25 prices under griselda_grudge, buyback at half, Chalk limited +to ONE ever (flag chalk_bought). Gold is called cogs. Esc leaves. + +data.items is Agent B's (parallel); imported LAZILY. Expected interface: + data.items.ITEMS[item_id] = { + "name": str, "sprite": int, "cost": int, + "kind": "consumable"|"weapon"|"trinket"|"key", + "shop": bool, # sold by Griselda + "act": int, # earliest act it appears in stock (default 1) + } +""" +import math + +import mcrfpy + +from core import palette +from core import ui +from core.inputstack import InputStack +from systems import dialogue +from systems.state import GS + +_MENU = None + + +def _items(): + from data import items + tbl = getattr(items, "ITEMS", None) + if tbl is None: + raise RuntimeError("shop needs data.items.ITEMS (see module docstring)") + return tbl + + +def _price(item_id): + base = int(_items()[item_id]["cost"]) + if GS.has("griselda_grudge"): + return int(math.ceil(base * 1.25)) + return base + + +def _sell_price(item_id): + return int(_items()[item_id]["cost"]) // 2 + + +class _Shop: + def __init__(self, scene, stack, on_close): + self.scene = scene + self.on_close = on_close + self.owns_stack = stack is None + self.stack = stack if stack is not None else InputStack(scene) + self.ch = scene.children + self.mode = "buy" # "buy" | "sell" + self.buy_menu = None + self.sell_menu = None + self.panel = None + self.gold_cap = None + self._build() + self.stack.push(self._handle, "shop") + + # ----------------------------------------------------------------- build + def _build(self): + self.panel = ui.Panel(self.ch, (72, 60), (880, 640), + outline_color=palette.GOLD, z_index=4000) + p = self.panel.children + ui.Label(p, "GRISELDA'S", (32, 22), color=palette.GOLD, + size=palette.NAME_SIZE) + ui.Label(p, "forty years of sharpening. buy something.", (32, 48), + color=palette.DIM, size=palette.SMALL_SIZE) + self.gold_cap = ui.Label(p, "", (880 - 32, 22), color=palette.GOLD, + size=palette.NAME_SIZE) + self._refresh_gold() + ui.Label(p, "BUY", (170, 84), color=palette.PARCH, size=palette.BODY_SIZE, + center=True) + ui.Label(p, "SELL", (620, 84), color=palette.PARCH, size=palette.BODY_SIZE, + center=True) + ui.Label(p, "Tab: switch column Enter: confirm Esc: leave", + (440, 612), color=palette.DIM, size=palette.SMALL_SIZE, center=True) + self._rebuild_lists() + + def _refresh_gold(self): + self.gold_cap.text = "%d cogs" % GS.gold + self.gold_cap.x = 880 - 32 - self.gold_cap.size.x + + def _stock_ids(self): + # data.items exposes stock per act via SHOP_STOCK (act -> [ids]); the + # older per-item "shop"/"act" keys this shop was first coded against + # never existed. Use the real table (Agent D integration). + from data import items + stock = getattr(items, "SHOP_STOCK", None) + if stock is None: + raise RuntimeError("shop needs data.items.SHOP_STOCK (act -> [ids])") + tbl = _items() + act = GS.act if GS.act in stock else max(stock) + return [iid for iid in stock[act] if iid in tbl] + + def _rebuild_lists(self): + p = self.panel.children + if self.buy_menu is not None: + self.buy_menu.destroy() + if self.sell_menu is not None: + self.sell_menu.destroy() + + buy_items = [] + for iid in self._stock_ids(): + d = _items()[iid] + enabled, reason = True, "" + price = _price(iid) + if iid == "chalk" and GS.has("chalk_bought"): + enabled, reason = False, "sold out" + elif GS.gold < price: + enabled, reason = False, "need %d" % price + label = "%-14s %d" % (d["name"], price) + buy_items.append((label, iid, enabled, reason)) + if not buy_items: + buy_items = [("(nothing in stock)", None, False, "")] + self.buy_menu = ui.MenuList(p, (40, 104), 360, buy_items, + on_pick=self._buy, on_cancel=self._leave) + + sell_items = [] + for iid, n in sorted(GS.inventory.items()): + if iid not in _items(): + continue + d = _items()[iid] + if d.get("kind") == "key": + continue + label = "%-12s x%d %d" % (d["name"], n, _sell_price(iid)) + sell_items.append((label, iid, True, "")) + if not sell_items: + sell_items = [("(nothing to sell)", None, False, "")] + self.sell_menu = ui.MenuList(p, (490, 104), 360, sell_items, + on_pick=self._sell, on_cancel=self._leave) + self._highlight() + + def _highlight(self): + self.buy_menu.panel.frame.outline_color = ( + palette.GOLD if self.mode == "buy" else palette.OUTLINE) + self.sell_menu.panel.frame.outline_color = ( + palette.GOLD if self.mode == "sell" else palette.OUTLINE) + + # ----------------------------------------------------------------- actions + def _buy(self, iid): + if iid is None: + return + price = _price(iid) + if not GS.spend_gold(price): + return + GS.grant(iid, 1) + if iid == "chalk": + GS.add_flag("chalk_bought") + ui.Toast(self.ch, "Bought %s" % _items()[iid]["name"], color=palette.GOLD) + self._refresh_gold() + self._rebuild_lists() + + def _sell(self, iid): + if iid is None: + return + if GS.inventory.get(iid, 0) <= 0: + return + gain = _sell_price(iid) + GS.take(iid, 1) + GS.add_gold(gain) + ui.Toast(self.ch, "Sold %s (+%d)" % (_items()[iid]["name"], gain), + color=palette.GRASS) + self._refresh_gold() + self._rebuild_lists() + + # ----------------------------------------------------------------- input + def _handle(self, key, state): + if state != mcrfpy.InputState.PRESSED: + return True + K = mcrfpy.Key + if key == K.ESCAPE: + self._leave() + return True + if key == K.TAB: + self.mode = "sell" if self.mode == "buy" else "buy" + self._highlight() + return True + active = self.buy_menu if self.mode == "buy" else self.sell_menu + active.handle(key, state) + return True + + def _leave(self): + self.destroy() + if self.on_close: + self.on_close() + + def destroy(self): + self.stack.pop("shop") + if self.buy_menu is not None: + self.buy_menu.destroy() + if self.sell_menu is not None: + self.sell_menu.destroy() + if self.panel is not None: + self.panel.destroy() + + +def open_shop(scene=None, stack=None, on_close=None): + global _MENU + scene = scene if scene is not None else mcrfpy.current_scene + _MENU = _Shop(scene, stack, on_close) + return _MENU + + +def _hook(): + # opened from dialogue on the overworld: reuse the overworld's input stack + stack = None + try: + from systems import overworld + if overworld.WORLD is not None: + stack = overworld.WORLD.stack + + def _reopen(): + overworld.WORLD.frozen = False + overworld.WORLD.refresh_hud() + open_shop(mcrfpy.current_scene, stack, on_close=_reopen) + overworld.WORLD.frozen = True + return + except Exception: + pass + open_shop(mcrfpy.current_scene, stack, on_close=None) + + +def register_hooks(): + dialogue.HOOKS["shop"] = _hook + + +register_hooks() diff --git a/games/unwritten/systems/state.py b/games/unwritten/systems/state.py new file mode 100644 index 0000000..f0eb848 --- /dev/null +++ b/games/unwritten/systems/state.py @@ -0,0 +1,244 @@ +"""UNWRITTEN - GameState singleton. Owner: Agent A. + +The single source of mutable game state (ARCHITECTURE section 5: no global +mutable state lives outside this module). Numeric tables (base stats, growth, +equipment bonuses) live in data.characters / data.items, which Agent B owns and +may not have written yet - so they are imported LAZILY inside methods. A missing +table at runtime raises (fail early); there is no fallback here. + +Expected data interfaces (documented for Agent B): + data.characters.BASE[char_id] -> {"HP","SP","ATK","MAG","DEF","SPD": int} + data.characters.GROWTH[char_id] -> list of up to 7 per-level growth dicts + (applied for levels 2..8; extra levels reuse + the last entry) + data.items.EQUIP[item_id] -> {stat: delta, ...} for weapons/trinkets +""" + +STAT_KEYS = ("HP", "SP", "ATK", "MAG", "DEF", "SPD") + +# Dialogue tag per character (BIBLE section 2). Canonical, owned here so the +# dialogue runner can evaluate ("tag", X) reqs without importing data.characters. +CHAR_TAGS = { + "PIP": "NIMBLE", + "BRAMBLE": "OATH", + "MOTH": "LITURGY", + "VERA": "ALCHEMY", + "CANTOR": "RESONANCE", + "NYX": "SHADOW", +} + + +def xp_for_level(level): + """Cumulative XP required to BE at `level`. Matches SYSTEMS section 1 + (20/60/120/200/300/420/560 for levels 2..8).""" + return 10 * level * (level - 1) + + +def level_for_xp(xp): + """Highest level (1..8) whose cumulative threshold is <= xp.""" + lvl = 1 + while lvl < 8 and xp >= xp_for_level(lvl + 1): + lvl += 1 + return lvl + + +class CharState: + """One character's live state. Stats are computed lazily from the data + tables so importing state without data.characters present does not crash.""" + + def __init__(self, char_id, level=1): + self.char_id = char_id + self.level = max(1, int(level)) + self.xp = xp_for_level(self.level) + self.weapon = None + self.trinket = None + self._hp = None + self._sp = None + + # ---- derived stats ----------------------------------------------------- + def _base_growth(self): + from data import characters + if self.char_id not in characters.BASE: + raise KeyError("no base stats for %r in data.characters.BASE" + % (self.char_id,)) + return characters.BASE[self.char_id], characters.GROWTH[self.char_id] + + def _equip_bonus(self): + bonus = {} + if not self.weapon and not self.trinket: + return bonus + from data import items + for eid in (self.weapon, self.trinket): + if not eid: + continue + if eid not in items.EQUIP: + raise KeyError("unknown equipment id %r in data.items.EQUIP" + % (eid,)) + for k, v in items.EQUIP[eid].items(): + bonus[k] = bonus.get(k, 0) + v + return bonus + + def stats(self): + base, growth = self._base_growth() + out = {k: base.get(k, 0) for k in STAT_KEYS} + for i in range(self.level - 1): + g = growth[i] if i < len(growth) else growth[-1] + for k, v in g.items(): + out[k] = out.get(k, 0) + v + for k, v in self._equip_bonus().items(): + out[k] = out.get(k, 0) + v + return out + + @property + def max_hp(self): + return self.stats()["HP"] + + @property + def max_sp(self): + return self.stats()["SP"] + + @property + def hp(self): + if self._hp is None: + self._hp = self.max_hp + return self._hp + + @hp.setter + def hp(self, v): + self._hp = max(0, min(int(v), self.max_hp)) + + @property + def sp(self): + if self._sp is None: + self._sp = self.max_sp + return self._sp + + @sp.setter + def sp(self, v): + self._sp = max(0, min(int(v), self.max_sp)) + + @property + def alive(self): + return self.hp > 0 + + def full_heal(self): + self._hp = self.max_hp + self._sp = self.max_sp + + def add_xp(self, amount): + """Add XP; return list of new level numbers reached (may be empty).""" + self.xp += int(amount) + target = level_for_xp(self.xp) + gained = [] + while self.level < target: + self.level += 1 + gained.append(self.level) + # top up current pools to the new maxima + self._hp = self.max_hp + self._sp = self.max_sp + return gained + + +class GameState: + """Singleton game state. Access via state.GS.""" + + def __init__(self): + self.reset() + + def reset(self): + self.party = [] # active char ids, order, max 3 + self.roster = {} # char_id -> CharState + self.flags = set() + self.points = 0 + self.gold = 0 + self.inventory = {} # item_id -> count + self.key_items = [] + self.act = 1 + self.hum_hook = None # set by dialogue runner: pulses the Book + + # ---- flags / points ---------------------------------------------------- + def has(self, flag): + return flag in self.flags + + def add_flag(self, flag): + self.flags.add(flag) + + def remove_flag(self, flag): + self.flags.discard(flag) + + def add_points(self, n): + self.points += int(n) + if self.hum_hook: + self.hum_hook() + + # ---- party / roster ---------------------------------------------------- + def _avg_level(self): + levels = [c.level for c in self.roster.values()] + if not levels: + return 1 + return max(1, sum(levels) // len(levels)) + + def recruit(self, char_id, level): + """Add a character to the roster (and to the active party if there is + room, max 3). level 0 means 'party average level'.""" + if char_id not in self.roster: + lvl = self._avg_level() if int(level) == 0 else int(level) + self.roster[char_id] = CharState(char_id, lvl) + if char_id not in self.party and len(self.party) < 3: + self.party.append(char_id) + return self.roster[char_id] + + def party_tags(self): + """Dialogue tags of the ACTIVE party members.""" + return set(CHAR_TAGS[c] for c in self.party if c in CHAR_TAGS) + + def in_party(self, char_id): + return char_id in self.party + + def heal_party(self): + for c in self.roster.values(): + c.full_heal() + + def xp_gain(self, amount): + """Whole roster gains XP (benched included). Returns level-up events as + a list of (char_id, new_level).""" + events = [] + for cid, c in self.roster.items(): + for lvl in c.add_xp(amount): + events.append((cid, lvl)) + return events + + # ---- economy / items --------------------------------------------------- + def add_gold(self, n): + self.gold = max(0, self.gold + int(n)) + + def spend_gold(self, n): + if self.gold >= int(n): + self.gold -= int(n) + return True + return False + + def grant(self, item_id, n=1): + self.inventory[item_id] = self.inventory.get(item_id, 0) + int(n) + + def take(self, item_id, n=1): + """Remove n of an item; clamps at zero, drops the key when empty.""" + have = self.inventory.get(item_id, 0) + left = max(0, have - int(n)) + if left: + self.inventory[item_id] = left + else: + self.inventory.pop(item_id, None) + + def add_key_item(self, item_id): + if item_id not in self.key_items: + self.key_items.append(item_id) + + def remove_key_item(self, item_id): + if item_id in self.key_items: + self.key_items.remove(item_id) + self.inventory.pop(item_id, None) + + +# module-level singleton +GS = GameState() diff --git a/games/unwritten/tests/playthrough.py b/games/unwritten/tests/playthrough.py new file mode 100644 index 0000000..14ccad6 --- /dev/null +++ b/games/unwritten/tests/playthrough.py @@ -0,0 +1,578 @@ +"""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() diff --git a/games/unwritten/tests/test_battle_sim.py b/games/unwritten/tests/test_battle_sim.py new file mode 100644 index 0000000..4099f94 --- /dev/null +++ b/games/unwritten/tests/test_battle_sim.py @@ -0,0 +1,317 @@ +"""UNWRITTEN - battle simulation + screenshots. Owner: Agent B. + +Runs 225 scripted headless battles across every pack (both bosses included) +with a level-appropriate party and an auto command policy, asserting: + - no exceptions, + - victory is possible for every pack, + - XP / gold / loot are actually granted, +then prints a balance report (win rate + avg rounds per pack) for Fable. + +Finally builds the real BattleScreen and screenshots three states: + battle_1.png command menu open on a player turn vs gw_mixed + battle_2.png mid-action, floating damage number + battle_3.png victory panel + +Run: + cd build && ./mcrogueface --headless --exec ../games/unwritten/tests/test_battle_sim.py +""" +import os +import sys +import random + +GAMEROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, GAMEROOT) + +import mcrfpy +from mcrfpy import automation + +from systems.state import GS +from systems import battle +from systems.battle import BattleCore +from data import characters as chardata +from data import items as itemdata + +SHOTS = os.path.join(GAMEROOT, "tests", "shots") + + +# ---------------------------------------------------------------- party setup +_WEAPON = { + 1: {"PIP": "knife", "BRAMBLE": "worn_cudgel", "MOTH": "wick_staff", + "VERA": "wick_staff", "CANTOR": "worn_cudgel", "NYX": "knife"}, + 3: {"PIP": "short_sword", "BRAMBLE": "short_sword", "MOTH": "wick_staff", + "VERA": "wick_staff", "CANTOR": "short_sword", "NYX": "short_sword"}, + 5: {"PIP": "twin_axe", "BRAMBLE": "broad_blade", "MOTH": "tide_rod", + "VERA": "tide_rod", "CANTOR": "broad_blade", "NYX": "twin_axe"}, +} +_TRINKET = {1: "river_stone", 3: "river_stone", 5: "waxed_charm"} + + +def _gear_tier(level): + if level >= 5: + return 5 + if level >= 3: + return 3 + return 1 + + +def setup(comp, level, points=0, act=1): + GS.reset() + GS.act = act + GS.points = points + tier = _gear_tier(level) + for cid in comp: + GS.recruit(cid, level) + cs = GS.roster[cid] + cs.weapon = _WEAPON[tier].get(cid) + cs.trinket = _TRINKET[tier] + cs.full_heal() + GS.gold = 0 + GS.grant("bread", 5) + GS.grant("red_tonic", 2) + GS.grant("blue_draught", 2) + GS.grant("bomb_flask", 1) + GS.grant("chalk", 1) + + +# ---------------------------------------------------------------- auto policy +def policy(core, actor): + skills = chardata.learned_skills(actor.char_id, actor.cstate.level) + enemies = core.alive_enemies() + allies = core.alive_party() + + # 1. heal a badly hurt ally + 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"] in ("kindle", "tonic_toss") and actor.sp >= s["sp"]: + if s["special"] == "tonic_toss" and tgt.char_id == "NYX": + continue + return {"type": "skill", "skill": s, "target": tgt} + + # 2. occasionally exercise a boss talk option + if core.is_boss and random.random() < 0.35: + opts = [o for o in core.boss_talk_options() if o["enabled"]] + if opts: + return {"type": "talk", "talk": opts[0]["id"]} + + # 3. occasionally set up buffs (exercise shieldwall / second shadow) + for s in skills: + if s["special"] == "shieldwall" and not core.party_guard_active \ + and actor.sp >= s["sp"] and random.random() < 0.25: + return {"type": "skill", "skill": s, "target": None} + if s["special"] == "second_shadow" and not actor.guaranteed_crit \ + and actor.sp >= s["sp"] and random.random() < 0.30: + return {"type": "skill", "skill": s, "target": None} + + # 4. offensive skill (prefer AoE with multiple foes) + dmg = [s for s in skills + if s["kind"] in ("phys", "magic") and actor.sp >= s["sp"]] + if dmg and random.random() < 0.6: + aoe = [s for s in dmg if s["target"] == "all_enemies"] + if aoe and len(enemies) >= 2: + s = random.choice(aoe) + else: + s = random.choice(dmg) + tgt = min(enemies, key=lambda e: e.hp) if enemies else None + return {"type": "skill", "skill": s, "target": tgt} + + # 5. basic attack, focus the weakest enemy + if enemies: + return {"type": "attack", "target": min(enemies, key=lambda e: e.hp)} + return {"type": "guard"} + + +# ---------------------------------------------------------------- one battle +def run_once(pack_id, comp, level, points=0, act=1): + setup(comp, level, points, act) + core = BattleCore(pack_id) + core.begin_round() + rounds = 1 + steps = 0 + while True: + na = core.next_actor() + if na is None: + core.end_round() + if core.check_end(): + break + core.begin_round() + rounds += 1 + if rounds > battle._MAX_ROUNDS: + core.result = "defeat" + break + continue + actor, forced = na + if forced is not None: + action = forced + elif actor.side == "enemy": + action = core.ai_action(actor) + else: + action = policy(core, actor) + result = core.resolve(actor, action) + if result["flee"]: + core.result = "flee" + break + if core.check_end(): + break + steps += 1 + if steps > 6000: + core.result = "defeat" + break + + xp = gold = 0 + drops = [] + if core.result == "victory": + xp, gold, drops = core.collect_rewards() + GS.xp_gain(xp) + GS.add_gold(gold) + for d in drops: + GS.grant(d, 1) + return core.result, rounds, xp, gold, drops + + +# ---------------------------------------------------------------- sim config +# pack_id -> (party comp, level, points, act, label for report) +SIM = [ + ("gw_bats", (["PIP", "BRAMBLE", "MOTH"], 1, 0, 1)), + ("gw_mixed", (["PIP", "BRAMBLE", "MOTH"], 2, 0, 1)), + ("gw_mixed2", (["PIP", "BRAMBLE", "MOTH"], 2, 0, 1)), + ("gw_scarabs", (["PIP", "BRAMBLE", "MOTH"], 2, 0, 1)), + ("gw_slimes", (["PIP", "BRAMBLE", "MOTH"], 1, 0, 1)), + ("uc_bones", (["PIP", "BRAMBLE", "CANTOR"], 3, 0, 2)), + ("uc_bones2", (["PIP", "BRAMBLE", "MOTH"], 4, 0, 2)), + ("uc_chimes", (["PIP", "CANTOR", "MOTH"], 4, 0, 2)), + ("uc_echoes", (["PIP", "BRAMBLE", "MOTH"], 3, 0, 2)), + ("uc_mixed", (["PIP", "CANTOR", "MOTH"], 4, 0, 2)), + ("uc_mimic", (["PIP", "BRAMBLE", "MOTH"], 4, 0, 2)), + ("st_echoes", (["PIP", "MOTH", "CANTOR"], 5, 0, 3)), + ("st_echoes2", (["PIP", "MOTH", "CANTOR"], 6, 0, 3)), + ("boss_gatekeeper", (["PIP", "BRAMBLE", "MOTH"], 5, 4, 2)), + ("boss_custodian", (["PIP", "MOTH", "CANTOR"], 7, 8, 3)), +] +TRIALS = 15 + + +def run_sim(): + random.seed(20260703) + report = [] + total_xp = total_gold = total_drops = 0 + total_battles = 0 + for pack_id, (comp, level, points, act) in SIM: + wins = 0 + round_sum = 0 + win_rounds = 0 + for _ in range(TRIALS): + res, rounds, xp, gold, drops = run_once( + pack_id, comp, level, points, act) + total_battles += 1 + total_xp += xp + total_gold += gold + total_drops += len(drops) + round_sum += rounds + if res == "victory": + wins += 1 + win_rounds += rounds + win_rate = 100.0 * wins / TRIALS + avg_rounds = (win_rounds / wins) if wins else round_sum / TRIALS + report.append((pack_id, level, win_rate, avg_rounds, wins)) + return report, total_battles, total_xp, total_gold, total_drops + + +def print_report(report, total_battles, total_xp, total_gold, total_drops): + print("", flush=True) + print("=" * 64, flush=True) + print("UNWRITTEN - BATTLE BALANCE REPORT (%d battles, %d per pack)" + % (total_battles, TRIALS), flush=True) + print("=" * 64, flush=True) + print("%-18s %3s %8s %10s" % ("pack", "Lv", "win%", "avg rounds"), + flush=True) + print("-" * 64, flush=True) + for pack_id, level, wr, ar, wins in report: + print("%-18s %3d %7.0f%% %10.1f" % (pack_id, level, wr, ar), flush=True) + print("-" * 64, flush=True) + print("totals: XP granted %d | cogs granted %d | drops %d" + % (total_xp, total_gold, total_drops), flush=True) + print("=" * 64, flush=True) + + +def assert_sim(report, total_xp, total_gold, total_drops): + problems = [] + for pack_id, level, wr, ar, wins in report: + if wins == 0: + problems.append("pack %s never won (win rate 0%%)" % pack_id) + if total_xp <= 0: + problems.append("no XP was ever granted") + if total_gold <= 0: + problems.append("no gold was ever granted") + if total_drops <= 0: + problems.append("no loot drops occurred") + if problems: + for p in problems: + print("FAIL: %s" % p, flush=True) + return False + return True + + +# ---------------------------------------------------------------- screenshots +def step(seconds): + n = int(seconds / (1.0 / 60.0)) + for _ in range(max(1, n)): + mcrfpy.step(1.0 / 60.0) + + +def take_screenshots(): + if not os.path.isdir(SHOTS): + os.makedirs(SHOTS) + random.seed(7) + + # --- shot 1: command menu open vs gw_mixed --- + setup(["PIP", "BRAMBLE", "MOTH"], 2) + GS.current_area = "gearwood" + screen = battle.start_battle("gw_mixed") + tries = 0 + while screen.state != "command" and tries < 400: + step(0.05) + tries += 1 + step(0.05) + automation.screenshot(os.path.join(SHOTS, "battle_1.png")) + print("shot 1 (command menu, state=%s) saved" % screen.state, flush=True) + + # --- shot 2: mid-action floating damage --- + actor = screen.cur_actor + target = screen.core.alive_enemies()[0] + if actor is not None and actor.side == "party": + screen._commit(actor, {"type": "attack", "target": target}) + step(0.15) + automation.screenshot(os.path.join(SHOTS, "battle_2.png")) + print("shot 2 (mid-action) saved", flush=True) + + # --- shot 3: victory panel --- + for e in screen.core.enemies: + e.hp = 0 + screen.core.result = "victory" + screen._on_battle_end() + step(0.1) + automation.screenshot(os.path.join(SHOTS, "battle_3.png")) + print("shot 3 (victory panel) saved", flush=True) + screen._teardown() + + +# ---------------------------------------------------------------- main +def main(): + report, total_battles, total_xp, total_gold, total_drops = run_sim() + print_report(report, total_battles, total_xp, total_gold, total_drops) + ok = assert_sim(report, total_xp, total_gold, total_drops) + + take_screenshots() + + if ok: + print("PASS test_battle_sim", flush=True) + sys.exit(0) + else: + print("FAIL test_battle_sim", flush=True) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/games/unwritten/tests/test_overworld.py b/games/unwritten/tests/test_overworld.py new file mode 100644 index 0000000..f3b7c8d --- /dev/null +++ b/games/unwritten/tests/test_overworld.py @@ -0,0 +1,116 @@ +"""UNWRITTEN - overworld / menus / epilogue headless test. Owner: Agent C. + +Run: + cd build && ./mcrogueface --headless --exec ../games/unwritten/tests/test_overworld.py + +Drives the overworld through its meaningful states and screenshots each: + ow_hollowbrook ow_dialogue ow_undercroft ow_grey ow_grey_partial ow_epilogue +""" +import os +import sys + +_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 overworld +from systems import epilogue +from systems import shop # noqa: F401 (registers HOOKS["shop"]) +from systems import party_menu # noqa: F401 (registers HOOKS["swap_menu"]) + +_SHOTS = os.path.join(_ROOT, "tests", "shots") + + +def _shot(name): + os.makedirs(_SHOTS, exist_ok=True) + automation.screenshot(os.path.join(_SHOTS, name)) + print(" shot", name, flush=True) + + +def _pump(n): + for _ in range(n): + mcrfpy.step(1.0 / 60.0) + + +def main_test(): + # encounters have no data.enemies yet (Agent B, parallel): use a placeholder + overworld.TEST_ENEMY_FALLBACK = 86 + + # ---- 1. Hollowbrook, Act 1, fresh ------------------------------------- + main._preset_act1() + overworld.enter_area("hollowbrook", "start") + w = overworld.WORLD + assert w is not None and w.area_id == "hollowbrook", "world not built" + assert GS.current_area == "hollowbrook", "GS.current_area not set" + # a few real movement steps + for _ in range(3): + w.try_step(0, -1) + _pump(10) + w.try_step(-1, 0) + _pump(20) + _shot("ow_hollowbrook.png") + print("PASS hollowbrook build + movement", flush=True) + + # ---- 2. Bump SER BRAMBLE -> dialogue opens ---------------------------- + assert "BRAMBLE" in w.npc_entities, "Bramble NPC missing in Act 1" + w.player.grid_pos = (11, 5) + w.player.draw_pos = (11, 5) + w.try_step(0, -1) # step up into Bramble at (11,4) + assert w.frozen, "bumping an NPC should freeze movement (dialogue open)" + _pump(40) # let the box slide up + typewriter run + _shot("ow_dialogue.png") + print("PASS bramble bump opens dialogue", flush=True) + + # ---- 3. Undercroft with FOV ------------------------------------------- + main._preset_act2() + overworld.enter_area("undercroft", "enter") + w2 = overworld.WORLD + assert w2.fov is not None, "undercroft should build an FOV overlay" + assert w2.grid.perspective is w2.player, "grid.perspective not the player" + w2.try_step(1, 0) + _pump(15) + w2.try_step(0, 1) + _pump(20) + _shot("ow_undercroft.png") + print("PASS undercroft FOV", flush=True) + + # ---- 4. Act 3 greyed Hollowbrook -------------------------------------- + main._preset_act3() + assert not GS.has("rewoke_quill"), "preset should start with no rewakes" + overworld.enter_area("hollowbrook", "from_ferry") + w3 = overworld.WORLD + assert w3._grey_factor() is not None, "grey wash should be active in Act 3" + _pump(20) + _shot("ow_grey.png") + print("PASS grey town (full wash)", flush=True) + + # ---- 5. Two rewakes -> color returns ---------------------------------- + GS.add_flag("rewoke_quill") + GS.add_flag("rewoke_griselda") + overworld.refresh_area() + f = w3._grey_factor() + assert f is not None and abs(f - 0.24) < 0.01, "wash should ease to ~0.24" + _pump(15) + _shot("ow_grey_partial.png") + print("PASS grey partial (color returning)", flush=True) + + # ---- 6. Epilogue page 1 ----------------------------------------------- + # a real Moth recruit always records a light-name; the act-3 preset omits it + GS.add_flag("moth_light_name_small") + ep = epilogue.start_epilogue() + assert GS.has("nyx_missed"), "epilogue must set nyx_missed when Nyx absent" + _pump(40) # 240ms schedule + fade-in + assert ep.pages, "epilogue collected no pages" + _shot("ow_epilogue.png") + print("PASS epilogue renders", flush=True) + + print("PASS test_overworld", flush=True) + sys.exit(0) + + +if __name__ == "__main__": + main_test() diff --git a/games/unwritten/tests/test_script_integrity.py b/games/unwritten/tests/test_script_integrity.py new file mode 100644 index 0000000..becd597 --- /dev/null +++ b/games/unwritten/tests/test_script_integrity.py @@ -0,0 +1,227 @@ +"""UNWRITTEN - script integrity walker. Owner: Agent A. + +Walks every scene in data/script_act1|2|3 plus data/epilogue.PAGES and asserts: + - every 'next' and choice target node exists within its scene + - every req and effect tuple is a form the dialogue runner supports + - every speaker id has a portrait entry (core.assets.PORTRAITS) + - every action string parses + - collects the battle pack ids referenced (for Agent B) + +Protects the authored content: run after ANY change to the scripts. + +Run: + cd build && ./mcrogueface --headless --exec ../games/unwritten/tests/test_script_integrity.py +""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from core import assets +from data import script_act1, script_act2, script_act3, epilogue + +REQ_KINDS = {"flag", "flag_not", "tag", "party", "points", "item"} +EFFECT_KINDS = {"flag", "points", "gold", "item", "act", "key_item_remove"} +ACTION_NAMES = {"end", "shop", "heal_party", "swap_menu", "recruit", + "battle", "goto_area", "epilogue", "act"} + +errors = [] +battle_packs = set() +counts = {"scenes": 0, "nodes": 0, "choices": 0, "effects": 0, "reqs": 0, + "actions": 0} + + +def err(where, msg): + errors.append("%s: %s" % (where, msg)) + + +def check_req(where, req): + if req is None: + return + counts["reqs"] += 1 + if not isinstance(req, tuple) or len(req) < 1: + err(where, "malformed req %r" % (req,)) + return + kind = req[0] + if kind not in REQ_KINDS: + err(where, "unknown req kind %r" % (kind,)) + return + if kind == "points": + if len(req) != 2 or not isinstance(req[1], int): + err(where, "points req needs int arg: %r" % (req,)) + else: + if len(req) != 2 or not isinstance(req[1], str): + err(where, "%s req needs str arg: %r" % (kind, req)) + + +def check_effects(where, effects): + if effects is None: + return + if not isinstance(effects, (list, tuple)): + err(where, "effects must be a list: %r" % (effects,)) + return + for e in effects: + counts["effects"] += 1 + if not isinstance(e, tuple) or len(e) < 1: + err(where, "malformed effect %r" % (e,)) + continue + kind = e[0] + if kind not in EFFECT_KINDS: + err(where, "unknown effect kind %r" % (kind,)) + continue + if kind in ("flag", "key_item_remove"): + if len(e) != 2 or not isinstance(e[1], str): + err(where, "%s effect needs str arg: %r" % (kind, e)) + elif kind in ("points", "gold", "act"): + if len(e) != 2 or not isinstance(e[1], int): + err(where, "%s effect needs int arg: %r" % (kind, e)) + elif kind == "item": + if len(e) != 3 or not isinstance(e[1], str) or not isinstance(e[2], int): + err(where, "item effect needs (id:str, n:int): %r" % (e,)) + + +def check_action(where, action): + counts["actions"] += 1 + if not isinstance(action, str): + err(where, "action must be a str: %r" % (action,)) + return + parts = action.split(":") + name = parts[0] + if name not in ACTION_NAMES: + err(where, "unknown action %r" % (action,)) + return + if name in ("end", "shop", "heal_party", "swap_menu", "epilogue"): + if len(parts) != 1: + err(where, "action %r takes no args" % (action,)) + elif name == "recruit": + if len(parts) != 3: + err(where, "recruit needs CHARID:LEVEL: %r" % (action,)) + else: + if parts[1] not in assets.PORTRAITS: + err(where, "recruit unknown char %r" % (parts[1],)) + try: + int(parts[2]) + except ValueError: + err(where, "recruit level not int: %r" % (action,)) + elif name == "battle": + if len(parts) != 2: + err(where, "battle needs pack id: %r" % (action,)) + else: + battle_packs.add(parts[1]) + elif name == "goto_area": + if len(parts) != 3: + err(where, "goto_area needs area:spawn: %r" % (action,)) + elif name == "act": + if len(parts) != 2 or not parts[1].isdigit(): + err(where, "act needs int: %r" % (action,)) + + +def walk_scenes(modname, scenes): + for scene_id, scene in scenes.items(): + counts["scenes"] += 1 + where0 = "%s/%s" % (modname, scene_id) + nodes = scene.get("nodes", {}) + start = scene.get("start") + if start not in nodes: + err(where0, "start node %r missing" % (start,)) + for node_id, node in nodes.items(): + counts["nodes"] += 1 + where = "%s/%s" % (where0, node_id) + + speaker = node.get("speaker", "NARRATOR") + if speaker not in assets.PORTRAITS: + err(where, "speaker %r has no portrait entry" % (speaker,)) + + check_effects(where, node.get("effects")) + + action = node.get("action") + if action is not None: + check_action(where, action) + + has_choices = "choices" in node + has_next = "next" in node + if has_choices: + for i, c in enumerate(node["choices"]): + counts["choices"] += 1 + cw = "%s/choice[%d]" % (where, i) + check_req(cw, c.get("req")) + check_effects(cw, c.get("effects")) + tgt = c.get("next") + if tgt is None: + err(cw, "choice has no 'next'") + elif tgt not in nodes: + err(cw, "choice target %r missing" % (tgt,)) + if has_next: + tgt = node["next"] + if tgt not in nodes: + err(where, "next target %r missing" % (tgt,)) + + # a node must resolve somehow: choices, next, or a terminal action + if not has_choices and not has_next and action is None: + err(where, "dead-end node (no choices, no next, no action)") + + +def walk_epilogue(): + valid = 0 + for i, entry in enumerate(epilogue.PAGES): + where = "epilogue/PAGES[%d]" % (i,) + if not isinstance(entry, tuple) or len(entry) != 2: + err(where, "page must be (condition, text): %r" % (entry,)) + continue + cond, text = entry + if cond is not None: + if not isinstance(cond, tuple) or cond[0] not in ("flag", "flag_not", "points"): + err(where, "unsupported page condition %r" % (cond,)) + if not isinstance(text, str) or not text.strip(): + err(where, "page text must be a non-empty str") + valid += 1 + return valid + + +def main(): + walk_scenes("act1", script_act1.SCENES) + walk_scenes("act2", script_act2.SCENES) + walk_scenes("act3", script_act3.SCENES) + pages = walk_epilogue() + + # cross-check: no duplicate scene ids across files + all_ids = (list(script_act1.SCENES) + list(script_act2.SCENES) + + list(script_act3.SCENES)) + dupes = set(x for x in all_ids if all_ids.count(x) > 1) + if dupes: + err("global", "duplicate scene ids across script files: %s" % sorted(dupes)) + + # battle packs referenced by the overworld maps (for Agent B's full list) + map_packs = set() + try: + from data import maps + for area in maps.AREAS.values(): + for enc in area.get("encounters", []): + map_packs.add(enc["pack"]) + except Exception as e: + print("note: could not read data/maps.py encounters (%s)" % (e,), flush=True) + + print("--- UNWRITTEN script integrity ---", flush=True) + print("scenes=%(scenes)d nodes=%(nodes)d choices=%(choices)d " + "effects=%(effects)d reqs=%(reqs)d actions=%(actions)d" + % counts, flush=True) + print("epilogue pages=%d" % pages, flush=True) + print("battle packs referenced in SCRIPTS: %s" + % (sorted(battle_packs) if battle_packs else "(none)"), flush=True) + print("battle packs referenced in MAPS: %s" + % (sorted(map_packs) if map_packs else "(none)"), flush=True) + print("ALL battle packs (for Agent B): %s" + % sorted(battle_packs | map_packs), flush=True) + + if errors: + print("FAIL test_script_integrity (%d problems):" % len(errors), flush=True) + for e in errors: + print(" " + e, flush=True) + sys.exit(1) + + print("PASS test_script_integrity", flush=True) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/games/unwritten/tests/test_ui_kit.py b/games/unwritten/tests/test_ui_kit.py new file mode 100644 index 0000000..5bee2c3 --- /dev/null +++ b/games/unwritten/tests/test_ui_kit.py @@ -0,0 +1,104 @@ +"""UNWRITTEN - UI kit headless test. Owner: Agent A. + +Builds a scene, exercises every widget (DialogueBox with a real node from +script_act1 'intro', a MenuList with a disabled row, a Bar at 30%, a Toast, a +TitleBanner), drives mcrfpy.step to advance the typewriter, and screenshots two +meaningful states. + +Run: + cd build && ./mcrogueface --headless --exec ../games/unwritten/tests/test_ui_kit.py +""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import mcrfpy +from core import palette, ui +from data import script_act1 + +SHOTS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "shots") + + +def step_n(n): + for _ in range(n): + mcrfpy.step(1.0 / 60.0) + + +def main(): + os.makedirs(SHOTS, exist_ok=True) + scene = mcrfpy.Scene("uikit") + mcrfpy.current_scene = scene + ch = scene.children + + # background + bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=palette.INK) + ch.append(bg) + + # --- MenuList with one disabled row (top-left) --- + menu = ui.MenuList( + ch, (60, 150), 300, + items=[ + ("Attack", "attack", True), + ("Skill", "skill", True), + ("Item", "item", False, "no items"), + ("Guard", "guard", True), + ], + title="COMMAND") + + # --- Bar at 30% (right) --- + bar_panel = ui.Panel(ch, (600, 150), (340, 70), outline_color=palette.OUTLINE) + ui.Label(bar_panel.children, "HP", (16, 14), color=palette.PARCH, + size=palette.SMALL_SIZE) + hp_bar = ui.Bar(bar_panel.children, (16, 34), (300, 22), palette.BLOOD, + cur=30, maxv=100) + + # --- Toast (top-right) --- + ui.Toast(ch, "+1 Story Point", color=palette.GOLD) + + # --- TitleBanner (top center) --- + ui.TitleBanner(ch, "THE GEARWOOD", cx=512, cy=70) + + # --- DialogueBox: a real NARRATOR node from 'intro', mid-typewriter --- + box = ui.DialogueBox(ch) + intro_n1 = script_act1.SCENES["intro"]["nodes"]["n1"] + box.show_node("NARRATOR", intro_n1["text"], on_advance=lambda: None) + + # let the entrance settle and reveal part of the body + step_n(16) + ok1 = mcrfpy.automation.screenshot(os.path.join(SHOTS, "uikit_1.png")) + assert ok1, "screenshot 1 failed" + partial = box.body.text + assert 0 < len(partial) < len(box._full), \ + "typewriter should be mid-reveal (%d/%d)" % (len(partial), len(box._full)) + + # --- DialogueBox choice mode: a real node with a disabled [ALCHEMY] choice --- + disp = script_act1.SCENES["shop_dispute"]["nodes"]["n3"] + # empty active party -> the [ALCHEMY] tag choice is locked (disabled), the + # two plain choices are enabled. This is the design pillar: shown but DIM. + choices = [] + for c in disp["choices"]: + enabled = c.get("req") is None + choices.append((c["label"], enabled)) + box.show_node("VERA", disp["text"], choices=choices, + on_choice=lambda i: None) + + # fully reveal the prompt so the choice list appears + step_n(180) + disabled_count = sum(1 for _lbl, en in choices if not en) + assert disabled_count == 1, "expected exactly one disabled choice" + assert len(box.choice_caps) == len(choices), "all choices should render" + ok2 = mcrfpy.automation.screenshot(os.path.join(SHOTS, "uikit_2.png")) + assert ok2, "screenshot 2 failed" + + box.destroy() + menu.destroy() + + print("uikit_1.png:", os.path.join(SHOTS, "uikit_1.png"), flush=True) + print("uikit_2.png:", os.path.join(SHOTS, "uikit_2.png"), flush=True) + print("PASS test_ui_kit", flush=True) + sys.exit(0) + + +if __name__ == "__main__": + main()