N3/P4: give every seat a row of its own

The turn engine seats a party since P3, but only seat 0 survived a
suspend: seats 1+ reopened from their Mods on every step, rearming their
once-per-fight one-shots -- a party Halfling rerolled a nat 1 every round.

Split CombatStatuses into the fight-scoped half (the enemy's stance, the
round cursor) and the per-character half, ActorStatuses. The embed is
anonymous and untagged, so statuses_json stays the same flat object it
always was and every in-flight prod row decodes unchanged.

Seat 0 keeps living on combat_session. Seats 1+ get combat_participant
rows. That asymmetry is the point: a solo fight -- which is every fight
that has ever run, and the whole balance corpus -- writes exactly the
bytes it wrote before, and no participant rows at all. roster_size
guards the read, so the solo loader never issues the second query.

Parties commit their seats in the same transaction as the session row;
solo keeps its single unwrapped UPDATE.

expedition_party is the co-op roster. No party_id on dnd_expedition:
expedition_id already identifies the party, and a second key would be a
second answer to "who is in this party". Absent means solo, in both new
tables, so neither needs a bootstrap backfill.

The combat characterization golden is byte-identical.

Also seeds and re-powers the two statistical subclass tests. They drew
from the package-global RNG, so their verdict depended on how much
randomness every test declared before them happened to consume -- which
is why they flaked on a clean tree. Sweeping 40 seeds: Precision Attack
had a mean margin of +127 wins against a +50 threshold but a worst case
of -42, and Assassinate averaged +12.8 with two seeds outright negative.
Both effects are real; the trial counts were too low to see them. Seeded,
and raised to 24000/6000 trials, where all 40 seeds clear.
This commit is contained in:
prosolis
2026-07-09 21:23:35 -07:00
parent ec614e84f1
commit d7d0230223
9 changed files with 1464 additions and 157 deletions

View File

@@ -352,6 +352,11 @@ func runMigrations(d *sql.DB) error {
// DEFAULT 0 is wrong for in-flight rows — bootstrapRoomsTraversed
// backfills them from visited_nodes.
`ALTER TABLE dnd_zone_run ADD COLUMN rooms_traversed INTEGER NOT NULL DEFAULT 0`,
// N3/P4 party combat. Every pre-existing fight is solo, and solo is
// exactly roster_size 1, so the DEFAULT is the correct value for every
// row and no bootstrap backfill is needed. The column is a read guard:
// loadCombatParticipants is skipped entirely when it reads 1.
`ALTER TABLE combat_session ADD COLUMN roster_size INTEGER NOT NULL DEFAULT 1`,
}
for _, stmt := range columnMigrations {
if _, err := d.Exec(stmt); err != nil {
@@ -2080,12 +2085,63 @@ CREATE TABLE IF NOT EXISTS combat_session (
status TEXT NOT NULL DEFAULT 'active', -- active|won|lost|fled|expired
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_action_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL
expires_at DATETIME NOT NULL,
-- N3/P4: seated player characters, seat 0 (this row) included. 1 == solo,
-- which is what every pre-N3 row is, so the DEFAULT needs no backfill. Read
-- as a guard: only a roster_size > 1 makes the loader touch
-- combat_participant, keeping the solo fight at one query.
roster_size INTEGER NOT NULL DEFAULT 1
);
CREATE INDEX IF NOT EXISTS idx_combat_session_active
ON combat_session(user_id, status);
CREATE INDEX IF NOT EXISTS idx_combat_session_expiry
ON combat_session(status, expires_at);
-- ── N3/P4 — party combat: the seats a session row cannot hold ─────────────
-- One row per party member from seat 1 up. Seat 0 is the session's own
-- user_id/player_hp/statuses_json, so a solo fight writes no rows here and
-- reads none: absent == solo, which is why this table needs no backfill.
--
-- seat: index into the combat roster. 1..N-1; seat 0 is the session.
-- hp / hp_max: this member's live pool. Seat 0's lives on combat_session.
-- statuses_json: serialized ActorStatuses — the per-character half of the
-- effect state (their concentration, their debuffs, their
-- once-per-fight one-shots). The enemy's stance and the round
-- cursor are fight-scoped and stay on combat_session.
CREATE TABLE IF NOT EXISTS combat_participant (
session_id TEXT NOT NULL,
seat INTEGER NOT NULL,
user_id TEXT NOT NULL,
hp INTEGER NOT NULL,
hp_max INTEGER NOT NULL,
statuses_json TEXT NOT NULL DEFAULT '{}',
PRIMARY KEY (session_id, seat)
);
CREATE INDEX IF NOT EXISTS idx_combat_participant_user
ON combat_participant(user_id);
-- ── N3/P4 — expedition parties ────────────────────────────────────────────
-- Membership for a co-op expedition. The leader's dnd_expedition row stays
-- the single source of truth for the shared clock, threat, and supply pool;
-- members reference it through here rather than owning a row of their own.
-- A solo expedition has no rows: absent == solo, so no backfill is needed.
--
-- There is deliberately no party_id on dnd_expedition. The expedition_id is
-- already the party's identity, and a second key would be a second source of
-- truth for "who is in this party" that could disagree with this table.
--
-- role: 'leader' | 'member'. Exactly one leader per expedition, matching
-- dnd_expedition.user_id; enforced in code, not by constraint (the
-- same discipline combat_session uses for one-active-per-user).
CREATE TABLE IF NOT EXISTS expedition_party (
expedition_id TEXT NOT NULL,
user_id TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (expedition_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_expedition_party_user
ON expedition_party(user_id);
`
// SeedSchedulerDefaults inserts default scheduler jobs if they don't exist.

View File

@@ -0,0 +1,331 @@
package plugin
import (
"encoding/json"
"math/rand/v2"
"testing"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// setupEmptyTestDB opens a fresh schema in a temp dir. Unlike setupZoneRunTestDB
// it copies nothing from data/gogobee.db, so it runs everywhere instead of
// skipping when the prod db is absent — the session/participant/party tables are
// the only ones these tests touch, and none of them need seeded characters.
func setupEmptyTestDB(t *testing.T) {
t.Helper()
db.Close()
if err := db.Init(t.TempDir()); err != nil {
t.Fatal(err)
}
t.Cleanup(db.Close)
}
// ── The wire format ─────────────────────────────────────────────────────────
// The ActorStatuses split moved ~25 fields behind an anonymous embed. That is a
// no-op on the wire only because the embed is untagged: encoding/json flattens
// it into the parent object. If someone ever gives it a json tag, every
// in-flight prod session silently loses its poison, its charges, and its
// once-per-fight one-shots on the next resume. Pin the flattening.
func TestCombatStatuses_JSONStaysFlat(t *testing.T) {
s := CombatStatuses{
ActorStatuses: ActorStatuses{PoisonTicks: 2, PoisonDmg: 5, WardCharges: 3},
Enraged: true,
EnemyRegen: 4,
}
raw, err := json.Marshal(s)
if err != nil {
t.Fatal(err)
}
var obj map[string]any
if err := json.Unmarshal(raw, &obj); err != nil {
t.Fatal(err)
}
for _, key := range []string{"poison_ticks", "poison_dmg", "ward_charges", "enraged", "enemy_regen"} {
if _, ok := obj[key]; !ok {
t.Errorf("key %q missing — ActorStatuses is nested, not flattened: %s", key, raw)
}
}
if _, nested := obj["ActorStatuses"]; nested {
t.Errorf("ActorStatuses serialized as a nested object: %s", raw)
}
}
// A statuses_json blob written before the split must decode unchanged.
func TestCombatStatuses_DecodesPreSplitRow(t *testing.T) {
const preSplit = `{"poison_ticks":3,"poison_dmg":6,"enraged":true,` +
`"ward_charges":2,"lucky_used":true,"enemy_retaliate_frac":0.25,` +
`"buff_ac_bonus":4,"max_hp_drain":7}`
var s CombatStatuses
if err := json.Unmarshal([]byte(preSplit), &s); err != nil {
t.Fatal(err)
}
if s.PoisonTicks != 3 || s.PoisonDmg != 6 || s.WardCharges != 2 ||
s.LuckyUsed != true || s.BuffACBonus != 4 || s.MaxHPDrain != 7 {
t.Errorf("per-actor fields lost: %+v", s.ActorStatuses)
}
if !s.Enraged || s.EnemyRetaliateFrac != 0.25 {
t.Errorf("fight-scoped fields lost: %+v", s)
}
}
// ── Solo stays solo ─────────────────────────────────────────────────────────
// The whole balance corpus rides the solo path. It must write no participant
// rows, and its roster_size must stay at the DEFAULT so the loader never issues
// the second query (project_combat_session_cache_deferred: don't make it worse).
func TestSoloSessionWritesNoParticipantRows(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@solo-noparts:example.org")
defer cleanupCombatSessions(uid)
s, err := startCombatSession(uid, "run-solo", "node-1", "owlbear", 40, 40, 60, 60)
if err != nil {
t.Fatal(err)
}
if err := saveCombatSession(s); err != nil {
t.Fatal(err)
}
var rows int
if err := db.Get().QueryRow(
`SELECT COUNT(*) FROM combat_participant WHERE session_id = ?`, s.SessionID,
).Scan(&rows); err != nil {
t.Fatal(err)
}
if rows != 0 {
t.Errorf("solo fight wrote %d participant rows, want 0", rows)
}
var rosterSize int
if err := db.Get().QueryRow(
`SELECT roster_size FROM combat_session WHERE session_id = ?`, s.SessionID,
).Scan(&rosterSize); err != nil {
t.Fatal(err)
}
if rosterSize != 1 {
t.Errorf("roster_size = %d, want 1", rosterSize)
}
got, err := getActiveCombatSession(uid)
if err != nil || got == nil {
t.Fatalf("getActiveCombatSession: %v / %v", got, err)
}
if got.IsParty() || got.RosterSize() != 1 || len(got.Participants) != 0 {
t.Errorf("solo session reads back as a party: %+v", got.Participants)
}
}
// ── Party seats ─────────────────────────────────────────────────────────────
func seatParty(t *testing.T, s *CombatSession, members ...CombatParticipant) {
t.Helper()
if err := insertCombatParticipants(s.SessionID, members); err != nil {
t.Fatalf("insertCombatParticipants: %v", err)
}
s.Participants = members
}
func TestCombatParticipants_RoundTrip(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@party-lead:example.org")
defer cleanupCombatSessions(uid)
s, err := startCombatSession(uid, "run-party", "node-1", "owlbear", 40, 40, 200, 200)
if err != nil {
t.Fatal(err)
}
seatParty(t, s,
CombatParticipant{Seat: 1, UserID: "@b:x", HP: 30, HPMax: 30,
Statuses: ActorStatuses{WardCharges: 2}},
CombatParticipant{Seat: 2, UserID: "@c:x", HP: 25, HPMax: 25,
Statuses: ActorStatuses{ConcentrationDmg: 9, LuckyUsed: true}},
)
got, err := getActiveCombatSession(uid)
if err != nil || got == nil {
t.Fatalf("getActiveCombatSession: %v / %v", got, err)
}
if !got.IsParty() || got.RosterSize() != 3 {
t.Fatalf("roster = %d, want 3", got.RosterSize())
}
if got.Participants[0].UserID != "@b:x" || got.Participants[0].Statuses.WardCharges != 2 {
t.Errorf("seat 1 round-trip wrong: %+v", got.Participants[0])
}
if got.Participants[1].Statuses.ConcentrationDmg != 9 || !got.Participants[1].Statuses.LuckyUsed {
t.Errorf("seat 2 round-trip wrong: %+v", got.Participants[1])
}
if want := []string{string(uid), "@b:x", "@c:x"}; !equalStrings(got.SeatUserIDs(), want) {
t.Errorf("SeatUserIDs = %v, want %v", got.SeatUserIDs(), want)
}
// Mutable half writes back; hp_max and user_id do not move.
got.Participants[0].HP = 11
got.Participants[0].Statuses.WardCharges = 0
got.Participants[0].Statuses.Raged = true
if err := saveCombatSession(got); err != nil {
t.Fatal(err)
}
again, err := getCombatSession(s.SessionID)
if err != nil || again == nil {
t.Fatalf("getCombatSession: %v / %v", again, err)
}
p := again.Participants[0]
if p.HP != 11 || p.HPMax != 30 || p.Statuses.WardCharges != 0 || !p.Statuses.Raged {
t.Errorf("seat 1 save round-trip wrong: %+v", p)
}
}
// A gap in the seat sequence would shift every member down one index, because
// the engine addresses the roster positionally. Fail the load instead.
func TestLoadCombatParticipants_RejectsSeatGap(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@party-gap:example.org")
defer cleanupCombatSessions(uid)
s, err := startCombatSession(uid, "run-gap", "node-1", "owlbear", 40, 40, 200, 200)
if err != nil {
t.Fatal(err)
}
// Seat 2 with no seat 1.
if err := insertCombatParticipants(s.SessionID, []CombatParticipant{
{Seat: 2, UserID: "@c:x", HP: 25, HPMax: 25},
}); err != nil {
t.Fatal(err)
}
if _, err := loadCombatParticipants(s.SessionID); err == nil {
t.Error("loadCombatParticipants accepted a seat gap")
}
}
// roster_size disagreeing with the persisted seats means a half-written fight.
// Reading it as solo would silently drop the party mid-combat.
func TestHydrateCombatParticipants_RejectsRosterMismatch(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@party-mismatch:example.org")
defer cleanupCombatSessions(uid)
s, err := startCombatSession(uid, "run-mm", "node-1", "owlbear", 40, 40, 200, 200)
if err != nil {
t.Fatal(err)
}
seatParty(t, s, CombatParticipant{Seat: 1, UserID: "@b:x", HP: 30, HPMax: 30})
if _, err := db.Get().Exec(
`UPDATE combat_session SET roster_size = 3 WHERE session_id = ?`, s.SessionID,
); err != nil {
t.Fatal(err)
}
if _, err := getActiveCombatSession(uid); err == nil {
t.Error("hydrate accepted roster_size 3 with 2 seats persisted")
}
}
func TestGetActiveCombatSessionForMember(t *testing.T) {
setupEmptyTestDB(t)
lead := id.UserID("@lead-lookup:example.org")
member := id.UserID("@member-lookup:example.org")
defer cleanupCombatSessions(lead)
s, err := startCombatSession(lead, "run-lookup", "node-1", "owlbear", 40, 40, 200, 200)
if err != nil {
t.Fatal(err)
}
seatParty(t, s, CombatParticipant{Seat: 1, UserID: string(member), HP: 30, HPMax: 30})
// The member owns no session row, so the seat-0 lookup must miss them...
if got, err := getActiveCombatSession(member); err != nil || got != nil {
t.Errorf("getActiveCombatSession found a row for a party member: %v / %v", got, err)
}
// ...and the member lookup must find the fight, fully hydrated.
got, err := getActiveCombatSessionForMember(member)
if err != nil || got == nil {
t.Fatalf("getActiveCombatSessionForMember: %v / %v", got, err)
}
if got.SessionID != s.SessionID || got.RosterSize() != 2 {
t.Errorf("wrong session for member: %+v", got)
}
// The leader is not a participant row, so the member lookup must miss them.
if got, err := getActiveCombatSessionForMember(lead); err != nil || got != nil {
t.Errorf("member lookup matched the leader: %v / %v", got, err)
}
}
// ── The engine writes every seat, not just the cursor ───────────────────────
// P3 shipped with seats 1+ opening fresh from their Mods on every resume: a
// party member's once-per-fight one-shots rearmed every single engine step. P4
// is what fixes that, so pin it. A round_end step mutates no one-shot, so an
// exact round-trip is the assertion.
func TestTurnEngine_PartySeatStatusesSurviveAStep(t *testing.T) {
sess := turnSession(CombatPhaseRoundEnd, 50, 50)
sess.Participants = []CombatParticipant{{
Seat: 1, UserID: "@b:x", HP: 30, HPMax: 30,
Statuses: ActorStatuses{
Raged: true, LuckyUsed: true, DeathSaveUsed: true,
WardCharges: 2, ArcaneWardHP: 8,
BuffACBonus: 3, // command-layer owned; commit must not zero it
},
}}
want := sess.Participants[0].Statuses
a, b, enemy := basePlayer(), basePlayer(), baseEnemy()
rng := rand.New(rand.NewPCG(42, phaseOrdinal(sess.Phase)))
te := resumeTurnEngine(sess, []*Combatant{&a, &b}, &enemy, rng)
if _, err := te.step(PlayerAction{}); err != nil {
t.Fatal(err)
}
te.commit()
if got := sess.Participants[0].Statuses; got != want {
t.Errorf("seat 1 statuses moved across a step:\n got %+v\nwant %+v", got, want)
}
if sess.Participants[0].HP != 30 {
t.Errorf("seat 1 HP = %d, want 30", sess.Participants[0].HP)
}
}
// commit reads each seat by index, never off the cursor — round_end parks the
// cursor at seat 0, and the enemy turn parks it on its target. A seat's damage
// must land on that seat's row.
func TestTurnEngine_CommitWritesSeatsByIndex(t *testing.T) {
sess := turnSession(CombatPhaseRoundEnd, 50, 50)
sess.Participants = []CombatParticipant{
{Seat: 1, UserID: "@b:x", HP: 30, HPMax: 30, Statuses: ActorStatuses{PoisonTicks: 1, PoisonDmg: 4}},
{Seat: 2, UserID: "@c:x", HP: 25, HPMax: 25},
}
a, b, c, enemy := basePlayer(), basePlayer(), basePlayer(), baseEnemy()
rng := rand.New(rand.NewPCG(42, phaseOrdinal(sess.Phase)))
te := resumeTurnEngine(sess, []*Combatant{&a, &b, &c}, &enemy, rng)
if _, err := te.step(PlayerAction{}); err != nil {
t.Fatal(err)
}
te.commit()
// Seat 1's poison ticked on seat 1 alone.
if sess.Participants[0].HP != 26 {
t.Errorf("seat 1 HP = %d, want 26 (30 - 4 poison)", sess.Participants[0].HP)
}
if sess.Participants[0].Statuses.PoisonTicks != 0 {
t.Errorf("seat 1 poison ticks = %d, want 0", sess.Participants[0].Statuses.PoisonTicks)
}
if sess.Participants[1].HP != 25 {
t.Errorf("seat 2 HP = %d, want 25 — seat 1's poison leaked", sess.Participants[1].HP)
}
if sess.PlayerHP != 50 {
t.Errorf("seat 0 HP = %d, want 50 — seat 1's poison leaked", sess.PlayerHP)
}
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

View File

@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"log/slog"
"strings"
"time"
"gogobee/internal/db"
@@ -43,41 +44,34 @@ const (
// reaper sweeps it. Matches the started_at + 1h note in the schema.
const combatSessionTTL = time.Hour
// CombatStatuses is the serialized between-round effect state for a fight: the
// subset of combatState that genuinely persists round-to-round, plus the
// fight-scoped buffs a mid-fight !cast / !consume layers on. Everything here
// round-trips combatState -> Statuses -> combatState across every engine step,
// so a fight resumes from exact mid-state after a suspend or bot restart.
// ActorStatuses is the per-seat half of the persisted effect state: everything
// that belongs to one character rather than to the fight. It mirrors the fields
// of `actor` (combat_engine.go) that must survive a suspend/resume.
//
// All fields are scalar by design — CombatStatuses must stay comparable so the
// The split follows P2's rule. A debuff the enemy stacked onto one character
// (stat_drain / debuff / max_hp_drain), that character's concentration, their
// depleting charges, and their once-per-fight one-shots are all per-actor — a
// party of three carries three independent copies. The enemy's own stance and
// the round cursor are fight-scoped and live on CombatStatuses.
//
// Seat 0's copy is embedded in the session row's statuses_json (see
// CombatStatuses); seats 1+ each get a combat_participant row carrying this
// struct alone. That asymmetry is deliberate: a solo fight writes exactly the
// bytes it wrote before parties existed, and no participant rows at all.
//
// All fields are scalar by design — ActorStatuses must stay comparable so the
// persistence round-trip can be asserted with ==.
type CombatStatuses struct {
// Monster-ability effects — the original between-round persistence set.
type ActorStatuses struct {
// Monster-ability effects landing on this character.
PoisonTicks int `json:"poison_ticks,omitempty"`
PoisonDmg int `json:"poison_dmg,omitempty"`
StunPlayer bool `json:"stun_player,omitempty"`
Enraged bool `json:"enraged,omitempty"`
ArmorBroken bool `json:"armor_broken,omitempty"`
ArmorBreakAmt float64 `json:"armor_break_amt,omitempty"`
// EnemySkipNext carries a control-spell skip (Hold Person, Sleep) from the
// player_turn phase it was cast in to the enemy_turn phase that consumes it
// — those phases resolve as separate engine steps with separate in-memory
// combatState, so the flag must survive the commit between them.
EnemySkipNext bool `json:"enemy_skip_next,omitempty"`
// TurnIdx is the position within the round's initiative order (see
// turnOrder) of whoever acts next. A solo fight's order is the fixed
// [player, enemy], so TurnIdx mirrors the phase and the field is dead
// weight — omitempty keeps it out of every solo row. Rows written before
// this field existed decode as 0 and are reconciled against Phase on
// resume, so a mid-fight upgrade lands on the right slot.
TurnIdx int `json:"turn_idx,omitempty"`
// Fight-scoped depleting resources — mirror the combatState charges that
// genuinely carry round-to-round. Seeded at fight start (Arcane Ward) or by
// a mid-fight !cast / !consume, restored into combatState on every resume,
// written back on every commit so a depleting charge can't silently reset
// when a fight is suspended and resumed.
// Depleting resources — mirror the actor charges that genuinely carry
// round-to-round. Seeded at fight start (Arcane Ward) or by a mid-fight
// !cast / !consume, restored into the actor on every resume, written back on
// every commit so a depleting charge can't silently reset when a fight is
// suspended and resumed.
WardCharges int `json:"ward_charges,omitempty"`
SporeRounds int `json:"spore_rounds,omitempty"`
ReflectFrac float64 `json:"reflect_frac,omitempty"`
@@ -85,7 +79,7 @@ type CombatStatuses struct {
ArcaneWardHP int `json:"arcane_ward_hp,omitempty"`
HealChargesLeft int `json:"heal_charges_left,omitempty"`
// ConcentrationDmg is the per-round damage of the player's active
// ConcentrationDmg is the per-round damage of this character's active
// concentration AOE (Spirit Guardians, Spike Growth, Call Lightning,
// Flaming Sphere…). A one-shot !cast lands its burst the casting round,
// then this re-ticks the aura at round_end every round until the fight
@@ -105,31 +99,15 @@ type CombatStatuses struct {
AssassinateReroll bool `json:"assassinate_reroll_used,omitempty"`
AssassinateBonus bool `json:"assassinate_bonus_used,omitempty"`
// Slice-3 stateful monster-ability effects — armed by applyAbility, read by
// the shared resolution primitives, round-tripped through combatState so a
// suspended/resumed fight (or a reaper auto-play) keeps the same effect
// state. EnemyEvadeNext is a one-shot; the rest persist for the fight.
EnemyEvadeNext bool `json:"enemy_evade_next,omitempty"`
EnemyBlockUp bool `json:"enemy_block_up,omitempty"`
EnemyAdvantage bool `json:"enemy_advantage,omitempty"`
EnemyRetaliateFrac float64 `json:"enemy_retaliate_frac,omitempty"`
EnemyRegen int `json:"enemy_regen,omitempty"`
EnemySurviveArmed bool `json:"enemy_survive_armed,omitempty"`
// Debuffs the enemy has stacked onto this character specifically.
PlayerAtkDrain int `json:"player_atk_drain,omitempty"`
PlayerACDebuff int `json:"player_ac_debuff,omitempty"`
MaxHPDrain int `json:"max_hp_drain,omitempty"`
// Slice-4 monster-ability effects — the former flavor-only placeholders.
// EnemyRevealNext is a one-shot; the other three persist for the fight.
EnemySpellResist bool `json:"enemy_spell_resist,omitempty"`
EnemyRevealNext bool `json:"enemy_reveal_next,omitempty"`
EnemyFearImmune bool `json:"enemy_fear_immune,omitempty"`
EnemyAtkBuff int `json:"enemy_atk_buff,omitempty"`
// Persistent stat buffs from mid-fight !cast / !consume, accumulated as
// deltas against the freshly-rebuilt combatant. applySessionBuffs folds
// these back onto the player every round; diffTurnBuff produces them.
// BuffDamageReductMul is multiplicative (0 = none / 1.0 neutral).
// Persistent stat buffs from this character's mid-fight !cast / !consume,
// accumulated as deltas against their freshly-rebuilt combatant.
// applySessionBuffs folds these back on every round; diffTurnBuff produces
// them. BuffDamageReductMul is multiplicative (0 = none / 1.0 neutral).
BuffACBonus int `json:"buff_ac_bonus,omitempty"`
BuffAtkBonus int `json:"buff_atk_bonus,omitempty"`
BuffSpeedBonus int `json:"buff_speed_bonus,omitempty"`
@@ -142,10 +120,65 @@ type CombatStatuses struct {
BuffSpiritDmg int `json:"buff_spirit_dmg,omitempty"`
}
// CombatStatuses is the serialized between-round effect state for a fight: the
// fight-scoped half (the enemy's stance, the round cursor) plus seat 0's
// ActorStatuses, embedded. Everything here round-trips combatState -> Statuses
// -> combatState across every engine step, so a fight resumes from exact
// mid-state after a suspend or bot restart.
//
// ActorStatuses is embedded anonymously and untagged, so it flattens into the
// same one-level JSON object the field list used to produce. Rows written
// before the split decode unchanged, and rows written after are byte-compatible
// with a reader that predates it.
//
// All fields are scalar by design — CombatStatuses must stay comparable so the
// persistence round-trip can be asserted with ==.
type CombatStatuses struct {
// Seat 0's per-character state. Seats 1+ carry their own copy in
// combat_participant rows.
ActorStatuses
Enraged bool `json:"enraged,omitempty"`
ArmorBroken bool `json:"armor_broken,omitempty"`
ArmorBreakAmt float64 `json:"armor_break_amt,omitempty"`
// EnemySkipNext carries a control-spell skip (Hold Person, Sleep) from the
// player_turn phase it was cast in to the enemy_turn phase that consumes it
// — those phases resolve as separate engine steps with separate in-memory
// combatState, so the flag must survive the commit between them. Holding the
// enemy holds it for the whole party, hence fight-scoped.
EnemySkipNext bool `json:"enemy_skip_next,omitempty"`
// TurnIdx is the position within the round's initiative order (see
// turnOrder) of whoever acts next. A solo fight's order is the fixed
// [player, enemy], so TurnIdx mirrors the phase and the field is dead
// weight — omitempty keeps it out of every solo row. Rows written before
// this field existed decode as 0 and are reconciled against Phase on
// resume, so a mid-fight upgrade lands on the right slot.
TurnIdx int `json:"turn_idx,omitempty"`
// Slice-3 stateful monster-ability effects — armed by applyAbility, read by
// the shared resolution primitives, round-tripped through combatState so a
// suspended/resumed fight (or a reaper auto-play) keeps the same effect
// state. EnemyEvadeNext is a one-shot; the rest persist for the fight.
EnemyEvadeNext bool `json:"enemy_evade_next,omitempty"`
EnemyBlockUp bool `json:"enemy_block_up,omitempty"`
EnemyAdvantage bool `json:"enemy_advantage,omitempty"`
EnemyRetaliateFrac float64 `json:"enemy_retaliate_frac,omitempty"`
EnemyRegen int `json:"enemy_regen,omitempty"`
EnemySurviveArmed bool `json:"enemy_survive_armed,omitempty"`
// Slice-4 monster-ability effects — the former flavor-only placeholders.
// EnemyRevealNext is a one-shot; the other three persist for the fight.
EnemySpellResist bool `json:"enemy_spell_resist,omitempty"`
EnemyRevealNext bool `json:"enemy_reveal_next,omitempty"`
EnemyFearImmune bool `json:"enemy_fear_immune,omitempty"`
EnemyAtkBuff int `json:"enemy_atk_buff,omitempty"`
}
// applyBuffDelta folds one resolved buff (the result of a !cast / !consume
// player turn) into the persisted fight-scoped state. Stat components
// player turn) into that character's persisted state. Stat components
// accumulate as deltas; depleting resources add to their running counters.
func (s *CombatStatuses) applyBuffDelta(d turnBuffDelta) {
func (s *ActorStatuses) applyBuffDelta(d turnBuffDelta) {
s.BuffACBonus += d.dAC
s.BuffAtkBonus += d.dAtk
s.BuffSpeedBonus += d.dSpeed
@@ -188,6 +221,17 @@ func seedCombatSessionOneShots(s *CombatSession, playerMods CombatModifiers) boo
st.AutoCritFirst || st.ArcaneWardHP != 0 || st.HealChargesLeft != 0
}
// CombatParticipant is one party member's seat in a fight, from seat 1 up.
// Seat 0 is the session row itself (UserID / PlayerHP / Statuses.ActorStatuses)
// and never appears here.
type CombatParticipant struct {
Seat int
UserID string
HP int
HPMax int
Statuses ActorStatuses
}
// CombatSession is the in-memory shape of a combat_session row.
type CombatSession struct {
SessionID string
@@ -207,11 +251,48 @@ type CombatSession struct {
StartedAt time.Time
LastActionAt time.Time
ExpiresAt time.Time
// Participants are the party's seats 1..N-1, ordered by seat and contiguous
// (loadCombatParticipants enforces both). Empty for a solo fight — the only
// kind that existed before N3 — so nothing in the solo path allocates or
// queries for it.
Participants []CombatParticipant
// rosterSize mirrors the combat_session.roster_size column as scanned. It
// exists only so a loader can tell "solo, skip the participant query" from
// "party, go fetch the seats" in one round trip. In memory Participants is
// the single source of truth; every write re-derives the column from it.
rosterSize int
}
// IsActive reports whether the fight is still in flight.
func (s *CombatSession) IsActive() bool { return s.Status == CombatStatusActive }
// RosterSize is the number of seated player characters: seat 0 plus the party.
func (s *CombatSession) RosterSize() int { return 1 + len(s.Participants) }
// IsParty reports whether more than one character is seated.
func (s *CombatSession) IsParty() bool { return len(s.Participants) > 0 }
// SeatUserIDs lists every seated member's user id in seat order.
func (s *CombatSession) SeatUserIDs() []string {
out := make([]string, 0, s.RosterSize())
out = append(out, s.UserID)
for _, p := range s.Participants {
out = append(out, p.UserID)
}
return out
}
// actorStatusesForSeat returns the persisted per-character state for a seat.
// Seat 0 reads through to the session's embedded copy.
func (s *CombatSession) actorStatusesForSeat(seat int) ActorStatuses {
if seat == 0 {
return s.Statuses.ActorStatuses
}
return s.Participants[seat-1].Statuses
}
// Errors returned by the combat session layer.
var (
ErrCombatSessionAlreadyActive = errors.New("combat session already active for player")
@@ -224,7 +305,7 @@ const combatSessionCols = `
session_id, user_id, run_id, encounter_id, enemy_id,
round, phase, player_hp, player_hp_max, enemy_hp, enemy_hp_max,
statuses_json, turn_log_json, status,
started_at, last_action_at, expires_at`
started_at, last_action_at, expires_at, roster_size`
// newCombatSessionID — 16-char hex token. Same scheme as zone runs / expeditions.
func newCombatSessionID() string {
@@ -296,7 +377,10 @@ func getActiveCombatSession(userID id.UserID) (*CombatSession, error) {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return s, err
if err != nil {
return nil, err
}
return s, hydrateCombatParticipants(s)
}
// hasActiveCombatSession reports whether the player is currently locked into a
@@ -324,7 +408,10 @@ func getCombatSession(sessionID string) (*CombatSession, error) {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return s, err
if err != nil {
return nil, err
}
return s, hydrateCombatParticipants(s)
}
// getCombatSessionForEncounter returns the most recent session for a
@@ -341,7 +428,10 @@ func getCombatSessionForEncounter(runID, encounterID string) (*CombatSession, er
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return s, err
if err != nil {
return nil, err
}
return s, hydrateCombatParticipants(s)
}
func scanCombatSession(row scanner) (*CombatSession, error) {
@@ -354,7 +444,7 @@ func scanCombatSession(row scanner) (*CombatSession, error) {
&s.SessionID, &s.UserID, &s.RunID, &s.EncounterID, &s.EnemyID,
&s.Round, &s.Phase, &s.PlayerHP, &s.PlayerHPMax, &s.EnemyHP, &s.EnemyHPMax,
&statusesJSON, &logJSON, &s.Status,
&s.StartedAt, &s.LastActionAt, &s.ExpiresAt,
&s.StartedAt, &s.LastActionAt, &s.ExpiresAt, &s.rosterSize,
); err != nil {
return nil, err
}
@@ -377,17 +467,23 @@ func scanCombatSession(row scanner) (*CombatSession, error) {
// saveCombatSession persists the mutable fields after a state-machine step.
// session_id / user_id / run_id / encounter_id / enemy_id / *_hp_max are
// immutable for a fight's lifetime and are not rewritten.
//
// A party fight also writes back its seats, in the same transaction as the
// session row: a crash between the two would leave seat 0 a round ahead of the
// rest of the roster. The solo path keeps its single unwrapped UPDATE — there
// are no seats to desync from, and it is the path the whole balance corpus and
// every pre-N3 fight take.
//
// roster_size is not rewritten here: insertCombatParticipants stamped it at
// fight start and the roster is fixed for the fight's lifetime (a dead member
// keeps their seat at 0 HP).
func saveCombatSession(s *CombatSession) error {
statusesJSON, _ := json.Marshal(s.Statuses)
logJSON, _ := json.Marshal(s.TurnLog)
now := time.Now().UTC()
if _, err := db.Get().Exec(`
UPDATE combat_session
SET round = ?, phase = ?,
player_hp = ?, enemy_hp = ?,
statuses_json = ?, turn_log_json = ?,
status = ?, last_action_at = ?
WHERE session_id = ?`,
if len(s.Participants) == 0 {
if _, err := db.Get().Exec(saveCombatSessionSQL,
s.Round, s.Phase, s.PlayerHP, s.EnemyHP,
string(statusesJSON), string(logJSON), s.Status, now, s.SessionID,
); err != nil {
@@ -395,14 +491,44 @@ func saveCombatSession(s *CombatSession) error {
}
s.LastActionAt = now
return nil
}
tx, err := db.Get().Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(saveCombatSessionSQL,
s.Round, s.Phase, s.PlayerHP, s.EnemyHP,
string(statusesJSON), string(logJSON), s.Status, now, s.SessionID,
); err != nil {
return err
}
if err := saveCombatParticipantsTx(tx, s.SessionID, s.Participants); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
s.LastActionAt = now
return nil
}
const saveCombatSessionSQL = `
UPDATE combat_session
SET round = ?, phase = ?,
player_hp = ?, enemy_hp = ?,
statuses_json = ?, turn_log_json = ?,
status = ?, last_action_at = ?
WHERE session_id = ?`
// listExpiredCombatSessions returns every active session past its expires_at.
// The timeout reaper (reapExpiredCombatSessions) auto-plays each of these to a
// real win/loss from persisted mid-state — per the 2026-05-13 decision, an
// abandoned fight is finished by the engine, not flatly marked as a retreat.
func listExpiredCombatSessions() ([]*CombatSession, error) {
rows, err := db.Get().Query(`SELECT `+combatSessionCols+`
rows, err := db.Get().Query(`SELECT ` + combatSessionCols + `
FROM combat_session
WHERE status = 'active'
AND expires_at <= CURRENT_TIMESTAMP
@@ -420,9 +546,162 @@ func listExpiredCombatSessions() ([]*CombatSession, error) {
}
out = append(out, s)
}
if err := rows.Err(); err != nil {
return nil, err
}
// Hydrate only after the cursor is closed: a nested query while iterating
// can stall on a single-connection pool.
rows.Close()
for _, s := range out {
if err := hydrateCombatParticipants(s); err != nil {
return nil, err
}
}
return out, nil
}
// ── Party seats (N3/P4) ──────────────────────────────────────────────────────
// hydrateCombatParticipants fills s.Participants for a party session. A solo
// session (roster_size 1, which is every row written before N3) returns
// immediately without touching combat_participant, so the common path stays at
// the single query it has always been.
func hydrateCombatParticipants(s *CombatSession) error {
if s == nil || s.rosterSize <= 1 {
return nil
}
ps, err := loadCombatParticipants(s.SessionID)
if err != nil {
return err
}
if got := 1 + len(ps); got != s.rosterSize {
return fmt.Errorf("combat session %s: roster_size %d but %d seats persisted",
s.SessionID, s.rosterSize, got)
}
s.Participants = ps
return nil
}
// loadCombatParticipants returns seats 1..N-1 in seat order, verifying that the
// seats are contiguous from 1. The engine indexes the roster positionally, so a
// gap would silently shift every member down a seat — better to fail the load.
func loadCombatParticipants(sessionID string) ([]CombatParticipant, error) {
rows, err := db.Get().Query(`
SELECT seat, user_id, hp, hp_max, statuses_json
FROM combat_participant
WHERE session_id = ?
ORDER BY seat ASC`, sessionID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []CombatParticipant
for rows.Next() {
var (
p CombatParticipant
statusesJSON string
)
if err := rows.Scan(&p.Seat, &p.UserID, &p.HP, &p.HPMax, &statusesJSON); err != nil {
return nil, err
}
if statusesJSON != "" {
if err := json.Unmarshal([]byte(statusesJSON), &p.Statuses); err != nil {
return nil, fmt.Errorf("decode participant statuses (seat %d): %w", p.Seat, err)
}
}
if want := len(out) + 1; p.Seat != want {
return nil, fmt.Errorf("combat session %s: seat %d out of order (want %d)",
sessionID, p.Seat, want)
}
out = append(out, p)
}
return out, rows.Err()
}
// insertCombatParticipants seats the party at fight start and stamps
// roster_size so later loads know to come back for them. Seat 0 is the session
// row's own user and is not written here; callers pass seats 1..N-1.
func insertCombatParticipants(sessionID string, ps []CombatParticipant) error {
if len(ps) == 0 {
return nil
}
tx, err := db.Get().Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, p := range ps {
statusesJSON, _ := json.Marshal(p.Statuses)
if _, err := tx.Exec(`
INSERT INTO combat_participant (session_id, seat, user_id, hp, hp_max, statuses_json)
VALUES (?, ?, ?, ?, ?, ?)`,
sessionID, p.Seat, p.UserID, p.HP, p.HPMax, string(statusesJSON),
); err != nil {
return fmt.Errorf("insert participant seat %d: %w", p.Seat, err)
}
}
if _, err := tx.Exec(
`UPDATE combat_session SET roster_size = ? WHERE session_id = ?`,
1+len(ps), sessionID,
); err != nil {
return fmt.Errorf("stamp roster_size: %w", err)
}
return tx.Commit()
}
// saveCombatParticipantsTx writes back the mutable half of every seat after an
// engine step. session_id / seat / user_id / hp_max are immutable for a fight's
// lifetime and are not rewritten. It runs inside the caller's transaction so
// the seats commit together with the session row that indexes them.
func saveCombatParticipantsTx(tx *sql.Tx, sessionID string, ps []CombatParticipant) error {
for _, p := range ps {
statusesJSON, _ := json.Marshal(p.Statuses)
if _, err := tx.Exec(`
UPDATE combat_participant
SET hp = ?, statuses_json = ?
WHERE session_id = ? AND seat = ?`,
p.HP, string(statusesJSON), sessionID, p.Seat,
); err != nil {
return fmt.Errorf("save participant seat %d: %w", p.Seat, err)
}
}
return nil
}
// getActiveCombatSessionForMember finds the in-flight fight a player is seated
// in at seat 1+, i.e. the one their own user_id does not key. Seat 0's fight is
// getActiveCombatSession's job; this is the party-member equivalent, and it
// returns (nil, nil) when there is none.
func getActiveCombatSessionForMember(userID id.UserID) (*CombatSession, error) {
row := db.Get().QueryRow(`SELECT `+prefixCols("cs", combatSessionCols)+`
FROM combat_session cs
JOIN combat_participant cp ON cp.session_id = cs.session_id
WHERE cp.user_id = ? AND cs.status = 'active'
ORDER BY cs.started_at DESC
LIMIT 1`, string(userID))
s, err := scanCombatSession(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return s, hydrateCombatParticipants(s)
}
// prefixCols qualifies every column in a SELECT list with a table alias, so the
// shared combatSessionCols can be reused inside a join without ambiguity on the
// columns both tables carry (session_id, user_id, statuses_json).
func prefixCols(alias, cols string) string {
fields := strings.Split(cols, ",")
for i, f := range fields {
fields[i] = alias + "." + strings.TrimSpace(f)
}
return strings.Join(fields, ", ")
}
// markCombatSessionExpired is the fallback terminal outcome for a stale session
// the reaper cannot auto-play (zone run gone, enemy missing from the bestiary,
// or a runaway fight that won't converge). It parks the row in 'expired'/'over'

View File

@@ -132,16 +132,17 @@ func (p *AdventurePlugin) combatantsForSession(sess *CombatSession) (player Comb
// onto the freshly-rebuilt player. The depleting one-shots (ward/spore/…)
// live on the session's Statuses and flow through the turn engine's
// resume/commit cycle, so only the persistent stat deltas are applied here.
applySessionBuffs(&player, sess.Statuses)
applySessionBuffs(&player, sess.Statuses.ActorStatuses)
return player, enemy, err
}
// applySessionBuffs re-derives the persistent stat effect of every mid-fight
// buff onto the rebuilt player. The buffs are stored as accumulated deltas
// (diffTurnBuff produced them against the player's state at cast time), so
// buff onto one rebuilt character. The buffs are stored as accumulated deltas
// (diffTurnBuff produced them against that character's state at cast time), so
// re-applying them to a deterministic rebuild reproduces the same totals every
// round without double-counting.
func applySessionBuffs(player *Combatant, s CombatStatuses) {
// round without double-counting. It takes ActorStatuses rather than the whole
// session because buffs are per-caster: each seat folds in only its own.
func applySessionBuffs(player *Combatant, s ActorStatuses) {
player.Stats.AC += s.BuffACBonus
player.Stats.AttackBonus += s.BuffAtkBonus
player.Stats.Speed += s.BuffSpeedBonus

View File

@@ -52,7 +52,7 @@ func TestStartCombatSession_RoundTrip(t *testing.T) {
got.Phase = CombatPhaseRoundEnd
got.PlayerHP = 40
got.EnemyHP = 15
got.Statuses = CombatStatuses{PoisonTicks: 2, PoisonDmg: 5, Enraged: true}
got.Statuses = CombatStatuses{ActorStatuses: ActorStatuses{PoisonTicks: 2, PoisonDmg: 5}, Enraged: true}
got.TurnLog = append(got.TurnLog, CombatEvent{Round: 3, Actor: "player", Action: "hit", Damage: 9})
if err := saveCombatSession(got); err != nil {
t.Fatalf("saveCombatSession: %v", err)
@@ -67,7 +67,7 @@ func TestStartCombatSession_RoundTrip(t *testing.T) {
if reloaded.PlayerHP != 40 || reloaded.EnemyHP != 15 {
t.Errorf("hp not persisted: %+v", reloaded)
}
if reloaded.Statuses != (CombatStatuses{PoisonTicks: 2, PoisonDmg: 5, Enraged: true}) {
if reloaded.Statuses != (CombatStatuses{ActorStatuses: ActorStatuses{PoisonTicks: 2, PoisonDmg: 5}, Enraged: true}) {
t.Errorf("statuses not persisted: %+v", reloaded.Statuses)
}
if len(reloaded.TurnLog) != 1 || reloaded.TurnLog[0].Action != "hit" {
@@ -223,7 +223,7 @@ func TestTurnEngine_PhaseProgression(t *testing.T) {
func TestTurnEngine_PoisonTickAtRoundEnd(t *testing.T) {
sess := turnSession(CombatPhaseRoundEnd, 50, 80)
sess.Statuses = CombatStatuses{PoisonTicks: 2, PoisonDmg: 7}
sess.Statuses = CombatStatuses{ActorStatuses: ActorStatuses{PoisonTicks: 2, PoisonDmg: 7}}
player, enemy := basePlayer(), baseEnemy()
events, err := stepEngine(sess, &player, &enemy, PlayerAction{})
@@ -246,7 +246,7 @@ func TestTurnEngine_PoisonTickAtRoundEnd(t *testing.T) {
func TestTurnEngine_PoisonTickCanBeLethal(t *testing.T) {
sess := turnSession(CombatPhaseRoundEnd, 4, 80)
sess.Statuses = CombatStatuses{PoisonTicks: 1, PoisonDmg: 9}
sess.Statuses = CombatStatuses{ActorStatuses: ActorStatuses{PoisonTicks: 1, PoisonDmg: 9}}
player, enemy := basePlayer(), baseEnemy()
if _, err := stepEngine(sess, &player, &enemy, PlayerAction{}); err != nil {
@@ -265,7 +265,7 @@ func TestTurnEngine_PoisonTickCanBeLethal(t *testing.T) {
func TestTurnEngine_ConcentrationTickAtRoundEnd(t *testing.T) {
sess := turnSession(CombatPhaseRoundEnd, 50, 80)
sess.Statuses = CombatStatuses{ConcentrationDmg: 12}
sess.Statuses = CombatStatuses{ActorStatuses: ActorStatuses{ConcentrationDmg: 12}}
player, enemy := basePlayer(), baseEnemy()
events, err := stepEngine(sess, &player, &enemy, PlayerAction{})
@@ -288,7 +288,7 @@ func TestTurnEngine_ConcentrationTickAtRoundEnd(t *testing.T) {
func TestTurnEngine_ConcentrationTickCanBeLethal(t *testing.T) {
sess := turnSession(CombatPhaseRoundEnd, 50, 9)
sess.Statuses = CombatStatuses{ConcentrationDmg: 12}
sess.Statuses = CombatStatuses{ActorStatuses: ActorStatuses{ConcentrationDmg: 12}}
player, enemy := basePlayer(), baseEnemy()
if _, err := stepEngine(sess, &player, &enemy, PlayerAction{}); err != nil {
@@ -433,7 +433,7 @@ func TestApplyBuffDelta(t *testing.T) {
func TestApplySessionBuffs(t *testing.T) {
player := basePlayer() // AC 0, AttackBonus 0, CritRate 0.05, Speed 10, DamageReduct 1.0
applySessionBuffs(&player, CombatStatuses{
applySessionBuffs(&player, ActorStatuses{
BuffACBonus: 3, BuffAtkBonus: 2, BuffSpeedBonus: 5, BuffCritRate: 0.15,
BuffDamageBonus: 0.25, BuffDamageReductMul: 0.8, BuffPetProc: 0.5, BuffPetDmg: 6,
})
@@ -473,7 +473,7 @@ func TestSeedCombatSessionOneShots(t *testing.T) {
// re-bank a depleted ward charge — on every resumed round.
func TestTurnEngine_OneShotsRoundTrip(t *testing.T) {
sess := turnSession(CombatPhaseRoundEnd, 50, 50)
sess.Statuses = CombatStatuses{
sess.Statuses = CombatStatuses{ActorStatuses: ActorStatuses{
WardCharges: 3, SporeRounds: 2, ReflectFrac: 0.5, AutoCritFirst: true,
ArcaneWardHP: 10, HealChargesLeft: 1,
Raged: true, LuckyUsed: true, DeathSaveUsed: true, PendingRage: true,
@@ -481,7 +481,7 @@ func TestTurnEngine_OneShotsRoundTrip(t *testing.T) {
// Buff stat deltas are owned by the command layer — commit must leave
// them untouched rather than zeroing them on every step.
BuffACBonus: 2, BuffDamageBonus: 0.15,
}
}}
want := sess.Statuses // round_end with no poison mutates none of these
player, enemy := basePlayer(), baseEnemy()

View File

@@ -215,44 +215,98 @@ func turnIdxForPhase(order []int, idx int, phase string) int {
// resumeTurnEngine rebuilds the in-memory combatState from a persisted session.
// rng is the deterministic source for this step (see combatSessionStepRNG).
//
// Seat 0 restores the session's persisted per-character statuses. Seats 1+ open
// fresh from their combatant's modifiers: a party's per-member statuses need the
// combat_participant rows that P4 adds, and until then no persisted session ever
// carries more than one seat.
func resumeTurnEngine(sess *CombatSession, players []*Combatant, enemy *Combatant, rng *rand.Rand) *turnEngine {
seat0 := &actor{
c: players[0],
playerHP: sess.PlayerHP,
hpMax: sess.PlayerHPMax,
poisonTicks: sess.Statuses.PoisonTicks,
poisonDmg: sess.Statuses.PoisonDmg,
stunPlayer: sess.Statuses.StunPlayer,
// Fight-scoped depleting resources + once-per-fight one-shots: restored
// from the persisted statuses so a charge or "already used" flag can't
// reset across a suspend/resume. commit writes the updated values back.
wardCharges: sess.Statuses.WardCharges,
sporeRounds: sess.Statuses.SporeRounds,
reflectFrac: sess.Statuses.ReflectFrac,
autoCrit: sess.Statuses.AutoCritFirst,
arcaneWardHP: sess.Statuses.ArcaneWardHP,
healChargesLeft: sess.Statuses.HealChargesLeft,
concentrationDmg: sess.Statuses.ConcentrationDmg,
deathSaveUsed: sess.Statuses.DeathSaveUsed,
luckyUsed: sess.Statuses.LuckyUsed,
raged: sess.Statuses.Raged,
pendingRageAttack: sess.Statuses.PendingRage,
firstAttackBonusUsed: sess.Statuses.FirstAtkBonusUsed,
assassinateRerollUsed: sess.Statuses.AssassinateReroll,
assassinateBonusUsed: sess.Statuses.AssassinateBonus,
// restoreActor rebuilds one seat from its persisted state: live HP, the pool
// ceiling, and the per-character effect set. Depleting resources and the
// once-per-fight "already used" flags are restored rather than rearmed, so a
// charge can't silently reset across a suspend/resume or a reaper auto-play.
// snapshotActor is its exact inverse — keep the two field lists in step.
func restoreActor(c *Combatant, hp, hpMax int, s ActorStatuses) *actor {
return &actor{
c: c,
playerHP: hp,
hpMax: hpMax,
poisonTicks: s.PoisonTicks,
poisonDmg: s.PoisonDmg,
stunPlayer: s.StunPlayer,
wardCharges: s.WardCharges,
sporeRounds: s.SporeRounds,
reflectFrac: s.ReflectFrac,
autoCrit: s.AutoCritFirst,
arcaneWardHP: s.ArcaneWardHP,
healChargesLeft: s.HealChargesLeft,
concentrationDmg: s.ConcentrationDmg,
deathSaveUsed: s.DeathSaveUsed,
luckyUsed: s.LuckyUsed,
raged: s.Raged,
pendingRageAttack: s.PendingRage,
firstAttackBonusUsed: s.FirstAtkBonusUsed,
assassinateRerollUsed: s.AssassinateReroll,
assassinateBonusUsed: s.AssassinateBonus,
// Enemy debuffs stacked onto this character specifically.
playerAtkDrain: sess.Statuses.PlayerAtkDrain,
playerACDebuff: sess.Statuses.PlayerACDebuff,
maxHPDrain: sess.Statuses.MaxHPDrain,
playerAtkDrain: s.PlayerAtkDrain,
playerACDebuff: s.PlayerACDebuff,
maxHPDrain: s.MaxHPDrain,
}
}
// snapshotActor folds one seat's live state back into its persisted form. The
// Buff* deltas are owned by the command layer (a !cast / !consume folds them in
// via applyBuffDelta) and are not combatState fields, so they are carried over
// from the prior snapshot rather than re-derived.
func snapshotActor(a *actor, prior ActorStatuses) ActorStatuses {
s := prior
s.PoisonTicks = a.poisonTicks
s.PoisonDmg = a.poisonDmg
s.StunPlayer = a.stunPlayer
s.WardCharges = a.wardCharges
s.SporeRounds = a.sporeRounds
s.ReflectFrac = a.reflectFrac
s.AutoCritFirst = a.autoCrit
s.ArcaneWardHP = a.arcaneWardHP
s.HealChargesLeft = a.healChargesLeft
s.ConcentrationDmg = a.concentrationDmg
s.DeathSaveUsed = a.deathSaveUsed
s.LuckyUsed = a.luckyUsed
s.Raged = a.raged
s.PendingRage = a.pendingRageAttack
s.FirstAtkBonusUsed = a.firstAttackBonusUsed
s.AssassinateReroll = a.assassinateRerollUsed
s.AssassinateBonus = a.assassinateBonusUsed
s.PlayerAtkDrain = a.playerAtkDrain
s.PlayerACDebuff = a.playerACDebuff
s.MaxHPDrain = a.maxHPDrain
return s
}
// Every seat restores its own persisted per-character statuses: seat 0 from the
// session row's embedded ActorStatuses, seats 1+ from their combat_participant
// row. Without that, a party member's once-per-fight one-shots would rearm on
// every engine step. players[i] must be the combatant for seat i — the caller
// owns that ordering, and it must match sess.Participants[i-1].UserID.
func resumeTurnEngine(sess *CombatSession, players []*Combatant, enemy *Combatant, rng *rand.Rand) *turnEngine {
seat0 := restoreActor(players[0], sess.PlayerHP, sess.PlayerHPMax, sess.Statuses.ActorStatuses)
actors := make([]*actor, len(players))
actors[0] = seat0
for i := 1; i < len(players); i++ {
// A roster longer than the persisted seats can only happen if a caller
// hands us combatants the session never seated. Open those fresh rather
// than panicking mid-fight; hydrateCombatParticipants already rejects
// the reverse (a session claiming more seats than it persisted).
if i-1 >= len(sess.Participants) {
actors[i] = newActor(players[i])
continue
}
p := sess.Participants[i-1]
actors[i] = restoreActor(players[i], p.HP, p.HPMax, p.Statuses)
}
st := &combatState{
actor: seat0,
@@ -712,53 +766,45 @@ func (te *turnEngine) finish(status string) {
//
// The Buff* stat deltas on Statuses are NOT combatState fields — they're owned
// by the command layer (a !cast / !consume folds them in) and applied to the
// rebuilt combatant by applySessionBuffs — so commit mutates Statuses in place
// rather than replacing it, leaving those deltas untouched.
// Per-actor state is read off seat 0 rather than off the cursor, which the
// enemy turn parks on its target and round_end walks across the roster. Seat 0
// is the only member a session row can hold; P4's participant rows carry the
// rest.
// rebuilt combatant by applySessionBuffs — so snapshotActor carries them over
// from the prior state rather than re-deriving them.
//
// Per-actor state is read off each seat by index, never off the cursor: the
// enemy turn parks the cursor on its target and round_end walks it across the
// roster, so whoever it points at when commit runs is an accident of the phase.
// Seat 0 lands on the session row; seats 1+ on their participant rows.
func (te *turnEngine) commit() {
st := te.st
a := st.actors[0]
te.sess.Round = st.round
te.sess.PlayerHP = a.playerHP
te.sess.EnemyHP = st.enemyHP
seat0 := st.actors[0]
te.sess.PlayerHP = seat0.playerHP
s := &te.sess.Statuses
s.PoisonTicks = a.poisonTicks
s.PoisonDmg = a.poisonDmg
s.StunPlayer = a.stunPlayer
s.ActorStatuses = snapshotActor(seat0, s.ActorStatuses)
for i := 1; i < len(st.actors) && i-1 < len(te.sess.Participants); i++ {
p := &te.sess.Participants[i-1]
p.HP = st.actors[i].playerHP
p.Statuses = snapshotActor(st.actors[i], p.Statuses)
}
// Fight-scoped: the enemy's own stance, and the debuffs it wears.
s.Enraged = st.enraged
s.ArmorBroken = st.armorBroken
s.ArmorBreakAmt = st.armorBreakAmt
s.EnemySkipNext = st.enemySkipFirst
s.WardCharges = a.wardCharges
s.SporeRounds = a.sporeRounds
s.ReflectFrac = a.reflectFrac
s.AutoCritFirst = a.autoCrit
s.ArcaneWardHP = a.arcaneWardHP
s.HealChargesLeft = a.healChargesLeft
s.ConcentrationDmg = a.concentrationDmg
s.DeathSaveUsed = a.deathSaveUsed
s.LuckyUsed = a.luckyUsed
s.Raged = a.raged
s.PendingRage = a.pendingRageAttack
s.FirstAtkBonusUsed = a.firstAttackBonusUsed
s.AssassinateReroll = a.assassinateRerollUsed
s.AssassinateBonus = a.assassinateBonusUsed
s.EnemyEvadeNext = st.enemyEvadeNext
s.EnemyBlockUp = st.enemyBlockUp
s.EnemyAdvantage = st.enemyAdvantage
s.EnemyRetaliateFrac = st.enemyRetaliateFrac
s.EnemyRegen = st.enemyRegen
s.EnemySurviveArmed = st.enemySurviveArmed
s.PlayerAtkDrain = a.playerAtkDrain
s.PlayerACDebuff = a.playerACDebuff
s.MaxHPDrain = a.maxHPDrain
s.EnemySpellResist = st.enemySpellResist
s.EnemyRevealNext = st.enemyRevealNext
s.EnemyFearImmune = st.enemyFearImmune
s.EnemyAtkBuff = st.enemyAtkBuff
te.sess.TurnLog = append(te.sess.TurnLog, st.events...)
}

View File

@@ -1,6 +1,7 @@
package plugin
import (
"math/rand/v2"
"testing"
"maunium.net/go/mautrix/id"
@@ -653,9 +654,12 @@ func TestSimulateCombat_FirstAttackBonusImprovesEarlyHits(t *testing.T) {
// Probabilistic: a +4 on the opening swing should noticeably lift the
// player's win rate against a stiff enemy. The lift is small (a single
// hit's worth), so the trial count needs to be high enough that
// variance doesn't swamp the signal — at 1500 trials we were seeing
// ~14 wins of difference on bad seeds vs. the 25-win threshold.
const trials = 6000
// variance doesn't swamp the signal. At 6000 trials it still did: sweeping
// 40 seeds, the mean margin was +127 wins but the worst was -42, against a
// +50 threshold — so this test failed for roughly one seed in ten and had
// been doing so on a clean tree. At 24000 every one of those 40 seeds
// clears by at least +267.
const trials = 24000
hardEnemy := Combatant{
Name: "Wall",
Stats: CombatStats{
@@ -664,12 +668,17 @@ func TestSimulateCombat_FirstAttackBonusImprovesEarlyHits(t *testing.T) {
},
Mods: CombatModifiers{DamageReduct: 1.0},
}
// Seeded, not global: SimulateCombat(nil rng) draws from the package-global
// stream, so this test's verdict used to depend on how much randomness every
// test declared before it happened to consume. It failed intermittently on a
// clean tree and re-flaked whenever an unrelated change shifted the stream.
rng := statCompareRNG()
plainWins, bmWins := 0, 0
for i := 0; i < trials; i++ {
if SimulateCombat(plainBMPlayer(), hardEnemy, defaultCombatPhases).PlayerWon {
if simulateCombatWithRNG(plainBMPlayer(), hardEnemy, defaultCombatPhases, rng).PlayerWon {
plainWins++
}
if SimulateCombat(bmPrecisionPlayer(), hardEnemy, defaultCombatPhases).PlayerWon {
if simulateCombatWithRNG(bmPrecisionPlayer(), hardEnemy, defaultCombatPhases, rng).PlayerWon {
bmWins++
}
}
@@ -678,13 +687,25 @@ func TestSimulateCombat_FirstAttackBonusImprovesEarlyHits(t *testing.T) {
}
}
// statCompareRNG seeds the two statistical A/B subclass tests below. They
// compare win counts between two builds over thousands of trials with a fixed
// threshold, which is only meaningful if the stream is theirs alone. The seed
// is arbitrary — any value where the true effect clears the threshold works;
// this one does, and pinning it is what makes the tests reproducible.
func statCompareRNG() *rand.Rand { return rand.New(rand.NewPCG(0x5eed, 0xc0ffee)) }
// Surface check: AssassinateBonusDmg only consumed once.
func TestResolvePlayerAttack_AssassinateBonusFirstHitOnly(t *testing.T) {
// Drive resolvePlayerAttack via a SimulateCombat run and confirm the
// total enemy damage taken on hit is the *base* damage on subsequent
// hits (i.e. only the first hit got the +bonus). Statistical compare
// against an Assassin L5 vs. an identical player without the bonus.
const trials = 800
//
// 800 trials was too few for the "any lift at all" threshold: over 40 seeds
// the mean margin was only +12.8 wins and two seeds came out negative. The
// bonus is genuinely small — one hit's worth, once per fight — so the trial
// count carries the signal. At 6000 the worst of those seeds clears by +68.
const trials = 6000
build := func(bonus int) Combatant {
return Combatant{
Name: "Assn", IsPlayer: true,
@@ -707,16 +728,18 @@ func TestResolvePlayerAttack_AssassinateBonusFirstHitOnly(t *testing.T) {
Mods: CombatModifiers{DamageReduct: 1.0},
}
}
// Seeded for the same reason as the Precision Attack test above.
rng := statCompareRNG()
plainWins, assnWins := 0, 0
for i := 0; i < trials; i++ {
if SimulateCombat(build(0), enemy(), defaultCombatPhases).PlayerWon {
if simulateCombatWithRNG(build(0), enemy(), defaultCombatPhases, rng).PlayerWon {
plainWins++
}
if SimulateCombat(build(8), enemy(), defaultCombatPhases).PlayerWon {
if simulateCombatWithRNG(build(8), enemy(), defaultCombatPhases, rng).PlayerWon {
assnWins++
}
}
if assnWins <= plainWins {
if assnWins-plainWins < 25 {
t.Errorf("Assassinate bonus damage didn't help: plain=%d assn=%d", plainWins, assnWins)
}
}

View File

@@ -0,0 +1,281 @@
package plugin
import (
"database/sql"
"errors"
"fmt"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// N3/P4 — expedition party membership.
//
// A co-op expedition is one dnd_expedition row plus an expedition_party roster.
// The leader's row stays the single source of truth for the shared clock, the
// threat track, and the supply pool; members reference it rather than owning a
// row of their own. There is no party_id column — expedition_id already
// identifies the party, and a second key would be a second answer to "who is in
// this party".
//
// Absence means solo. Every expedition that existed before N3 has no rows here,
// which is exactly the right reading, so nothing needs backfilling. A solo
// expedition started after N3 writes no rows either: the roster materializes on
// the first successful invite (P6), and until then partyMembers returns empty.
// Party roles (expedition_party.role).
const (
PartyRoleLeader = "leader"
PartyRoleMember = "member"
)
// expeditionPartyMax is the roster ceiling including the leader. C1 scopes v1
// to 23 players; the combat roster and the supply pool are both sized off this.
const expeditionPartyMax = 3
// Errors returned by the party layer.
var (
ErrPartyFull = errors.New("expedition party is full")
ErrAlreadyInParty = errors.New("player is already in this party")
ErrNotPartyLeader = errors.New("only the party leader may do that")
ErrPlayerBusyElsewhere = errors.New("player already has an active expedition")
)
// PartyMember is one seat on an expedition roster.
type PartyMember struct {
ExpeditionID string
UserID string
Role string
JoinedAt time.Time
}
// IsLeader reports whether this member owns the expedition row.
func (m PartyMember) IsLeader() bool { return m.Role == PartyRoleLeader }
// partyMembers returns the roster in join order, leader first. A solo
// expedition has no rows and returns an empty slice — callers should treat
// len(roster) == 0 and len(roster) == 1 alike, as "one player".
func partyMembers(expeditionID string) ([]PartyMember, error) {
rows, err := db.Get().Query(`
SELECT expedition_id, user_id, role, joined_at
FROM expedition_party
WHERE expedition_id = ?
ORDER BY (role <> 'leader'), joined_at ASC`, expeditionID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []PartyMember
for rows.Next() {
var m PartyMember
if err := rows.Scan(&m.ExpeditionID, &m.UserID, &m.Role, &m.JoinedAt); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
// partyMemberIDs is partyMembers reduced to user ids, leader first. It is what
// the fan-out seams (digest, briefing, recap) want: a solo expedition yields
// just the owner, so a caller can loop unconditionally.
func partyMemberIDs(expeditionID, ownerID string) ([]id.UserID, error) {
members, err := partyMembers(expeditionID)
if err != nil {
return nil, err
}
if len(members) == 0 {
return []id.UserID{id.UserID(ownerID)}, nil
}
out := make([]id.UserID, 0, len(members))
for _, m := range members {
out = append(out, id.UserID(m.UserID))
}
return out, nil
}
// partySize is the number of seated players: 1 for a solo expedition.
func partySize(expeditionID string) (int, error) {
var n int
err := db.Get().QueryRow(
`SELECT COUNT(*) FROM expedition_party WHERE expedition_id = ?`,
expeditionID).Scan(&n)
if err != nil {
return 0, err
}
if n == 0 {
return 1, nil
}
return n, nil
}
// openParty seats the leader, converting a solo expedition row into a party of
// one. It is idempotent: a second call on the same expedition is a no-op, so
// the invite path can call it unconditionally before adding a member.
func openParty(expeditionID string, leaderID id.UserID) error {
_, err := db.Get().Exec(`
INSERT INTO expedition_party (expedition_id, user_id, role)
VALUES (?, ?, 'leader')
ON CONFLICT (expedition_id, user_id) DO NOTHING`,
expeditionID, string(leaderID))
return err
}
// joinParty seats a member. It refuses a full roster, a duplicate, and a player
// who already leads or rides an expedition of their own — the "one active
// expedition per user" rule that startExpedition enforces in code, extended to
// cover membership. The check and the insert share a transaction so two
// simultaneous invites cannot both find the last seat free.
//
// It seats the leader first if nobody has. A roster whose leader is missing
// would quietly drop them from every fan-out (partyMemberIDs reads the roster,
// not the expedition row), so the ordering is enforced here rather than left to
// each caller to remember.
func joinParty(expeditionID string, userID id.UserID) error {
tx, err := db.Get().Begin()
if err != nil {
return err
}
defer tx.Rollback()
if err := seatLeader(tx, expeditionID); err != nil {
return err
}
var n int
if err := tx.QueryRow(
`SELECT COUNT(*) FROM expedition_party WHERE expedition_id = ?`,
expeditionID).Scan(&n); err != nil {
return err
}
if n >= expeditionPartyMax {
return ErrPartyFull
}
if err := assertNotAdventuring(tx, expeditionID, userID); err != nil {
return err
}
if _, err := tx.Exec(`
INSERT INTO expedition_party (expedition_id, user_id, role)
VALUES (?, ?, 'member')`,
expeditionID, string(userID)); err != nil {
return fmt.Errorf("seat %s: %w", userID, err)
}
return tx.Commit()
}
// seatLeader is openParty inside a caller's transaction: it reads the owner off
// the expedition row rather than trusting a passed-in id, so the roster's leader
// can never disagree with dnd_expedition.user_id.
func seatLeader(tx *sql.Tx, expeditionID string) error {
var owner string
err := tx.QueryRow(
`SELECT user_id FROM dnd_expedition WHERE expedition_id = ?`,
expeditionID).Scan(&owner)
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("expedition %s not found", expeditionID)
}
if err != nil {
return err
}
_, err = tx.Exec(`
INSERT INTO expedition_party (expedition_id, user_id, role)
VALUES (?, ?, 'leader')
ON CONFLICT (expedition_id, user_id) DO NOTHING`, expeditionID, owner)
return err
}
// assertNotAdventuring fails if the player is already committed elsewhere:
// seated on this or any other party, or owning an active expedition row. Runs
// inside joinParty's transaction so the guard and the insert are atomic.
func assertNotAdventuring(tx *sql.Tx, expeditionID string, userID id.UserID) error {
var seatedIn string
err := tx.QueryRow(`
SELECT expedition_id FROM expedition_party WHERE user_id = ? LIMIT 1`,
string(userID)).Scan(&seatedIn)
switch {
case err == nil && seatedIn == expeditionID:
return ErrAlreadyInParty
case err == nil:
return ErrPlayerBusyElsewhere
case !errors.Is(err, sql.ErrNoRows):
return err
}
var owned int
if err := tx.QueryRow(`
SELECT COUNT(*) FROM dnd_expedition
WHERE user_id = ? AND status IN ('active', 'extracting')`,
string(userID)).Scan(&owned); err != nil {
return err
}
if owned > 0 {
return ErrPlayerBusyElsewhere
}
return nil
}
// leaveParty removes one member. The leader cannot leave — an expedition
// without its owner has no row to hang the shared clock on; the leader ends the
// run for everyone with !extract instead.
func leaveParty(expeditionID string, userID id.UserID) error {
res, err := db.Get().Exec(`
DELETE FROM expedition_party
WHERE expedition_id = ? AND user_id = ? AND role <> 'leader'`,
expeditionID, string(userID))
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotPartyLeader
}
return nil
}
// disbandParty clears the roster. Called when an expedition reaches a terminal
// status, so every member is free to start their own next run.
func disbandParty(expeditionID string) error {
_, err := db.Get().Exec(
`DELETE FROM expedition_party WHERE expedition_id = ?`, expeditionID)
return err
}
// expeditionForMember resolves the expedition a player is seated on but does
// not own. getActiveExpedition keys on dnd_expedition.user_id and so is blind to
// members; every player-facing lookup wants both, which is what
// activeExpeditionFor provides.
func expeditionForMember(userID id.UserID) (*Expedition, error) {
row := db.Get().QueryRow(`
SELECT e.expedition_id, e.user_id, e.zone_id, e.run_id, e.status,
e.start_date, e.current_day, e.current_region, e.boss_defeated,
e.supplies_json, e.camp_json, e.threat_level, e.threat_siege,
e.threat_events, e.temporal_stack, e.region_state,
e.xp_earned, e.coins_earned, e.gm_mood,
e.last_briefing_at, e.last_recap_at, e.last_ambient_kind,
e.last_activity, e.completed_at
FROM dnd_expedition e
JOIN expedition_party p ON p.expedition_id = e.expedition_id
WHERE p.user_id = ? AND p.role <> 'leader' AND e.status = 'active'
ORDER BY e.start_date DESC
LIMIT 1`, string(userID))
e, err := scanExpedition(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return e, err
}
// activeExpeditionFor returns the expedition a player is on, whether they lead
// it or ride it, plus whether they lead it. This is the lookup every
// player-facing command wants; getActiveExpedition alone silently tells a party
// member they have no expedition.
func activeExpeditionFor(userID id.UserID) (e *Expedition, isLeader bool, err error) {
if e, err = getActiveExpedition(userID); err != nil || e != nil {
return e, e != nil, err
}
e, err = expeditionForMember(userID)
return e, false, err
}

View File

@@ -0,0 +1,290 @@
package plugin
import (
"errors"
"testing"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// seedExpedition inserts the minimum dnd_expedition row the party layer reads:
// the owner, the status, and enough non-null columns for scanExpedition.
func seedExpedition(t *testing.T, expeditionID string, owner id.UserID, status string) {
t.Helper()
if _, err := db.Get().Exec(`
INSERT INTO dnd_expedition (expedition_id, user_id, zone_id, run_id, status, start_date)
VALUES (?, ?, 'goblin_warrens', ?, ?, ?)`,
expeditionID, string(owner), "run-"+expeditionID, status, time.Now().UTC(),
); err != nil {
t.Fatal(err)
}
}
func TestParty_SoloExpeditionHasNoRoster(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@solo:example.org")
seedExpedition(t, "exp-solo", owner, "active")
members, err := partyMembers("exp-solo")
if err != nil {
t.Fatal(err)
}
if len(members) != 0 {
t.Errorf("solo expedition has %d roster rows, want 0", len(members))
}
// Absent must read as "one player", not "zero players" — every fan-out seam
// loops over this.
if n, err := partySize("exp-solo"); err != nil || n != 1 {
t.Errorf("partySize = %d (%v), want 1", n, err)
}
ids, err := partyMemberIDs("exp-solo", string(owner))
if err != nil {
t.Fatal(err)
}
if len(ids) != 1 || ids[0] != owner {
t.Errorf("partyMemberIDs = %v, want [%s]", ids, owner)
}
}
func TestParty_OpenIsIdempotent(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@lead:example.org")
seedExpedition(t, "exp-1", owner, "active")
for i := 0; i < 2; i++ {
if err := openParty("exp-1", owner); err != nil {
t.Fatalf("openParty #%d: %v", i+1, err)
}
}
members, err := partyMembers("exp-1")
if err != nil {
t.Fatal(err)
}
if len(members) != 1 || !members[0].IsLeader() {
t.Fatalf("roster = %+v, want one leader", members)
}
}
func TestParty_JoinSeatsMembersLeaderFirst(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@lead2:example.org")
seedExpedition(t, "exp-2", owner, "active")
if err := openParty("exp-2", owner); err != nil {
t.Fatal(err)
}
for _, u := range []id.UserID{"@b:x", "@c:x"} {
if err := joinParty("exp-2", u); err != nil {
t.Fatalf("joinParty %s: %v", u, err)
}
}
members, err := partyMembers("exp-2")
if err != nil {
t.Fatal(err)
}
if len(members) != 3 {
t.Fatalf("roster = %d, want 3", len(members))
}
if !members[0].IsLeader() || members[0].UserID != string(owner) {
t.Errorf("leader is not first: %+v", members)
}
if n, err := partySize("exp-2"); err != nil || n != 3 {
t.Errorf("partySize = %d (%v), want 3", n, err)
}
}
// An invite that lands before anyone called openParty must still produce a
// roster with the leader on it — partyMemberIDs reads the roster, not the
// expedition row, so a leaderless party would silently stop DMing the leader.
func TestParty_JoinSeatsTheLeaderIfMissing(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@lead-implicit:example.org")
seedExpedition(t, "exp-implicit", owner, "active")
// No openParty call at all.
if err := joinParty("exp-implicit", "@b:x"); err != nil {
t.Fatal(err)
}
members, err := partyMembers("exp-implicit")
if err != nil {
t.Fatal(err)
}
if len(members) != 2 {
t.Fatalf("roster = %+v, want leader + member", members)
}
if !members[0].IsLeader() || members[0].UserID != string(owner) {
t.Errorf("leader missing from roster: %+v", members)
}
// And the leader cannot then be double-seated as a member.
if err := joinParty("exp-implicit", owner); !errors.Is(err, ErrAlreadyInParty) {
t.Errorf("leader join: err = %v, want ErrAlreadyInParty", err)
}
}
func TestParty_JoinRefusesFullRoster(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@lead3:example.org")
seedExpedition(t, "exp-3", owner, "active")
if err := openParty("exp-3", owner); err != nil {
t.Fatal(err)
}
for _, u := range []id.UserID{"@b:x", "@c:x"} {
if err := joinParty("exp-3", u); err != nil {
t.Fatal(err)
}
}
// Leader + 2 == expeditionPartyMax.
if err := joinParty("exp-3", "@d:x"); !errors.Is(err, ErrPartyFull) {
t.Errorf("4th seat: err = %v, want ErrPartyFull", err)
}
}
func TestParty_JoinRefusesDuplicate(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@lead4:example.org")
seedExpedition(t, "exp-4", owner, "active")
if err := openParty("exp-4", owner); err != nil {
t.Fatal(err)
}
if err := joinParty("exp-4", "@b:x"); err != nil {
t.Fatal(err)
}
if err := joinParty("exp-4", "@b:x"); !errors.Is(err, ErrAlreadyInParty) {
t.Errorf("re-join: err = %v, want ErrAlreadyInParty", err)
}
// The leader is already seated, so inviting them back is a duplicate too.
if err := joinParty("exp-4", owner); !errors.Is(err, ErrAlreadyInParty) {
t.Errorf("leader re-join: err = %v, want ErrAlreadyInParty", err)
}
}
// "One active expedition per user" is code-enforced for owners
// (startExpedition). Membership must not be a way around it: a player who owns
// a run, or already rides someone else's, cannot take a third seat.
func TestParty_JoinRefusesPlayerBusyElsewhere(t *testing.T) {
setupEmptyTestDB(t)
seedExpedition(t, "exp-5", "@lead5:example.org", "active")
seedExpedition(t, "exp-6", "@lead6:example.org", "active")
if err := openParty("exp-5", "@lead5:example.org"); err != nil {
t.Fatal(err)
}
if err := openParty("exp-6", "@lead6:example.org"); err != nil {
t.Fatal(err)
}
// Owns their own active expedition.
seedExpedition(t, "exp-own", "@busy:example.org", "active")
if err := joinParty("exp-5", "@busy:example.org"); !errors.Is(err, ErrPlayerBusyElsewhere) {
t.Errorf("owner of another run: err = %v, want ErrPlayerBusyElsewhere", err)
}
// Already seated on another party.
if err := joinParty("exp-5", "@rider:example.org"); err != nil {
t.Fatal(err)
}
if err := joinParty("exp-6", "@rider:example.org"); !errors.Is(err, ErrPlayerBusyElsewhere) {
t.Errorf("rider of another party: err = %v, want ErrPlayerBusyElsewhere", err)
}
// A leader is seated on their own party, so they are busy too.
if err := joinParty("exp-6", "@lead5:example.org"); !errors.Is(err, ErrPlayerBusyElsewhere) {
t.Errorf("leader of another party: err = %v, want ErrPlayerBusyElsewhere", err)
}
}
func TestParty_LeaveRemovesMemberButNotLeader(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@lead7:example.org")
seedExpedition(t, "exp-7", owner, "active")
if err := openParty("exp-7", owner); err != nil {
t.Fatal(err)
}
if err := joinParty("exp-7", "@b:x"); err != nil {
t.Fatal(err)
}
if err := leaveParty("exp-7", "@b:x"); err != nil {
t.Fatalf("leaveParty: %v", err)
}
if n, _ := partySize("exp-7"); n != 1 {
t.Errorf("after leave, partySize = %d, want 1", n)
}
// The expedition row hangs off the leader; they extract, they don't leave.
if err := leaveParty("exp-7", owner); !errors.Is(err, ErrNotPartyLeader) {
t.Errorf("leader leave: err = %v, want ErrNotPartyLeader", err)
}
// Leaving frees the member for their own next run.
seedExpedition(t, "exp-7b", "@lead7b:example.org", "active")
if err := openParty("exp-7b", "@lead7b:example.org"); err != nil {
t.Fatal(err)
}
if err := joinParty("exp-7b", "@b:x"); err != nil {
t.Errorf("departed member could not rejoin elsewhere: %v", err)
}
}
func TestParty_DisbandFreesEveryone(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@lead8:example.org")
seedExpedition(t, "exp-8", owner, "complete")
if err := openParty("exp-8", owner); err != nil {
t.Fatal(err)
}
if err := joinParty("exp-8", "@b:x"); err != nil {
t.Fatal(err)
}
if err := disbandParty("exp-8"); err != nil {
t.Fatal(err)
}
if n, _ := partySize("exp-8"); n != 1 {
t.Errorf("after disband, partySize = %d, want 1 (no rows)", n)
}
seedExpedition(t, "exp-8b", "@lead8b:example.org", "active")
if err := openParty("exp-8b", "@lead8b:example.org"); err != nil {
t.Fatal(err)
}
if err := joinParty("exp-8b", "@b:x"); err != nil {
t.Errorf("disbanded member still looks busy: %v", err)
}
}
// getActiveExpedition keys on dnd_expedition.user_id, so it is blind to party
// members. activeExpeditionFor is the lookup every player-facing command wants.
func TestParty_ActiveExpeditionForResolvesBothRoles(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@lead9:example.org")
member := id.UserID("@member9:example.org")
seedExpedition(t, "exp-9", owner, "active")
if err := openParty("exp-9", owner); err != nil {
t.Fatal(err)
}
if err := joinParty("exp-9", member); err != nil {
t.Fatal(err)
}
// The member owns no row, so the legacy lookup misses them entirely.
if e, err := getActiveExpedition(member); err != nil || e != nil {
t.Errorf("getActiveExpedition found a row for a member: %v / %v", e, err)
}
e, isLeader, err := activeExpeditionFor(owner)
if err != nil || e == nil || !isLeader || e.ID != "exp-9" {
t.Errorf("leader lookup: %+v leader=%v err=%v", e, isLeader, err)
}
e, isLeader, err = activeExpeditionFor(member)
if err != nil || e == nil || isLeader || e.ID != "exp-9" {
t.Errorf("member lookup: %+v leader=%v err=%v", e, isLeader, err)
}
// The member resolves to the leader's expedition, clock and all.
if e != nil && e.UserID != string(owner) {
t.Errorf("member resolved to expedition owned by %q, want %q", e.UserID, owner)
}
// A stranger is on no expedition at all.
e, _, err = activeExpeditionFor("@nobody:example.org")
if err != nil || e != nil {
t.Errorf("stranger lookup: %+v / %v", e, err)
}
}