N5/D1c: the finale — !expedition start epilogue

Closes the Hollow King arc with a solo one-shot boss fight, reached via
`!expedition start epilogue` (intercepted before the zone/loadout
machinery). Unlock: all 24 journal pages found + both Tier-5 zones cleared.

- Encounter: runZoneCombat + renderBossOutcome, the arena's own pattern —
  no supplies, no walk, no party, no new mechanics. The stat block is
  Belaxath's (the abyss boss whose gate "lets one thing come home")
  re-dressed as the King returned in full, HP ×1.25 for a capstone; the
  ability/AC/CR carry over unchanged.
- Reward-once: first clear grants a unique title ("Kingsbane") + one
  Legendary + a games-room notice; later clears are a flavour-only
  rematch. The flag is a dedicated player_meta.epilogue_cleared column,
  latched by an atomic UPDATE (never the bulk save) so the monotonic
  false→true can't be clobbered — same discipline as D1a's journal
  bitmask. A loss costs a death, as any boss fight does.
- Title is written after a fresh character reload so runZoneCombat's
  post-fight HP persist isn't overwritten (the survivalist-milestone
  gotcha).

Golden byte-identical; go test ./... green.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
This commit is contained in:
prosolis
2026-07-10 15:37:53 -07:00
parent aab7a7bad0
commit 9b6c1ff9a4
6 changed files with 290 additions and 1 deletions

View File

@@ -421,6 +421,33 @@ func grantJournalPageDB(userID id.UserID, page int) error {
return err
}
// loadEpilogueCleared reads the finale reward-once flag (N5/D1c).
func loadEpilogueCleared(userID id.UserID) (bool, error) {
var cleared int
err := db.Get().QueryRow(
`SELECT epilogue_cleared FROM player_meta WHERE user_id = ?`,
string(userID),
).Scan(&cleared)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, err
}
return cleared != 0, nil
}
// markEpilogueClearedDB latches the finale reward-once flag atomically, so the
// monotonic false→true set survives any concurrent character save.
func markEpilogueClearedDB(userID id.UserID) error {
_, err := db.Get().Exec(
`INSERT INTO player_meta (user_id, epilogue_cleared) VALUES (?, 1)
ON CONFLICT(user_id) DO UPDATE SET epilogue_cleared = 1`,
string(userID),
)
return err
}
// HouseState is the in-memory mirror of player_meta's house_* columns. Phase
// L4e ports housing/mortgage off AdvCharacter (gogobee_legacy_migration.md
// §6.5). All six fields mutate together at known sites (purchase, payoff,
@@ -1319,6 +1346,9 @@ func applyPlayerMetaOverlay(c *AdventureCharacter) {
if mask, err := loadJournalPages(uid); err == nil {
c.JournalPages = mask
}
if cleared, err := loadEpilogueCleared(uid); err == nil {
c.EpilogueCleared = cleared
}
if s, err := loadHouseState(uid); err == nil {
c.HouseTier = s.Tier
c.HouseLoanBalance = s.LoanBalance