N6/C3 review fixes: close the killed-boss-resolves-as-survived window

Two findings from an adversarial review of the C3 diff, both fixed:

1. (medium) A killing blow commits the shared pool to 0 inline, but the status
   flip to 'defeated' was deferred until AFTER the multi-second narration stream.
   In that window a crash/redeploy or the ticker's survive path could resolve a
   boss the town KILLED as a survival — debiting the pot and paying no bounties.
   Fix: resolve the defeat BEFORE streaming narration, and add a ticker safety
   net that resolves any active 0-HP boss as defeated (never falls through to the
   survive/pot-debit branch). Both guarded by setWorldBossStatus, so still once.

2. (minor) Pool damage was applied before the best-effort contrib/gate write, so
   a failed upsert let a player refight a pool they had already drained and lose
   the credit. Fix: write the contribution (which sets the once-per-day gate)
   BEFORE draining the pool, as a hard error that aborts the bout with the pool
   untouched.

Also corrected the applyWorldBossDamage comment (two concurrent killers CAN both
see killed=true; the status guard dedupes the payout — the old comment claimed
only one would). New ticker tests cover both resolution paths; suite green,
golden byte-identical.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
This commit is contained in:
prosolis
2026-07-10 18:09:11 -07:00
parent 127d252982
commit e2c0c3359b
2 changed files with 76 additions and 11 deletions

View File

@@ -368,3 +368,50 @@ func TestResolveWorldBossBout_FellsTinyPool(t *testing.T) {
t.Errorf("bout should not self-resolve; status=%q", after.Status)
}
}
// TestWorldBossTick_ResolvesZeroHPBossAsDefeated: a killing blow that committed
// the pool to 0 but (crash/redeploy) never resolved the status must be resolved
// as DEFEATED by the ticker net — never fall through to the survive/pot-debit
// path. No contributors here, so resolution never touches euro.
func TestWorldBossTick_ResolvesZeroHPBossAsDefeated(t *testing.T) {
newWorldBossTestDB(t)
now := time.Now().UTC()
communityPotAdd(1000)
bossID, err := insertWorldBoss("Ghost", 3, 100, now, now.Add(worldBossWindow))
if err != nil {
t.Fatal(err)
}
if _, err := db.Get().Exec(`UPDATE world_boss SET hp_current = 0 WHERE id = ?`, bossID); err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
p.worldBossTick()
after, _ := loadWorldBoss(bossID)
if after.Status != "defeated" {
t.Errorf("status = %q, want defeated (ticker safety net)", after.Status)
}
if bal := communityPotBalance(); bal != 1000 {
t.Errorf("pot = %d, want 1000 — a defeat mints, it must not debit the pot", bal)
}
}
// TestWorldBossTick_ResolvesLapsedBossAsSurvived: a boss whose window lapsed with
// the pool still up survives and loots the pot.
func TestWorldBossTick_ResolvesLapsedBossAsSurvived(t *testing.T) {
newWorldBossTestDB(t)
now := time.Now().UTC()
communityPotAdd(1000)
bossID, err := insertWorldBoss("Titan", 4, 500, now.Add(-worldBossWindow), now.Add(-time.Minute))
if err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
p.worldBossTick()
after, _ := loadWorldBoss(bossID)
if after.Status != "survived" {
t.Errorf("status = %q, want survived", after.Status)
}
if bal := communityPotBalance(); bal != 800 {
t.Errorf("pot = %d, want 800 after 20%% tribute", bal)
}
}