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:
parent
d94a25706a
commit
80908f771c
31 changed files with 8847 additions and 0 deletions
227
games/unwritten/tests/test_script_integrity.py
Normal file
227
games/unwritten/tests/test_script_integrity.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue