adventure: T6 Amendment (Custodian) + in-combat round-end boss seam (P8)

First in-combat Layer-2 mechanic. applyBossInCombatRoundEnd is a new
round-boundary seam called from the turn engine's stepRoundEnd, the
counterpart to the pre-combat applyBossRunModifiers. The Custodian snapshots
its HP at end of round 3 and rewinds to it once on the phase-2 crossing
(refunding front-loaded burst); a soft midnight timer past round 20 climbs
its Attack via the existing enemyAtkBuff. New state (EnemyRewindHP/Used)
round-trips through CombatStatuses so a suspend/resume can't replay the
rewind. Sim A/B (n=120 L20 party): last_meridian fighter 65->37.5% clear,
a clean -27.5pp swing attributable to the mechanic; shipped as-is per the
opt-in-endgame difficulty call.

Claude-Session: https://claude.ai/code/session_0156WqjgsbmSY2U8eQ3Kkb1s
This commit is contained in:
prosolis
2026-07-16 09:20:29 -07:00
parent db13ed75b9
commit 189a44e1eb
6 changed files with 323 additions and 12 deletions

View File

@@ -199,3 +199,168 @@ func approxEq(a, b float64) bool {
d := a - b
return d < 1e-9 && d > -1e-9
}
// custodianState builds a live combatState seated against a Custodian-shaped
// enemy (MaxHP given), at the given round and current enemy HP, for direct
// applyAmendment tests. Uses the real turn engine so the embedded actor/roster
// are fully formed.
func custodianState(t *testing.T, round, enemyHP, enemyMaxHP int) *combatState {
t.Helper()
sess := turnSession(CombatPhaseRoundEnd, 10000, enemyHP)
sess.Round = round
sess.EnemyID = "boss_custodian"
p := basePlayer()
e := baseEnemy()
e.Stats.MaxHP = enemyMaxHP
te := resumeTurnEngine(sess, []*Combatant{&p}, &e, combatSessionStepRNG(sess, enemySeat))
te.st.enemyHP = enemyHP
return te.st
}
func countEvents(events []CombatEvent, action string) int {
n := 0
for _, ev := range events {
if ev.Action == action {
n++
}
}
return n
}
func TestApplyAmendment_SnapshotAtRoundThree(t *testing.T) {
// Before round 3: no snapshot.
st := custodianState(t, 2, 400, 500)
applyAmendment(st, 500)
if st.enemyRewindHP != 0 {
t.Errorf("round 2 snapshot = %d, want 0 (not yet)", st.enemyRewindHP)
}
// End of round 3: snapshot the current HP.
st = custodianState(t, 3, 380, 500)
applyAmendment(st, 500)
if st.enemyRewindHP != 380 {
t.Errorf("round 3 snapshot = %d, want 380", st.enemyRewindHP)
}
// A later round does not re-snapshot over the captured value.
st.round = 5
st.enemyHP = 200
applyAmendment(st, 500)
if st.enemyRewindHP != 380 {
t.Errorf("post-capture snapshot = %d, want it frozen at 380", st.enemyRewindHP)
}
}
func TestApplyAmendment_RewindOnceAtPhaseTwo(t *testing.T) {
// Snapshot 400; boss now at 200, below the 0.45*500=225 phase-two line.
st := custodianState(t, 6, 200, 500)
st.enemyRewindHP = 400
applyAmendment(st, 500)
if st.enemyHP != 400 {
t.Errorf("post-rewind HP = %d, want restored to snapshot 400", st.enemyHP)
}
if !st.enemyRewindUsed {
t.Error("rewind did not mark itself used")
}
if countEvents(st.events, "amendment_rewind") != 1 {
t.Errorf("amendment_rewind events = %d, want 1", countEvents(st.events, "amendment_rewind"))
}
// A second crossing does not rewind again.
st.enemyHP = 150
mark := len(st.events)
applyAmendment(st, 500)
if st.enemyHP != 150 {
t.Errorf("second-crossing HP = %d, want left at 150 (rewind spent)", st.enemyHP)
}
if len(st.events) != mark {
t.Error("a spent rewind emitted another event")
}
}
func TestApplyAmendment_NoRewindAbovePhaseTwo(t *testing.T) {
// Boss above the phase-two line: no rewind even with a snapshot in hand.
st := custodianState(t, 6, 300, 500) // 300 > 225
st.enemyRewindHP = 450
applyAmendment(st, 500)
if st.enemyHP != 300 || st.enemyRewindUsed {
t.Errorf("HP=%d used=%v; want no rewind above phase two", st.enemyHP, st.enemyRewindUsed)
}
}
func TestApplyAmendment_NoRewindWithoutSnapshot(t *testing.T) {
// Bursted into phase two before round 3: no snapshot, so no free heal.
st := custodianState(t, 2, 100, 500)
applyAmendment(st, 500)
if st.enemyHP != 100 || st.enemyRewindUsed {
t.Errorf("HP=%d used=%v; want no rewind without a snapshot", st.enemyHP, st.enemyRewindUsed)
}
}
func TestApplyAmendment_MidnightTimer(t *testing.T) {
// Before round 20: no attack climb.
st := custodianState(t, 19, 300, 500)
applyAmendment(st, 500)
if st.enemyAtkBuff != 0 {
t.Errorf("round 19 atk buff = %d, want 0", st.enemyAtkBuff)
}
// Round 20+: +2 per round, capped.
st = custodianState(t, 20, 300, 500)
applyAmendment(st, 500)
if st.enemyAtkBuff != midnightAtkStep {
t.Errorf("round 20 atk buff = %d, want %d", st.enemyAtkBuff, midnightAtkStep)
}
if countEvents(st.events, "midnight_toll") != 1 {
t.Errorf("midnight_toll events = %d, want 1", countEvents(st.events, "midnight_toll"))
}
// The cap holds against a truly endless stall.
st.enemyAtkBuff = midnightAtkCap
mark := len(st.events)
applyAmendment(st, 500)
if st.enemyAtkBuff != midnightAtkCap {
t.Errorf("capped atk buff = %d, want %d", st.enemyAtkBuff, midnightAtkCap)
}
if len(st.events) != mark {
t.Error("midnight timer emitted a toll after hitting the cap")
}
}
func TestApplyBossInCombatRoundEnd_DispatchesOnlyCustodian(t *testing.T) {
// A non-Custodian enemy is untouched even at a rewind-eligible HP.
st := custodianState(t, 6, 200, 500)
st.enemyRewindHP = 400
applyBossInCombatRoundEnd(st, "boss_seraphel", 500)
if st.enemyHP != 200 || st.enemyRewindUsed {
t.Errorf("HP=%d used=%v; want no-op for a non-Custodian boss", st.enemyHP, st.enemyRewindUsed)
}
// The Custodian id does resolve the hook.
applyBossInCombatRoundEnd(st, "boss_custodian", 500)
if st.enemyHP != 400 || !st.enemyRewindUsed {
t.Errorf("HP=%d used=%v; want the rewind for the Custodian", st.enemyHP, st.enemyRewindUsed)
}
}
func TestTurnEngine_AmendmentRoundTrips(t *testing.T) {
// Drive a real Custodian round-end and confirm the once-only rewind is
// captured through CombatStatuses (a suspend/resume can't replay it).
sess := turnSession(CombatPhaseRoundEnd, 10000, 200)
sess.Round = 6
sess.EnemyID = "boss_custodian"
sess.EnemyHPMax = 500
sess.Statuses.EnemyRewindHP = 400 // snapshot taken earlier in the fight
p := basePlayer()
e := baseEnemy()
e.Stats.MaxHP = 500
te := resumeTurnEngine(sess, []*Combatant{&p}, &e, combatSessionStepRNG(sess, enemySeat))
if _, err := te.step(PlayerAction{}); err != nil {
t.Fatal(err)
}
te.commit()
if sess.EnemyHP != 400 {
t.Errorf("committed EnemyHP = %d, want the 400 rewind pool", sess.EnemyHP)
}
if !sess.Statuses.EnemyRewindUsed {
t.Error("EnemyRewindUsed did not persist through commit")
}
if countEvents(sess.TurnLog, "amendment_rewind") != 1 {
t.Errorf("amendment_rewind events = %d, want 1", countEvents(sess.TurnLog, "amendment_rewind"))
}
}