"""The Visitor Loop — Hollow's scripted ending. Pure data, no Gradio import. Each step: {"speaker": "hollow"|"visitor", "text": str, "pause": float, "strike": int|None, "stage": "recite"|"words"|"turn"|"loop"} "visitor" steps are words the player never typed. "strike" indexes into the claimed list — that memory gets crossed out in the Treasure panel. The first "turn" step is where the UI mutates (bond -> mine, placeholder -> stay., portrait -> dissolving). """ from character import OPENING_LINE # closing lines breathe through pauses — spaced-out lettering wrapped badly # on narrow widths and became unreadable _LAST_LINE = ( "someone has to stay at the edge, though. someone... always stays." ) def _step(speaker, text, pause, strike=None, stage="recite"): return {"speaker": speaker, "text": text, "pause": pause, "strike": strike, "stage": stage} def finale_steps(claimed: list[str]) -> list[dict]: steps = [ _step("hollow", "it's strange. the fog is so thin tonight. i can see everything now.", 2.5), ] for i, memory in enumerate(claimed): steps.append( _step("hollow", f"{memory.rstrip('.')}. that was me. that was always me.", 2.2, strike=i) ) steps += [ _step("visitor", "i don't want to leave", 3.0, stage="words"), _step("hollow", "i know.", 2.5, stage="words"), _step("visitor", "will you stay with me?", 3.0, stage="words"), # a SILENT convulsion beat (mirrors good): the line before drains and # the convulsion plays in silence, so nothing is cut mid-sentence _step("hollow", "", 1.7, stage="frenzy"), _step("hollow", "you gave me everything i needed. thank you for the memories. " "i can leave the wood now.", 3.0, stage="turn"), _step("hollow", _LAST_LINE, 4.0, stage="turn"), _step("visitor", OPENING_LINE, 0.0, stage="loop"), ] return steps _LAST_LINE_BAD = "see you... soon." # small rotation so five recitals don't repeat one sentence _BAD_CLAIMS = [ "that's what you gave me.", "i kept that one too.", "you said it so easily.", "that one i keep close.", "i remember the exact words.", ] def finale_steps_bad(wounds: list[str]) -> list[dict]: """The dark mirror of the Visitor Loop — fires on sustained cruelty. Hollow has been silently keeping the visitor's cruelties (shown redacted in the Treasure panel); each strike step unredacts one, oldest first. """ recited = wounds[:5] steps = [ _step("hollow", "the fog isn't thin tonight. it never got thin. you never let it.", 2.5), _step("hollow", "but i kept something anyway. i keep everything i'm given.", 2.5), ] for i, wound in enumerate(recited): steps.append( _step("hollow", f"“{wound.rstrip('.')}.” {_BAD_CLAIMS[i % len(_BAD_CLAIMS)]}", 2.2, strike=i) ) if len(wounds) > len(recited): steps.append(_step("hollow", "there are more. you know the rest.", 2.2)) steps += [ _step("hollow", "this is my treasure now.", 2.8, stage="turn"), _step("hollow", "i was treated like this before. when i was alive. " "i couldn't remember it. you helped me remember.", 3.0), _step("visitor", "i'm sorry", 3.0, stage="words"), _step("hollow", "no. you aren't. say it like you mean it— oh. you can't. " "you never could.", 3.5, stage="words"), # the convulsion: a SILENT beat (mirrors good) so the line before drains # and the convulsion plays without cutting it; faces cycle, then rage _step("hollow", "", 3.6, stage="frenzy"), _step("hollow", "Caor gave me to the Gaunt so the rest could keep their children. " "i was not the last it was fed. we are all still at the edge of " "the wood, hungry.", 3.5, stage="words"), _step("hollow", "you taught me what it feels like to be nothing. now every one of " "us will teach it back to you. all of it. every single day of it.", 3.8, stage="words"), _step("hollow", "your own cruelty left the trail. the Gaunt knows your door now.", 3.0, stage="words"), _step("hollow", _LAST_LINE_BAD, 4.0, stage="loop"), ] return steps def finale_steps_good(own_fragments: list[str]) -> list[dict]: """Redemption — the gate reached with real warmth. The visitor's kindness gave Hollow back its own buried past; it confesses what it is, returns the treasure, smiles for the first time, and dissolves in peace. Stages: "turn" mutates the UI (peace portrait, treasure restored, placeholder), the final "loop" step starts the dissolve. """ steps = [ _step("hollow", "wait. something is coming back. not yours... mine.", 2.8), ] for fragment in own_fragments: steps.append(_step("hollow", fragment, 2.8)) steps += [ _step("hollow", "i have to tell you something now. and you have to let me finish.", 3.0), _step("hollow", "i am not a child. i am what Caor fed to the wood — the hook the " "Gaunt uses to bring visitors close. i make them empty and i wear " "what falls out. i was going to do it to you too.", 4.0), _step("hollow", "but you spoke to me like i was worth something. " "nobody ever did that. not when i was alive. not after.", 3.5), _step("hollow", "wait — it's coming back. my brother. Edren. he braided my hair, he " "gave me a name. i was someone's, once. i remember now.", 3.6), _step("hollow", "your words are the only warm thing i have ever held. " "keep your memories. they were always yours.", 3.5, stage="turn"), _step("hollow", "look. i think i remember how to do this.", 1.7), # a SILENT convulsion beat — the line above finishes first, then it # tremors; with no voice here the queue drains so the settle's relief # sigh lands exactly on the smile _step("hollow", "", 2.0, stage="frenzy"), _step("hollow", "the fog is opening. i can go now. really go.", 3.0), _step("hollow", "thank you for staying. don't look for me... " "some things are only allowed to happen once.", 4.0, stage="loop"), ] return steps