diff --git a/internal/plugin/adventure_flavor_campaign.go b/internal/plugin/adventure_flavor_campaign.go index 740666c..f695b12 100644 --- a/internal/plugin/adventure_flavor_campaign.go +++ b/internal/plugin/adventure_flavor_campaign.go @@ -269,6 +269,17 @@ func bossEpilogueLine(zoneID ZoneID) string { return bossEpilogues[zoneID] } +// writeBossEpilogue appends a zone's campaign capstone (if any) to a +// victory narration. Shared by every boss-down render path — the manual +// turn-based finishes (finishCombatSession, finishPartyWin) and the compact +// autopilot boss resolve — so the D1b epilogue fires no matter how the boss +// was cleared. Caller gates on "this was a boss, not an elite". +func writeBossEpilogue(b *strings.Builder, zoneID ZoneID) { + if ep := bossEpilogueLine(zoneID); ep != "" { + b.WriteString("\n" + ep + "\n") + } +} + // 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 @@ -463,6 +474,15 @@ func (p *AdventurePlugin) finishEpilogueWin(userID id.UserID, alreadyCleared boo return "_The throne stays empty. You came to be sure. You are sure._" } + // Latch the once-only flag BEFORE handing out the Legendary + title, so a + // failed write (or a crash) can never leave the reward repeatable. If the + // latch fails, skip the grant entirely — the player re-enters uncleared and + // can try again, rather than pocketing a second Legendary on the retry. + if err := markEpilogueClearedDB(userID); err != nil { + slog.Error("epilogue: mark cleared failed", "user", userID, "err", err) + return "_The account won't quite close — the ledger jams. Try `!expedition start epilogue` again._" + } + var b strings.Builder b.WriteString("🎖️ **The account is closed.** You are named **" + finaleTitle + "** — the one who unhoused the Hollow King.\n") if mi, ok := pickMagicItemForRarity(RarityLegendary, nil); ok { @@ -481,9 +501,6 @@ func (p *AdventurePlugin) finishEpilogueWin(userID id.UserID, alreadyCleared boo } } } - if err := markEpilogueClearedDB(userID); err != nil { - slog.Error("epilogue: mark cleared failed", "user", userID, "err", err) - } if gr := gamesRoom(); gr != "" { if dn, _ := loadDisplayName(userID); dn != "" { diff --git a/internal/plugin/adventure_npcs.go b/internal/plugin/adventure_npcs.go index c3c48d5..563b845 100644 --- a/internal/plugin/adventure_npcs.go +++ b/internal/plugin/adventure_npcs.go @@ -131,6 +131,15 @@ func (p *AdventurePlugin) npcFireEncounter(userID id.UserID, npc string) { return } + // Don't consume the encounter — or its one-shot arc beat — if the player is + // already mid-interaction (shop, treasure, another NPC). Bail before + // touching MistyEncounterCount so a contended slot defers the whole + // encounter to a later fire instead of durably advancing the counter past + // a 5/15/30 threshold and losing that beat forever. + if _, occupied := p.pending.Load(string(userID)); occupied { + return + } + now := time.Now().UTC() var opening, prompt string @@ -165,11 +174,6 @@ func (p *AdventurePlugin) npcFireEncounter(userID id.UserID, npc string) { slog.Error("player_meta: npc last_seen dual-write failed", "user", userID, "npc", npc, "err", err) } - // Don't overwrite an existing pending interaction (shop, treasure, etc.) - if _, occupied := p.pending.Load(string(userID)); occupied { - return - } - // Set pending interaction — NPC encounters stay valid until end of UTC day endOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).Add(24 * time.Hour) p.pending.Store(string(userID), &advPendingInteraction{ diff --git a/internal/plugin/adventure_robbie.go b/internal/plugin/adventure_robbie.go index e1ad486..8ab31ce 100644 --- a/internal/plugin/adventure_robbie.go +++ b/internal/plugin/adventure_robbie.go @@ -171,7 +171,10 @@ func (p *AdventurePlugin) robbieVisitPlayer(userID id.UserID, displayName string if err == nil { char.RobbieVisitCount++ if char.RobbieVisitCount%robbieGiftEveryNVisits == 0 { - if gifts := consumableCache(robbieGiftTier(char.CombatLevel), 1); len(gifts) > 0 { + // Use the canonical DnD level (like the arena's tier gate), not the + // frozen legacy CombatLevel — that snapshots at 1–3 once D&D setup + // completes, so reading it here would peg every gift at tier 1. + if gifts := consumableCache(robbieGiftTier(arenaDnDLevelOrZero(userID)), 1); len(gifts) > 0 { if err := addAdvInventoryItem(userID, gifts[0]); err == nil { leftGift = &gifts[0] } @@ -200,10 +203,12 @@ func (p *AdventurePlugin) robbieVisitPlayer(userID id.UserID, displayName string func robbieQualifyingItems(inv []AdvItem, equip map[EquipmentSlot]*AdvEquipment) []AdvItem { var result []AdvItem for _, item := range inv { - // Never touch Arena gear, cards, or consumables. Consumables are a - // player-curated stockpile (crafted or dropped); selling them is an - // explicit decision the player must make themselves. - if item.Type == "ArenaGear" || item.Type == "card" || item.Type == "consumable" { + // Never touch Arena gear, cards, consumables, or keys. Consumables are + // a player-curated stockpile (crafted or dropped); selling them is an + // explicit decision the player must make themselves. Keys are cross-zone + // unlock tokens (N5/D4) that must persist in inventory to open their + // vault later — sweeping one permanently breaks that unlock. + if item.Type == "ArenaGear" || item.Type == "card" || item.Type == "consumable" || item.Type == "key" { continue } diff --git a/internal/plugin/combat_cmd.go b/internal/plugin/combat_cmd.go index 91cc703..d110d4e 100644 --- a/internal/plugin/combat_cmd.go +++ b/internal/plugin/combat_cmd.go @@ -382,9 +382,7 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess b.WriteString(drop + "\n") } if !elite { - if ep := bossEpilogueLine(zone.ID); ep != "" { - b.WriteString("\n" + ep + "\n") - } + writeBossEpilogue(&b, zone.ID) } if bossOnExpedition { // The boss is the expedition's climax. Frame the close-out as diff --git a/internal/plugin/combat_party_finish.go b/internal/plugin/combat_party_finish.go index 0273a47..b491bae 100644 --- a/internal/plugin/combat_party_finish.go +++ b/internal/plugin/combat_party_finish.go @@ -119,9 +119,7 @@ func (p *AdventurePlugin) finishPartyWin( } } if !elite { - if ep := bossEpilogueLine(zone.ID); ep != "" { - b.WriteString("\n" + ep + "\n") - } + writeBossEpilogue(&b, zone.ID) } switch { case bossOnExpedition && seat == 0: diff --git a/internal/plugin/dnd_zone_cmd.go b/internal/plugin/dnd_zone_cmd.go index 0b07733..bc712d9 100644 --- a/internal/plugin/dnd_zone_cmd.go +++ b/internal/plugin/dnd_zone_cmd.go @@ -1130,6 +1130,13 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z ob.WriteString("\n") ob.WriteString(line) } + // D1b campaign capstone. The compact autopilot resolves boss rooms + // itself (the primary long-expedition path), so the epilogue has to + // fire here too — otherwise almost every player clears the boss + // without ever seeing it. + if isBoss { + writeBossEpilogue(&ob, zone.ID) + } outcome = ob.String() return }