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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vxWJ6GY384SmZiMVXgrF4
This commit is contained in:
John McCardle 2026-07-03 10:11:12 -04:00
commit 80908f771c
31 changed files with 8847 additions and 0 deletions

63
games/unwritten/README.md Normal file
View file

@ -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.

View file

@ -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]

View file

@ -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

View file

@ -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

View file

@ -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

582
games/unwritten/core/ui.py Normal file
View file

@ -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

View file

@ -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..."],
},
}

View file

@ -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,))

View file

@ -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]

View file

@ -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.

View file

@ -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"

View file

@ -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)],
}

View file

@ -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"},
},
},
}

View file

@ -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"},
},
},
}

View file

@ -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"},
},
},
}

View file

@ -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/<test>.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/<name>_1.png")` at 2+ meaningful
states, prints "PASS <name>", `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.

View file

@ -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.

View file

@ -0,0 +1,152 @@
# UNWRITTEN — Systems Spec (numbers are canon)
Playtime target: 3045 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 18. 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.

149
games/unwritten/main.py Normal file
View file

@ -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()

File diff suppressed because it is too large Load diff

View file

@ -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)

View file

@ -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()

View file

@ -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_<area>" 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_<area>_<x>_<y>_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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

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

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()