diff --git a/internal/db/db.go b/internal/db/db.go index b37c8c8..e0b461b 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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. diff --git a/internal/plugin/combat_participant_test.go b/internal/plugin/combat_participant_test.go new file mode 100644 index 0000000..557fd62 --- /dev/null +++ b/internal/plugin/combat_participant_test.go @@ -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 +} diff --git a/internal/plugin/combat_session.go b/internal/plugin/combat_session.go index d122027..5861ba2 100644 --- a/internal/plugin/combat_session.go +++ b/internal/plugin/combat_session.go @@ -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. - 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"` +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"` - // 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"` - PlayerAtkDrain int `json:"player_atk_drain,omitempty"` - PlayerACDebuff int `json:"player_ac_debuff,omitempty"` - MaxHPDrain int `json:"max_hp_drain,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,32 +467,68 @@ 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 { + return err + } + 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' diff --git a/internal/plugin/combat_session_build.go b/internal/plugin/combat_session_build.go index 25b643b..7efaeed 100644 --- a/internal/plugin/combat_session_build.go +++ b/internal/plugin/combat_session_build.go @@ -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 diff --git a/internal/plugin/combat_session_test.go b/internal/plugin/combat_session_test.go index 51554bf..5cf19df 100644 --- a/internal/plugin/combat_session_test.go +++ b/internal/plugin/combat_session_test.go @@ -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() diff --git a/internal/plugin/combat_turn_engine.go b/internal/plugin/combat_turn_engine.go index 6282d2e..7bd2342 100644 --- a/internal/plugin/combat_turn_engine.go +++ b/internal/plugin/combat_turn_engine.go @@ -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++ { - actors[i] = newActor(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...) } diff --git a/internal/plugin/dnd_subclass_combat_test.go b/internal/plugin/dnd_subclass_combat_test.go index 69665f6..207dee9 100644 --- a/internal/plugin/dnd_subclass_combat_test.go +++ b/internal/plugin/dnd_subclass_combat_test.go @@ -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) } } diff --git a/internal/plugin/expedition_party.go b/internal/plugin/expedition_party.go new file mode 100644 index 0000000..ae441d2 --- /dev/null +++ b/internal/plugin/expedition_party.go @@ -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 2–3 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 +} diff --git a/internal/plugin/expedition_party_test.go b/internal/plugin/expedition_party_test.go new file mode 100644 index 0000000..6576e9c --- /dev/null +++ b/internal/plugin/expedition_party_test.go @@ -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) + } +}