mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-18 09:52:41 +00:00
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
171 lines
7.4 KiB
Go
171 lines
7.4 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.
|
||
//
|
||
// 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.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
|
||
}
|