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
244 lines
7.7 KiB
Python
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()
|