From fc9e055083fcf5a2dbd5331fa2a266f050115e25 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Thu, 16 Jul 2026 07:44:10 -0700 Subject: [PATCH] adventure: T6 postgame Layer-2 seam + Aurvandryx's Greed Tax (P8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/plugin/combat_armed_ability_test.go | 6 +- internal/plugin/combat_cmd.go | 2 +- internal/plugin/combat_party_start.go | 9 +- internal/plugin/combat_party_start_test.go | 10 +- internal/plugin/combat_session_build.go | 7 ++ internal/plugin/postgame_boss_hooks.go | 98 ++++++++++++++++++++ internal/plugin/postgame_boss_hooks_test.go | 92 ++++++++++++++++++ 7 files changed, 214 insertions(+), 10 deletions(-) create mode 100644 internal/plugin/postgame_boss_hooks.go create mode 100644 internal/plugin/postgame_boss_hooks_test.go diff --git a/internal/plugin/combat_armed_ability_test.go b/internal/plugin/combat_armed_ability_test.go index b143d06..e984b9a 100644 --- a/internal/plugin/combat_armed_ability_test.go +++ b/internal/plugin/combat_armed_ability_test.go @@ -97,7 +97,7 @@ func TestBuildFightSeats_ConsumesTheAbilityOnceAndCarriesItOnTheSeat(t *testing. ragingBerserker(t, uid) seats, _, _, refusal := (&AdventurePlugin{}).buildFightSeats( - uid, []id.UserID{uid}, dndBestiary["goblin"], 1, 0) + uid, []id.UserID{uid}, dndBestiary["goblin"], 1, 0, nil) if refusal != "" { t.Fatalf("fight refused: %s", refusal) } @@ -125,7 +125,7 @@ func TestBuildZoneCombatants_RebuildKeepsTheRageForTheWholeFight(t *testing.T) { ragingBerserker(t, uid) 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 != "" { t.Fatalf("fight refused: %s", refusal) } @@ -171,7 +171,7 @@ func TestBuildFightSeats_SatOutMemberKeepsTheirArmedAbility(t *testing.T) { } 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 != "" { t.Fatalf("fight refused: %s", refusal) } diff --git a/internal/plugin/combat_cmd.go b/internal/plugin/combat_cmd.go index bfa1697..ed813d5 100644 --- a/internal/plugin/combat_cmd.go +++ b/internal/plugin/combat_cmd.go @@ -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 // 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 != "" { return p.replyDM(ctx, refusal) } diff --git a/internal/plugin/combat_party_start.go b/internal/plugin/combat_party_start.go index 7707489..9641185 100644 --- a/internal/plugin/combat_party_start.go +++ b/internal/plugin/combat_party_start.go @@ -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 // from (monster, tier, dmMood); only the player half varies. 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) { skip := func(uid id.UserID, why string) { if uid == sender { @@ -157,6 +157,13 @@ func (p *AdventurePlugin) buildFightSeats( // actually seated — a member who was skipped (downed, busy elsewhere) never // joined the fight and must not be charged to the enemy. 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, "" } diff --git a/internal/plugin/combat_party_start_test.go b/internal/plugin/combat_party_start_test.go index 7d8f05a..b3d153c 100644 --- a/internal/plugin/combat_party_start_test.go +++ b/internal/plugin/combat_party_start_test.go @@ -109,7 +109,7 @@ func TestBuildFightSeats_SoloSeatsExactlyThePlayer(t *testing.T) { fightTestChar(t, solo, 30) 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 != "" { 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} seats, _, skip, refusal := (&AdventurePlugin{}).buildFightSeats( - leader, roster, dndBestiary["goblin"], 1, 0) + leader, roster, dndBestiary["goblin"], 1, 0, nil) if 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. _, _, skip, refusal = (&AdventurePlugin{}).buildFightSeats( - downed, roster, dndBestiary["goblin"], 1, 0) + downed, roster, dndBestiary["goblin"], 1, 0, nil) if 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} 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 == "" { 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) } - _, _, _, refusal = p.buildFightSeats(member, roster, dndBestiary["goblin"], 1, 0) + _, _, _, refusal = p.buildFightSeats(member, roster, dndBestiary["goblin"], 1, 0, nil) if !strings.Contains(refusal, "leader") { t.Errorf("the member should be told it is the leader holding things up, got %q", refusal) } diff --git a/internal/plugin/combat_session_build.go b/internal/plugin/combat_session_build.go index 70003a3..7c8366f 100644 --- a/internal/plugin/combat_session_build.go +++ b/internal/plugin/combat_session_build.go @@ -211,6 +211,13 @@ func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Com // until every seat is built. 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 // compounds. Matches the scalar startPartyCombatSession used for the initial // persist; solo (one seat, weight 1) scales by 1.0. diff --git a/internal/plugin/postgame_boss_hooks.go b/internal/plugin/postgame_boss_hooks.go new file mode 100644 index 0000000..b764cad --- /dev/null +++ b/internal/plugin/postgame_boss_hooks.go @@ -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.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)) +} diff --git a/internal/plugin/postgame_boss_hooks_test.go b/internal/plugin/postgame_boss_hooks_test.go new file mode 100644 index 0000000..db86a67 --- /dev/null +++ b/internal/plugin/postgame_boss_hooks_test.go @@ -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 +}