Files
gogobee/internal/plugin/postgame_boss_hooks.go
prosolis 686434f8e3 adventure: T6 Phylactery Verses + stackable-rebirth engine primitive (P8)
The second Layer-2 postgame boss mechanic, and the first stateful in-combat one.
Valdris ("boss_valdris_ascendant", the Ossuary Ascendant) is bound to three
Verses hidden on the zone's secret nodes: every Verse the player finds and walks
before the fight unbinds one rebirth, every Verse they skip leaves it armed. A
full-clear explorer strips all three and fights a mortal lich; a speedrunner who
blows past the secrets fights a god who will not stay down.

New engine primitive: stackable rebirth. The stock survive_at_1 is a one-shot
1-HP stay; Valdris needs several rebirths that each restore a real pool.
combatState/CombatStatuses gain EnemyReviveCharges/EnemyReviveHP (round-tripped
through the turn engine so a suspend/resume keeps the live count), and enemyDown
consumes a charge after the survive_at_1 check, reviving to 25% of the
party-scaled max and emitting a phylactery_rebirth event. Zero for every
non-Valdris fight.

Unlike Greed Tax (a pure per-round recompute in applyBossRunModifiers), rebirths
are spent mid-fight, so the charge count is seeded ONCE at session creation
(seedBossRunStatuses, from unvisited secret Verses) and never re-derived on the
per-round enemy rebuild. Seeded from handleFightCmd after startPartyCombatSession.

Sim A/B (millenia, n=120 L20 party+Pete+pets, same binary, control neuters the
dispatch): mortal end (verses found) fighter 42.5% -- reproduces the deployed P7
ossuary baseline, confirming the mechanic doesn't touch the validated full-clear
path -- and the god end (0 verses) fighter 12.5%, a deadly-but-beatable flex arm.
The sim walks 0 verses (autopilot takes the first unlocked fork; Verses are
behind Perception locks), so the A/B brackets the whole player-agency gradient.

Claude-Session: https://claude.ai/code/session_0156WqjgsbmSY2U8eQ3Kkb1s
2026-07-16 08:04:19 -07:00

171 lines
7.4 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.
//
// 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.
//
// 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.
// 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
}