Files
gogobee/internal/plugin/combat_grim_harvest_turn_test.go
prosolis c34e740008 Review follow-up M: Grim Harvest fires in turn-based combat
The doc's item M claimed all three mage subclass spell hooks were dead on the
turn path because applyMageSubclassSpellHooks had one caller. It has three.
resolveTurnSpell has called it since 5cd343a, so Empowered Evocation and
Overchannel always worked -- they only move mods.SpellPreDamage, which
resolveTurnSpell returns as EnemyDamage.

Grim Harvest was the real defect, with a narrower cause: the hook wrote
mods.GrimHarvestSlot into a local CombatModifiers that resolveTurnSpell
discarded, because turnSpellOutcome had no field to carry it out. A Necromancy
Mage who killed with a spell in a manual fight never healed.

The stash can't ride on fight-start mods the way auto-resolve's does -- the
spell is cast mid-fight and the turn engine rebuilds combatants every round --
so it rides on the casting seat's ActorStatuses, like ArmedAbility. Each
damaging cast overwrites it; snapshotActor carries it across commit().

grimHarvestHeal also scanned for the *first* spell_cast event to ask whether
the spell landed the killing blow. Auto-resolve casts once, pre-combat, so
first == last there. A turn-based mage casts every round, so a non-lethal
opening cantrip vetoed the heal the killing spell had earned. Now scans for the
last spell_cast -- provably identical on the auto-resolve path, so the golden
corpus does not move.

Balance: a caster buff on the manual surface only, and the one the subclass was
written to have. Auto-resolve already paid it out.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 08:25:56 -07:00

233 lines
9.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugin
import (
"testing"
"maunium.net/go/mautrix/id"
)
// Grim Harvest on the turn-based surface.
//
// The auto-resolve path stashes the killing spell's slot level on the
// fight-start CombatModifiers, where the close-out reads it. A turn-based fight
// has nowhere to keep that: it rebuilds its combatants from the session row on
// every !attack / !cast. resolveTurnSpell computed the stash into a local mod
// set and dropped it on the floor, so a Necromancy Mage who killed with a spell
// never healed — on a class that already trails.
//
// The stash now rides on the casting seat's ActorStatuses, and the close-out
// asks whether the *last* spell_cast is the one that dropped the enemy to 0.
//
// (Empowered Evocation and Overchannel were never broken here: they only move
// mods.SpellPreDamage, which resolveTurnSpell already returns as EnemyDamage.)
// necromancer is a Mage who has reached L5 Grim Harvest.
func necromancer(t *testing.T, uid id.UserID, level int) *DnDCharacter {
t.Helper()
if err := createAdvCharacter(uid, string(uid)); err != nil {
t.Fatal(err)
}
c := &DnDCharacter{
UserID: uid, Race: RaceHuman, Class: ClassMage, Subclass: SubclassNecromancy, Level: level,
STR: 8, DEX: 12, CON: 12, INT: 16, WIS: 10, CHA: 10,
HPMax: 30, HPCurrent: 10, ArmorClass: 12,
}
if err := SaveDnDCharacter(c); err != nil {
t.Fatal(err)
}
return c
}
// tough is a stat block a spell can be cast at without any of it mattering.
func tough() CombatStats { return CombatStats{MaxHP: 40, AC: 10} }
// ── the leak itself ──────────────────────────────────────────────────────────
// resolveTurnSpell must hand the stash back to its caller. Before this, the
// slot level lived and died inside the function.
func TestResolveTurnSpell_CarriesTheGrimHarvestStashOut(t *testing.T) {
c := &DnDCharacter{Class: ClassMage, Subclass: SubclassNecromancy, Level: 5, INT: 16}
// magic_missile: auto-hit, force damage, upcast to a 3rd-level slot.
spell, _ := lookupSpell("magic_missile")
enemy := tough()
out, ok := resolveTurnSpell(c, spell, 3, &enemy)
if !ok {
t.Fatal("magic_missile should be a supported turn spell")
}
if out.GrimHarvestSlot != 3 {
t.Errorf("GrimHarvestSlot = %d, want 3 (the slot it was cast at)", out.GrimHarvestSlot)
}
if out.GrimHarvestNecrotic {
t.Error("magic_missile deals force damage — Necrotic should be false")
}
// blight: auto-hit, necrotic — the flag that triples the heal.
spell, _ = lookupSpell("blight")
enemy = tough()
out, _ = resolveTurnSpell(c, spell, 4, &enemy)
if out.GrimHarvestSlot != 4 || !out.GrimHarvestNecrotic {
t.Errorf("blight L4: slot=%d necrotic=%v, want 4/true", out.GrimHarvestSlot, out.GrimHarvestNecrotic)
}
}
// Nobody else stashes. A Mage below L5 has not learned the harvest, and an
// Evocation Mage never will.
func TestResolveTurnSpell_NoStashForAnyoneElse(t *testing.T) {
spell, _ := lookupSpell("magic_missile")
for _, c := range []*DnDCharacter{
{Class: ClassMage, Subclass: SubclassNecromancy, Level: 4, INT: 16},
{Class: ClassMage, Subclass: SubclassEvocation, Level: 12, INT: 16},
{Class: ClassSorcerer, Level: 12, CHA: 16},
} {
enemy := tough()
out, _ := resolveTurnSpell(c, spell, 3, &enemy)
if out.GrimHarvestSlot != 0 {
t.Errorf("%s/%s L%d stashed slot %d, want 0",
c.Class, c.Subclass, c.Level, out.GrimHarvestSlot)
}
}
}
// ── which cast counts ────────────────────────────────────────────────────────
// The killing-blow check used to break on the first spell_cast it saw. In a
// turn-based fight the mage casts every round, so the first one is an opening
// cantrip that left the enemy standing — and it vetoed the heal the killing
// spell had earned. It is the last cast that has to be lethal.
func TestGrimHarvestHeal_ReadsTheLastCastNotTheFirst(t *testing.T) {
c := &DnDCharacter{Class: ClassMage, Subclass: SubclassNecromancy, Level: 5, INT: 16}
mods := CombatModifiers{GrimHarvestSlot: 2, GrimHarvestNecrotic: true}
result := CombatResult{PlayerWon: true, Events: []CombatEvent{
{Round: 1, Action: "spell_cast", EnemyHP: 22}, // opening cantrip, enemy stands
{Round: 2, Action: "enemy_attack", EnemyHP: 22},
{Round: 2, Action: "spell_cast", EnemyHP: 0}, // this one killed it
}}
if got := grimHarvestHeal(c, result, mods); got != 6 {
t.Errorf("heal = %d, want 6 (3× a 2nd-level necrotic slot)", got)
}
}
// The mirror: the mage softened it up with a spell, then finished it with a
// weapon. No spell landed the blow, so no harvest.
func TestGrimHarvestHeal_WeaponKillAfterASpellDoesNotHarvest(t *testing.T) {
c := &DnDCharacter{Class: ClassMage, Subclass: SubclassNecromancy, Level: 5, INT: 16}
mods := CombatModifiers{GrimHarvestSlot: 2, GrimHarvestNecrotic: true}
result := CombatResult{PlayerWon: true, Events: []CombatEvent{
{Round: 1, Action: "spell_cast", EnemyHP: 9},
{Round: 2, Action: "player_attack", EnemyHP: 0},
}}
if got := grimHarvestHeal(c, result, mods); got != 0 {
t.Errorf("heal = %d, want 0 — the sword landed the blow, not the spell", got)
}
}
// ── the seam through the session ─────────────────────────────────────────────
// The stash has to survive the round-trip the fight puts it through: parked on
// the seat by the cast, carried across commit()'s snapshot, read back by the
// close-out.
func TestSeatFightStartMods_ReadsTheStashOffTheSeat(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@seatstash:example.org")
c := necromancer(t, uid, 5)
sess := &CombatSession{UserID: string(uid), Status: CombatStatusWon}
sess.Statuses.GrimHarvestSlot = 3
sess.Statuses.GrimHarvestNecrotic = true
mods := seatFightStartMods(sess, 0, c)
if mods.GrimHarvestSlot != 3 || !mods.GrimHarvestNecrotic {
t.Fatalf("seatFightStartMods lost the stash: %+v", mods)
}
// snapshotActor rebuilds ActorStatuses from combatState each commit; fields
// with no combatState counterpart must be carried over from the prior
// snapshot, the way ArmedAbility is.
kept := snapshotActor(&actor{}, sess.Statuses.ActorStatuses)
if kept.GrimHarvestSlot != 3 || !kept.GrimHarvestNecrotic {
t.Errorf("commit() dropped the stash: %+v", kept)
}
}
// End to end at the close-out: a necromancer who ended the fight at 10/30 HP
// with a lethal 2nd-level necrotic spell walks away with 6 HP back.
func TestPostCombatBookkeepingForSeat_GrimHarvestHealsOnASpellKill(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@harvest:example.org")
necromancer(t, uid, 5)
sess := &CombatSession{
UserID: string(uid), Status: CombatStatusWon, Round: 3,
PlayerHP: 10, PlayerHPMax: 30, EnemyHP: 0,
TurnLog: []CombatEvent{
{Round: 1, Action: "spell_cast", EnemyHP: 14},
{Round: 3, Action: "spell_cast", EnemyHP: 0},
},
}
sess.Statuses.GrimHarvestSlot = 2
sess.Statuses.GrimHarvestNecrotic = true
(&AdventurePlugin{}).postCombatBookkeepingForSeat(sess, 0)
got, _ := LoadDnDCharacter(uid)
if got.HPCurrent != 16 {
t.Errorf("HPCurrent = %d after a turn-based spell kill, want 16 (10 + 3×2)", got.HPCurrent)
}
}
// A seat with no stash — the mage never cast, or missed every time — is left
// exactly as the fight left them.
func TestPostCombatBookkeepingForSeat_NoStashNoHeal(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@nostash:example.org")
necromancer(t, uid, 5)
sess := &CombatSession{
UserID: string(uid), Status: CombatStatusWon, Round: 2,
PlayerHP: 10, PlayerHPMax: 30, EnemyHP: 0,
TurnLog: []CombatEvent{{Round: 2, Action: "player_attack", EnemyHP: 0}},
}
(&AdventurePlugin{}).postCombatBookkeepingForSeat(sess, 0)
got, _ := LoadDnDCharacter(uid)
if got.HPCurrent != 10 {
t.Errorf("HPCurrent = %d, want 10 — nothing was stashed", got.HPCurrent)
}
}
// Party fights: the stash is per-seat, so seat 1's harvest cannot heal seat 0.
// This is the same class of bug P5 fixed for mid-fight buffs.
func TestGrimHarvestStash_IsPerSeat(t *testing.T) {
setupEmptyTestDB(t)
leader, member := id.UserID("@leader:example.org"), id.UserID("@member:example.org")
fightTestChar(t, leader, 30)
necromancer(t, member, 5)
sess := &CombatSession{
UserID: string(leader), Status: CombatStatusWon, Round: 2,
PlayerHP: 20, PlayerHPMax: 30, EnemyHP: 0,
Participants: []CombatParticipant{{Seat: 1, UserID: string(member), HP: 10, HPMax: 30}},
TurnLog: []CombatEvent{
{Round: 2, Seat: 1, Action: "spell_cast", EnemyHP: 0},
},
}
sess.Participants[0].Statuses.GrimHarvestSlot = 2
sess.Participants[0].Statuses.GrimHarvestNecrotic = true
p := &AdventurePlugin{}
p.postCombatBookkeepingForSeat(sess, 0)
p.postCombatBookkeepingForSeat(sess, 1)
gotLeader, _ := LoadDnDCharacter(leader)
if gotLeader.HPCurrent != 30 {
t.Errorf("leader HPCurrent = %d, want 30 — the member's harvest is not theirs", gotLeader.HPCurrent)
}
gotMember, _ := LoadDnDCharacter(member)
if gotMember.HPCurrent != 16 {
t.Errorf("member HPCurrent = %d, want 16 (10 + 3×2)", gotMember.HPCurrent)
}
}