From aab7a7bad05b4961a54bcb09dc462534d9d06374 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:26:20 -0700 Subject: [PATCH] N5/D1b: boss epilogues + TwinBee's journal reactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Boss epilogues: a 2-3 sentence campaign capstone per zone boss, tying each kill to the Hollow King arc. Appended to the boss-down moment in both close-out paths (finishCombatSession solo, finishPartyWin party), gated on the boss room (!elite) so it fires for any boss kill — expedition or legacy !zone — and never for elites or the arena (which has no ZoneID entry). Forest of Shadows is the King himself; its epilogue frames the fall as a shed shell, leaving the arc for the finale. - TwinBee digest reactions: a journal page found mid-expedition writes a "journal" log beat; the end-of-day digest emits one first-person, deterministically-picked TwinBee line reacting to the day's pages. No net-new DM — it rides the existing night-camp digest. Golden byte-identical; go test ./... green. Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa --- internal/plugin/adventure_flavor_campaign.go | 54 +++++++++++++++++++ .../plugin/adventure_flavor_campaign_test.go | 35 ++++++++++++ internal/plugin/combat_cmd.go | 5 ++ internal/plugin/combat_party_finish.go | 5 ++ internal/plugin/expedition_autorun_digest.go | 9 ++++ 5 files changed, 108 insertions(+) diff --git a/internal/plugin/adventure_flavor_campaign.go b/internal/plugin/adventure_flavor_campaign.go index a22f5a9..d7d3fe6 100644 --- a/internal/plugin/adventure_flavor_campaign.go +++ b/internal/plugin/adventure_flavor_campaign.go @@ -135,10 +135,64 @@ func (p *AdventurePlugin) grantJournalPage(userID id.UserID, rng *rand.Rand) str if err := grantJournalPageDB(userID, page); err != nil { return "" } + // If the page turned up mid-expedition, drop a log beat so the end-of-day + // digest can have TwinBee react to it. No expedition (legacy !zone, or a + // secret room opened outside a run) simply means no digest to react in. + if exp, err := getActiveExpedition(userID); err == nil && exp != nil { + _ = appendExpeditionLog(exp.ID, exp.CurrentDay, "journal", + journalPages[page-1].Title, fmt.Sprintf("page %d", page)) + } return fmt.Sprintf("📖 A torn journal page — _%s_ (page %d of %d). See `!adventure journal`.", journalPages[page-1].Title, page, journalTotalPages) } +// bossEpilogues ties each zone boss's death to the Hollow King arc: a 2-3 +// sentence capstone appended to the boss-down moment. Forest of Shadows is the +// King himself — but what falls there is a shell he shed, which is why the arc +// (and the finale) outlives it. In-world narration, not TwinBee's voice. +var bossEpilogues = map[ZoneID]string{ + ZoneGoblinWarrens: "Grol dies clutching a coin no goblin minted — a king's face worn smooth by handling. Whatever paid the warren to give up its deep tunnels, it paid in a currency older than these hills.", + ZoneCryptValdris: "Valdris tried to cheat the grave and managed only to furnish it. In his last rattle he says a name that isn't his — _hollow, hollow_ — as if warning you of a colleague who did it better.", + ZoneForestShadows: "The Hollow King falls without weight, a coat slipped from its peg — and the woods do not go quiet. What you felled here was a thing he shed, not the thing he is. Somewhere, the account he owes goes on accruing.", + ZoneSunkenTemple: "The Aboleth's dream breaks and the drowned bells still at last. In the silence you understand what they were counting toward — and that the count did not begin with this temple, and does not end with it.", + ZoneManorBlackspire: "Aldric was hollowed the same way, by the same hand, and made a poor imitation: a lord kept past his death to hold a house for a guest who never came. He thanks you. It is the first thing he has meant in a century.", + ZoneUnderforge: "Thyrak's fires gutter out, and the half-made things on the anvils cool into what they were always going to be — regalia, and soldiers, and a crown with no head to fit. The forge was filling an order placed a long time ago.", + ZoneUnderdark: "Ilvaras ruled the throat that swallows everything downward, toward the door at the bottom of the world. She dies certain she served a queen. She served a direction, and the direction has a name it never told her.", + ZoneFeywildCrossing: "The Thornmother's garden was the loveliest cage on the road, tended for a patient guest. He can afford patience; you are learning why. She wilts, and the too-kind light dims by exactly one degree.", + ZoneDragonsLair: "Behind Infernax's hoard, past the last of the gold, a single crown rests on no head — guarded better than the treasure, because it was the one thing here he was ever paid to keep. The dragon dies never knowing what it was.", + ZoneAbyssPortal: "Belaxath guarded a door that opens outward, built by someone who only ever meant to leave through it. As the demon falls, the gate does not close. It was never meant to keep things out — only to let one thing come home.", +} + +// bossEpilogueLine returns the campaign capstone for a zone boss, or "" for +// zones with none (and for the synthetic arena, which has no ZoneID entry). +func bossEpilogueLine(zoneID ZoneID) string { + return bossEpilogues[zoneID] +} + +// twinBeeJournalReactions are TwinBee's morning/digest reactions to pages found +// during the day — first-person, implicit subject, he/him, one line, curious, +// never expository (feedback_twinbee_voice, feedback_twinbee_is_male). Picked +// deterministically so a re-rendered digest reads the same. +var twinBeeJournalReactions = []string{ + "📖 Found a torn page in your kit tonight — been reading it by the fire while you sleep. This king of theirs was not a well man.", + "📖 Another page. Keep turning them up and I keep piecing him together, and I do not much like the shape.", + "📖 Read the new page twice. Whoever wrote it was frightened of something patient. I think we are walking toward it.", + "📖 Slipped the day's page into the others. The story's filling in at the edges, and none of the edges are kind.", +} + +// twinBeeJournalReaction picks one reaction line deterministically from the day +// and the number of pages found, so the digest is stable across re-renders. +func twinBeeJournalReaction(day, pagesToday int) string { + if len(twinBeeJournalReactions) == 0 || pagesToday <= 0 { + return "" + } + idx := (day + pagesToday) % len(twinBeeJournalReactions) + if idx < 0 { + idx = -idx + } + return twinBeeJournalReactions[idx] +} + // handleJournalCmd renders the player's collected campaign pages. func (p *AdventurePlugin) handleJournalCmd(ctx MessageContext) error { char, _, err := p.ensureCharacter(ctx.Sender) diff --git a/internal/plugin/adventure_flavor_campaign_test.go b/internal/plugin/adventure_flavor_campaign_test.go index 84d6a86..9cd1e09 100644 --- a/internal/plugin/adventure_flavor_campaign_test.go +++ b/internal/plugin/adventure_flavor_campaign_test.go @@ -172,3 +172,38 @@ func TestJournalDropChance_Sane(t *testing.T) { t.Fatalf("drop chance %v out of (0,1)", journalPageDropChance) } } + +func TestBossEpilogues_EveryRegisteredZoneHasOne(t *testing.T) { + for _, z := range allZones() { + if strings.TrimSpace(bossEpilogueLine(z.ID)) == "" { + t.Errorf("zone %s (%s) has no boss epilogue", z.ID, z.Display) + } + } + // The synthetic arena is not a campaign zone — it must have none. + if bossEpilogueLine(ZoneArena) != "" { + t.Errorf("arena should have no boss epilogue") + } + if bossEpilogueLine(ZoneID("nonexistent")) != "" { + t.Errorf("unknown zone should have no epilogue") + } +} + +func TestTwinBeeJournalReaction_DeterministicAndGuarded(t *testing.T) { + if twinBeeJournalReaction(3, 0) != "" { + t.Errorf("zero pages should produce no reaction") + } + first := twinBeeJournalReaction(3, 2) + if first == "" { + t.Fatalf("a found page should produce a reaction") + } + if again := twinBeeJournalReaction(3, 2); again != first { + t.Errorf("reaction not deterministic: %q vs %q", first, again) + } + // Voice guard: TwinBee never speaks of himself in the third person + // (feedback_twinbee_voice). None of the pooled lines may contain "TwinBee". + for _, line := range twinBeeJournalReactions { + if strings.Contains(line, "TwinBee") { + t.Errorf("third-person TwinBee reference in reaction: %q", line) + } + } +} diff --git a/internal/plugin/combat_cmd.go b/internal/plugin/combat_cmd.go index d930520..91cc703 100644 --- a/internal/plugin/combat_cmd.go +++ b/internal/plugin/combat_cmd.go @@ -381,6 +381,11 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess if drop := p.dropZoneLoot(userID, zone.ID, monster, !elite, elite); drop != "" { b.WriteString(drop + "\n") } + if !elite { + if ep := bossEpilogueLine(zone.ID); ep != "" { + b.WriteString("\n" + ep + "\n") + } + } if bossOnExpedition { // The boss is the expedition's climax. Frame the close-out as // the win rather than a "keep walking" nudge. One more diff --git a/internal/plugin/combat_party_finish.go b/internal/plugin/combat_party_finish.go index dff7366..0273a47 100644 --- a/internal/plugin/combat_party_finish.go +++ b/internal/plugin/combat_party_finish.go @@ -118,6 +118,11 @@ func (p *AdventurePlugin) finishPartyWin( b.WriteString(drop + "\n") } } + if !elite { + if ep := bossEpilogueLine(zone.ID); ep != "" { + b.WriteString("\n" + ep + "\n") + } + } switch { case bossOnExpedition && seat == 0: b.WriteString("🎉 **Zone cleared — the expedition is won.** `!expedition run` to march out and claim your spoils.") diff --git a/internal/plugin/expedition_autorun_digest.go b/internal/plugin/expedition_autorun_digest.go index 0d3f244..77bf87f 100644 --- a/internal/plugin/expedition_autorun_digest.go +++ b/internal/plugin/expedition_autorun_digest.go @@ -44,11 +44,14 @@ func renderEndOfDayDigest(expID string, prevDay int) string { threatLines []string milestoneLine []string narrativeBits []string + journalPages int ) for _, e := range entries { switch e.Type { case "walk": walks++ + case "journal": + journalPages++ case "harvest": // Only count successful gathers — failed rolls / errors are noise. if strings.Contains(e.Summary, "success") { @@ -115,6 +118,12 @@ func renderEndOfDayDigest(expID string, prevDay int) string { b.WriteString("\n") bulleted = true } + if react := twinBeeJournalReaction(prevDay, journalPages); react != "" { + b.WriteString("• ") + b.WriteString(react) + b.WriteString("\n") + bulleted = true + } if !bulleted { // All entries were filtered out — fall back to the bare camp block. return ""