Files
gogobee/internal/plugin/combat_armed_ability_test.go
prosolis fc9e055083 adventure: T6 postgame Layer-2 seam + Aurvandryx's Greed Tax (P8)
Add the pre-combat boss-hook seam for Tier-6 postgame bosses and the first
bespoke mechanic on it.

Seam: applyBossRunModifiers(bossID, enemy, run) in postgame_boss_hooks.go —
a pure, idempotent, bestiary-ID-dispatched hook that folds run-state-derived
adjustments into the freshly-built boss Combatant. No-op for every non-hooked
enemy or nil run. The turn engine (which resolves both prod bosses and the
sim) rebuilds the enemy every round via partyCombatantsForSession, so the hook
must be a pure function of run state — fine at a terminal boss room, where the
route is frozen. Wired at both enemy-finalization points: the per-round rebuild
and buildFightSeats' initial HP persist (threaded a run param; the caller
already had it).

Greed Tax (boss_aurvandryx / first_hoard): her Attack rises with the richness
of the route walked to reach her — the summed excess LootBias (>1.0) over the
run's visited nodes. Note run.LootCollected is the wrong signal (BossOnly
signature manifest, empty at the boss); the gilded veins on the graph are.
Attack += min(2.0*richness, 12). Same-binary A/B sweep (n=150, L20 party +
Pete + pets): taxless 66.7% -> taxed 48.0%, landing first_hoard mid-band.

Claude-Session: https://claude.ai/code/session_0156WqjgsbmSY2U8eQ3Kkb1s
2026-07-16 07:44:10 -07:00

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