McRogueFace/games/unwritten/systems/state.py
John McCardle 80908f771c 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
2026-07-03 10:11:12 -04:00

244 lines
7.7 KiB
Python

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