From 127d2529823fb346bffe54fce59b85828e7881da Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:59:17 -0700 Subject: [PATCH] =?UTF-8?q?N6/C3=20W2:=20world=20boss=20=E2=80=94=20the=20?= =?UTF-8?q?daily=20bout,=20command,=20operator=20spawn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/plugin/adventure.go | 5 + internal/plugin/adventure_worldboss.go | 211 ++++++++++++++++++++ internal/plugin/adventure_worldboss_test.go | 99 +++++++++ 3 files changed, 315 insertions(+) diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index ff1b023..00c5b32 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -537,6 +537,10 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error { return p.handleJournalCmd(ctx) case lower == "shadow": return p.handleShadowCmd(ctx) + case lower == "worldboss" || strings.HasPrefix(lower, "worldboss "): + return p.handleWorldBossCmd(ctx, strings.TrimSpace(args[len("worldboss"):])) + case lower == "siege" || strings.HasPrefix(lower, "siege "): + return p.handleWorldBossCmd(ctx, strings.TrimSpace(args[len("siege"):])) } return p.SendDM(ctx.Sender, "Unknown command. Type `!adventure help` to see available commands.") @@ -570,6 +574,7 @@ const advHelpText = `**Adventure Commands** ` + "`!adventure treasures`" + ` — List your treasures · ` + "`treasures lock`" + ` to refuse swaps ` + "`!adventure journal`" + ` — Read the campaign pages you've recovered ` + "`!adventure shadow`" + ` — See how your rival's run compares to yours +` + "`!adventure worldboss`" + ` — The monthly Siege: one communal bout a day (` + "`fight`" + ` to join) ` + "`!hospital`" + ` — Visit St. Guildmore's Memorial Hospital (same-day revival when dead) ` + "`!thom`" + ` — Visit Thom Krooke (housing and loans) ` + "`!adventure help`" + ` — This message diff --git a/internal/plugin/adventure_worldboss.go b/internal/plugin/adventure_worldboss.go index 98985d9..1a1d9d0 100644 --- a/internal/plugin/adventure_worldboss.go +++ b/internal/plugin/adventure_worldboss.go @@ -553,3 +553,214 @@ func (p *AdventurePlugin) announceWorldBossSurvived(boss *worldBossState, tribut } p.announceWorldBoss(msg) } + +// ── Player command (W2) ────────────────────────────────────────────────────── + +// handleWorldBossCmd routes `!adventure worldboss [status|fight|spawn]` (alias +// `!adventure siege …`). Bare / "status" shows the board; "fight" takes today's +// bout; "spawn" is an operator override. +func (p *AdventurePlugin) handleWorldBossCmd(ctx MessageContext, args string) error { + switch strings.ToLower(strings.TrimSpace(args)) { + case "", "status": + return p.worldBossStatusDM(ctx) + case "fight": + return p.fightWorldBoss(ctx) + case "spawn": + return p.worldBossOperatorSpawn(ctx) + } + return p.SendDM(ctx.Sender, "Usage: `!adventure worldboss [status|fight]`") +} + +// worldBossOperatorSpawn lets an admin start a Siege on demand (the override half +// of the "both" spawn decision). Silent to non-admins, like the other admin +// commands. +func (p *AdventurePlugin) worldBossOperatorSpawn(ctx MessageContext) error { + if !p.IsAdmin(ctx.Sender) { + return nil + } + key := "manual-" + time.Now().UTC().Format("2006-01-02T15:04") + boss, err := p.spawnWorldBoss(key) + if err != nil { + return p.SendDM(ctx.Sender, fmt.Sprintf("Could not spawn the Siege: %v", err)) + } + return p.SendDM(ctx.Sender, fmt.Sprintf( + "Spawned **%s** (Tier %d, %s HP). It camps for 72 hours.", + boss.Name, boss.Tier, groupInt(boss.HPMax))) +} + +// fightWorldBoss runs one player's daily bout against the Siege: an arena-style +// solo fight whose damage is subtracted from the shared pool win or lose. Real +// HP cost, no death — a loss leaves the fighter battered (floored at 1 HP) but +// standing. The per-user lock serialises a player's own repeat submits, so the +// once-per-day gate can't be raced by a double-tap. +func (p *AdventurePlugin) fightWorldBoss(ctx MessageContext) error { + userMu := p.advUserLock(ctx.Sender) + userMu.Lock() + defer userMu.Unlock() + + boss, err := loadActiveWorldBoss() + if err != nil { + return p.SendDM(ctx.Sender, "Something went wrong reaching the Siege. Try again in a moment.") + } + if boss == nil { + return p.SendDM(ctx.Sender, "No Siege is camped outside town right now.") + } + + char, err := loadAdvCharacter(ctx.Sender) + if err != nil || char == nil { + return p.SendDM(ctx.Sender, "You need an adventurer first — type `!adventure` to begin.") + } + if !char.Alive { + return p.SendDM(ctx.Sender, "You're dead. The Siege will have to wait until you're back on your feet.") + } + + today := time.Now().UTC().Format("2006-01-02") + if worldBossBoutUsedToday(boss.ID, ctx.Sender, today) { + return p.SendDM(ctx.Sender, fmt.Sprintf( + "You've already taken your bout against **%s** today. Come back tomorrow — one fight per day.", boss.Name)) + } + + bout, err := p.resolveWorldBossBout(ctx.Sender, boss, today) + if err != nil { + slog.Error("worldboss: bout failed", "user", ctx.Sender, "err", err) + return p.SendDM(ctx.Sender, "The Siege combat hit an error. Try again in a moment.") + } + + playerName, _ := loadDisplayName(ctx.Sender) + if playerName == "" { + playerName = "You" + } + phaseMessages := append([]string{ + fmt.Sprintf("⚔️ **The Siege — %s** (Tier %d)", boss.Name, boss.Tier), + }, RenderCombatLog(bout.Combat, playerName, boss.Name)...) + + var footer string + if bout.Killed { + footer = fmt.Sprintf("💥 You deal **%d** damage — the killing blow! **%s** falls!", bout.Damage, boss.Name) + } else { + footer = fmt.Sprintf("💥 You deal **%d** damage. **%s** has **%s / %s HP** left.", + bout.Damage, boss.Name, groupInt(bout.Remaining), groupInt(boss.HPMax)) + } + if bout.Battered { + footer += "\nYou stagger out of the fight at 1 HP — rest up before your next outing." + } + + <-p.sendZoneCombatMessages(ctx.Sender, phaseMessages, footer) + + if bout.Killed { + p.resolveWorldBossDefeated(boss) + } + return nil +} + +// worldBossBoutUsedToday reports whether a player has already spent their daily +// bout against this boss. +func worldBossBoutUsedToday(bossID int64, userID id.UserID, dateKey string) bool { + c, err := loadWorldBossContrib(bossID, userID) + return err == nil && c != nil && c.LastFightDate == dateKey +} + +// worldBossBoutResult is the outcome of one resolved bout, before narration. +type worldBossBoutResult struct { + Damage int + Remaining int + Killed bool + Battered bool + Combat CombatResult +} + +// resolveWorldBossBout runs the arena-style fight, applies the real HP cost with +// the no-death floor, subtracts the damage dealt from the shared pool, and +// records the contribution. It performs no DM or games-room I/O, so it is the +// testable core of fightWorldBoss. +func (p *AdventurePlugin) resolveWorldBossBout(userID id.UserID, boss *worldBossState, dateKey string) (worldBossBoutResult, error) { + monster := worldBossTemplate(boss.Name, boss.Tier) + result, err := p.runZoneCombat(userID, monster, boss.Tier, bossCombatPhases, 50) + if err != nil { + return worldBossBoutResult{}, err + } + + // No death / no hospital: floor the fighter at 1 HP so a loss never reads as + // a corpse. runZoneCombat already persisted the real HP cost. + battered := worldBossFloorHP(userID) + + dmg := result.EnemyEntryHP - result.EnemyEndHP + if dmg < 0 { + dmg = 0 + } + remaining, killed, err := applyWorldBossDamage(boss.ID, dmg) + if err != nil { + return worldBossBoutResult{}, err + } + if err := upsertWorldBossContrib(boss.ID, userID, dmg, dateKey); err != nil { + slog.Warn("worldboss: contrib upsert failed", "user", userID, "err", err) + } + return worldBossBoutResult{ + Damage: dmg, Remaining: remaining, Killed: killed, Battered: battered, Combat: result, + }, nil +} + +// worldBossFloorHP raises a player to 1 HP if the bout left them at zero, and +// reports whether it had to. Keeps "no death" honest without undoing the real +// HP cost of a bout the player mostly survived. +func worldBossFloorHP(userID id.UserID) bool { + cur, _ := dndHPSnapshot(userID) + if cur > 0 { + return false + } + if _, err := db.Get().Exec( + `UPDATE dnd_character SET hp_current = 1, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?`, + string(userID)); err != nil { + slog.Warn("worldboss: hp floor failed", "user", userID, "err", err) + } + return true +} + +// worldBossStatusDM renders the current Siege board to the caller. +func (p *AdventurePlugin) worldBossStatusDM(ctx MessageContext) error { + boss, err := loadActiveWorldBoss() + if err != nil { + return p.SendDM(ctx.Sender, "Something went wrong reaching the Siege. Try again in a moment.") + } + if boss == nil { + return p.SendDM(ctx.Sender, + "No Siege is camped outside town right now. The next one arrives at the start of the month.") + } + + var sb strings.Builder + pct := 0 + if boss.HPMax > 0 { + pct = boss.HPCurrent * 100 / boss.HPMax + } + fmt.Fprintf(&sb, "⚔️ **The Siege — %s** (Tier %d)\n", boss.Name, boss.Tier) + fmt.Fprintf(&sb, "Pool: **%s / %s HP** (%d%%)\n", groupInt(boss.HPCurrent), groupInt(boss.HPMax), pct) + if left := time.Until(boss.EndsAt); left > 0 { + h := int(left.Hours()) + m := int(left.Minutes()) % 60 + fmt.Fprintf(&sb, "Time left: **%dh %dm**\n", h, m) + } + + today := time.Now().UTC().Format("2006-01-02") + if c, _ := loadWorldBossContrib(boss.ID, ctx.Sender); c != nil { + line := fmt.Sprintf("\nYou: %d fight(s), %s damage dealt.", c.Fights, groupInt(c.Damage)) + if c.LastFightDate == today { + line += " Your bout is spent for today." + } else { + line += " Your bout is ready — `!adventure worldboss fight`." + } + sb.WriteString(line + "\n") + } else { + sb.WriteString("\nYou haven't fought yet — `!adventure worldboss fight`.\n") + } + + if contribs, _ := loadWorldBossContribs(boss.ID); len(contribs) > 0 { + sb.WriteString("\n**Top of the muster:**\n") + for i, c := range contribs { + if i >= 5 { + break + } + fmt.Fprintf(&sb, "%d. %s — %d fight(s)\n", i+1, p.DisplayName(c.UserID), c.Fights) + } + } + return p.SendDM(ctx.Sender, sb.String()) +} diff --git a/internal/plugin/adventure_worldboss_test.go b/internal/plugin/adventure_worldboss_test.go index 3bed905..d0ba008 100644 --- a/internal/plugin/adventure_worldboss_test.go +++ b/internal/plugin/adventure_worldboss_test.go @@ -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) + } +}