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

@@ -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

View File

@@ -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)",

View File

@@ -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

View File

@@ -370,6 +370,9 @@ func resumeTurnEngine(sess *CombatSession, players []*Combatant, enemy *Combatan
enemyRevealNext: sess.Statuses.EnemyRevealNext,
enemyFearImmune: sess.Statuses.EnemyFearImmune,
enemyAtkBuff: sess.Statuses.EnemyAtkBuff,
// 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)
@@ -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...)
}

View File

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

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"))
}
}