mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
N5/D1b: boss epilogues + TwinBee's journal reactions
- 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
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
Reference in New Issue
Block a user