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) } }