Files
gogobee/internal/plugin/postgame_boss_hooks.go
prosolis 479f77b9c5 adventure: T6 Inversion Stitch (Seamstress) + boss re-strengthen (P8)
Second in-combat Layer-2 mechanic, reusing Amendment's round-end seam. In the
Seamstress's phase 2 (<=35% HP) the room sews inside-out in a repeating
warn(1)->sting(2) cadence, telegraphed one round ahead: during a pulse player
heals (self + ally) invert to damage, floored at 1 HP. State
inversionActive/inversionTelegraph round-trips through CombatStatuses so a
suspend/resume can't drop or double a pulse.

Sim surfaced that unplace is reach-bound (only ~37% reach the boss, then cleared
her ~98%), so the mechanic alone is sub-noise. Re-strengthened the Seamstress,
the weakest T6 boss (over-softened by P7 for a zone lift that never came):
HP 385->460, Atk 39->45, Needle Rain proc 0.40->0.45. Sim-validated at
fighter zone 37.5% (in range), and a real boss fight now instead of a victory lap.

Claude-Session: https://claude.ai/code/session_0156WqjgsbmSY2U8eQ3Kkb1s
2026-07-16 12:14:41 -07:00

335 lines
16 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
// Tier-6 postgame "Layer-2" boss mechanics (Phase P8).
//
// Layer 1 (shipped) is everything the stock engine expresses: a single
// MonsterAbility rider plus HP/AC/Attack/PhaseTwoAt on the stat block. Layer 2
// 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.
//
// It holds BOTH halves of that seam:
//
// - 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
// here. It is dispatched by bestiary ID and is a no-op for every enemy that
// isn't a hooked T6 boss, so the two build seams can call it unconditionally on
// whatever they just built. A nil enemy or nil run is a no-op.
func applyBossRunModifiers(bossID string, enemy *Combatant, run *DungeonRun) {
if enemy == nil || run == nil {
return
}
switch bossID {
case "boss_aurvandryx":
applyGreedTax(enemy, run)
}
}
// ── Greed Tax — Aurvandryx, the Ember Before Fire (First Hoard) ─────────────
//
// The First Hoard carries the game's richest LootBias nodes on purpose (the
// gilded veins at 2.53.0; nowhere else in the game exceeds 1.0). Aurvandryx —
// the hoard's true owner, not its guard dog — takes the interest out of your
// hide: her Attack rises with how much of the gilded route you walked to reach
// her cradle. Strip the veins and meet a furious wyrm; take the vow-of-poverty
// line through the zone and fight her lean.
//
// Signal: the summed excess LootBias (above the 1.0 baseline) of every node the
// run walked — NOT run.LootCollected, which only tracks the boss-only signature
// manifest and is empty at the boss. A full-explore route through the First
// Hoard accrues ~3.5 richness (+7 Attack); the cap covers a maximal line.
//
// Calibration (P8 sim sweep, L20 party+Pete+pets): the full-explore route lands
// first_hoard at ~47% clear (mid-band 4055), down from the taxless 56%. A lean
// prod route pays no tax and fights her at the taxless rate; a maximal route
// hits the cap. No base-stat retune was needed — the tax corrects the prior
// slight overshoot into the band. NOTE: the sim's autopilot explores the whole
// graph, so the sweep only exercises the full-route point; the lean/greedy
// spread is a prod-only player-agency lever the headless sim cannot walk.
const (
// greedTaxRichnessMult converts summed route richness into Attack points.
greedTaxRichnessMult = 2.0
// greedTaxMaxAttack caps the tax so a maximal hoard-run meets a very hard
// wyrm, never a literally-unbeatable one.
greedTaxMaxAttack = 12
)
// greedRouteRichness sums the excess LootBias (above the 1.0 baseline) of every
// node the run has walked. Extracted for a deterministic unit test.
func greedRouteRichness(run *DungeonRun) float64 {
g, ok := loadZoneGraph(run.ZoneID)
if !ok {
return 0
}
var rich float64
for _, id := range run.VisitedNodes {
if n, exists := g.Nodes[id]; exists && n.Content.LootBias > 1.0 {
rich += n.Content.LootBias - 1.0
}
}
return rich
}
// greedTaxAttack is the Attack surcharge for a route of the given richness.
func greedTaxAttack(richness float64) int {
tax := int(richness * greedTaxRichnessMult)
if tax > greedTaxMaxAttack {
tax = greedTaxMaxAttack
}
if tax < 0 {
tax = 0
}
return tax
}
func applyGreedTax(enemy *Combatant, run *DungeonRun) {
enemy.Stats.Attack += greedTaxAttack(greedRouteRichness(run))
}
// ── Phylactery Verses — Valdris, At Last (The Ossuary Ascendant) ────────────
//
// The plan Valdris has been running since he "died" in the T1 Crypt: the
// phylactery shard players looted for years was bait, and he has rebuilt as a
// true lich. His rebirths are bound into three Verses hidden in the cathedral,
// each a NodeKindSecret behind a Perception gate. Every Verse a player finds and
// walks before the fight UNBINDS one rebirth; every Verse they skip leaves it
// armed. Full-clear explorers strip all three and fight a mortal lich;
// speedrunners who blow past the secrets fight a god who will not stay down.
//
// This is the mirror-image of the Greed Tax's design axis: the Tax punishes
// greedy exploration, the Verses reward thorough exploration. Both are prod
// player-agency levers the headless sim only samples at whatever route its
// autopilot happens to walk.
//
// Unlike the Greed Tax (a pure per-round Attack recompute), a rebirth is spent
// mid-fight, so it is stateful: seedBossRunStatuses freezes the charge count
// onto the session ONCE at fight start, and the turn engine round-trips the
// live count through CombatStatuses. It must NOT be re-derived on the per-round
// enemy rebuild, or spent rebirths would come back every round.
const (
// phylacteryReviveFrac is the fraction of the boss's (party-scaled) max HP
// each unbound rebirth restores him to — a real second wind, not the 1-HP
// stay of survive_at_1.
phylacteryReviveFrac = 4 // 1/4 == 25%
)
// phylacteryReviveCharges is the number of rebirths still armed on Valdris: one
// per zone Verse (NodeKindSecret) the run has NOT visited. The Ossuary's only
// secret nodes are the three Verses (the shared builder stamps none by default),
// so counting unvisited secrets is exactly "unbound rebirths". Zero for any
// non-Valdris boss or a nil run. A pure function of frozen run state.
func phylacteryReviveCharges(bossID string, run *DungeonRun) int {
if bossID != "boss_valdris_ascendant" || run == nil {
return 0
}
g, ok := loadZoneGraph(run.ZoneID)
if !ok {
return 0
}
visited := make(map[string]bool, len(run.VisitedNodes))
for _, id := range run.VisitedNodes {
visited[id] = true
}
charges := 0
for id, n := range g.Nodes {
if n.Kind == NodeKindSecret && !visited[id] {
charges++
}
}
return charges
}
// seedBossRunStatuses folds any ONCE-AT-FIGHT-START Layer-2 boss state into the
// freshly-created session, reading the run the player took to get here. It is
// the stateful counterpart to applyBossRunModifiers (which is a per-round pure
// recompute): whatever it seeds here is mutated by the fight and round-tripped,
// never re-derived. enemyMaxHP is the party-scaled pool already persisted onto
// the session. Returns whether it changed anything (so the caller can skip a
// redundant save). No-op — false — for every non-hooked boss.
func seedBossRunStatuses(sess *CombatSession, bossID string, enemyMaxHP int, run *DungeonRun) bool {
if sess == nil {
return false
}
if charges := phylacteryReviveCharges(bossID, run); charges > 0 {
sess.Statuses.EnemyReviveCharges = charges
sess.Statuses.EnemyReviveHP = max(1, enemyMaxHP/phylacteryReviveFrac)
return true
}
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)
case "boss_seamstress":
applyInversionStitch(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,
})
}
}
// ── Inversion Stitch — The Seamstress (The Unplace) ──────────────────────────
//
// The Seamstress is sewing the tear shut from the inside, and once she's far
// enough into the work (phase 2) she starts turning the room inside-out around
// the party: for a two-round pulse, healing runs the wrong direction — every
// cure lands as a wound (gated in stepPlayerActionEffect, floored at 1 HP so a
// player is never killed by their own healer, only softened for the Seamstress's
// own blows to finish). The catch is that she can't invert the room without a
// visible tell: each pulse is telegraphed one full round ahead, so a player who
// reads it holds their heals and waits it out. The autopilot that drives the sim
// does not read tells — so the headless sweep sees the mechanic at its harshest,
// the way an undisciplined party would, and a real player who respects the
// warning fares better. That gap IS the difficulty lever, the same way the Greed
// Tax and the Phylactery Verses are prod-only player-agency levers.
//
// It is the second in-combat Layer-2 mechanic, so it rides the same round-end
// seam Amendment opened. Its state (inversionActive countdown + inversionTelegraph
// warning) round-trips through CombatStatuses so a suspend/resume can't drop or
// double a pulse. A no-op outside the Seamstress's phase 2.
const (
// seamstressPhaseTwoFrac must track The Seamstress's PhaseTwoAt in
// postgame_zone_defs.go (0.35): the inversion only starts once she is sewn
// deep enough into the tear. Kept local for the same reason as
// custodianPhaseTwoFrac — the round-end hook resolves off the session, not the
// ZoneDefinition, so the threshold isn't otherwise in reach here.
seamstressPhaseTwoFrac = 0.35
// inversionPulseRounds is how many rounds each inside-out pulse lasts once it
// activates (heals sting for this many player-rounds).
inversionPulseRounds = 2
)
// applyInversionStitch drives the Seamstress's phase-2 inversion cadence at round
// end: telegraph a pulse one round ahead, activate it for inversionPulseRounds,
// then re-telegraph the next — giving a repeating warn(1) → sting(2) rhythm the
// player can play around. No-op until the boss is in phase 2.
func applyInversionStitch(st *combatState, enemyMaxHP int) {
// Layer-1 fight until the Seamstress is sewn into her own phase 2.
phaseTwo := int(seamstressPhaseTwoFrac * float64(enemyMaxHP))
if st.enemyHP > phaseTwo {
return
}
// An active pulse counts down one round per round end. While it stays above
// zero the next round's heals still sting; when it lapses this round, fall
// through so a fresh telegraph is scheduled immediately.
if st.inversionActive > 0 {
st.inversionActive--
if st.inversionActive > 0 {
return
}
}
// A telegraphed pulse activates: the room turns inside-out for the pulse.
if st.inversionTelegraph {
st.inversionActive = inversionPulseRounds
st.inversionTelegraph = false
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "enemy", Action: "inversion_stitch",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
return
}
// Otherwise warn: the next round's pulse is one round out.
st.inversionTelegraph = true
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "enemy", Action: "inversion_telegraph",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
}