From 189a44e1ebfb37b606a8728df9456fad4767f82a Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:20:29 -0700 Subject: [PATCH] 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 --- internal/plugin/combat_engine.go | 9 ++ internal/plugin/combat_narrative.go | 20 +++ internal/plugin/combat_session.go | 11 ++ internal/plugin/combat_turn_engine.go | 15 +- internal/plugin/postgame_boss_hooks.go | 115 ++++++++++++-- internal/plugin/postgame_boss_hooks_test.go | 165 ++++++++++++++++++++ 6 files changed, 323 insertions(+), 12 deletions(-) diff --git a/internal/plugin/combat_engine.go b/internal/plugin/combat_engine.go index d501df6..78cec7b 100644 --- a/internal/plugin/combat_engine.go +++ b/internal/plugin/combat_engine.go @@ -443,6 +443,15 @@ type combatState struct { enemyFearImmune bool // fear_immune: player control spells (enemy-skip) fizzle against this enemy enemyAtkBuff int // ally_buff: flat, accumulating bonus to the enemy's attack damage + // Amendment (T6 Custodian) — an in-combat Layer-2 hook resolved at round end + // by applyBossInCombatRoundEnd. enemyRewindHP is the boss's round-3 HP + // snapshot (0 until captured); enemyRewindUsed gates the once-only rewind + // that restores it to that snapshot when the boss crosses into phase 2. The + // soft midnight timer past round 20 rides enemyAtkBuff. Round-tripped through + // CombatStatuses; zero/false for every non-Custodian fight. + enemyRewindHP int + enemyRewindUsed bool + round int events []CombatEvent diff --git a/internal/plugin/combat_narrative.go b/internal/plugin/combat_narrative.go index 35be08a..ddb0fc6 100644 --- a/internal/plugin/combat_narrative.go +++ b/internal/plugin/combat_narrative.go @@ -333,6 +333,10 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul return pickRand(narrativeSurvive) case "phylactery_rebirth": return pickRand(narrativePhylacteryRebirth) + case "amendment_rewind": + return pickRand(narrativeAmendmentRewind) + case "midnight_toll": + return fmt.Sprintf(pickRand(narrativeMidnightToll), e.Damage) case "stat_drain": return fmt.Sprintf(pickRand(narrativeStatDrain), e.Damage) case "debuff": @@ -772,6 +776,22 @@ var narrativePhylacteryRebirth = []string{ "💀 That should have been the end of him. A Verse still hums somewhere in the cathedral, and Valdris simply *begins again*.", } +// narrativeAmendmentRewind fires when the Custodian rewinds itself to its round-3 +// HP snapshot — the once-only Amendment. Each line reads as time being undone so +// the mechanic (front-loaded burst is partly refunded) teaches itself over a run. +var narrativeAmendmentRewind = []string{ + "🕰️ The Custodian raises a hand and *edits the last few minutes out of the record.* Wounds close in reverse; the clock-golem stands as it did rounds ago.", + "🕰️ \"That entry is amended.\" The damage you dealt simply un-happens — the Custodian rewinds to where it was and resumes, unhurried.", + "🕰️ Verdigris rings spin backward. Time you spent hurting it is refunded to the golem; it returns to its round-three self and keeps working.", +} + +// narrativeMidnightToll fires past round 20 — the soft closing-time timer, the +// Custodian's Attack climbing each round a stalled fight refuses to end. +var narrativeMidnightToll = []string{ + "🔔 A bell tolls somewhere above. Closing time — the Custodian's swings come harder. (+%d attack)", + "🔔 The hour is nearly spent, and so is its patience; each blow lands with more weight now. (+%d attack)", +} + var narrativeStatDrain = []string{ "🩸 The enemy saps your strength — your swings feel heavier, weaker. (-%d hit damage)", "🩸 Something drains out of your limbs. Your hits won't bite as deep now. (-%d damage)", diff --git a/internal/plugin/combat_session.go b/internal/plugin/combat_session.go index 8db97a5..a565bc3 100644 --- a/internal/plugin/combat_session.go +++ b/internal/plugin/combat_session.go @@ -232,6 +232,17 @@ type CombatStatuses struct { EnemyRevealNext bool `json:"enemy_reveal_next,omitempty"` EnemyFearImmune bool `json:"enemy_fear_immune,omitempty"` EnemyAtkBuff int `json:"enemy_atk_buff,omitempty"` + + // Amendment (Tier-6 postgame, The Custodian of the Last Hour). An in-combat + // Layer-2 hook resolved at round end (applyBossInCombatRoundEnd), not proc- + // armed: EnemyRewindHP snapshots the boss's HP at the end of round 3 (0 until + // captured); when the boss then crosses into phase 2 the hook restores it to + // that snapshot exactly once and sets EnemyRewindUsed. Both round-trip through + // combatState so the once-only rewind survives a suspend/resume. Zero/false + // for every other enemy. The soft midnight timer past round 20 rides the + // existing EnemyAtkBuff, so it needs no field of its own. + EnemyRewindHP int `json:"enemy_rewind_hp,omitempty"` + EnemyRewindUsed bool `json:"enemy_rewind_used,omitempty"` } // applyBuffDelta folds one resolved buff (the result of a !cast / !consume diff --git a/internal/plugin/combat_turn_engine.go b/internal/plugin/combat_turn_engine.go index e6ffa73..5f8d537 100644 --- a/internal/plugin/combat_turn_engine.go +++ b/internal/plugin/combat_turn_engine.go @@ -370,7 +370,10 @@ func resumeTurnEngine(sess *CombatSession, players []*Combatant, enemy *Combatan enemyRevealNext: sess.Statuses.EnemyRevealNext, enemyFearImmune: sess.Statuses.EnemyFearImmune, enemyAtkBuff: sess.Statuses.EnemyAtkBuff, - rng: rng, + // Amendment (T6 Custodian) — round-3 snapshot + once-only rewind. + enemyRewindHP: sess.Statuses.EnemyRewindHP, + enemyRewindUsed: sess.Statuses.EnemyRewindUsed, + rng: rng, } order := turnOrder(sess, sess.Round, players, enemy) sess.Statuses.TurnIdx = turnIdxForPhase(order, sess.Statuses.TurnIdx, sess.Phase) @@ -879,6 +882,14 @@ func (te *turnEngine) stepRoundEnd() { Damage: st.enemyRegen, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, }) } + // Tier-6 in-combat Layer-2 boss hooks (Amendment): round-3 HP snapshot + + // once-only phase-2 rewind + soft midnight timer, resolved on the round that + // just finished. A no-op for every enemy but the hooked bosses, and only + // while the enemy still stands, so it is safe to call unconditionally here + // after the round's damage has settled. + if st.enemyHP > 0 { + applyBossInCombatRoundEnd(st, te.sess.EnemyID, te.enemy.Stats.MaxHP) + } st.round++ // Initiative is re-rolled each round, so the next round's order is derived // here — off st.round, since commit has not yet pushed it onto the session. @@ -939,6 +950,8 @@ func (te *turnEngine) commit() { s.EnemyRevealNext = st.enemyRevealNext s.EnemyFearImmune = st.enemyFearImmune s.EnemyAtkBuff = st.enemyAtkBuff + s.EnemyRewindHP = st.enemyRewindHP + s.EnemyRewindUsed = st.enemyRewindUsed te.sess.TurnLog = append(te.sess.TurnLog, st.events...) } diff --git a/internal/plugin/postgame_boss_hooks.go b/internal/plugin/postgame_boss_hooks.go index a20c78d..f1cc9ff 100644 --- a/internal/plugin/postgame_boss_hooks.go +++ b/internal/plugin/postgame_boss_hooks.go @@ -7,18 +7,26 @@ package plugin // is the bespoke, per-boss stuff the plan promised — mechanics that read the // *run* the player took to reach the boss, not just the fight in front of them. // -// This file holds the PRE-COMBAT half of that seam: adjustments derived once -// from run state and folded into the boss's live Combatant before the fight -// resolves. It must be a PURE, IDEMPOTENT function of run state, because the -// turn engine rebuilds the enemy from the bestiary every single round -// (partyCombatantsForSession) — the boss's numbers are re-derived on every -// !attack. That is fine as long as the inputs are frozen for the duration of -// the fight, which they are: the boss room is terminal, so no more nodes are -// walked once the fight begins. +// It holds BOTH halves of that seam: // -// In-combat Layer-2 hooks (Inversion Stitch, Amendment, Two Hearts) are a -// separate seam — a new MonsterAbility.Effect case in applyAbility, shared by -// both engines — and are not in this file. +// - PRE-COMBAT (applyBossRunModifiers / seedBossRunStatuses): adjustments +// derived once from run state and folded into the boss's live Combatant (or +// the fresh session) before the fight resolves. applyBossRunModifiers must be +// a PURE, IDEMPOTENT function of run state, because the turn engine rebuilds +// the enemy from the bestiary every single round (partyCombatantsForSession) +// — the boss's numbers are re-derived on every !attack. That is fine as long +// as the inputs are frozen for the fight, which they are: the boss room is +// terminal, so no more nodes are walked once the fight begins. +// +// - IN-COMBAT (applyBossInCombatRoundEnd): round-boundary mechanics that read +// and mutate the live combatState — HP snapshots, once-only rewinds, escalating +// timers. Called from the turn engine's stepRoundEnd after the round's damage +// has settled. Any state it spends round-trips through CombatStatuses so a +// suspend/resume can't replay it. +// +// The remaining planned in-combat mechanics (Inversion Stitch, Two Hearts) are +// still a separate seam — a new MonsterAbility.Effect case in applyAbility — and +// are not in this file yet. // applyBossRunModifiers folds any pre-combat Layer-2 mechanic for bossID into // the freshly-built enemy Combatant, reading the run the player took to get @@ -168,3 +176,88 @@ func seedBossRunStatuses(sess *CombatSession, bossID string, enemyMaxHP int, run } return false } + +// ── Amendment — The Custodian of the Last Hour (The Last Meridian) ─────────── +// +// The Custodian has concluded its contract is complete and is dismantling the +// hours on its way out — including, once, the last few minutes of its own fight. +// Its true durability is HIDDEN in the opening rounds: it snapshots its HP at the +// end of round 3, and the first time the party knocks it into phase 2 it *rewinds +// itself* to that snapshot — once. Front-loaded burst is partially refunded (the +// damage past the snapshot is undone), so sustained builds that can grind through +// the extra pool shine over glass-cannon alpha strikes. A soft "midnight timer" +// then leans on stalls: every round past round 20 the Custodian's Attack climbs, +// so a fight that can't close still resolves before the clock runs out. +// +// This is the first IN-COMBAT Layer-2 mechanic. Unlike the pre-combat hooks it +// reads/mutates the live combatState at round end, and its once-only state +// (EnemyRewindHP snapshot + EnemyRewindUsed) round-trips through CombatStatuses so +// a suspend/resume can't hand the boss a second rewind. Dispatched by bestiary ID, +// so it is a no-op for every other enemy. +const ( + // custodianSnapshotRound is the round whose end HP the Custodian rewinds to. + // Snapshotting late enough that a normal party has committed real damage, but + // early enough that the refund still matters, is the whole point — the fight's + // true length is concealed until the rewind fires. + custodianSnapshotRound = 3 + + // custodianPhaseTwoFrac must track The Custodian's PhaseTwoAt in + // postgame_zone_defs.go (0.45): the rewind is meant to fire exactly as the + // party crosses the boss into phase 2. Kept as a local constant because the + // turn engine resolves off the bestiary template + session, not the + // ZoneDefinition, so the threshold isn't otherwise in reach at round end. + custodianPhaseTwoFrac = 0.45 + + // midnightAtkStep / midnightAtkCap: the soft closing-time timer. Every round + // past midnightTimerAfter adds midnightAtkStep to the boss's Attack (via the + // shared EnemyAtkBuff, which enemyAttackStat already folds in), capped so a + // truly stalled fight still ends without the number becoming meaningless. + midnightTimerAfter = 20 + midnightAtkStep = 2 + midnightAtkCap = 40 +) + +// applyBossInCombatRoundEnd resolves the round-boundary in-combat Layer-2 +// mechanics for the enemy the turn engine is fighting. It runs after the round's +// damage has settled and only while the enemy still stands. Dispatched by +// bestiary ID; a no-op for every enemy that isn't a hooked T6 boss. +func applyBossInCombatRoundEnd(st *combatState, bossID string, enemyMaxHP int) { + if st == nil { + return + } + switch bossID { + case "boss_custodian": + applyAmendment(st, enemyMaxHP) + } +} + +// applyAmendment resolves the Custodian's Amendment (round-3 snapshot + once-only +// phase-2 rewind) and its soft midnight timer, given the round that just finished +// (st.round, pre-increment) and the boss's party-scaled MaxHP. +func applyAmendment(st *combatState, enemyMaxHP int) { + // Snapshot the boss's HP at the end of round 3 — the concealed "true length". + if st.round == custodianSnapshotRound && st.enemyRewindHP == 0 { + st.enemyRewindHP = st.enemyHP + } + // The first time the party crosses the boss into phase 2, rewind to the + // snapshot — once. Guarded on snapshot > current so a party that never got + // below the round-3 HP (and a fight bursted into phase 2 before the snapshot + // was even taken, EnemyRewindHP == 0) can't be handed a free heal. + phaseTwo := int(custodianPhaseTwoFrac * float64(enemyMaxHP)) + if !st.enemyRewindUsed && st.enemyRewindHP > st.enemyHP && st.enemyHP <= phaseTwo { + st.enemyHP = min(enemyMaxHP, st.enemyRewindHP) + st.enemyRewindUsed = true + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "enemy", Action: "amendment_rewind", + PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + }) + } + // Soft midnight timer: closing time leans on a stalled fight. + if st.round >= midnightTimerAfter && st.enemyAtkBuff < midnightAtkCap { + st.enemyAtkBuff = min(midnightAtkCap, st.enemyAtkBuff+midnightAtkStep) + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "enemy", Action: "midnight_toll", + Damage: midnightAtkStep, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + }) + } +} diff --git a/internal/plugin/postgame_boss_hooks_test.go b/internal/plugin/postgame_boss_hooks_test.go index 3ce2d06..24e0e6f 100644 --- a/internal/plugin/postgame_boss_hooks_test.go +++ b/internal/plugin/postgame_boss_hooks_test.go @@ -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")) + } +}