mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 00:52:40 +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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user