adventure: T6 postgame Layer-2 seam + Aurvandryx's Greed Tax (P8)

Add the pre-combat boss-hook seam for Tier-6 postgame bosses and the first
bespoke mechanic on it.

Seam: applyBossRunModifiers(bossID, enemy, run) in postgame_boss_hooks.go —
a pure, idempotent, bestiary-ID-dispatched hook that folds run-state-derived
adjustments into the freshly-built boss Combatant. No-op for every non-hooked
enemy or nil run. The turn engine (which resolves both prod bosses and the
sim) rebuilds the enemy every round via partyCombatantsForSession, so the hook
must be a pure function of run state — fine at a terminal boss room, where the
route is frozen. Wired at both enemy-finalization points: the per-round rebuild
and buildFightSeats' initial HP persist (threaded a run param; the caller
already had it).

Greed Tax (boss_aurvandryx / first_hoard): her Attack rises with the richness
of the route walked to reach her — the summed excess LootBias (>1.0) over the
run's visited nodes. Note run.LootCollected is the wrong signal (BossOnly
signature manifest, empty at the boss); the gilded veins on the graph are.
Attack += min(2.0*richness, 12). Same-binary A/B sweep (n=150, L20 party +
Pete + pets): taxless 66.7% -> taxed 48.0%, landing first_hoard mid-band.

Claude-Session: https://claude.ai/code/session_0156WqjgsbmSY2U8eQ3Kkb1s
This commit is contained in:
prosolis
2026-07-16 07:44:10 -07:00
parent 85e5ba5fce
commit fc9e055083
7 changed files with 214 additions and 10 deletions

View File

@@ -97,7 +97,7 @@ func TestBuildFightSeats_ConsumesTheAbilityOnceAndCarriesItOnTheSeat(t *testing.
ragingBerserker(t, uid) ragingBerserker(t, uid)
seats, _, _, refusal := (&AdventurePlugin{}).buildFightSeats( seats, _, _, refusal := (&AdventurePlugin{}).buildFightSeats(
uid, []id.UserID{uid}, dndBestiary["goblin"], 1, 0) uid, []id.UserID{uid}, dndBestiary["goblin"], 1, 0, nil)
if refusal != "" { if refusal != "" {
t.Fatalf("fight refused: %s", refusal) t.Fatalf("fight refused: %s", refusal)
} }
@@ -125,7 +125,7 @@ func TestBuildZoneCombatants_RebuildKeepsTheRageForTheWholeFight(t *testing.T) {
ragingBerserker(t, uid) ragingBerserker(t, uid)
p := &AdventurePlugin{} p := &AdventurePlugin{}
seats, _, _, refusal := p.buildFightSeats(uid, []id.UserID{uid}, dndBestiary["goblin"], 1, 0) seats, _, _, refusal := p.buildFightSeats(uid, []id.UserID{uid}, dndBestiary["goblin"], 1, 0, nil)
if refusal != "" { if refusal != "" {
t.Fatalf("fight refused: %s", refusal) t.Fatalf("fight refused: %s", refusal)
} }
@@ -171,7 +171,7 @@ func TestBuildFightSeats_SatOutMemberKeepsTheirArmedAbility(t *testing.T) {
} }
seats, _, _, refusal := (&AdventurePlugin{}).buildFightSeats( seats, _, _, refusal := (&AdventurePlugin{}).buildFightSeats(
leader, []id.UserID{leader, downed}, dndBestiary["goblin"], 1, 0) leader, []id.UserID{leader, downed}, dndBestiary["goblin"], 1, 0, nil)
if refusal != "" { if refusal != "" {
t.Fatalf("fight refused: %s", refusal) t.Fatalf("fight refused: %s", refusal)
} }

View File

@@ -97,7 +97,7 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
// Seat the whole party, leader first. A solo player is a one-seat roster and // Seat the whole party, leader first. A solo player is a one-seat roster and
// takes the path they always took: one build, one INSERT, no participant rows. // takes the path they always took: one build, one INSERT, no participant rows.
seats, enemy, senderSkip, refusal := p.buildFightSeats(ctx.Sender, roster, monster, int(zone.Tier), run.DMMood) seats, enemy, senderSkip, refusal := p.buildFightSeats(ctx.Sender, roster, monster, int(zone.Tier), run.DMMood, run)
if refusal != "" { if refusal != "" {
return p.replyDM(ctx, refusal) return p.replyDM(ctx, refusal)
} }

View File

@@ -51,7 +51,7 @@ func fightRoster(sender id.UserID) []id.UserID {
// The enemy is built once. Every seat's build derives the identical stat block // The enemy is built once. Every seat's build derives the identical stat block
// from (monster, tier, dmMood); only the player half varies. // from (monster, tier, dmMood); only the player half varies.
func (p *AdventurePlugin) buildFightSeats( func (p *AdventurePlugin) buildFightSeats(
sender id.UserID, roster []id.UserID, monster DnDMonsterTemplate, tier, dmMood int, sender id.UserID, roster []id.UserID, monster DnDMonsterTemplate, tier, dmMood int, run *DungeonRun,
) (seats []CombatSeatSetup, enemy *Combatant, senderSkip, refusal string) { ) (seats []CombatSeatSetup, enemy *Combatant, senderSkip, refusal string) {
skip := func(uid id.UserID, why string) { skip := func(uid id.UserID, why string) {
if uid == sender { if uid == sender {
@@ -157,6 +157,13 @@ func (p *AdventurePlugin) buildFightSeats(
// actually seated — a member who was skipped (downed, busy elsewhere) never // actually seated — a member who was skipped (downed, busy elsewhere) never
// joined the fight and must not be charged to the enemy. // joined the fight and must not be charged to the enemy.
applySeatWeights(seatCombatants(seats), levels, companions) applySeatWeights(seatCombatants(seats), levels, companions)
// Fold in any Layer-2 pre-combat boss mechanic before this enemy is used to
// persist the initial HP pool. partyCombatantsForSession re-applies the same
// modifier on every round's rebuild; doing it here keeps the persisted stat
// block consistent with the fight the engine will actually run. No-op for
// every non-hooked enemy.
applyBossRunModifiers(monster.ID, enemy, run)
return seats, enemy, senderSkip, "" return seats, enemy, senderSkip, ""
} }

View File

@@ -109,7 +109,7 @@ func TestBuildFightSeats_SoloSeatsExactlyThePlayer(t *testing.T) {
fightTestChar(t, solo, 30) fightTestChar(t, solo, 30)
seats, enemy, skip, refusal := (&AdventurePlugin{}).buildFightSeats( seats, enemy, skip, refusal := (&AdventurePlugin{}).buildFightSeats(
solo, []id.UserID{solo}, dndBestiary["goblin"], 1, 0) solo, []id.UserID{solo}, dndBestiary["goblin"], 1, 0, nil)
if refusal != "" || skip != "" { if refusal != "" || skip != "" {
t.Fatalf("solo fight refused: %s / %s", refusal, skip) t.Fatalf("solo fight refused: %s / %s", refusal, skip)
} }
@@ -140,7 +140,7 @@ func TestBuildFightSeats_DownedMemberSitsOut(t *testing.T) {
roster := []id.UserID{leader, downed, standing} roster := []id.UserID{leader, downed, standing}
seats, _, skip, refusal := (&AdventurePlugin{}).buildFightSeats( seats, _, skip, refusal := (&AdventurePlugin{}).buildFightSeats(
leader, roster, dndBestiary["goblin"], 1, 0) leader, roster, dndBestiary["goblin"], 1, 0, nil)
if refusal != "" { if refusal != "" {
t.Fatalf("party refused over a downed member: %s", refusal) t.Fatalf("party refused over a downed member: %s", refusal)
} }
@@ -156,7 +156,7 @@ func TestBuildFightSeats_DownedMemberSitsOut(t *testing.T) {
// The one who was left behind typed `!fight` too, and silence is not an answer. // The one who was left behind typed `!fight` too, and silence is not an answer.
_, _, skip, refusal = (&AdventurePlugin{}).buildFightSeats( _, _, skip, refusal = (&AdventurePlugin{}).buildFightSeats(
downed, roster, dndBestiary["goblin"], 1, 0) downed, roster, dndBestiary["goblin"], 1, 0, nil)
if refusal != "" { if refusal != "" {
t.Fatalf("a downed member must not refuse the party's fight: %s", refusal) t.Fatalf("a downed member must not refuse the party's fight: %s", refusal)
} }
@@ -176,7 +176,7 @@ func TestBuildFightSeats_DownedLeaderRefusesTheFightForEveryone(t *testing.T) {
roster := []id.UserID{leader, member} roster := []id.UserID{leader, member}
p := &AdventurePlugin{} p := &AdventurePlugin{}
seats, _, _, refusal := p.buildFightSeats(leader, roster, dndBestiary["goblin"], 1, 0) seats, _, _, refusal := p.buildFightSeats(leader, roster, dndBestiary["goblin"], 1, 0, nil)
if len(seats) != 0 || refusal == "" { if len(seats) != 0 || refusal == "" {
t.Fatalf("downed leader seated %d players, refusal %q", len(seats), refusal) t.Fatalf("downed leader seated %d players, refusal %q", len(seats), refusal)
} }
@@ -184,7 +184,7 @@ func TestBuildFightSeats_DownedLeaderRefusesTheFightForEveryone(t *testing.T) {
t.Errorf("the leader should be told to rest, got %q", refusal) t.Errorf("the leader should be told to rest, got %q", refusal)
} }
_, _, _, refusal = p.buildFightSeats(member, roster, dndBestiary["goblin"], 1, 0) _, _, _, refusal = p.buildFightSeats(member, roster, dndBestiary["goblin"], 1, 0, nil)
if !strings.Contains(refusal, "leader") { if !strings.Contains(refusal, "leader") {
t.Errorf("the member should be told it is the leader holding things up, got %q", refusal) t.Errorf("the member should be told it is the leader holding things up, got %q", refusal)
} }

View File

@@ -211,6 +211,13 @@ func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Com
// until every seat is built. // until every seat is built.
applySeatWeights(players, levels, companions) applySeatWeights(players, levels, companions)
// Layer-2 pre-combat boss mechanics: fold in any run-state-derived
// adjustment (e.g. Aurvandryx's Greed Tax) before the party HP scaling. This
// is re-derived every round like everything else here; its inputs are frozen
// for a terminal boss fight, so the result is stable. No-op for every
// non-hooked enemy.
applyBossRunModifiers(monster.ID, &enemy, run)
// Party-only enemy HP bump, re-derived each turn from the template so it never // Party-only enemy HP bump, re-derived each turn from the template so it never
// compounds. Matches the scalar startPartyCombatSession used for the initial // compounds. Matches the scalar startPartyCombatSession used for the initial
// persist; solo (one seat, weight 1) scales by 1.0. // persist; solo (one seat, weight 1) scales by 1.0.

View File

@@ -0,0 +1,98 @@
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))
}

View File

@@ -0,0 +1,92 @@
package plugin
import "testing"
func TestGreedTaxAttack(t *testing.T) {
cases := []struct {
rich float64
want int
}{
{rich: -1, want: 0},
{rich: 0, want: 0},
{rich: 0.4, want: 0}, // 0.4*2 = 0.8, floors to 0
{rich: 0.5, want: 1}, // one point per 0.5 richness
{rich: 3.5, want: 7}, // the sim's full-explore route
{rich: 5.25, want: 10},
{rich: 6, want: 12}, // exactly the cap
{rich: 100, want: 12}, // capped
}
for _, c := range cases {
if got := greedTaxAttack(c.rich); got != c.want {
t.Errorf("greedTaxAttack(%.2f) = %d, want %d", c.rich, got, c.want)
}
}
}
func TestGreedRouteRichness(t *testing.T) {
// The First Hoard's gilded veins are the only >1.0 LootBias nodes in the
// game: cap12@3.0, f1b2@2.75, r2b2@2.5 (see zone_graph_first_hoard.go). The
// graph builder prefixes every node id with the zone id + ".".
const pre = "first_hoard."
// The full gilded route sums every vein's excess bias.
all := &DungeonRun{ZoneID: ZoneFirstHoard, VisitedNodes: []string{pre + "entry", pre + "cap12", pre + "f1b2", pre + "r2b2", pre + "boss"}}
if got := greedRouteRichness(all); !approxEq(got, 5.25) {
t.Errorf("full route richness = %.2f, want 5.25", got)
}
// A single vein contributes only its own excess.
one := &DungeonRun{ZoneID: ZoneFirstHoard, VisitedNodes: []string{pre + "entry", pre + "cap12", pre + "boss"}}
if got := greedRouteRichness(one); !approxEq(got, 2.0) {
t.Errorf("one-vein (cap12@3.0) richness = %.2f, want 2.0", got)
}
// A lean line that never touches a vein pays nothing.
lean := &DungeonRun{ZoneID: ZoneFirstHoard, VisitedNodes: []string{pre + "entry", pre + "p1", pre + "p2", pre + "boss"}}
if got := greedRouteRichness(lean); !approxEq(got, 0) {
t.Errorf("lean route richness = %.2f, want 0", got)
}
// An unknown zone has no graph and no richness.
if got := greedRouteRichness(&DungeonRun{ZoneID: "nope", VisitedNodes: []string{"x"}}); got != 0 {
t.Errorf("unknown-zone richness = %.2f, want 0", got)
}
}
func TestApplyBossRunModifiers_GreedTax(t *testing.T) {
richRun := &DungeonRun{ZoneID: ZoneFirstHoard, VisitedNodes: []string{"first_hoard.cap12", "first_hoard.f1b2", "first_hoard.r2b2"}} // 5.25 rich → +10 Attack
leanRun := &DungeonRun{ZoneID: ZoneFirstHoard, VisitedNodes: []string{"first_hoard.entry", "first_hoard.boss"}} // 0 rich → +0
// Aurvandryx's Attack rises with the gilded route walked into her cradle.
enemy := &Combatant{Stats: CombatStats{Attack: 45}}
applyBossRunModifiers("boss_aurvandryx", enemy, richRun)
if enemy.Stats.Attack != 45+10 {
t.Errorf("greedy-route Aurvandryx Attack = %d, want %d", enemy.Stats.Attack, 45+10)
}
// A lean run leaves her at her base Attack.
lean := &Combatant{Stats: CombatStats{Attack: 45}}
applyBossRunModifiers("boss_aurvandryx", lean, leanRun)
if lean.Stats.Attack != 45 {
t.Errorf("lean-run Aurvandryx Attack = %d, want 45", lean.Stats.Attack)
}
// Every non-hooked enemy is untouched, even on the richest route.
other := &Combatant{Stats: CombatStats{Attack: 45}}
applyBossRunModifiers("boss_seamstress", other, richRun)
if other.Stats.Attack != 45 {
t.Errorf("non-hooked boss Attack = %d, want 45 (no-op)", other.Stats.Attack)
}
// A nil run is a no-op, not a panic.
nilRun := &Combatant{Stats: CombatStats{Attack: 45}}
applyBossRunModifiers("boss_aurvandryx", nilRun, nil)
if nilRun.Stats.Attack != 45 {
t.Errorf("nil-run Attack = %d, want 45 (no-op)", nilRun.Stats.Attack)
}
}
func approxEq(a, b float64) bool {
d := a - b
return d < 1e-9 && d > -1e-9
}