mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-18 01:42:42 +00:00
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
335 lines
16 KiB
Go
335 lines
16 KiB
Go
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.5–3.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 40–55), 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,
|
||
})
|
||
}
|