Adv 2.0 D&D Phase R7: post-completion audit hardening

High-priority fixes from the multi-agent audit of Adventure 2.0:

- Phase 11 nil-deref cluster: zoneOrFallback() helper replaces 8 unsafe
  getZone(_) sites in dnd_zone_cmd.go. Corrupted zone IDs render a
  placeholder instead of panicking.
- Briefing/recap idempotency: deliverBriefing/deliverRecap now claim
  the rollover via a conditional UPDATE. Double-fires from clock skew
  or restart become no-ops; supply burn and day++ no longer reapply.
- Graceful ticker shutdown: AdventurePlugin gains stopCh + Stop(); all
  11 background tickers now select on stopCh in addition to ticker.C.
- Mood decay (§3.2): math.Round(elapsed*2) replaces int() truncation
  so sub-hour gaps decay correctly.
- 24h auto-abandon (§4.3): getActiveZoneRun returns clean slate and
  abandons stale runs whose LastActionAt is over 24h old.
- Respec / auto-migrate orphan cleanup: !respec and the auto-migrated
  draft wipe now abandon active zone runs and expeditions before
  deleting the dnd_character row.
- Phase R combat-link: applyBossDefeatThreat now wired from
  resolveBossRoom (-20 threat); applyRoomCombatThreatForUser adds
  +5/+8 from non-boss/elite kills (§8.1).
- Starvation → forced extraction: briefing-time check forces extract
  with §10.2 coin tax when supplies hit zero.
- GMNat20/Nat1 narration wired into resolveCombatRoom and
  resolveBossRoom (nat-20 takes precedence over nat-1).
- Treasure-undo race: LoadAndDelete on both timer-fire and `undo`
  paths so only one side wins.
- Battle Master: Disarming, Menacing, Parry maneuvers added (3 → 6
  of 10). Remaining 4 (Pushing, Goading, Riposte, Commander's Strike)
  documented inline as needing ally/reaction mechanics the engine
  doesn't model.
- Threat-70 warning: tracked in RegionState["siege_warning_fired"]
  so a drop-and-recross doesn't re-fire the beat.
- region_state JSON decode error now logged via slog.Warn instead
  of silently discarded.

Failing TestProdDB_DnDLayer fixed via option (a): track migrated
characters and only run round-trip / idempotency assertions on those,
skipping pre-existing prod-DB rows accumulated from live bot use.

New tests in dnd_audit_phase_R7_test.go cover: 24h auto-abandon,
briefing double-fire idempotency, threat-70 warning idempotency,
multi-region extract→resume state preservation, and starvation
forced-extraction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-08 19:29:54 -07:00
parent 8a1d9a16ce
commit 45863bd5f3
19 changed files with 837 additions and 273 deletions

View File

@@ -46,7 +46,9 @@ func TestProdDB_DnDLayer(t *testing.T) {
t.Logf("loaded %d real adventure characters", len(chars))
// 2. For each, verify they have NO dnd_character row yet, then auto-migrate
// and verify the resulting row is sensible.
// and verify the resulting row is sensible. Track which chars we
// actually migrated so steps 3+ can target one of those.
migrated := make(map[id.UserID]bool)
for i := range chars {
char := &chars[i]
uid := char.UserID
@@ -57,7 +59,10 @@ func TestProdDB_DnDLayer(t *testing.T) {
continue
}
if existing != nil {
t.Errorf("user=%s: had dnd_character row pre-migrate (should be empty)", uid)
// Real prod DB has accumulated rows from live bot use.
// Skip these — the auto-migrate path is exercised by
// other characters in the loop.
t.Logf("user=%s: skipping (already has dnd_character row)", uid)
continue
}
@@ -110,14 +115,22 @@ func TestProdDB_DnDLayer(t *testing.T) {
}
}
migrated[uid] = true
t.Logf("auto-migrated user=%s → L%d %s %s HP=%d AC=%d STR/DEX/CON/INT/WIS/CHA=%d/%d/%d/%d/%d/%d",
uid, dnd.Level, dnd.Race, dnd.Class, dnd.HPMax, dnd.ArmorClass,
dnd.STR, dnd.DEX, dnd.CON, dnd.INT, dnd.WIS, dnd.CHA)
}
if len(migrated) == 0 {
t.Skip("no characters available for auto-migrate (all pre-existing); steps 3+ require a fresh migrate")
}
// 3. Re-load each auto-migrated char — round-trip integrity.
for i := range chars {
uid := chars[i].UserID
if !migrated[uid] {
continue
}
dnd, err := LoadDnDCharacter(uid)
if err != nil {
t.Errorf("user=%s: re-load failed: %v", uid, err)
@@ -134,10 +147,17 @@ func TestProdDB_DnDLayer(t *testing.T) {
}
// 4. ensureDnDCharacterForCombat is idempotent — second call returns the
// same row, doesn't create a duplicate.
uid := chars[0].UserID
// same row, doesn't create a duplicate. Pick the first migrated char.
var idemIdx int
for i := range chars {
if migrated[chars[i].UserID] {
idemIdx = i
break
}
}
uid := chars[idemIdx].UserID
first, _ := LoadDnDCharacter(uid)
second, freshAgain, err := ensureDnDCharacterForCombat(uid, &chars[0])
second, freshAgain, err := ensureDnDCharacterForCombat(uid, &chars[idemIdx])
if err != nil {
t.Errorf("idempotent ensure failed: %v", err)
}