mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -269,6 +269,17 @@ func bossEpilogueLine(zoneID ZoneID) string {
|
|||||||
return bossEpilogues[zoneID]
|
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
|
// twinBeeJournalReactions are TwinBee's morning/digest reactions to pages found
|
||||||
// during the day — first-person, implicit subject, he/him, one line, curious,
|
// during the day — first-person, implicit subject, he/him, one line, curious,
|
||||||
// never expository (feedback_twinbee_voice, feedback_twinbee_is_male). Picked
|
// 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._"
|
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
|
var b strings.Builder
|
||||||
b.WriteString("🎖️ **The account is closed.** You are named **" + finaleTitle + "** — the one who unhoused the Hollow King.\n")
|
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 {
|
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 gr := gamesRoom(); gr != "" {
|
||||||
if dn, _ := loadDisplayName(userID); dn != "" {
|
if dn, _ := loadDisplayName(userID); dn != "" {
|
||||||
|
|||||||
@@ -131,6 +131,15 @@ func (p *AdventurePlugin) npcFireEncounter(userID id.UserID, npc string) {
|
|||||||
return
|
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()
|
now := time.Now().UTC()
|
||||||
|
|
||||||
var opening, prompt string
|
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)
|
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
|
// 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)
|
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{
|
p.pending.Store(string(userID), &advPendingInteraction{
|
||||||
|
|||||||
@@ -171,7 +171,10 @@ func (p *AdventurePlugin) robbieVisitPlayer(userID id.UserID, displayName string
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
char.RobbieVisitCount++
|
char.RobbieVisitCount++
|
||||||
if char.RobbieVisitCount%robbieGiftEveryNVisits == 0 {
|
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 {
|
if err := addAdvInventoryItem(userID, gifts[0]); err == nil {
|
||||||
leftGift = &gifts[0]
|
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 {
|
func robbieQualifyingItems(inv []AdvItem, equip map[EquipmentSlot]*AdvEquipment) []AdvItem {
|
||||||
var result []AdvItem
|
var result []AdvItem
|
||||||
for _, item := range inv {
|
for _, item := range inv {
|
||||||
// Never touch Arena gear, cards, or consumables. Consumables are a
|
// Never touch Arena gear, cards, consumables, or keys. Consumables are
|
||||||
// player-curated stockpile (crafted or dropped); selling them is an
|
// a player-curated stockpile (crafted or dropped); selling them is an
|
||||||
// explicit decision the player must make themselves.
|
// explicit decision the player must make themselves. Keys are cross-zone
|
||||||
if item.Type == "ArenaGear" || item.Type == "card" || item.Type == "consumable" {
|
// 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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -382,9 +382,7 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess
|
|||||||
b.WriteString(drop + "\n")
|
b.WriteString(drop + "\n")
|
||||||
}
|
}
|
||||||
if !elite {
|
if !elite {
|
||||||
if ep := bossEpilogueLine(zone.ID); ep != "" {
|
writeBossEpilogue(&b, zone.ID)
|
||||||
b.WriteString("\n" + ep + "\n")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if bossOnExpedition {
|
if bossOnExpedition {
|
||||||
// The boss is the expedition's climax. Frame the close-out as
|
// The boss is the expedition's climax. Frame the close-out as
|
||||||
|
|||||||
@@ -119,9 +119,7 @@ func (p *AdventurePlugin) finishPartyWin(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !elite {
|
if !elite {
|
||||||
if ep := bossEpilogueLine(zone.ID); ep != "" {
|
writeBossEpilogue(&b, zone.ID)
|
||||||
b.WriteString("\n" + ep + "\n")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case bossOnExpedition && seat == 0:
|
case bossOnExpedition && seat == 0:
|
||||||
|
|||||||
@@ -1130,6 +1130,13 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
|
|||||||
ob.WriteString("\n")
|
ob.WriteString("\n")
|
||||||
ob.WriteString(line)
|
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()
|
outcome = ob.String()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user