| """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 |
|
|
|
|
| |
| |
| |
|
|
|
|
| @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 = "" |
|
|
| 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 |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| STORY_GRAPH: dict = { |
|
|
| |
| |
| |
| "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"], |
| ), |
|
|
| |
| |
| |
| "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"], |
| ), |
|
|
| |
| |
| |
| "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"], |
| ), |
|
|
| |
| |
| |
| "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"], |
| ), |
|
|
| |
| |
| |
| "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: 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" |
|
|
|
|
| |
| |
| |
|
|
|
|
| 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, |
| last_narration: str, |
| last_tactic: str, |
| client, |
| 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 |
|
|
| |
| 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: |
| |
| 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) |
|
|
| |
| |
| 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 "" |
|
|
|
|
| |
| |
| |
| |
| |
| _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) |
|
|
|
|
| |
| |
| |
| |
| |
| |
|
|
|
|
| def walk_story_tree( |
| oracles_text: list, |
| client, |
| 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) |
| |
| |
| |
| for i in range(4): |
| 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) |
| |
| 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): |
| |
| 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: |
| |
| 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 |
|
|
|
|
| |
| |
| |
|
|
|
|
| def render_themed_setups( |
| path: list, |
| theme, |
| client, |
| 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) |
|
|