mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user