mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +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
|
// DEFAULT 0 is wrong for in-flight rows — bootstrapRoomsTraversed
|
||||||
// backfills them from visited_nodes.
|
// backfills them from visited_nodes.
|
||||||
`ALTER TABLE dnd_zone_run ADD COLUMN rooms_traversed INTEGER NOT NULL DEFAULT 0`,
|
`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 {
|
for _, stmt := range columnMigrations {
|
||||||
if _, err := d.Exec(stmt); err != nil {
|
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
|
status TEXT NOT NULL DEFAULT 'active', -- active|won|lost|fled|expired
|
||||||
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
last_action_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
|
CREATE INDEX IF NOT EXISTS idx_combat_session_active
|
||||||
ON combat_session(user_id, status);
|
ON combat_session(user_id, status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_combat_session_expiry
|
CREATE INDEX IF NOT EXISTS idx_combat_session_expiry
|
||||||
ON combat_session(status, expires_at);
|
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.
|
// 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"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
@@ -43,41 +44,34 @@ const (
|
|||||||
// reaper sweeps it. Matches the started_at + 1h note in the schema.
|
// reaper sweeps it. Matches the started_at + 1h note in the schema.
|
||||||
const combatSessionTTL = time.Hour
|
const combatSessionTTL = time.Hour
|
||||||
|
|
||||||
// CombatStatuses is the serialized between-round effect state for a fight: the
|
// ActorStatuses is the per-seat half of the persisted effect state: everything
|
||||||
// subset of combatState that genuinely persists round-to-round, plus the
|
// that belongs to one character rather than to the fight. It mirrors the fields
|
||||||
// fight-scoped buffs a mid-fight !cast / !consume layers on. Everything here
|
// of `actor` (combat_engine.go) that must survive a suspend/resume.
|
||||||
// round-trips combatState -> Statuses -> combatState across every engine step,
|
|
||||||
// so a fight resumes from exact mid-state after a suspend or bot restart.
|
|
||||||
//
|
//
|
||||||
// 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 ==.
|
// persistence round-trip can be asserted with ==.
|
||||||
type CombatStatuses struct {
|
type ActorStatuses struct {
|
||||||
// Monster-ability effects — the original between-round persistence set.
|
// Monster-ability effects landing on this character.
|
||||||
PoisonTicks int `json:"poison_ticks,omitempty"`
|
PoisonTicks int `json:"poison_ticks,omitempty"`
|
||||||
PoisonDmg int `json:"poison_dmg,omitempty"`
|
PoisonDmg int `json:"poison_dmg,omitempty"`
|
||||||
StunPlayer bool `json:"stun_player,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
|
// Depleting resources — mirror the actor charges that genuinely carry
|
||||||
// turnOrder) of whoever acts next. A solo fight's order is the fixed
|
// round-to-round. Seeded at fight start (Arcane Ward) or by a mid-fight
|
||||||
// [player, enemy], so TurnIdx mirrors the phase and the field is dead
|
// !cast / !consume, restored into the actor on every resume, written back on
|
||||||
// weight — omitempty keeps it out of every solo row. Rows written before
|
// every commit so a depleting charge can't silently reset when a fight is
|
||||||
// this field existed decode as 0 and are reconciled against Phase on
|
// suspended and resumed.
|
||||||
// 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.
|
|
||||||
WardCharges int `json:"ward_charges,omitempty"`
|
WardCharges int `json:"ward_charges,omitempty"`
|
||||||
SporeRounds int `json:"spore_rounds,omitempty"`
|
SporeRounds int `json:"spore_rounds,omitempty"`
|
||||||
ReflectFrac float64 `json:"reflect_frac,omitempty"`
|
ReflectFrac float64 `json:"reflect_frac,omitempty"`
|
||||||
@@ -85,7 +79,7 @@ type CombatStatuses struct {
|
|||||||
ArcaneWardHP int `json:"arcane_ward_hp,omitempty"`
|
ArcaneWardHP int `json:"arcane_ward_hp,omitempty"`
|
||||||
HealChargesLeft int `json:"heal_charges_left,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,
|
// concentration AOE (Spirit Guardians, Spike Growth, Call Lightning,
|
||||||
// Flaming Sphere…). A one-shot !cast lands its burst the casting round,
|
// 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
|
// 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"`
|
AssassinateReroll bool `json:"assassinate_reroll_used,omitempty"`
|
||||||
AssassinateBonus bool `json:"assassinate_bonus_used,omitempty"`
|
AssassinateBonus bool `json:"assassinate_bonus_used,omitempty"`
|
||||||
|
|
||||||
// Slice-3 stateful monster-ability effects — armed by applyAbility, read by
|
// Debuffs the enemy has stacked onto this character specifically.
|
||||||
// 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"`
|
PlayerAtkDrain int `json:"player_atk_drain,omitempty"`
|
||||||
PlayerACDebuff int `json:"player_ac_debuff,omitempty"`
|
PlayerACDebuff int `json:"player_ac_debuff,omitempty"`
|
||||||
MaxHPDrain int `json:"max_hp_drain,omitempty"`
|
MaxHPDrain int `json:"max_hp_drain,omitempty"`
|
||||||
|
|
||||||
// Slice-4 monster-ability effects — the former flavor-only placeholders.
|
// Persistent stat buffs from this character's mid-fight !cast / !consume,
|
||||||
// EnemyRevealNext is a one-shot; the other three persist for the fight.
|
// accumulated as deltas against their freshly-rebuilt combatant.
|
||||||
EnemySpellResist bool `json:"enemy_spell_resist,omitempty"`
|
// applySessionBuffs folds these back on every round; diffTurnBuff produces
|
||||||
EnemyRevealNext bool `json:"enemy_reveal_next,omitempty"`
|
// them. BuffDamageReductMul is multiplicative (0 = none / 1.0 neutral).
|
||||||
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).
|
|
||||||
BuffACBonus int `json:"buff_ac_bonus,omitempty"`
|
BuffACBonus int `json:"buff_ac_bonus,omitempty"`
|
||||||
BuffAtkBonus int `json:"buff_atk_bonus,omitempty"`
|
BuffAtkBonus int `json:"buff_atk_bonus,omitempty"`
|
||||||
BuffSpeedBonus int `json:"buff_speed_bonus,omitempty"`
|
BuffSpeedBonus int `json:"buff_speed_bonus,omitempty"`
|
||||||
@@ -142,10 +120,65 @@ type CombatStatuses struct {
|
|||||||
BuffSpiritDmg int `json:"buff_spirit_dmg,omitempty"`
|
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
|
// 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.
|
// 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.BuffACBonus += d.dAC
|
||||||
s.BuffAtkBonus += d.dAtk
|
s.BuffAtkBonus += d.dAtk
|
||||||
s.BuffSpeedBonus += d.dSpeed
|
s.BuffSpeedBonus += d.dSpeed
|
||||||
@@ -188,6 +221,17 @@ func seedCombatSessionOneShots(s *CombatSession, playerMods CombatModifiers) boo
|
|||||||
st.AutoCritFirst || st.ArcaneWardHP != 0 || st.HealChargesLeft != 0
|
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.
|
// CombatSession is the in-memory shape of a combat_session row.
|
||||||
type CombatSession struct {
|
type CombatSession struct {
|
||||||
SessionID string
|
SessionID string
|
||||||
@@ -207,11 +251,48 @@ type CombatSession struct {
|
|||||||
StartedAt time.Time
|
StartedAt time.Time
|
||||||
LastActionAt time.Time
|
LastActionAt time.Time
|
||||||
ExpiresAt 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.
|
// IsActive reports whether the fight is still in flight.
|
||||||
func (s *CombatSession) IsActive() bool { return s.Status == CombatStatusActive }
|
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.
|
// Errors returned by the combat session layer.
|
||||||
var (
|
var (
|
||||||
ErrCombatSessionAlreadyActive = errors.New("combat session already active for player")
|
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,
|
session_id, user_id, run_id, encounter_id, enemy_id,
|
||||||
round, phase, player_hp, player_hp_max, enemy_hp, enemy_hp_max,
|
round, phase, player_hp, player_hp_max, enemy_hp, enemy_hp_max,
|
||||||
statuses_json, turn_log_json, status,
|
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.
|
// newCombatSessionID — 16-char hex token. Same scheme as zone runs / expeditions.
|
||||||
func newCombatSessionID() string {
|
func newCombatSessionID() string {
|
||||||
@@ -296,7 +377,10 @@ func getActiveCombatSession(userID id.UserID) (*CombatSession, error) {
|
|||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, nil
|
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
|
// 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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return s, err
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s, hydrateCombatParticipants(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getCombatSessionForEncounter returns the most recent session for a
|
// 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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return s, err
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s, hydrateCombatParticipants(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanCombatSession(row scanner) (*CombatSession, error) {
|
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.SessionID, &s.UserID, &s.RunID, &s.EncounterID, &s.EnemyID,
|
||||||
&s.Round, &s.Phase, &s.PlayerHP, &s.PlayerHPMax, &s.EnemyHP, &s.EnemyHPMax,
|
&s.Round, &s.Phase, &s.PlayerHP, &s.PlayerHPMax, &s.EnemyHP, &s.EnemyHPMax,
|
||||||
&statusesJSON, &logJSON, &s.Status,
|
&statusesJSON, &logJSON, &s.Status,
|
||||||
&s.StartedAt, &s.LastActionAt, &s.ExpiresAt,
|
&s.StartedAt, &s.LastActionAt, &s.ExpiresAt, &s.rosterSize,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -377,17 +467,23 @@ func scanCombatSession(row scanner) (*CombatSession, error) {
|
|||||||
// saveCombatSession persists the mutable fields after a state-machine step.
|
// saveCombatSession persists the mutable fields after a state-machine step.
|
||||||
// session_id / user_id / run_id / encounter_id / enemy_id / *_hp_max are
|
// session_id / user_id / run_id / encounter_id / enemy_id / *_hp_max are
|
||||||
// immutable for a fight's lifetime and are not rewritten.
|
// 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 {
|
func saveCombatSession(s *CombatSession) error {
|
||||||
statusesJSON, _ := json.Marshal(s.Statuses)
|
statusesJSON, _ := json.Marshal(s.Statuses)
|
||||||
logJSON, _ := json.Marshal(s.TurnLog)
|
logJSON, _ := json.Marshal(s.TurnLog)
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
if _, err := db.Get().Exec(`
|
|
||||||
UPDATE combat_session
|
if len(s.Participants) == 0 {
|
||||||
SET round = ?, phase = ?,
|
if _, err := db.Get().Exec(saveCombatSessionSQL,
|
||||||
player_hp = ?, enemy_hp = ?,
|
|
||||||
statuses_json = ?, turn_log_json = ?,
|
|
||||||
status = ?, last_action_at = ?
|
|
||||||
WHERE session_id = ?`,
|
|
||||||
s.Round, s.Phase, s.PlayerHP, s.EnemyHP,
|
s.Round, s.Phase, s.PlayerHP, s.EnemyHP,
|
||||||
string(statusesJSON), string(logJSON), s.Status, now, s.SessionID,
|
string(statusesJSON), string(logJSON), s.Status, now, s.SessionID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -395,14 +491,44 @@ func saveCombatSession(s *CombatSession) error {
|
|||||||
}
|
}
|
||||||
s.LastActionAt = now
|
s.LastActionAt = now
|
||||||
return nil
|
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.
|
// listExpiredCombatSessions returns every active session past its expires_at.
|
||||||
// The timeout reaper (reapExpiredCombatSessions) auto-plays each of these to a
|
// 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
|
// 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.
|
// abandoned fight is finished by the engine, not flatly marked as a retreat.
|
||||||
func listExpiredCombatSessions() ([]*CombatSession, error) {
|
func listExpiredCombatSessions() ([]*CombatSession, error) {
|
||||||
rows, err := db.Get().Query(`SELECT `+combatSessionCols+`
|
rows, err := db.Get().Query(`SELECT ` + combatSessionCols + `
|
||||||
FROM combat_session
|
FROM combat_session
|
||||||
WHERE status = 'active'
|
WHERE status = 'active'
|
||||||
AND expires_at <= CURRENT_TIMESTAMP
|
AND expires_at <= CURRENT_TIMESTAMP
|
||||||
@@ -420,9 +546,162 @@ func listExpiredCombatSessions() ([]*CombatSession, error) {
|
|||||||
}
|
}
|
||||||
out = append(out, s)
|
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()
|
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
|
// markCombatSessionExpired is the fallback terminal outcome for a stale session
|
||||||
// the reaper cannot auto-play (zone run gone, enemy missing from the bestiary,
|
// 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'
|
// 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/…)
|
// onto the freshly-rebuilt player. The depleting one-shots (ward/spore/…)
|
||||||
// live on the session's Statuses and flow through the turn engine's
|
// 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.
|
// 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
|
return player, enemy, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// applySessionBuffs re-derives the persistent stat effect of every mid-fight
|
// applySessionBuffs re-derives the persistent stat effect of every mid-fight
|
||||||
// buff onto the rebuilt player. The buffs are stored as accumulated deltas
|
// buff onto one rebuilt character. The buffs are stored as accumulated deltas
|
||||||
// (diffTurnBuff produced them against the player's state at cast time), so
|
// (diffTurnBuff produced them against that character's state at cast time), so
|
||||||
// re-applying them to a deterministic rebuild reproduces the same totals every
|
// re-applying them to a deterministic rebuild reproduces the same totals every
|
||||||
// round without double-counting.
|
// round without double-counting. It takes ActorStatuses rather than the whole
|
||||||
func applySessionBuffs(player *Combatant, s CombatStatuses) {
|
// 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.AC += s.BuffACBonus
|
||||||
player.Stats.AttackBonus += s.BuffAtkBonus
|
player.Stats.AttackBonus += s.BuffAtkBonus
|
||||||
player.Stats.Speed += s.BuffSpeedBonus
|
player.Stats.Speed += s.BuffSpeedBonus
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ func TestStartCombatSession_RoundTrip(t *testing.T) {
|
|||||||
got.Phase = CombatPhaseRoundEnd
|
got.Phase = CombatPhaseRoundEnd
|
||||||
got.PlayerHP = 40
|
got.PlayerHP = 40
|
||||||
got.EnemyHP = 15
|
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})
|
got.TurnLog = append(got.TurnLog, CombatEvent{Round: 3, Actor: "player", Action: "hit", Damage: 9})
|
||||||
if err := saveCombatSession(got); err != nil {
|
if err := saveCombatSession(got); err != nil {
|
||||||
t.Fatalf("saveCombatSession: %v", err)
|
t.Fatalf("saveCombatSession: %v", err)
|
||||||
@@ -67,7 +67,7 @@ func TestStartCombatSession_RoundTrip(t *testing.T) {
|
|||||||
if reloaded.PlayerHP != 40 || reloaded.EnemyHP != 15 {
|
if reloaded.PlayerHP != 40 || reloaded.EnemyHP != 15 {
|
||||||
t.Errorf("hp not persisted: %+v", reloaded)
|
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)
|
t.Errorf("statuses not persisted: %+v", reloaded.Statuses)
|
||||||
}
|
}
|
||||||
if len(reloaded.TurnLog) != 1 || reloaded.TurnLog[0].Action != "hit" {
|
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) {
|
func TestTurnEngine_PoisonTickAtRoundEnd(t *testing.T) {
|
||||||
sess := turnSession(CombatPhaseRoundEnd, 50, 80)
|
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()
|
player, enemy := basePlayer(), baseEnemy()
|
||||||
|
|
||||||
events, err := stepEngine(sess, &player, &enemy, PlayerAction{})
|
events, err := stepEngine(sess, &player, &enemy, PlayerAction{})
|
||||||
@@ -246,7 +246,7 @@ func TestTurnEngine_PoisonTickAtRoundEnd(t *testing.T) {
|
|||||||
|
|
||||||
func TestTurnEngine_PoisonTickCanBeLethal(t *testing.T) {
|
func TestTurnEngine_PoisonTickCanBeLethal(t *testing.T) {
|
||||||
sess := turnSession(CombatPhaseRoundEnd, 4, 80)
|
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()
|
player, enemy := basePlayer(), baseEnemy()
|
||||||
|
|
||||||
if _, err := stepEngine(sess, &player, &enemy, PlayerAction{}); err != nil {
|
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) {
|
func TestTurnEngine_ConcentrationTickAtRoundEnd(t *testing.T) {
|
||||||
sess := turnSession(CombatPhaseRoundEnd, 50, 80)
|
sess := turnSession(CombatPhaseRoundEnd, 50, 80)
|
||||||
sess.Statuses = CombatStatuses{ConcentrationDmg: 12}
|
sess.Statuses = CombatStatuses{ActorStatuses: ActorStatuses{ConcentrationDmg: 12}}
|
||||||
player, enemy := basePlayer(), baseEnemy()
|
player, enemy := basePlayer(), baseEnemy()
|
||||||
|
|
||||||
events, err := stepEngine(sess, &player, &enemy, PlayerAction{})
|
events, err := stepEngine(sess, &player, &enemy, PlayerAction{})
|
||||||
@@ -288,7 +288,7 @@ func TestTurnEngine_ConcentrationTickAtRoundEnd(t *testing.T) {
|
|||||||
|
|
||||||
func TestTurnEngine_ConcentrationTickCanBeLethal(t *testing.T) {
|
func TestTurnEngine_ConcentrationTickCanBeLethal(t *testing.T) {
|
||||||
sess := turnSession(CombatPhaseRoundEnd, 50, 9)
|
sess := turnSession(CombatPhaseRoundEnd, 50, 9)
|
||||||
sess.Statuses = CombatStatuses{ConcentrationDmg: 12}
|
sess.Statuses = CombatStatuses{ActorStatuses: ActorStatuses{ConcentrationDmg: 12}}
|
||||||
player, enemy := basePlayer(), baseEnemy()
|
player, enemy := basePlayer(), baseEnemy()
|
||||||
|
|
||||||
if _, err := stepEngine(sess, &player, &enemy, PlayerAction{}); err != nil {
|
if _, err := stepEngine(sess, &player, &enemy, PlayerAction{}); err != nil {
|
||||||
@@ -433,7 +433,7 @@ func TestApplyBuffDelta(t *testing.T) {
|
|||||||
|
|
||||||
func TestApplySessionBuffs(t *testing.T) {
|
func TestApplySessionBuffs(t *testing.T) {
|
||||||
player := basePlayer() // AC 0, AttackBonus 0, CritRate 0.05, Speed 10, DamageReduct 1.0
|
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,
|
BuffACBonus: 3, BuffAtkBonus: 2, BuffSpeedBonus: 5, BuffCritRate: 0.15,
|
||||||
BuffDamageBonus: 0.25, BuffDamageReductMul: 0.8, BuffPetProc: 0.5, BuffPetDmg: 6,
|
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.
|
// re-bank a depleted ward charge — on every resumed round.
|
||||||
func TestTurnEngine_OneShotsRoundTrip(t *testing.T) {
|
func TestTurnEngine_OneShotsRoundTrip(t *testing.T) {
|
||||||
sess := turnSession(CombatPhaseRoundEnd, 50, 50)
|
sess := turnSession(CombatPhaseRoundEnd, 50, 50)
|
||||||
sess.Statuses = CombatStatuses{
|
sess.Statuses = CombatStatuses{ActorStatuses: ActorStatuses{
|
||||||
WardCharges: 3, SporeRounds: 2, ReflectFrac: 0.5, AutoCritFirst: true,
|
WardCharges: 3, SporeRounds: 2, ReflectFrac: 0.5, AutoCritFirst: true,
|
||||||
ArcaneWardHP: 10, HealChargesLeft: 1,
|
ArcaneWardHP: 10, HealChargesLeft: 1,
|
||||||
Raged: true, LuckyUsed: true, DeathSaveUsed: true, PendingRage: true,
|
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
|
// Buff stat deltas are owned by the command layer — commit must leave
|
||||||
// them untouched rather than zeroing them on every step.
|
// them untouched rather than zeroing them on every step.
|
||||||
BuffACBonus: 2, BuffDamageBonus: 0.15,
|
BuffACBonus: 2, BuffDamageBonus: 0.15,
|
||||||
}
|
}}
|
||||||
want := sess.Statuses // round_end with no poison mutates none of these
|
want := sess.Statuses // round_end with no poison mutates none of these
|
||||||
player, enemy := basePlayer(), baseEnemy()
|
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.
|
// resumeTurnEngine rebuilds the in-memory combatState from a persisted session.
|
||||||
// rng is the deterministic source for this step (see combatSessionStepRNG).
|
// rng is the deterministic source for this step (see combatSessionStepRNG).
|
||||||
//
|
//
|
||||||
// Seat 0 restores the session's persisted per-character statuses. Seats 1+ open
|
// restoreActor rebuilds one seat from its persisted state: live HP, the pool
|
||||||
// fresh from their combatant's modifiers: a party's per-member statuses need the
|
// ceiling, and the per-character effect set. Depleting resources and the
|
||||||
// combat_participant rows that P4 adds, and until then no persisted session ever
|
// once-per-fight "already used" flags are restored rather than rearmed, so a
|
||||||
// carries more than one seat.
|
// charge can't silently reset across a suspend/resume or a reaper auto-play.
|
||||||
func resumeTurnEngine(sess *CombatSession, players []*Combatant, enemy *Combatant, rng *rand.Rand) *turnEngine {
|
// snapshotActor is its exact inverse — keep the two field lists in step.
|
||||||
seat0 := &actor{
|
func restoreActor(c *Combatant, hp, hpMax int, s ActorStatuses) *actor {
|
||||||
c: players[0],
|
return &actor{
|
||||||
playerHP: sess.PlayerHP,
|
c: c,
|
||||||
hpMax: sess.PlayerHPMax,
|
playerHP: hp,
|
||||||
poisonTicks: sess.Statuses.PoisonTicks,
|
hpMax: hpMax,
|
||||||
poisonDmg: sess.Statuses.PoisonDmg,
|
|
||||||
stunPlayer: sess.Statuses.StunPlayer,
|
poisonTicks: s.PoisonTicks,
|
||||||
// Fight-scoped depleting resources + once-per-fight one-shots: restored
|
poisonDmg: s.PoisonDmg,
|
||||||
// from the persisted statuses so a charge or "already used" flag can't
|
stunPlayer: s.StunPlayer,
|
||||||
// reset across a suspend/resume. commit writes the updated values back.
|
|
||||||
wardCharges: sess.Statuses.WardCharges,
|
wardCharges: s.WardCharges,
|
||||||
sporeRounds: sess.Statuses.SporeRounds,
|
sporeRounds: s.SporeRounds,
|
||||||
reflectFrac: sess.Statuses.ReflectFrac,
|
reflectFrac: s.ReflectFrac,
|
||||||
autoCrit: sess.Statuses.AutoCritFirst,
|
autoCrit: s.AutoCritFirst,
|
||||||
arcaneWardHP: sess.Statuses.ArcaneWardHP,
|
arcaneWardHP: s.ArcaneWardHP,
|
||||||
healChargesLeft: sess.Statuses.HealChargesLeft,
|
healChargesLeft: s.HealChargesLeft,
|
||||||
concentrationDmg: sess.Statuses.ConcentrationDmg,
|
|
||||||
deathSaveUsed: sess.Statuses.DeathSaveUsed,
|
concentrationDmg: s.ConcentrationDmg,
|
||||||
luckyUsed: sess.Statuses.LuckyUsed,
|
|
||||||
raged: sess.Statuses.Raged,
|
deathSaveUsed: s.DeathSaveUsed,
|
||||||
pendingRageAttack: sess.Statuses.PendingRage,
|
luckyUsed: s.LuckyUsed,
|
||||||
firstAttackBonusUsed: sess.Statuses.FirstAtkBonusUsed,
|
raged: s.Raged,
|
||||||
assassinateRerollUsed: sess.Statuses.AssassinateReroll,
|
pendingRageAttack: s.PendingRage,
|
||||||
assassinateBonusUsed: sess.Statuses.AssassinateBonus,
|
firstAttackBonusUsed: s.FirstAtkBonusUsed,
|
||||||
|
assassinateRerollUsed: s.AssassinateReroll,
|
||||||
|
assassinateBonusUsed: s.AssassinateBonus,
|
||||||
|
|
||||||
// Enemy debuffs stacked onto this character specifically.
|
// Enemy debuffs stacked onto this character specifically.
|
||||||
playerAtkDrain: sess.Statuses.PlayerAtkDrain,
|
playerAtkDrain: s.PlayerAtkDrain,
|
||||||
playerACDebuff: sess.Statuses.PlayerACDebuff,
|
playerACDebuff: s.PlayerACDebuff,
|
||||||
maxHPDrain: sess.Statuses.MaxHPDrain,
|
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 := make([]*actor, len(players))
|
||||||
actors[0] = seat0
|
actors[0] = seat0
|
||||||
for i := 1; i < len(players); i++ {
|
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])
|
actors[i] = newActor(players[i])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p := sess.Participants[i-1]
|
||||||
|
actors[i] = restoreActor(players[i], p.HP, p.HPMax, p.Statuses)
|
||||||
}
|
}
|
||||||
st := &combatState{
|
st := &combatState{
|
||||||
actor: seat0,
|
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
|
// 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
|
// by the command layer (a !cast / !consume folds them in) and applied to the
|
||||||
// rebuilt combatant by applySessionBuffs — so commit mutates Statuses in place
|
// rebuilt combatant by applySessionBuffs — so snapshotActor carries them over
|
||||||
// rather than replacing it, leaving those deltas untouched.
|
// from the prior state rather than re-deriving them.
|
||||||
// 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
|
// Per-actor state is read off each seat by index, never off the cursor: the
|
||||||
// is the only member a session row can hold; P4's participant rows carry the
|
// enemy turn parks the cursor on its target and round_end walks it across the
|
||||||
// rest.
|
// 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() {
|
func (te *turnEngine) commit() {
|
||||||
st := te.st
|
st := te.st
|
||||||
a := st.actors[0]
|
|
||||||
te.sess.Round = st.round
|
te.sess.Round = st.round
|
||||||
te.sess.PlayerHP = a.playerHP
|
|
||||||
te.sess.EnemyHP = st.enemyHP
|
te.sess.EnemyHP = st.enemyHP
|
||||||
|
|
||||||
|
seat0 := st.actors[0]
|
||||||
|
te.sess.PlayerHP = seat0.playerHP
|
||||||
s := &te.sess.Statuses
|
s := &te.sess.Statuses
|
||||||
s.PoisonTicks = a.poisonTicks
|
s.ActorStatuses = snapshotActor(seat0, s.ActorStatuses)
|
||||||
s.PoisonDmg = a.poisonDmg
|
|
||||||
s.StunPlayer = a.stunPlayer
|
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.Enraged = st.enraged
|
||||||
s.ArmorBroken = st.armorBroken
|
s.ArmorBroken = st.armorBroken
|
||||||
s.ArmorBreakAmt = st.armorBreakAmt
|
s.ArmorBreakAmt = st.armorBreakAmt
|
||||||
s.EnemySkipNext = st.enemySkipFirst
|
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.EnemyEvadeNext = st.enemyEvadeNext
|
||||||
s.EnemyBlockUp = st.enemyBlockUp
|
s.EnemyBlockUp = st.enemyBlockUp
|
||||||
s.EnemyAdvantage = st.enemyAdvantage
|
s.EnemyAdvantage = st.enemyAdvantage
|
||||||
s.EnemyRetaliateFrac = st.enemyRetaliateFrac
|
s.EnemyRetaliateFrac = st.enemyRetaliateFrac
|
||||||
s.EnemyRegen = st.enemyRegen
|
s.EnemyRegen = st.enemyRegen
|
||||||
s.EnemySurviveArmed = st.enemySurviveArmed
|
s.EnemySurviveArmed = st.enemySurviveArmed
|
||||||
s.PlayerAtkDrain = a.playerAtkDrain
|
|
||||||
s.PlayerACDebuff = a.playerACDebuff
|
|
||||||
s.MaxHPDrain = a.maxHPDrain
|
|
||||||
s.EnemySpellResist = st.enemySpellResist
|
s.EnemySpellResist = st.enemySpellResist
|
||||||
s.EnemyRevealNext = st.enemyRevealNext
|
s.EnemyRevealNext = st.enemyRevealNext
|
||||||
s.EnemyFearImmune = st.enemyFearImmune
|
s.EnemyFearImmune = st.enemyFearImmune
|
||||||
s.EnemyAtkBuff = st.enemyAtkBuff
|
s.EnemyAtkBuff = st.enemyAtkBuff
|
||||||
|
|
||||||
te.sess.TurnLog = append(te.sess.TurnLog, st.events...)
|
te.sess.TurnLog = append(te.sess.TurnLog, st.events...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math/rand/v2"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"maunium.net/go/mautrix/id"
|
"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
|
// 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
|
// 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
|
// 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
|
// variance doesn't swamp the signal. At 6000 trials it still did: sweeping
|
||||||
// ~14 wins of difference on bad seeds vs. the 25-win threshold.
|
// 40 seeds, the mean margin was +127 wins but the worst was -42, against a
|
||||||
const trials = 6000
|
// +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{
|
hardEnemy := Combatant{
|
||||||
Name: "Wall",
|
Name: "Wall",
|
||||||
Stats: CombatStats{
|
Stats: CombatStats{
|
||||||
@@ -664,12 +668,17 @@ func TestSimulateCombat_FirstAttackBonusImprovesEarlyHits(t *testing.T) {
|
|||||||
},
|
},
|
||||||
Mods: CombatModifiers{DamageReduct: 1.0},
|
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
|
plainWins, bmWins := 0, 0
|
||||||
for i := 0; i < trials; i++ {
|
for i := 0; i < trials; i++ {
|
||||||
if SimulateCombat(plainBMPlayer(), hardEnemy, defaultCombatPhases).PlayerWon {
|
if simulateCombatWithRNG(plainBMPlayer(), hardEnemy, defaultCombatPhases, rng).PlayerWon {
|
||||||
plainWins++
|
plainWins++
|
||||||
}
|
}
|
||||||
if SimulateCombat(bmPrecisionPlayer(), hardEnemy, defaultCombatPhases).PlayerWon {
|
if simulateCombatWithRNG(bmPrecisionPlayer(), hardEnemy, defaultCombatPhases, rng).PlayerWon {
|
||||||
bmWins++
|
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.
|
// Surface check: AssassinateBonusDmg only consumed once.
|
||||||
func TestResolvePlayerAttack_AssassinateBonusFirstHitOnly(t *testing.T) {
|
func TestResolvePlayerAttack_AssassinateBonusFirstHitOnly(t *testing.T) {
|
||||||
// Drive resolvePlayerAttack via a SimulateCombat run and confirm the
|
// Drive resolvePlayerAttack via a SimulateCombat run and confirm the
|
||||||
// total enemy damage taken on hit is the *base* damage on subsequent
|
// total enemy damage taken on hit is the *base* damage on subsequent
|
||||||
// hits (i.e. only the first hit got the +bonus). Statistical compare
|
// hits (i.e. only the first hit got the +bonus). Statistical compare
|
||||||
// against an Assassin L5 vs. an identical player without the bonus.
|
// 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 {
|
build := func(bonus int) Combatant {
|
||||||
return Combatant{
|
return Combatant{
|
||||||
Name: "Assn", IsPlayer: true,
|
Name: "Assn", IsPlayer: true,
|
||||||
@@ -707,16 +728,18 @@ func TestResolvePlayerAttack_AssassinateBonusFirstHitOnly(t *testing.T) {
|
|||||||
Mods: CombatModifiers{DamageReduct: 1.0},
|
Mods: CombatModifiers{DamageReduct: 1.0},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Seeded for the same reason as the Precision Attack test above.
|
||||||
|
rng := statCompareRNG()
|
||||||
plainWins, assnWins := 0, 0
|
plainWins, assnWins := 0, 0
|
||||||
for i := 0; i < trials; i++ {
|
for i := 0; i < trials; i++ {
|
||||||
if SimulateCombat(build(0), enemy(), defaultCombatPhases).PlayerWon {
|
if simulateCombatWithRNG(build(0), enemy(), defaultCombatPhases, rng).PlayerWon {
|
||||||
plainWins++
|
plainWins++
|
||||||
}
|
}
|
||||||
if SimulateCombat(build(8), enemy(), defaultCombatPhases).PlayerWon {
|
if simulateCombatWithRNG(build(8), enemy(), defaultCombatPhases, rng).PlayerWon {
|
||||||
assnWins++
|
assnWins++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if assnWins <= plainWins {
|
if assnWins-plainWins < 25 {
|
||||||
t.Errorf("Assassinate bonus damage didn't help: plain=%d assn=%d", plainWins, assnWins)
|
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