mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
A. An armed ability lasted one round of a turn-based fight.
buildZoneCombatants called applyArmedAbility, which applies an ability's mods
*and* clears ArmedAbility and saves the sheet. The turn engine calls that
builder again on every !attack / !cast / !consume, so round 1 fired the ability
and disarmed the character, and every later round rebuilt them with none of its
mods. A Berserker paid stamina for a single round of BerserkerRage /
RageMeleeDmg / PhysicalResistRage / FrenzyDmgBonus. Every entry in
dndActiveAbilities had the same shape. mods.BerserkerRage was not merely unread
at close-out — by then it no longer existed.
Split arming into its two halves:
consumeArmedAbility(c) mutates: disarms, saves, returns the id. Once,
at fight start.
applyAbilityByID(c, id, mods) pure: no DB write, no disarm. Safe on every
rebuild. (No ability's Apply writes to the
character, so this really is pure.)
armAbilityForFight(c, mods) consume + apply, for the auto-resolve callers
that build and fight in one breath.
buildZoneCombatants now takes the already-consumed id and re-applies it. The id
rides on ActorStatuses.ArmedAbility, seeded per seat at fight start, so
partyCombatantsForSession reproduces the ability every rebuild and the close-out
can still see that a rage fired.
The close-out itself: postCombatBookkeeping now carries grantCombatAchievements
+ persistDnDPostCombatSubclass, and all four close-outs route through it —
runDungeonCombat, runZoneCombatRoster, finishCombatSession,
finishPartyCombatSession. It fires on every terminal status, not just a win: a
Berserker who rages and loses is still exhausted, which is what auto-resolve
always did.
Also: buildFightSeats and runZoneCombatRoster consumed the ability before the
checks that could sit a seat out, so a downed member was disarmed for a fight
they never joined. The refusals now run first.
B. Six unlocked read-modify-writes against the shared supply pool.
updateSupplies rewrites supplies_json wholesale, so a caller folding its delta
onto an *Expedition it read earlier discards whatever landed in between.
Handlers run one goroutine per event, so those writers genuinely interleave.
All six now go through withExpeditionSupplies, which takes advExpeditionLock,
re-reads the row, hands the closure the fresh copy and persists what it returns:
nightRolloverBurn (forage + burn in one write), grantTwoWeeksCache,
advanceToNextRegion's transit burn, campPitch, pitchAutopilotCamp, and the
ambient pack-rat drain. expeditionCmdAccept's hand-rolled lock folds onto the
same helper. expedition_sim.go is left alone: single-threaded, takes no locks.
Known consequence, for the balance track: trySimAutoArm used to live inside the
rebuild, so a simulated Fighter (second_wind) or Cleric (healing_word) re-armed
and re-spent a resource every round of every elite/boss fight. expedition-sim
drives those through the turn engine, so every prior expedition-sim corpus
overstates those two classes. Re-baseline after this, not before.
Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
295 lines
9.8 KiB
Go
295 lines
9.8 KiB
Go
package plugin
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// An armed ability is consumed once, at fight start, and its id is parked on the
|
|
// seat. Everything here defends that split.
|
|
//
|
|
// Before it existed, buildZoneCombatants consumed the ability itself — and the
|
|
// turn-based engine calls that builder again on every !attack. So a Berserker
|
|
// raged on round 1, the builder cleared their armed flag and saved, and round 2
|
|
// rebuilt them with no rage at all. They paid stamina for one round of a buff
|
|
// that is supposed to span the fight, and the close-out could not see the rage
|
|
// it was supposed to charge exhaustion for.
|
|
|
|
// ragingBerserker is a Fighter who has already `!arm`ed rage.
|
|
func ragingBerserker(t *testing.T, uid id.UserID) *DnDCharacter {
|
|
t.Helper()
|
|
if err := createAdvCharacter(uid, string(uid)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c := &DnDCharacter{
|
|
UserID: uid, Race: RaceHuman, Class: ClassFighter, Subclass: SubclassBerserker, Level: 5,
|
|
STR: 16, DEX: 12, CON: 14, INT: 10, WIS: 10, CHA: 10,
|
|
HPMax: 40, HPCurrent: 40, ArmorClass: 15,
|
|
ArmedAbility: "rage",
|
|
}
|
|
if err := SaveDnDCharacter(c); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return c
|
|
}
|
|
|
|
// ── the split ────────────────────────────────────────────────────────────────
|
|
|
|
// consumeArmedAbility disarms and reports; applyAbilityByID applies and does not
|
|
// disarm. Calling the second one twice must be indistinguishable from once.
|
|
func TestApplyAbilityByID_IsPureAndRepeatable(t *testing.T) {
|
|
setupEmptyTestDB(t)
|
|
c := ragingBerserker(t, "@pure:example.org")
|
|
|
|
armed := consumeArmedAbility(c)
|
|
if armed != "rage" {
|
|
t.Fatalf("consumeArmedAbility = %q, want rage", armed)
|
|
}
|
|
if c.ArmedAbility != "" {
|
|
t.Errorf("character still armed after consume: %q", c.ArmedAbility)
|
|
}
|
|
if again := consumeArmedAbility(c); again != "" {
|
|
t.Errorf("second consume returned %q, want \"\" — the ability is spent", again)
|
|
}
|
|
|
|
// Same id, applied to two independent mod sets, yields the same rage.
|
|
for i, want := range []bool{true, true} {
|
|
var mods CombatModifiers
|
|
name, fired := applyAbilityByID(c, armed, &mods)
|
|
if !fired || name != "Rage" {
|
|
t.Fatalf("apply #%d: fired=%v name=%q", i+1, fired, name)
|
|
}
|
|
if mods.BerserkerRage != want {
|
|
t.Errorf("apply #%d: BerserkerRage = %v, want %v", i+1, mods.BerserkerRage, want)
|
|
}
|
|
if mods.RageMeleeDmg != 2 || !mods.PhysicalResistRage {
|
|
t.Errorf("apply #%d: rage did not carry its full mod set: %+v", i+1, mods)
|
|
}
|
|
}
|
|
|
|
// And it never writes back to the sheet.
|
|
got, _ := LoadDnDCharacter(c.UserID)
|
|
if got.ArmedAbility != "" {
|
|
t.Errorf("applyAbilityByID re-armed the sheet: %q", got.ArmedAbility)
|
|
}
|
|
}
|
|
|
|
func TestApplyAbilityByID_UnknownAndEmptyAreNoOps(t *testing.T) {
|
|
c := &DnDCharacter{Class: ClassFighter}
|
|
for _, id := range []string{"", "no_such_ability"} {
|
|
var mods CombatModifiers
|
|
if _, fired := applyAbilityByID(c, id, &mods); fired {
|
|
t.Errorf("applyAbilityByID(%q) fired", id)
|
|
}
|
|
if mods.BerserkerRage {
|
|
t.Errorf("applyAbilityByID(%q) set rage", id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── the fight-start seat ─────────────────────────────────────────────────────
|
|
|
|
// The seat carries the id forward, and the sheet is disarmed exactly once.
|
|
func TestBuildFightSeats_ConsumesTheAbilityOnceAndCarriesItOnTheSeat(t *testing.T) {
|
|
setupEmptyTestDB(t)
|
|
uid := id.UserID("@berserk:example.org")
|
|
ragingBerserker(t, uid)
|
|
|
|
seats, _, _, refusal := (&AdventurePlugin{}).buildFightSeats(
|
|
uid, []id.UserID{uid}, dndBestiary["goblin"], 1, 0)
|
|
if refusal != "" {
|
|
t.Fatalf("fight refused: %s", refusal)
|
|
}
|
|
if len(seats) != 1 {
|
|
t.Fatalf("seats = %d, want 1", len(seats))
|
|
}
|
|
if seats[0].ArmedAbility != "rage" {
|
|
t.Errorf("seat.ArmedAbility = %q, want rage", seats[0].ArmedAbility)
|
|
}
|
|
if !seats[0].Mods.BerserkerRage {
|
|
t.Error("seat built without rage in its mods")
|
|
}
|
|
got, _ := LoadDnDCharacter(uid)
|
|
if got.ArmedAbility != "" {
|
|
t.Errorf("sheet still armed after fight start: %q", got.ArmedAbility)
|
|
}
|
|
}
|
|
|
|
// The regression itself: rebuilding the combatant — what every !attack does —
|
|
// must reproduce the rage from the persisted id, not from the (now cleared)
|
|
// sheet. Two rebuilds in a row, both raging.
|
|
func TestBuildZoneCombatants_RebuildKeepsTheRageForTheWholeFight(t *testing.T) {
|
|
setupEmptyTestDB(t)
|
|
uid := id.UserID("@rebuild:example.org")
|
|
ragingBerserker(t, uid)
|
|
p := &AdventurePlugin{}
|
|
|
|
seats, _, _, refusal := p.buildFightSeats(uid, []id.UserID{uid}, dndBestiary["goblin"], 1, 0)
|
|
if refusal != "" {
|
|
t.Fatalf("fight refused: %s", refusal)
|
|
}
|
|
armed := seats[0].ArmedAbility
|
|
|
|
for round := 2; round <= 3; round++ {
|
|
player, _, _, err := p.buildZoneCombatants(uid, dndBestiary["goblin"], 1, 0, armed)
|
|
if err != nil {
|
|
t.Fatalf("round %d rebuild: %v", round, err)
|
|
}
|
|
if !player.Mods.BerserkerRage {
|
|
t.Errorf("round %d: rage evaporated on rebuild", round)
|
|
}
|
|
if player.Mods.RageMeleeDmg != 2 {
|
|
t.Errorf("round %d: RageMeleeDmg = %d, want 2", round, player.Mods.RageMeleeDmg)
|
|
}
|
|
}
|
|
|
|
// A seat that armed nothing rebuilds without rage — the id is the only source.
|
|
player, _, _, err := p.buildZoneCombatants(uid, dndBestiary["goblin"], 1, 0, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if player.Mods.BerserkerRage {
|
|
t.Error("unarmed rebuild raged anyway")
|
|
}
|
|
}
|
|
|
|
// A member who is down sits the fight out. They must not be charged the ability
|
|
// they had readied for it — the refusal is checked before the arm.
|
|
func TestBuildFightSeats_SatOutMemberKeepsTheirArmedAbility(t *testing.T) {
|
|
setupEmptyTestDB(t)
|
|
leader := id.UserID("@lead:example.org")
|
|
downed := id.UserID("@downed:example.org")
|
|
fightTestChar(t, leader, 30)
|
|
ragingBerserker(t, downed)
|
|
|
|
// Drop the member to 0 HP with rage still armed.
|
|
c, _ := LoadDnDCharacter(downed)
|
|
c.HPCurrent = 0
|
|
if err := SaveDnDCharacter(c); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
seats, _, _, refusal := (&AdventurePlugin{}).buildFightSeats(
|
|
leader, []id.UserID{leader, downed}, dndBestiary["goblin"], 1, 0)
|
|
if refusal != "" {
|
|
t.Fatalf("fight refused: %s", refusal)
|
|
}
|
|
if len(seats) != 1 || seats[0].UserID != leader {
|
|
t.Fatalf("seats = %+v, want the leader alone", seats)
|
|
}
|
|
got, _ := LoadDnDCharacter(downed)
|
|
if got.ArmedAbility != "rage" {
|
|
t.Errorf("downed member was disarmed for a fight they never joined: %q", got.ArmedAbility)
|
|
}
|
|
}
|
|
|
|
// ── the close-out ────────────────────────────────────────────────────────────
|
|
|
|
func TestSeatFightStartMods_ReadsTheRageOffTheSeatsStatuses(t *testing.T) {
|
|
c := &DnDCharacter{Class: ClassFighter, Subclass: SubclassBerserker, Level: 5}
|
|
|
|
raging := &CombatSession{}
|
|
raging.Statuses.ArmedAbility = "rage"
|
|
if !seatFightStartMods(raging, 0, c).BerserkerRage {
|
|
t.Error("seat 0 armed rage, mods say otherwise")
|
|
}
|
|
|
|
// Seat 1 reads its own statuses, not the leader's.
|
|
party := &CombatSession{Participants: []CombatParticipant{{Seat: 1}}}
|
|
party.Statuses.ArmedAbility = "rage"
|
|
if seatFightStartMods(party, 1, c).BerserkerRage {
|
|
t.Error("a member inherited the leader's rage")
|
|
}
|
|
}
|
|
|
|
func TestSeatCombatResult_ReadsWinAndNearDeathOffTheSession(t *testing.T) {
|
|
sess := &CombatSession{
|
|
Status: CombatStatusWon, Round: 4, PlayerHP: 5, PlayerHPMax: 40, EnemyHP: 0,
|
|
TurnLog: []CombatEvent{
|
|
{Action: "attack", Seat: 0},
|
|
{Action: "use_consumable", Seat: 1},
|
|
},
|
|
}
|
|
got := seatCombatResult(sess, 0)
|
|
if !got.PlayerWon {
|
|
t.Error("PlayerWon = false on a won session")
|
|
}
|
|
if !got.NearDeath {
|
|
t.Error("5/40 HP is under the 15% near-death line")
|
|
}
|
|
if len(got.Events) != 1 || got.Events[0].Action != "attack" {
|
|
t.Errorf("seat 0 got seat 1's events: %+v", got.Events)
|
|
}
|
|
|
|
sess.PlayerHP = 30
|
|
if seatCombatResult(sess, 0).NearDeath {
|
|
t.Error("30/40 HP is not near death")
|
|
}
|
|
sess.Status = CombatStatusLost
|
|
sess.PlayerHP = 0
|
|
if seatCombatResult(sess, 0).PlayerWon {
|
|
t.Error("PlayerWon = true on a lost session")
|
|
}
|
|
}
|
|
|
|
// Item A: the manual kill now costs the Berserker their exhaustion, exactly as
|
|
// the auto-resolved one always has.
|
|
func TestPostCombatBookkeepingForSeat_RageCostsExhaustionOnTheTurnBasedPath(t *testing.T) {
|
|
setupEmptyTestDB(t)
|
|
uid := id.UserID("@exhausted:example.org")
|
|
ragingBerserker(t, uid)
|
|
|
|
sess := &CombatSession{
|
|
UserID: string(uid), Status: CombatStatusWon, Round: 3,
|
|
PlayerHP: 12, PlayerHPMax: 40,
|
|
}
|
|
sess.Statuses.ArmedAbility = "rage"
|
|
|
|
(&AdventurePlugin{}).postCombatBookkeepingForSeat(sess, 0)
|
|
|
|
got, _ := LoadDnDCharacter(uid)
|
|
if got.Exhaustion != 1 {
|
|
t.Errorf("Exhaustion = %d after a raging win, want 1", got.Exhaustion)
|
|
}
|
|
}
|
|
|
|
// A fight nobody raged in leaves the sheet alone.
|
|
func TestPostCombatBookkeepingForSeat_NoRageNoExhaustion(t *testing.T) {
|
|
setupEmptyTestDB(t)
|
|
uid := id.UserID("@calm:example.org")
|
|
fightTestChar(t, uid, 30)
|
|
|
|
sess := &CombatSession{
|
|
UserID: string(uid), Status: CombatStatusWon, Round: 2,
|
|
PlayerHP: 20, PlayerHPMax: 30,
|
|
}
|
|
(&AdventurePlugin{}).postCombatBookkeepingForSeat(sess, 0)
|
|
|
|
got, _ := LoadDnDCharacter(uid)
|
|
if got.Exhaustion != 0 {
|
|
t.Errorf("Exhaustion = %d with no rage, want 0", got.Exhaustion)
|
|
}
|
|
}
|
|
|
|
// Losing while raging still exhausts you — the close-out runs on every terminal
|
|
// status, not just the win. This is the half the turn-based path used to skip.
|
|
func TestPostCombatBookkeepingForSeat_RageCostsExhaustionOnALoss(t *testing.T) {
|
|
setupEmptyTestDB(t)
|
|
uid := id.UserID("@dead:example.org")
|
|
ragingBerserker(t, uid)
|
|
|
|
sess := &CombatSession{
|
|
UserID: string(uid), Status: CombatStatusLost, Round: 6,
|
|
PlayerHP: 0, PlayerHPMax: 40,
|
|
}
|
|
sess.Statuses.ArmedAbility = "rage"
|
|
|
|
(&AdventurePlugin{}).postCombatBookkeepingForSeat(sess, 0)
|
|
|
|
got, _ := LoadDnDCharacter(uid)
|
|
if got.Exhaustion != 1 {
|
|
t.Errorf("Exhaustion = %d after a raging loss, want 1", got.Exhaustion)
|
|
}
|
|
}
|