mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-18 01:42:42 +00:00
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:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
98
internal/plugin/postgame_boss_hooks.go
Normal file
98
internal/plugin/postgame_boss_hooks.go
Normal 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.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))
|
||||||
|
}
|
||||||
92
internal/plugin/postgame_boss_hooks_test.go
Normal file
92
internal/plugin/postgame_boss_hooks_test.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user