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