mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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.
|
||||
|
||||
331
internal/plugin/combat_participant_test.go
Normal file
331
internal/plugin/combat_participant_test.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -397,6 +493,36 @@ func saveCombatSession(s *CombatSession) error {
|
||||
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
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
281
internal/plugin/expedition_party.go
Normal file
281
internal/plugin/expedition_party.go
Normal 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 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
|
||||
}
|
||||
290
internal/plugin/expedition_party_test.go
Normal file
290
internal/plugin/expedition_party_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user