mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-18 01:42:42 +00:00
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:
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user