N5 review fixes: protect keys from Robbie, fire epilogue on autopilot, atomic finale reward

Five correctness fixes from a code review of the N5 branch:

- Robbie no longer sweeps/sells cross-zone keys (Type "key"), which
  permanently broke the vault unlock they exist to open.
- Robbie's gift tier now reads the canonical DnD level, not the frozen
  legacy CombatLevel that pegged every gift at tier 1.
- Boss epilogue (D1b) now fires on the compact autopilot boss resolve —
  the primary long-expedition path — not just manual !fight. Deduped the
  two manual sites into a shared writeBossEpilogue helper.
- Finale reward latches epilogue_cleared before granting the Legendary +
  title, so a failed/late write can't make the reward repeatable.
- Misty arc beat's occupied-slot guard moved above the counter increment,
  so a contended pending slot defers the encounter instead of consuming a
  5/15/30 beat forever.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
This commit is contained in:
prosolis
2026-07-10 16:53:32 -07:00
parent 3103b519fb
commit 81b2359109
6 changed files with 48 additions and 19 deletions

View File

@@ -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 != "" {

View File

@@ -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{

View File

@@ -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 13 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
}

View File

@@ -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

View File

@@ -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:

View File

@@ -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
}