N6/C3 W2: world boss — the daily bout, command, operator spawn

Wires the player-facing half of the Siege on top of W1's model + lifecycle.

- !adventure worldboss [status|fight|spawn] (alias !adventure siege). Status
  shows the shared-pool board + your daily bout state + the muster; fight
  takes today's bout; spawn is an admin override (the "both" spawn decision).
- The bout is an arena-style solo fight through runZoneCombat vs a disposable
  per-tier stat block; the damage dealt (EnemyEntryHP-EnemyEndHP) is subtracted
  from the shared pool atomically (MAX(0, …) WHERE status='active') win or lose.
  Real HP cost like the arena, but no death/no hospital: worldBossFloorHP
  raises a 0-HP loser to 1 so a loss reads as "battered", not a corpse.
- One bout per player per UTC day (worldBossBoutUsedToday off the contrib's
  last_fight_date). The per-user advUserLock serialises a player's own repeat
  submits so the gate can't be raced; cross-player pool-crossing is safe
  because only the setWorldBossStatus winner resolves a defeat.
- A killing bout trips resolveWorldBossDefeated (minted bounty by fights +
  cache + treasure roll), after the fighter's own bout DM has streamed.

Bout core (resolveWorldBossBout) + the once/day predicate + the HP floor are
factored out of the DM path so they're unit-tested end to end with a real
fightable character; the DM/games-room emission stays thin (no client stub).
Combat golden byte-identical; full plugin suite green.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
This commit is contained in:
prosolis
2026-07-10 17:59:17 -07:00
parent c3e122694b
commit 127d252982
3 changed files with 315 additions and 0 deletions

View File

@@ -269,3 +269,102 @@ func TestResolveWorldBossSurvived_DebitsPot(t *testing.T) {
t.Errorf("pot = %d, want 800 after 20%% tribute", bal)
}
}
// ── W2: the bout ─────────────────────────────────────────────────────────────
func fightableChar(t *testing.T, uid id.UserID) {
t.Helper()
if err := createAdvCharacter(uid, "boxer"); err != nil {
t.Fatalf("createAdvCharacter: %v", err)
}
c := &DnDCharacter{
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: 12,
STR: 18, DEX: 14, CON: 16, INT: 10, WIS: 10, CHA: 10,
HPMax: 120, HPCurrent: 120, ArmorClass: 18,
}
if err := SaveDnDCharacter(c); err != nil {
t.Fatalf("SaveDnDCharacter: %v", err)
}
}
func TestWorldBossFloorHP(t *testing.T) {
newWorldBossTestDB(t)
uid := id.UserID("@floor:test.invalid")
fightableChar(t, uid)
if worldBossFloorHP(uid) {
t.Error("floor should be a no-op above 0 HP")
}
if _, err := db.Get().Exec(`UPDATE dnd_character SET hp_current = 0 WHERE user_id = ?`, string(uid)); err != nil {
t.Fatal(err)
}
if !worldBossFloorHP(uid) {
t.Error("floor should raise a 0-HP fighter")
}
if cur, _ := dndHPSnapshot(uid); cur != 1 {
t.Errorf("hp after floor = %d, want 1", cur)
}
}
func TestResolveWorldBossBout_SubtractsAndRecords(t *testing.T) {
newWorldBossTestDB(t)
uid := id.UserID("@bout:test.invalid")
fightableChar(t, uid)
now := time.Now().UTC()
// Big pool so one bout can only chip it — assert the partial subtract + contrib.
bossID, err := insertWorldBoss("Kravok", 3, 100000, now, now.Add(worldBossWindow))
if err != nil {
t.Fatal(err)
}
boss, _ := loadWorldBoss(bossID)
p := &AdventurePlugin{}
bout, err := p.resolveWorldBossBout(uid, boss, "2026-07-05")
if err != nil {
t.Fatalf("bout: %v", err)
}
if bout.Damage < 0 {
t.Errorf("damage negative: %d", bout.Damage)
}
if bout.Killed {
t.Error("a 100k pool should survive one bout")
}
if bout.Remaining != 100000-bout.Damage {
t.Errorf("remaining %d != max-dmg %d", bout.Remaining, 100000-bout.Damage)
}
c, _ := loadWorldBossContrib(bossID, uid)
if c == nil || c.Fights != 1 || c.Damage != bout.Damage || c.LastFightDate != "2026-07-05" {
t.Errorf("contrib mismatch: %+v (bout dmg %d)", c, bout.Damage)
}
if !worldBossBoutUsedToday(bossID, uid, "2026-07-05") {
t.Error("bout should read as used for that day")
}
if worldBossBoutUsedToday(bossID, uid, "2026-07-06") {
t.Error("a different day should still be available")
}
}
func TestResolveWorldBossBout_FellsTinyPool(t *testing.T) {
newWorldBossTestDB(t)
uid := id.UserID("@kill:test.invalid")
fightableChar(t, uid)
now := time.Now().UTC()
bossID, err := insertWorldBoss("Wisp", 3, 1, now, now.Add(worldBossWindow))
if err != nil {
t.Fatal(err)
}
boss, _ := loadWorldBoss(bossID)
p := &AdventurePlugin{}
bout, err := p.resolveWorldBossBout(uid, boss, "2026-07-05")
if err != nil {
t.Fatalf("bout: %v", err)
}
// A L12 fighter lands at least one blow on a T3 dummy over a full boss fight,
// felling a 1-HP pool.
if !bout.Killed || bout.Remaining != 0 {
t.Errorf("tiny pool not felled: killed=%v remaining=%d dmg=%d", bout.Killed, bout.Remaining, bout.Damage)
}
// Resolution is the caller's job — the bout leaves the boss active.
after, _ := loadWorldBoss(bossID)
if after.Status != "active" {
t.Errorf("bout should not self-resolve; status=%q", after.Status)
}
}