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

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

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

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

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

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

The combat characterization golden is byte-identical.

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

View File

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