"""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\": \"\", \"reason\": \"\"}\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\": \"\", \"reason\": \"\"}\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)