the-apprentice / oracles /story_graph.py
AndrewRqy
Initial commit — The Apprentice for Build Small
5afb7b3
Raw
History Blame Contribute Delete
52.5 kB
"""Branching story graph for the Wizard's Oracles.
Replaces the linear ``5 random obstacles → dragon → epilogue`` chain
with a hand-authored DAG of 15 obstacle nodes that branch on trials
2, 3, and 4 and converge into one of 5 distinct endings on trial 5.
Each node knows its own ``setup`` text in en + zh, the candidate
successor node-ids, and (for trial-5 boss nodes) the ``ending_id``
to play once the player survives that boss.
Branch picking happens at trials 2/3/4 — when the player finishes a
trial, the runtime calls ``pick_next_node()`` which asks the LLM
"given the player's narration so far and oracle history, pick one of
these N candidates." This is a tiny separate LLM call per fork
(not the main trial resolution), so failures fall back to a
deterministic heuristic and never block gameplay.
The graph is fantasy-theme only for now. Other themes still run the
old random-obstacles flow.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
# ---------------------------------------------------------------------------
# Node + ending dataclasses
# ---------------------------------------------------------------------------
@dataclass
class StoryNode:
"""One obstacle on the branching tree.
Two ways a node's surface text can be rendered:
* **Hand-authored** (fantasy theme): ``setup_en`` / ``setup_zh`` are
vivid 2-3 sentence descriptions, used verbatim.
* **LLM-rendered** (every other theme): the ``concept`` field is a
short theme-neutral role description (e.g. "first gatekeeper —
a single large antagonist blocks a chokepoint the apprentice MUST
cross"). At send-off time, ``generate_themed_setups`` fires one
LLM call per node that expands the concept into the target theme's
world and language.
``shape`` is the obstacle shape (beast / trap / passage / seekers /
finale) — controls which sprite gets composited into the trial
image.
``trial`` is the trial number (1..5) this node lives at — used
only for sanity-checking + tree-rendering.
``successors`` is the list of node ids the player can transition to
after resolving this node. For trial-5 nodes ``successors`` is
empty and ``ending_id`` is set instead.
``ending_id`` (only on trial-5 nodes) selects which ENDINGS entry
plays as the epilogue.
``tag`` is a soft thematic tag (action / wit / mystic / cunning /
lore) the LLM uses as a hint when picking branches.
"""
id: str
trial: int
shape: str
setup_en: str
setup_zh: str
concept: str = ""
tag: str = ""
successors: list = field(default_factory=list)
ending_id: str = ""
is_dragon: bool = False
def setup(self, lang: str) -> str:
if lang == "zh" and self.setup_zh:
return self.setup_zh
return self.setup_en
@dataclass
class Ending:
id: str
title_en: str
title_zh: str
seed_en: str
seed_zh: str
shape: str = "" # theme-neutral outcome shape — LLM expands per theme
def title(self, lang: str) -> str:
return self.title_zh if lang == "zh" and self.title_zh else self.title_en
def seed(self, lang: str) -> str:
return self.seed_zh if lang == "zh" and self.seed_zh else self.seed_en
# ---------------------------------------------------------------------------
# The graph: 15 obstacle nodes (1 → 2 → 4 → 4 → 4) + 5 endings
# ---------------------------------------------------------------------------
#
# Structure:
# T1: hornet_oak (shared opening)
# T2: troll_bridge | sphinx_riddle (combat / wit fork)
# T3: bandit_archers | wraith_fog (from troll)
# smuggler_bazaar | sunken_archive(from sphinx)
# T4: spectral_ferryman | wailing_gorge | whispering_vault | hall_of_mirrors
# T5 bosses: dragon_violent | dragon_greedy | dragon_ancient | dragon_mirrored
# (the fifth "secret" ending is reachable via a meta route described below)
#
# Branching: each T1/T2/T3/T4 node lists 2 successors. The runtime
# picks one via an LLM call (see pick_next_node()).
# ---------------------------------------------------------------------------
STORY_GRAPH: dict = {
# =====================================================================
# TRIAL 1 — shared opening
# =====================================================================
"hornet_oak": StoryNode(
id="hornet_oak",
trial=1,
shape="seekers",
tag="opening",
concept=(
"OPENING SWARM. Countless small hostile things (insects, dust, "
"shards, particles, drones, etc.) erupt from a natural opening "
"and cover the only forward path. The apprentice cannot retreat. "
"Establishes tone."
),
setup_en=(
"A black river of hornets boils out of a split hollow oak and "
"covers the path ahead. The buzzing is so loud it shakes the "
"leaves on the ground. The forest behind is shut by their "
"wings; the only way forward is through them."
),
setup_zh=(
"黑色的胡蜂从一棵裂开的空心橡树里涌出,覆盖了前方的小径,"
"嗡嗡声震得地上的落叶都在颤动。身后被它们的翅膀封住——只能"
"穿过这片蜂海前行。"
),
successors=["troll_bridge", "sphinx_riddle"],
),
# =====================================================================
# TRIAL 2 — first fork: combat path vs wit path
# =====================================================================
"troll_bridge": StoryNode(
id="troll_bridge",
trial=2,
shape="beast",
tag="combat",
concept=(
"FIRST GATEKEEPER. A single LARGE solitary antagonist with an "
"obvious weapon stands on the only crossing/passage. It has "
"refused to let anyone through for years. Combat-coded, no "
"speech beyond threats."
),
setup_en=(
"A grey troll the size of an ox squats on the only stone bridge "
"across the gorge. It has a rusted greataxe across its knees "
"and a necklace of dried teeth, and it has not let anyone pass "
"in seven years."
),
setup_zh=(
"一只灰色的山妖蹲在跨越峡谷的唯一石桥上,体形如牛,膝上横"
"着一柄锈迹斑斑的巨斧,胸前挂着一串干瘪的牙齿。七年了,它"
"没有放任何人过桥。"
),
successors=["bandit_archers", "wraith_fog"],
),
"sphinx_riddle": StoryNode(
id="sphinx_riddle",
trial=2,
shape="seekers",
tag="wit",
concept=(
"FIRST TEST OF WITS. A riddler / interrogator / oracle with the "
"power to kill blocks the path and demands a correct answer to "
"a single question. Surrounded by the remains of failures. "
"Wit-coded."
),
setup_en=(
"A grey sphinx with the body of a lion and the cracked face of "
"an old woman blocks the mountain pass. She offers a single "
"riddle in a voice like wind through dry reeds — and warns "
"that all who answer wrong have already been eaten."
),
setup_zh=(
"一只灰色的斯芬克斯挡住了山路,狮身,老妇人裂纹般的面容。她"
"用风吹过干芦苇般的嗓音抛出一个谜语,并冷冷地告诉旅人——所有"
"答错的人,她早已吃尽。"
),
successors=["smuggler_bazaar", "sunken_archive"],
),
# =====================================================================
# TRIAL 3 — second fork (4 nodes)
# =====================================================================
"bandit_archers": StoryNode(
id="bandit_archers",
trial=3,
shape="seekers",
tag="action",
concept=(
"ARMED AMBUSH. Multiple hostile mortals (3-7) with ranged "
"weapons rise from cover, weapons already aimed. They have "
"done this many times. The apprentice is in the open. Quick decisive "
"action required."
),
setup_en=(
"Five bandit archers rise from behind a low stone wall, arrows "
"already nocked. Their faces are masked in soot and they say "
"nothing — they only point their bows and wait, in a line, "
"with the patience of people who have done this many times."
),
setup_zh=(
"五名蒙面强盗从矮石墙后站起,箭已搭弦。他们用煤灰涂黑脸,"
"一言不发,只是排成一排把弓对准来者,那耐心是干过无数次的"
"样子。"
),
successors=["spectral_ferryman", "wailing_gorge"],
),
"wraith_fog": StoryNode(
id="wraith_fog",
trial=3,
shape="passage",
tag="mystic",
concept=(
"DISORIENTING OTHERWORLDLY PASSAGE. A wall of fog / mist / "
"void / static / shadow rolls across the road. Half-real "
"figures inside mutter contradictory directions that loop the "
"traveler. The air is colder. Mystic-coded."
),
setup_en=(
"A wall of grey fog rolls across the road, and within it the "
"shadows of long-dead travelers stagger sideways, mumbling "
"directions that always loop back to where they started. "
"Stepping in feels like stepping into a colder country."
),
setup_zh=(
"一堵灰雾横在路上,雾里晃动着早已死去的旅人的影子,他们"
"斜着身子,喃喃自语,所指的方向永远绕回原地。踏入雾中,仿"
"佛踏进了另一个更冷的国度。"
),
successors=["spectral_ferryman", "whispering_vault"],
),
"smuggler_bazaar": StoryNode(
id="smuggler_bazaar",
trial=3,
shape="trap",
tag="cunning",
concept=(
"DANGEROUS MARKETPLACE. A black-market / hidden bazaar of "
"contraband, lit unnaturally. A one-eyed / scarred merchant "
"already knows the apprentice's name. He offers safe passage for "
"a 'small' price — the price will ruin the apprentice. Cunning-coded."
),
setup_en=(
"A midnight bazaar opens between rotten warehouses — caged "
"beasts, contraband relics, a one-eyed merchant who knows "
"your name. He says he can sell you safe passage. He says "
"his price is small. He says it with a smile that has no warmth."
),
setup_zh=(
"腐朽的仓库之间,一场午夜集市悄然开张:笼中走兽、私运的"
"圣物,一名独眼商人径自唤出旅人的名字。他说他能贩售你一段"
"安然无虞的路。他说价钱不高。他笑着说——笑里全无暖意。"
),
successors=["wailing_gorge", "hall_of_mirrors"],
),
"sunken_archive": StoryNode(
id="sunken_archive",
trial=3,
shape="passage",
tag="lore",
concept=(
"FLOODED REPOSITORY OF KNOWLEDGE. A half-ruined archive / "
"library / data-vault / scriptorium under water/sand/snow. "
"Holds a map or key the apprentice needs. Something is also waiting "
"in here, patient. Lore-coded."
),
setup_en=(
"A door under the riverbank opens onto a flooded library — "
"marble shelves, books rotting in still water up to your "
"chest, a single guttering candle waiting. Somewhere in here "
"is a map. Something else in here is waiting for the map "
"to be found."
),
setup_zh=(
"河岸下一扇门通往一座被淹的藏书阁——大理石书架,浸到胸口"
"的死水里腐烂的旧卷,一支孤零零的烛火等在那儿。这里某处藏"
"着一张地图。这里某物正等着这张地图被找到。"
),
successors=["whispering_vault", "hall_of_mirrors"],
),
# =====================================================================
# TRIAL 4 — convergence layer (4 themed gates)
# =====================================================================
"spectral_ferryman": StoryNode(
id="spectral_ferryman",
trial=4,
shape="passage",
tag="action+mystic",
concept=(
"TOLL-KEEPER OF PASSAGE. A liminal figure (ferryman / gate-"
"keeper / customs officer) at the only crossing. Refuses "
"money. Demands payment in the form of the apprentice confessing "
"the most violent act of his life — aloud, in detail."
),
setup_en=(
"At the black river a hooded ferryman waits, his face two coins "
"and a held breath. He takes no money. He takes the most violent "
"thing the traveler has ever done, and he expects to be paid in "
"the telling. The far bank is the only road on."
),
setup_zh=(
"黑河之畔,一名戴风帽的渡夫正等候,他的脸只剩两枚铜钱与一口"
"屏住的气。他不收银钱,他收旅人此生最暴烈的一桩往事——并要"
"你亲口讲出。彼岸是唯一可继续前行的路。"
),
successors=["dragon_violent"],
),
"wailing_gorge": StoryNode(
id="wailing_gorge",
trial=4,
shape="trap",
tag="action+cunning",
concept=(
"CROSSING OF FEAR. A precarious crossing (rope-bridge / "
"narrow ledge / failing transit) over a deadly drop. The "
"wind / silence / hum BECOMES a voice that knows the apprentice's "
"specific debts and demands payment in backsteps. Each "
"backstep degrades the crossing."
),
setup_en=(
"The gorge mouths out as a single rope-bridge over a drop so "
"deep the bottom is just noise. Halfway across, the wind turns "
"into a voice that knows the traveler's debts and demands "
"payment in steps backwards. Every backstep is a length of "
"rope that frays."
),
setup_zh=(
"峡谷口仅余一座绳桥,桥下深不见底,只剩呼啸的回声。行至中"
"段,风化成了人声——它说出旅人欠下的每一笔账,要求他以倒退"
"的步子偿还。每后退一步,绳索就磨损一段。"
),
successors=["dragon_greedy"],
),
"whispering_vault": StoryNode(
id="whispering_vault",
trial=4,
shape="passage",
tag="mystic+lore",
concept=(
"ANCIENT INTERLOCUTOR. A sealed chamber / hibernation pod / "
"buried mind that opens only for THIS apprentice. Inside is a "
"voice that has had centuries to think. It will trade the "
"next step for the apprentice's most precious memory."
),
setup_en=(
"A vault sealed for nine hundred years opens to the traveler "
"alone. Inside is one chair, one cup, and one voice that has "
"had nothing to do but think since before the language was "
"written. It will trade the next step for the traveler's "
"favorite memory."
),
setup_zh=(
"一座封闭九百年的密室独为这位旅人开启。其中只一张椅、一只"
"杯,与一道声音——它在语言诞生之前就独自思索,至今未停。它"
"愿换旅人最爱的一段回忆,以换得下一步可走。"
),
successors=["dragon_ancient"],
),
"hall_of_mirrors": StoryNode(
id="hall_of_mirrors",
trial=4,
shape="trap",
tag="cunning+lore",
concept=(
"TEST OF IDENTITY. A space of reflections / parallel "
"realities / branching screens. Each one shows the apprentice a "
"possible alternate life (softer, richer, kinder, crueler, "
"dead). Stepping into the wrong reflection becomes that life "
"permanently."
),
setup_en=(
"A black-glass hall opens with a hundred reflections that "
"do not quite move when the traveler moves. Each mirror "
"shows him a possible life — softer, richer, dead, kind, "
"cruel. Picking the wrong mirror means walking into it and "
"becoming that life."
),
setup_zh=(
"一座黑曜镜厅敞开,百面镜子映出的影像——并不完全随旅人"
"起步而动。每一面镜中都是他一种可能的人生:更柔软、更富"
"足、已死、慈悲、残忍。选错了镜子,便会径直走入其中,从此"
"成为那一生。"
),
successors=["dragon_mirrored"],
),
# =====================================================================
# TRIAL 5 — the dragon, four faces (ending route encoded in node)
# =====================================================================
"dragon_violent": StoryNode(
id="dragon_violent",
trial=5, shape="finale", tag="boss", is_dragon=True,
concept=(
"WOUNDED APEX FINALE BOSS. The final adversary in this "
"world. Half rage, half ancient injury (an old wound from "
"someone long dead). Does not negotiate. Pure violence. "
"Maximum stakes."
),
setup_en=(
"The dragon is half wound, half rage — its left flank scarred "
"where a forgotten knight cut it a century back and bled out "
"for the trouble. It does not roar. It opens its mouth and "
"the cave gets a degree hotter every second it stays open."
),
setup_zh=(
"那条龙一半是旧伤,一半是怒火——左侧的鳞甲上仍有一道百年"
"前一位无名骑士留下的剑痕,那骑士也因此流尽了血。它并不"
"咆哮。它张口时,洞穴每过一息就再热一度。"
),
ending_id="steel_and_ashes",
successors=[],
),
"dragon_greedy": StoryNode(
id="dragon_greedy",
trial=5, shape="finale", tag="boss", is_dragon=True,
concept=(
"MERCANTILE FINALE BOSS. The final adversary, who hoards "
"wealth (literal coins / souls / data / records). Sees the "
"apprentice not as prey but as an entry in an unbalanced "
"ledger. Intends to settle the account."
),
setup_en=(
"The dragon lies on a hill of coins that move when it breathes. "
"Each scale is a little ledger. It does not see prey when it "
"looks at the traveler — it sees an entry in a column that has "
"been short for a long time, and it intends to balance it."
),
setup_zh=(
"那条龙伏在金币之丘上,每一次呼吸都让金币微微涌动。鳞甲"
"如一页页账本。它瞧向旅人的眼神里并无猎物,只有一笔久"
"悬未平的账——它意欲就此结清。"
),
ending_id="silver_tongue",
successors=[],
),
"dragon_ancient": StoryNode(
id="dragon_ancient",
trial=5, shape="finale", tag="boss", is_dragon=True,
concept=(
"TIRED ANCIENT FINALE BOSS. The final adversary has been "
"aware for thousands of years and is exhausted by it. Does "
"not lunge. Recites the apprentice's lineage from memory and asks "
"an unanswerable question."
),
setup_en=(
"The dragon has been awake for three thousand years and is "
"tired of being awake. It does not lunge. It tells the traveler "
"the names of his grandparents' grandparents, in order, and "
"asks if he knows the name of the thing that will end him."
),
setup_zh=(
"那条龙已醒了三千年,早已倦于醒着。它并不扑来。它将旅人"
"祖辈以上历代先人的名字一一道出,按辈序而排,再问他可"
"知,那将终结他的事物,姓甚名谁?"
),
ending_id="hollow_crown",
successors=[],
),
"dragon_mirrored": StoryNode(
id="dragon_mirrored",
trial=5, shape="finale", tag="boss", is_dragon=True,
concept=(
"MIRROR-SELF FINALE BOSS. The final adversary wears the "
"apprentice's own face / voice / movements. Anticipates every "
"tactic before the apprentice names it. Perfectly symmetrical "
"match. Whoever wins, both lose something."
),
setup_en=(
"The dragon wears the traveler's own face. When the traveler "
"speaks, the dragon's jaw moves in time. When the traveler "
"thinks of a tactic, the dragon's eyes flick to it before he "
"has named it. It is going to be a very fair fight."
),
setup_zh=(
"那条龙长着旅人自己的面孔。旅人开口,龙的颌也随之而动;"
"旅人心生一计,龙的眼神已先一步望向那处——尚未命名便已"
"知晓。这将是一场极其公平的对决。"
),
ending_id="the_last_laugh",
successors=[],
),
}
# ---------------------------------------------------------------------------
# Endings — picked by the trial-5 boss the player ended up fighting
# ---------------------------------------------------------------------------
ENDINGS: dict = {
"steel_and_ashes": Ending(
id="steel_and_ashes",
title_en="Steel and Ashes",
title_zh="钢与灰烬",
shape=(
"HEROIC BUT COSTLY VICTORY. The apprentice won by direct "
"violence. The weapon broke or failed at the last moment. The "
"home was half-destroyed before he got back. People sing for "
"him anyway, perhaps too loudly."
),
seed_en=(
"The apprentice won the way the old songs said it should be won: "
"with a weapon that finally broke, a wound that did not close, "
"and a home half-burned by the time he returned.\n\n"
"WHY THE BOSS FELL: it died fighting because it had only ever "
"known how to fight. Wounded a hundred times before this last "
"meeting, it never learned a different posture; in the apprentice "
"it met something that could not be talked down either, and that "
"was the symmetry that ended it.\n\n"
"WHAT THE APPRENTICE CARRIED HOME: a body that no longer moves "
"without remembering the cost. He knows now that the songs leave "
"out the parts where the iron tastes like copper and the silence "
"after is not peaceful. He will not sing those parts back, but he "
"will hear them every night."
),
seed_zh=(
"他赢得这一战的方式,与古老歌谣中所唱的一模一样:兵刃终于折"
"断,伤口始终未合,等他回到家时,半座家园已成灰烬。\n\n"
"那位最终的对手为何陨落:它战死,是因为它此生只懂得战斗。这"
"之前已百次受伤,却从未学会另一种姿态;在他这里它遇上了同样"
"无法被言语劝退的另一个人——正是这种对称,终结了它。\n\n"
"他带回家的,是一具再不能不带着代价行动的身体。他懂了:歌谣"
"唱的是凯旋,唱不出铁腥气压在舌底的滋味,唱不出胜利之后那并"
"不安宁的寂静。这些他不会再唱出来,却会夜夜入耳。"
),
),
"silver_tongue": Ending(
id="silver_tongue",
title_en="The Silver Tongue",
title_zh="银舌的胜利",
shape=(
"BLOODLESS VICTORY THROUGH WORDS / CUNNING. The apprentice never "
"fought. He talked the boss out of its anger, its hoard, almost "
"out of being itself. The village got rich on its spoils — and "
"never slept easy again."
),
seed_en=(
"The apprentice never lifted a weapon. He talked the boss out "
"of its hoard, then out of its anger, then — almost — out of "
"being itself.\n\n"
"WHY THE BOSS GAVE UP THE HOARD: every ledger it kept was an "
"argument it was having with someone long dead. The apprentice "
"was the first to ask, in good faith, what the original debt was. "
"The boss could not name it — and once it could not name what it "
"was protecting, it could not protect it. It walked out into the "
"open land, no longer a guardian of anything, and that was the "
"shape its surrender took.\n\n"
"WHAT THE APPRENTICE CARRIED HOME: the ability to make someone "
"give up a thing they have been guarding for centuries by simply "
"asking the right question. The village grew rich on coins still "
"warm from the boss's belly and slept uneasily forever after — "
"because every villager could now see the trick had a price, and "
"the price was that the apprentice could never again be trusted "
"with a confidence."
),
seed_zh=(
"他从未挥过一剑。他用一张嘴谈走了那位对手的财宝,又谈走了它"
"的怒火,最后——几乎——谈走了它身为它自己这件事。\n\n"
"它为何放下了所守之物:它所记的每一笔账,都是与一位早已亡故"
"之人的旧争执。他是第一个真诚询问那笔原始债究竟为何之人。它"
"答不上来——一旦答不上自己在守护什么,也就守不住了。它走入"
"旷野,不再是任何东西的守护者;这便是它投降的方式。\n\n"
"他带回家的,是一种本事:仅凭一句问得对的话,就能让一个守了"
"千年的存在放下所守。家园靠着尚带它腹中余温的财货富了起来,"
"却夜夜难眠,自此以往——因为每个人都看得见这门本事是有代价"
"的,而代价是:他从此再不能被人托付任何秘密。"
),
),
"hollow_crown": Ending(
id="hollow_crown",
title_en="The Hollow Crown",
title_zh="空心的王冠",
shape=(
"PYRRHIC MEMORY-LOSS VICTORY. The apprentice won, but along "
"the way he traded away the memory of why he came. He stands "
"in the boss's domain trying to recall the name of his own "
"home. Walks home; is never the same."
),
seed_en=(
"The apprentice won, but by trading away the memory of why he "
"had come.\n\n"
"WHY THE BOSS WALKED AWAY: it had asked the unanswerable "
"question — the name of the thing that would end it. The "
"apprentice's silence as he tried to remember his own home was "
"the answer. The boss recognized him: not as prey, but as the "
"next bearer of long memory. It bowed once, the way an exhausted "
"thing bows when it can finally pass its burden along, and left "
"the cave open. It had been waiting for someone who could carry "
"the weight.\n\n"
"WHAT THE APPRENTICE CARRIED HOME: a thousand years of names — "
"ancestors, debts, treaties, the genealogies of strangers — "
"lodged in his head where the simple memory of a village and a "
"mentor used to be. He sat on the hoard three days trying to "
"recall his own name. When he finally stood up and walked out, "
"he understood why bosses are so still: their stillness is the "
"only posture in which that much remembering does not crush a "
"body. He is now that still."
),
seed_zh=(
"他赢了——却以遗忘自己当初为何启程为代价。\n\n"
"那位对手为何转身离去:它抛出了那个无人能答的问题——那个能"
"终结它之物,姓甚名谁?他沉默着,试图记起自己家园的名字——这"
"沉默便是答案。它认出了他:不是猎物,而是漫长记忆的下一个承"
"载者。它低头一礼,那是一件极度疲惫之物终于得以将重担交付时"
"才有的姿态。它将洞口留为敞开,转身离去。它等的,本就是一个"
"能背得起这副担子的人。\n\n"
"他带回家的,是千年的名字——历代先人、旧债、盟约、陌生人的"
"家谱——尽数挤进他脑中,把原本属于家园与导师的那份简单记忆"
"挤了出去。他在那堆金山上枯坐三日,试图记起自己叫什么。当他"
"终于站起身走出洞口,他明白了:那些古老的对手为何总是一动不"
"动——那是唯一能扛起如此记忆而身躯不被压垮的姿态。如今他也"
"学会了这般静止。"
),
),
"the_last_laugh": Ending(
id="the_last_laugh",
title_en="The Last Laugh",
title_zh="最后一笑",
shape=(
"COMIC STALEMATE / UNEXPECTED FRIENDSHIP. The apprentice and "
"the boss fought to a perfect draw, looked at each other, "
"started laughing — at the same thing. Left together, "
"discussing supplies."
),
seed_en=(
"The boss and the apprentice fought to a perfect draw, looked at "
"each other, and started laughing. They were laughing at the same "
"thing.\n\n"
"WHY THE BOSS LAUGHED FIRST: it had spent its long existence "
"training to defeat one person — itself, eventually — and in the "
"apprentice it finally met a fight where every move was countered "
"by an equal move. The absurdity of an enemy this perfect, after "
"all the imperfect ones, finally cracked it. The laugh was relief: "
"I am not alone in the geometry of my own life, and I am not "
"going to die today after all.\n\n"
"WHAT THE APPRENTICE CARRIED HOME: a friend nobody back home would "
"believe in, an inside joke a thousand years old, and the lifelong "
"knowledge that a fair fight isn't a fight at all — it's the start "
"of a conversation. He brings the boss to dinners and weddings "
"now. People get used to it. Mostly."
),
seed_zh=(
"那位对手与他战至完全平手,对望一眼,便一起放声大笑。两人笑"
"的,竟是同一件事。\n\n"
"对手为何先笑出声:它毕生只为一战而练——最终要击败的,是它"
"自己。而在他这里,它终于遇上一场每一招都被对等之招化解的对"
"决。在经历过那么多并不对称的敌人之后,这种荒谬的、过分完美"
"的对称,终究让它绷不住了。那一笑是松弛:原来我并不孤独于自"
"己生命的几何之中,原来今日我也不会死。\n\n"
"他带回家的,是一位家中无人肯信其存在的朋友、一个千年之久的"
"内部笑话,以及一份伴他一生的领悟——真正公平的对决根本不是"
"对决,而是一段对话的开端。如今他赴宴或参加婚礼,常将那位对"
"手一同带去。众人也就慢慢习惯了。大致是这样。"
),
),
"false_dawn": Ending(
id="false_dawn",
title_en="False Dawn",
title_zh="伪明",
shape=(
"QUIET NON-RESOLUTION. The apprentice did not win. The boss "
"did not lose. The apprentice turned around at the threshold "
"and walked home. The story you are reading was found half-"
"finished in an abandoned hearth. Some endings are like that."
),
seed_en=(
"The apprentice did not win. The boss did not lose. The story "
"you are reading was found, half-finished, in the ash of a "
"hearth that nobody remembers tending.\n\n"
"WHY THERE WAS NO CONFRONTATION: at the threshold, the "
"apprentice saw the boss for what it was — old, and tired, and "
"still doing the work it had been set to do. He had been sent to "
"end the work. He saw, in that moment, that ending the work "
"would not change the world that asked for the work in the first "
"place. The boss looked at him and recognized that recognition. "
"Neither moved. Both understood. That was enough.\n\n"
"WHAT THE APPRENTICE CARRIED HOME: nothing he could show the "
"village, and one thing he could never explain — the knowledge "
"that some stories are not meant to end, only to be witnessed by "
"someone else for a moment, then handed back. He went home. He "
"did not become a different person. The mentor never asked. "
"Some songs end like that, too."
),
seed_zh=(
"他没有胜。那位对手也未败。你所读的这一卷故事,是有人在一处"
"早已无人照看的炉灰里发现的,只写了一半。\n\n"
"为何没有对决:他在门槛上,看见了那位对手的真实模样——年迈"
"了,倦了,仍在做着它被安排去做的事。他被遣来终结这桩工作。"
"他在那一刻明白,终结工作并不能改变那个最初要求工作的世界。"
"那位对手望着他,也认出了这份认知。双方都未动。双方都明白。"
"已然足够。\n\n"
"他带回家的,是没有什么可以拿给众人看的东西,以及一件他永远"
"无法解释的事——有些故事并非为了被讲完,只为了让另一人短暂"
"见证一刻,再交还回去。他回了家。他没有变成另一个人。导师也"
"从未追问。有的歌,结尾本就如此。"
),
),
}
ROOT_NODE_ID = "hornet_oak"
DEFAULT_ENDING_ID = "false_dawn"
# ---------------------------------------------------------------------------
# Runtime helpers
# ---------------------------------------------------------------------------
def get_node(node_id: str) -> Optional[StoryNode]:
return STORY_GRAPH.get(node_id)
def get_ending(ending_id: str) -> Ending:
return ENDINGS.get(ending_id, ENDINGS[DEFAULT_ENDING_ID])
def root_node() -> StoryNode:
return STORY_GRAPH[ROOT_NODE_ID]
def candidate_nodes(current_node_id: str) -> list:
"""Return the list of StoryNode objects the player can transition to
after resolving ``current_node_id``. Empty for trial-5 (boss) nodes."""
node = get_node(current_node_id)
if node is None:
return []
return [STORY_GRAPH[s] for s in node.successors if s in STORY_GRAPH]
def pick_next_node(
current_node_id: str,
oracle_history: list, # list of {"text": str, "trial": int}
last_narration: str,
last_tactic: str,
client, # LLMClient
language: str = "English",
) -> str:
"""Ask the LLM to choose one of the candidate successor nodes given
the player's narration so far + oracle history + recent tactic.
Returns the chosen node id. On any failure (mock client, malformed
LLM output, network), falls back to picking the candidate with the
closest tag-match to the recent oracle vibe — but never blocks.
"""
candidates = candidate_nodes(current_node_id)
if not candidates:
return ""
if len(candidates) == 1:
return candidates[0].id
# Build the picker prompt — short, JSON-locked, no humor required.
picker_system = (
"You are the storyteller for a branching fairy-tale game. The "
"apprentice has just survived a trial. Pick which of the candidate "
"next obstacles best fits the tone of the story so far.\n\n"
"Return ONLY a JSON object of the form:\n"
" {\"choice\": \"<one of the candidate IDs>\", \"reason\": \"<one sentence>\"}\n\n"
f"Write the reason in {language}.\n"
)
cand_lines = "\n".join(
f" - id: {c.id}\n tag: {c.tag}\n setup: {c.setup_en[:240]}"
for c in candidates
)
oracles_lines = "\n".join(
f" - trial {o.get('trial', '?')}: {o.get('text', '')[:80]}"
for o in (oracle_history or [])
) or " (none yet)"
picker_user = (
f"ORACLES THE APPRENTICE HAS USED SO FAR:\n{oracles_lines}\n\n"
f"LAST TRIAL'S NARRATION (excerpt):\n{(last_narration or '')[:600]}\n\n"
f"LAST TRIAL'S TACTIC ONE-LINER:\n{(last_tactic or '')[:200]}\n\n"
f"CANDIDATE NEXT OBSTACLES:\n{cand_lines}\n\n"
"Pick the one whose tag and setup best continues this story. "
"Return only the JSON object."
)
try:
result = client.complete_json(
system=picker_system,
user=picker_user,
max_tokens=200,
temperature=0.85,
model=_picker_model_for_lang(language),
)
choice = (result or {}).get("choice", "")
if any(choice == c.id for c in candidates):
return choice
except Exception as _e:
# Silent on mock client (expected), log when a real LLM was used.
if not getattr(client, "using_mock", True):
import sys
print(f"[story_graph.pick_next_node] LLM picker failed: "
f"{type(_e).__name__} {_e}", file=sys.stderr)
# Deterministic fallback — keyword-match the most recent oracle to a
# candidate's tag, else just take the first candidate.
if oracle_history:
last = (oracle_history[-1].get("text", "") or "").lower()
for c in candidates:
for keyword in _TAG_KEYWORDS.get(c.tag, []):
if keyword in last:
return c.id
return candidates[0].id
def _picker_model_for_lang(language: str) -> str:
"""Same routing rule as resolution._model_for_lang — use the base
model for zh because the LoRA's English-bias would mangle the JSON
reason field."""
if language and ("Chinese" in language or "中文" in language):
return "llm"
return ""
# Tiny keyword → tag map used for the deterministic fallback when the
# LLM picker fails. Each tag's keyword list also includes neighbouring
# tags' words so the cascade still differentiates oracle vibes at T2
# (where only combat/wit exist) — e.g. a "deal" (cunning) oracle still
# steers to the wit branch at T2, and on to the cunning child at T3.
_TAG_KEYWORDS = {
"combat": ["fight", "sword", "strike", "blood", "rage", "burn",
"smash", "run", "throw", "jump", "kick", "punch", "shoot",
"wound", "hit"],
"wit": ["riddle", "trick", "puzzle", "joke", "lie", "word", "name",
"book", "scroll", "memory", "history", "library", "knowledge",
"coin", "deal", "trade", "smile", "wink", "barter", "steal",
"ghost", "spirit", "fog", "dream", "shadow", "ancient", "rune",
"think", "clever", "talk", "speak", "story"],
"action": ["run", "throw", "jump", "kick", "punch", "shoot",
"fight", "strike", "rush", "chase"],
"mystic": ["ghost", "spirit", "fog", "dream", "shadow", "ancient",
"rune", "magic", "spell", "wraith", "haunt", "echo"],
"cunning": ["coin", "deal", "trade", "smile", "wink", "barter", "steal",
"lie", "trick", "smuggle", "bribe", "swap"],
"lore": ["book", "scroll", "memory", "name", "history", "library",
"knowledge", "word", "rune", "secret", "ancient", "tome"],
}
def total_node_count() -> int:
return len(STORY_GRAPH)
def total_ending_count() -> int:
return len(ENDINGS)
# ---------------------------------------------------------------------------
# Pre-walk-at-send-off — picks branches up front using the inscribed
# oracles as context. Keeps the existing precompute / resolution pipeline
# intact (we still produce a flat list of 5 obstacles by the time trials
# begin) and lets every oracle drive both a trial AND a branch decision.
# ---------------------------------------------------------------------------
def walk_story_tree(
oracles_text: list, # list of 5 oracle text strings (in order)
client, # LLMClient (may be mock)
language: str = "English",
) -> list:
"""Walk the tree from root to a leaf, picking each successor via an
LLM call seeded by one inscribed oracle.
Returns a list of 5 StoryNode objects [trial-1, ..., trial-5]. Trial 5
is always a boss node so its ``ending_id`` is non-empty.
When ``client.using_mock`` is True the LLM picker is skipped and we
fall back to the deterministic keyword-match (or "first candidate")
rule inside ``pick_next_node`` — playing through still works offline,
just with less reactive branching.
"""
path: list = []
node = root_node()
path.append(node)
# Pretend oracle 1 was "used to clear" trial 1 → drives the T2 fork,
# oracle 2 drives T3, oracle 3 drives T4, oracle 4 drives T5 (the boss).
# Oracle 5 is reserved for the trial-5 RESOLUTION, not branch-picking.
for i in range(4): # 4 picks → trials 2..5
if not node.successors:
break
oracle_text = oracles_text[i] if i < len(oracles_text) else ""
chosen = _pick_next_with_oracle(
node, oracle_text, client, language,
)
node = get_node(chosen) or candidate_nodes(node.id)[0]
path.append(node)
# Defensive pad if the tree is malformed (e.g. a leaf at trial 3)
while len(path) < 5:
path.append(path[-1])
return path[:5]
def _pick_next_with_oracle(
current: StoryNode,
oracle_text: str,
client,
language: str,
) -> str:
"""Single-fork picker. Mirrors pick_next_node() but uses oracle text
as the context signal (since narrations don't exist yet at send-off).
"""
cands = candidate_nodes(current.id)
if not cands:
return ""
if len(cands) == 1:
return cands[0].id
if getattr(client, "using_mock", True):
# Mock client — skip the LLM call and use the deterministic fallback
return _fallback_pick(cands, oracle_text)
picker_system = (
"You are the storyteller for a branching fairy-tale game. Given "
"the player's chosen oracle for the upcoming trial, pick which "
"candidate next obstacle best fits the tone.\n\n"
"Return ONLY a JSON object of the form:\n"
" {\"choice\": \"<one of the candidate IDs>\", \"reason\": \"<one sentence>\"}\n\n"
f"Write the reason in {language}."
)
cand_lines = "\n".join(
f" - id: {c.id}\n tag: {c.tag}\n setup: {c.setup_en[:240]}"
for c in cands
)
picker_user = (
f"PREVIOUS OBSTACLE (just resolved):\n id: {current.id}\n"
f" setup: {current.setup_en[:240]}\n\n"
f"THE PLAYER'S ORACLE FOR THE UPCOMING TRIAL:\n {oracle_text[:160]}\n\n"
f"CANDIDATE NEXT OBSTACLES:\n{cand_lines}\n\n"
"Pick the candidate whose tag/setup best continues the story given "
"what the player's oracle suggests they will improvise with. "
"Return only the JSON object."
)
try:
result = client.complete_json(
system=picker_system,
user=picker_user,
max_tokens=200,
temperature=0.85,
model=_picker_model_for_lang(language),
)
choice = (result or {}).get("choice", "")
if any(choice == c.id for c in cands):
return choice
except Exception:
pass
return _fallback_pick(cands, oracle_text)
def _fallback_pick(cands: list, oracle_text: str) -> str:
"""Keyword-match the oracle text against each candidate's tag-keyword
list. Compound tags like ``"action+cunning"`` get their keyword sets
merged. Returns the first candidate whose merged keyword set matches
a token in ``oracle_text``; on no match (or empty oracle) returns the
first candidate."""
text = (oracle_text or "").lower()
if not text:
return cands[0].id
best = None
best_score = 0
for c in cands:
# Compound tags ("action+cunning") merge both sub-lists.
keywords: list = []
for sub_tag in (c.tag or "").split("+"):
keywords.extend(_TAG_KEYWORDS.get(sub_tag.strip(), []))
score = sum(1 for k in keywords if k in text)
if score > best_score:
best_score = score
best = c
if best is not None:
return best.id
return cands[0].id
# ---------------------------------------------------------------------------
# Theme expansion — render each visited node's setup in the target theme
# ---------------------------------------------------------------------------
def render_themed_setups(
path: list, # list of StoryNode (the player's walked path)
theme, # Theme dataclass (from oracles.themes)
client, # LLMClient
language: str = "English",
hero_name: str = "the apprentice",
village_name: str = "his village",
max_workers: int = 5,
) -> dict:
"""For each node in ``path`` return a 2-3 sentence obstacle setup
rendered in the active theme + language.
Fantasy theme keeps each node's hand-authored setup_en/zh. Every
other theme fires one LLM call per node that expands ``node.concept``
into themed prose. Calls run in parallel.
Returns a dict ``{node.id: setup_str}``. On per-node LLM failures
falls back to the canonical setup_en/zh so the trial still has text.
"""
import concurrent.futures as _f
is_fantasy = (getattr(theme, "key", "") == "fantasy")
lang_code = "zh" if (language and ("Chinese" in language or "中文" in language)) else "en"
out: dict = {}
if is_fantasy or client is None or getattr(client, "using_mock", True):
for node in path:
out[node.id] = node.setup(lang_code)
return out
def _one(node) -> tuple:
try:
txt = _llm_render_node(
node, theme, client,
language=language,
hero_name=hero_name,
village_name=village_name,
)
if txt and len(txt.strip()) > 30:
return (node.id, txt.strip())
except Exception:
pass
return (node.id, node.setup(lang_code))
with _f.ThreadPoolExecutor(max_workers=max_workers) as ex:
for node_id, setup in ex.map(_one, path):
out[node_id] = setup
return out
def _llm_render_node(
node: StoryNode,
theme,
client,
language: str = "English",
hero_name: str = "the apprentice",
village_name: str = "his village",
) -> str:
"""Single LLM call that expands ``node.concept`` into a vivid 2-3
sentence obstacle setup IN the target theme + language. The output
is what becomes ``Obstacle.setup`` for that trial.
The prompt deliberately keeps the LLM tightly constrained to the
concept's role (no extra trials, no resolution, no oracle-use) — it
just writes the obstacle, not the story around it.
"""
th_name = getattr(theme, "display_name", "the world")
th_blurb = getattr(theme, "blurb", "")
th_finale = getattr(theme, "finale_descriptor", "the final adversary")
th_style = getattr(theme, "style_cues", "")
system = (
f"You are a setup writer for a branching fairy-tale game set in "
f"the world of {th_name}. Brief: {th_blurb}\n"
f"Style cues: {th_style}\n\n"
f"You will receive an abstract OBSTACLE CONCEPT. Your job is to "
f"render it as a vivid 2-3 sentence obstacle SETUP in this "
f"world, in {language}. Do NOT write a resolution. Do NOT "
f"narrate the apprentice's actions. Do NOT use the oracle. Just "
f"describe the obstacle as the apprentice meets it, in present tense.\n\n"
f"Apprentice name: {hero_name}. Village/home: {village_name}. "
f"The finale of this run is {th_finale}. This is trial "
f"{node.trial} of 5.\n\n"
f"Output ONLY the obstacle setup — no preamble, no quotes, no "
f"labels."
)
user = (
f"OBSTACLE CONCEPT (trial {node.trial}, tag={node.tag}):\n"
f"{node.concept}\n\n"
f"Write the obstacle setup now, in {language}."
)
try:
from oracles.resolution import _wrap_with_language_force
system = _wrap_with_language_force(system, language)
except Exception:
pass
text = client.complete_text(
system=system, user=user,
max_tokens=300, temperature=0.95,
model=_picker_model_for_lang(language),
)
return (text or "").strip()
def render_themed_ending_seed(
ending: Ending,
theme,
client,
language: str = "English",
hero_name: str = "the apprentice",
village_name: str = "his village",
) -> str:
"""Single LLM call that expands ``ending.shape`` into a vivid 2-3
sentence ending seed in the active theme + language. Falls back to
the hand-authored seed_en/zh on any error.
Used by ``generate_epilogue`` when theme != fantasy.
"""
lang_code = "zh" if (language and ("Chinese" in language or "中文" in language)) else "en"
if client is None or getattr(client, "using_mock", True):
return ending.seed(lang_code)
th_name = getattr(theme, "display_name", "the world")
th_blurb = getattr(theme, "blurb", "")
th_finale = getattr(theme, "finale_descriptor", "the final adversary")
th_style = getattr(theme, "style_cues", "")
system = (
f"You are an ending seed writer for a branching fairy-tale game "
f"set in the world of {th_name}. Brief: {th_blurb}\n"
f"Style cues: {th_style}\n\n"
f"You receive an abstract ENDING SHAPE. Render it as a vivid 3-4 "
f"sentence ending seed in this world, in {language}. Stay TRUE "
f"to the shape's outcome (heroic / cunning / pyrrhic / comic / "
f"non-resolution). Do NOT use the word 'dragon' unless this "
f"world has dragons. The boss in this world is {th_finale}.\n\n"
f"Apprentice: {hero_name}. Home: {village_name}.\n\n"
f"Output ONLY the ending seed — no preamble, no quotes."
)
user = (
f"ENDING SHAPE (id={ending.id}):\n{ending.shape}\n\n"
f"Write the ending seed now, in {language}."
)
try:
from oracles.resolution import _wrap_with_language_force
system = _wrap_with_language_force(system, language)
except Exception:
pass
try:
text = client.complete_text(
system=system, user=user,
max_tokens=400, temperature=0.9,
model=_picker_model_for_lang(language),
)
text = (text or "").strip()
if len(text) > 40:
return text
except Exception:
pass
return ending.seed(lang_code)