Skip to content

July26 - #57

Open
oskarrough wants to merge 80 commits into
mainfrom
july26
Open

July26#57
oskarrough wants to merge 80 commits into
mainfrom
july26

Conversation

@oskarrough

@oskarrough oskarrough commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Letting the vibes loose.

oskarrough and others added 30 commits July 26, 2026 16:49
A fight replays identically for a given seed, which is what makes two runs
comparable when one balance number changes between them. Unseeded — how the
browser plays — random() is still Math.random.

Combat events now carry the time in milliseconds since the fight started, so a
fight simulated in 200ms of wall clock reads the same as one played for real,
and collection moved out of the pino serializer so silencing the logger no
longer stops the log from being collected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A fight is now described by data — {party: ['Tank'], enemies: ['TinyWolf']} —
and every unit that joins it goes through Encounter.spawn(unitId): boot, the dev
console, the Balance Lab, a simulation, a test. Registry lookup, faction
routing, duplicate numbering and the unknown-id error are written once.

That collapses three parallel spawn paths into one and deletes DemoEncounter.
Two things fall out of it: restart() replays the roster you were fighting
instead of hardcoding the demo one, and duplicate numbering now applies to
units spawned mid-fight, not just those in the starting roster.

Units live in their own registry module because player.ts imports the spell
registry — naming the Player class from inside registry.ts reads it
mid-initialisation and silently yields undefined.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The real GameLoop, characters, spells and combat log, with two substitutions:
the browser's frame clock becomes a fixed step, so two minutes of fight resolve
in a fraction of a second, and the keyboard becomes an Autopilot casting through
the same player.castSpell() a human does. It is not a second implementation of
the game.

analyze() is a pure function over the combat log, so the terminal, the tests and
the in-game panel all read the same events the same way.

    bun run sim --enemies 'TinyWolf*3' --policy triage --repeat 10

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A HoT's parent is the unit being healed, so Renew's ticks were logged with the
healed unit as the source — a tank appeared to out-heal the healer. HOT now
carries its caster the way DoT already did.

Renew also overrode Spell.tick(), which is what calls SpellCast.succeed(), so it
never emitted a cast event and the report compensated by counting ticks as
casts. Spells now override cast() instead, a seam that runs after the cast is
logged, and the inference is gone: Renew reports 5 casts and 25 ticks rather
than 30 casts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
game.perform(action) is now the only way anything changes a fight. The keyboard,
the spell buttons, the dev console, the Balance Lab, the Autopilot, tests and
agents hand the same GameAction to the same interpreter and get back an explicit
{ok, value} or {ok, error} instead of a silently missing return value.

commands.ts is gone. So are enemyRegistry, partyRegistry and balance.ts's private
copy of the unit class list -- four hardcoded lists of the same four classes are
now one, made possible by turning faction into a static template field so
unitIds('enemy') can answer without spawning anyone.

Fixes a bug the minified build had: retuning a unit matched live characters by
constructor.name, which esbuild mangles, so the deployed Balance Lab reached
nobody. It matches unitId now, with a test.

Also folds in the Fight report panel and the architecture docs, plus four fixes
to them: the report keys actors by id rather than display name, which a mid-fight
rename would otherwise split in two; a simulation no longer fires combatlog-update
at the live panels; the Simulate button paints before it blocks; and the sim CLI
rejects valueless or non-numeric flags instead of quietly running seed 1.
Four places applied a number to a health bar, floated it and logged it, each
differently: Spell.applyHeal, HOT.tick, DoT.tick and DamageEffect.tick. The
overheal maths was written twice, the floating text was hand-built twice next
to an fct() helper that already did it, and DoT.tick logged nothing at all --
so a damage-over-time effect was invisible to the combat log, the Fight report
and the simulator alike.

applyHit() is now the only way a health bar changes. It applies, floats, logs
and announces the death, so a mechanic that does not show up in the report is
no longer something you can write by forgetting a line.

That collapses HOT and DoT into one PeriodicEffect, since the sign of `amount`
was the only thing still separating them, and takes DoT's unused Poison, Bleed
and Burn with it -- along with the CSS classes it generated, which no
stylesheet has ever defined, and its caster.constructor.name, which the
minified build mangles.

A death is now logged on the transition rather than by whatever swung, so a
kill by any means is recorded exactly once. Damage floats gained a minus sign;
they were bare positive numbers before, told apart from heals only by colour.

Simulation output is unchanged: triage and renew over the same seeds give the
same durations, hps, overheal and deaths as before.
A unit at zero health called this.disconnect(), which left it half in and half
out of the fight: vroum's teardown nulls parent, but the unit stayed in
encounter.party. Anything that walked that array and reached back up the tree —
Player.getTarget() reads this.parent.tank — threw from the first death onwards,
Game Over screen included.

Character now hands over to Encounter.onDeath(), which stops the unit without
removing it: cancels its target, the effects ticking on it, and any cast it was
halfway through. The dead stay in party/enemies because that is who joined the
fight — rosterOf() rebuilds every health bar from them, the fight report
re-simulates the composition from them, and a healer has to go on seeing a
fallen party member. Who is still standing is the new Character.alive, which
replaces five copies of health.current > 0 and is what targeting, the autopilot,
casting and the win/lose checks now ask. Leaving the unit connected also lets it
come back: heal a corpse and it resumes.
Nasty arrow hit for 500-700 against a 300hp tank, so the boss killed through any
amount of healing — the fight was not hard, it was arithmetic. Now 120-180 on a
12s cadence: about half a health bar, big enough that ignoring it kills, small
enough that a heal answers it. Nakroth drops 750 -> 500hp to keep the fight from
outlasting the mana pool now that it is survivable.

A sim test pins both ends, so a retune that puts the boss back out of reach, or
one that lets the party win without healing, fails.
There was one .FloatingCombatText container for the whole game, so every hit on
every unit stacked in the same spot and you could not tell who was being hit.
Each unit frame already has its own container; fct() now takes a character id and
looks up that frame's, cached per id and re-resolved when the frame leaves the
DOM. The container spans its frame so numbers land on the right health bar.

Drops the crit styling with it: isCrit was 'the number is over 950', which after
the retune no hit can reach and which was never a crit to begin with. Real crits
arrive with the sweet spot mechanic (#33).

Also adds mana spent per actor and a per-spell table to the fight report, so
casts/hits/overheal are visible per spell and not only as a fight total.
perform() has always returned {ok: false, error} with a real reason — 'Not enough
mana', "Can't cast without a target" — and both callers in the UI threw it away.
Pressing 3 with 40 mana did visibly nothing, which reads as a broken button
rather than a rule.

The refusal is recorded in perform() itself, as game.lastRefusal on the fight
clock, so no call site has to remember to handle it and every future one gets it
free. The UI shows it under the cast bar for 1.2s, alongside the cast bar rather
than instead of it — "Can't cast while casting" only ever happens while the bar
is up, so replacing one with the other would silence exactly that case. Cleared
by loadEncounter(), or a refusal from the last fight would sit in the new one's
future and never expire.

Still open in #35: the action bar gives no hint before you click. Doing that from
SpellCast.validate() as-is would grey out every icon during every GCD and cast —
it answers 'can this be cast right now', which conflates whether the player can
act with whether the spell is usable. That split is worth making with #32, where
per-spell cooldowns add a third state anyway.
Two of the smaller gaps in #35.

shakeTarget() looked up '.PartyMember[data-character-id=...]', but enemy frames
are '.Enemy'. The Tank's Shield Bash is a DamageEffect like any other, so every
hit the party landed drew no reaction at all — a miss here is silent, the hit just
doesn't animate. Keyed on the character id alone now, which is unique. Confirmed
against the running game: the old selector found the avatar for 0 of 1 enemies,
the new one for all 3 units. ('is-takingDamage' does have a rule, contrary to the
issue — src/style.css:134.)

Health and mana now ease to their new value over 150ms instead of jumping. The
'transition: width 600ms' that sat commented out on '.Bar div' could not be
un-commented as it was: that selector also matches the cast bar, a progress meter
re-set every frame, where a transition lags behind the cast and never reaches the
end — and the potential-value overlay, whose left and width move together, so
tweening one desyncs it from the other. Hence a 'Bar-value' class to aim at, and
'cast' excluded. 150ms rather than 600ms because the health bar is the instrument
you heal off, and a slow tween is a bar that misreports current health for as
long as it runs.
They cost exactly the same per point healed -- 1.50 each -- so choosing between
them was arithmetic, not judgement. Three numbers move: Heal 75 to 80, Greater
Heal 150 to 145, Flash Heal's cost 75 to 80.

That orders healing-per-mana as Heal 1.60, Greater Heal 1.45, Flash Heal 1.25,
so throughput is always bought with mana you will want later and the panic
button is the most wasteful thing you can press.

Outcomes hold across 20 seeds on both shaped fights: idle 0%, triage 100%,
panic 0% on each. The seed 1 boss win heals 848 for the same 600 mana instead
of 794, at 8% overheal instead of 12% -- same nine casts, spent better.
PeriodicEffect.tick() applies `amount / repeat`, so RenewEffect's `amount = 30` over
five ticks landed 6 a tick and 30 in total, not 150. At 60 mana that made Renew the
worst spell in the game by a distance -- more expensive than Heal and a third of the
healing, strictly dominated, never worth pressing. It predates the applyHit refactor;
the old HOT class divided the same way.

The number now lives on the spell as `Renew.heal`, the total it lands over the whole
effect, and the effect takes it as a constructor argument. That is also what makes
Renew tunable: it has cost, heal and castTime like the other three, so the balance lab
picks it up with no changes to balance.ts and no second row for the effect.

120 puts it at 2.00 healing per mana, the top of the ladder above Heal's 1.60 -- the
patient spell is the efficient one. Renew is not what flips the fights, though: the
renew policy goes from 15% to 100% on Nakroth at any sane value, 90 included. It was
losing because Renew healed 30, not because patient healing is weak. 120 over 150 for
the waste: 14% overheal against 23%, same outcomes either way. No other policy casts
Renew, so the other six rows of the sim table are untouched.

A test pins the advertised total to the total that lands, since `amount` reads like a
per-tick number and that is the whole bug.
`amount` reads like the number that lands each tick, but `tick()` applies
`amount / repeat` -- and reading it the wrong way is not hypothetical, it is
exactly how Renew came to heal 30 instead of 150 and stay that way for years.

Renaming it `total` costs nothing today, since Renew is the only effect in the
game. It costs something the first time somebody writes a poison.
A single seed cannot tell a balanced fight from a lucky roll, and 'the boss is winnable by
healing' is exactly the claim a retune breaks quietly. A 25-seed sweep puts Nakroth at 25/25
victories under triage and 25/25 defeats under idle, so five seeds cost 8 fights and would
catch a change that leaves the boss merely usually winnable.
Twenty minutes went into a unit-frame render test before finding that uhtml cannot interpolate
an attribute under happy-dom at all. That is why this repo has no component tests, and it is
worth one paragraph next to the environment note rather than being rediscovered.
`validate()` answered two questions as one. Three of its checks are about the player — dead,
global cooldown, already casting — and three are about the spell — no target, not in the
spellbook, not enough mana. An action bar icon wants only the second set, and drawing it from
the combined answer would grey out every icon at once for a fraction of a second after every
cast, several times a second, until the bar meant nothing.

So `validate()` now composes `canAct()` and `canUse()` in the order it used to check them,
which leaves every player-facing refusal message exactly as it was.

`canUse()` takes the target being considered rather than reading `getTarget()`, because the
Autopilot chooses who to heal and hands the target over with the cast — asking about the
selected target there answers about a different cast. That also lets the Autopilot drop its own
`affordable()`, which compared mana to cost in a second place and would have gone quietly out
of step the moment spells grew cooldowns of their own.

The 600-fight sweep is byte-identical before and after.
Only the global cooldown existed, so every spell decision collapsed to mana and cast time and
the strong heal was simply always correct when you could afford it. `cooldown` is now a tunable
static like `cost` and `heal`, enforced by `canUse()` and shown in the Balance Lab.

Every value ships at 0, so no fight changes — the 600-fight sweep is byte-identical. What the
numbers should be is a balance question and belongs where it can be swept, not here.

Cooldown state is an expiry stamp per spell name on the Player, not a Task like GlobalCooldown.
vroum defers `connect()` to a microtask, so a cooldown Task created during a cast is not mounted
on the next line — a window long enough to answer "off cooldown" to a question asked in the same
tick. Storing when it ends rather than how long it lasts also means retuning a cooldown mid-fight
leaves the running one alone, which is the rule the rest of balance follows. A fight builds a
fresh Player, so nothing needs resetting between fights.

The Autopilot picks this up for free, because it now asks `canUse()` instead of comparing mana to
cost itself. Verified: with a 20s cooldown tuned onto Flash Heal, the panic policy drops from 8
casts to 2. Without that, every simulated fight would have quietly ignored cooldowns.
The bar only ever went dim on game over, so an unaffordable spell looked exactly like an
affordable one and the only way to find out was to press it and have nothing happen.

Three states, because what the player should do about each differs: on cooldown (wait, and the
countdown says how long), out of mana (wait longer, or spend less), or otherwise unusable. The
global cooldown is deliberately *not* one of them — it applies to every icon at once and lifts
within a second of every cast, so drawing it as a state would strobe the whole bar several times
a second. It keeps the sweep it always had, which is a clock rather than a state.

This is why `canUse()` had to be separated from `validate()` first: reading the combined answer
here is exactly the strobe described above.

None of the three sets `disabled`, which is the trap the issue set for itself. A disabled button
swallows the click, and the click is what produces the refusal message explaining why nothing
happened. So an unavailable spell looks unavailable and stays pressable, and pressing it says
why. Only game over still truly disables.

Verified in the browser, since components cannot be render-tested: at 23 mana all four icons read
unaffordable and none is disabled; a spell tuned to an 8s cooldown reads `cooldown` with a
countdown of 8 while the other three stay ready; and across 40 frames spanning a global cooldown
there are exactly two distinct renders, differing only in the GCD sweep and never in icon state.
`bun run sim --repeat` answers how one fight usually goes. Nothing answered the question above
it — is the curve the shape we think it is — and the answer turned out to be no: three trash
mobs are unwinnable while the boss is a guaranteed win (#40). That was invisible because
checking it meant 24 separate invocations and reading them side by side.

`bun run sweep` runs every roster against every policy over many seeds and prints one table.
It is also the only way to check #40 is fixed, and to keep it fixed, so it belongs in the repo
rather than in a scratch file that goes away with the session.

The `idle` column is the point of the whole thing. It is the control group: a retune that lifts
a win rate by making the healer irrelevant shows up as `idle` climbing alongside `triage`, and
without it "the win rate went up" reads as success.

Verified against the 25-seed table it replaces — every figure identical.
#39 said regen never fires, reasoning that a Nakroth win spent exactly the 600-point pool "and
not one point more". The inference does not hold — it would only follow if the pool ended at
zero, and it ended at 45. Regen was firing, about fifteen times a fight, in five to seven
genuine gaps of nine seconds or more. It was worth 7% of what the fight cost, which is
decoration.

So neither lever the issue proposed was the one binding. The gaps already run well past five
seconds, so `fiveSecondRule` is left alone and now says in the code why. What was wrong was the
size: 3 mana per second against a spend rate of ten.

The rate moves to the unit that owns the pool, next to `maxMana`, which makes it tunable from the
Balance Lab and read once at construction — so retuning it mid-fight leaves the running fight
alone, as everywhere else. At 9/s regeneration covers 12-19% of a boss fight's spend and a lull
is worth about a Heal, which is the decision the issue was after: top everyone off now, or bank
it for the next spike.

Across a 25-seed sweep this cannot cheat, and that is worth stating: `idle` never casts, so no
regen number can lift the control group. It stays at 0% everywhere. The boss holds at 100% with
triage, `TinyWolf*5` stays the wall, and `panic` still beats nothing. Three wolves with `renew`
goes from 4% to 72% — real progress on #40, though `triage` there is still 0%, so the quadratic
ramp is untouched. Rates past 15 start winning fights with `panic`, which would defang a trap the
design wants, so 9 is deliberately the small end.
TinyWolf and Nakroth both swung MediumAttack, so there was no way to tune a pack of trash mobs
without tuning the boss by the same amount — and #40 needs them to move in opposite directions.

WolfBite carries the same numbers Heavy Blow did, so nothing about any fight changes: the 25-seed
sweep across six rosters and four policies comes back identical, cell for cell. Only the combat log
reads differently, and better — a wolf now bites you instead of landing the boss's Heavy Blow.

Quick Stab was already wolf-only, so with this the whole pack is tunable on its own.
Three trash mobs were unwinnable while the boss was a guaranteed win. A wolf now lands about
6.8 dps instead of 10.2, and over 25 seeds `TinyWolf*3` with triage goes from 0% to 84%.

The shape stays quadratic, on purpose. The tank kills one enemy at a time, so an extra wolf raises
incoming damage *and* lengthens the fight, and what the healer must cover grows with the square of
the pack. #40 asked for that flattened, but it also asked for `TinyWolf*5` to stay a wall, and those
two cannot both be true — a curve flat enough to make three wolves comfortable makes five winnable
by out-sustaining. What was actually wrong was where the cliff fell, so the cliff moved.

Nakroth is untouched, and the sweep proves it rather than asserting it: every boss cell is identical
to before, down to the 60.0s median and 14.8 hps, which is what splitting WolfBite off MediumAttack
in the previous commit was for.

Two things worth knowing about how this number was chosen. 0.65 of the old damage is the smallest
step that clears 80% — 0.70 leaves triage at 64%. And I tried to protect the `panic` trap with a
Flash Heal cooldown, the mechanic #32 shipped with every value at 0 and deferred to this issue.
It backfires: a cooldown makes `panic` *better* (0% to 56% on the boss at 6s, back to 0% at 10s),
because the policy has no fallback spell — when Flash Heal is unavailable it casts nothing and banks
the mana. That is a fact about the policy, not the encounter, so the cooldown stays at 0.

The residue is honest: `panic` now wins two wolves outright, where it used to lose. It still never
beats `triage` on any roster, which is what #40 asked for, but the trap is duller on that one fight.

Docs updated, since simulation.md stated the inverted curve as fact and showed a 0%-win table.
The note claimed the five-second rule "was never the thing binding". That is wrong, and measurement
says so: a healer's pauses run about 6-8s, so the rule eats five of those seconds and only the
remainder pays out. It is a major suppressor, not a bystander.

Both levers work and they land in the same place — raising the rate to 9 and lowering the threshold
to 3000 both put regeneration at 16% of a boss fight's spend. The rate keeps `fiveSecondRule` an
honest name, and it is the number a character can differ on later (#31), so it stays the one we
tune. The comment now says that instead of dismissing the alternative.
panic is the only policy whose whole output is one spell, so it is the only one a cooldown can turn
into something else: with no fallback it casts nothing while Flash Heal is down, which makes it a
fixed-rate healer delivering 100 healing per cooldown period instead of a spam policy. Measured, a 6s
Flash Heal cooldown takes it from 0% to 56% on a boss it exists to lose.

That is why every cooldown in spells.ts is still 0, and it is not obvious from either file, so the
note goes where someone would write the next policy. triage and renew are immune for a reason worth
stating too — they fall back to Greater Heal or Heal, so their throughput never rides on one spell
being available.

Nothing else changes; this is a comment.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 28, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
balancemender 21e50ae Commit Preview URL

Branch Preview URL
Jul 28 2026, 11:07 PM

@socket-security

socket-security Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updated@​types/​bun@​1.3.13 ⏵ 1.3.141001004887100
Updatedwrangler@​4.90.1 ⏵ 4.114.066 -321009296100
Updatedxid-ts@​1.2.2 ⏵ 1.3.078 +110010090 +3100
Updatedvitest@​4.1.6 ⏵ 4.1.1098 +110079 +199100
Updatedvite-plus@​0.1.20 ⏵ 0.2.679 -1100 +75100 +1100100
Updated@​cloudflare/​vite-plugin@​1.36.4 ⏵ 1.47.098 -110087 +196 -4100
Updatedtypescript@​6.0.3 ⏵ 7.0.29910089 -1100100 +10
Updatedsharp@​0.34.5 ⏵ 0.35.397 +5100 +16100 +194100
Updatedtinybase@​8.3.0 ⏵ 9.3.095 +3010010096 +1100

View full report

@socket-security

socket-security Bot commented Jul 28, 2026

Copy link
Copy Markdown

Warning

Review the following alerts detected in dependencies.

According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.

Action Severity Alert  (click "▶" to expand/collapse)
Warn High
Obfuscated code: npm @emnapi/runtime is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: ?npm/vitest@4.1.10npm/vite-plus@0.2.6npm/@cloudflare/vite-plugin@1.47.0npm/@emnapi/runtime@1.11.1

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/@emnapi/runtime@1.11.1. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

oskarrough and others added 10 commits July 28, 2026 19:32
A policy was a word for two things: the function that decides what to cast, and the
CLI flag that picks one. Both are now bot — `Bot`, `bots`, `BotName`, `BotDriver`,
and `--bot` on sim and sweep. `autopilot.ts` becomes `bot.ts` to match.

Also drops the glossary's "Words we don't use" section, which had become a second
place to keep the vocabulary in step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SuhjpYUuA1GR9KhKZC5AYm
… the unit

A unit held one currentTarget that every ability read back at landing time, so a driver could
only aim by writing into the unit first and hoping nothing else wrote over it. The Autopilot did
exactly that round trip — weigh a target, write it into the player, let the spell read it out —
which meant an automated heal moved the frame the player was aiming at. And a unit that both hit
and healed had nowhere to put the second target, which is why WolfShaman carries no attacks.

Now the driver that decides when also decides who, and hands both to useAbility(id, target).
Validation and every effect see the same unit, and Ability.target is readonly, so a cast holds the
target it started with. What a cast cannot hold still is the world around it, so land() re-checks
eligibility: the target can die, and it can be removed from the fight outright. Removal is not
death — Encounter.remove() splices the unit out but leaves its health bar full — so a guard that
asked only `alive` would heal someone no longer in the fight and mount auras on a node vroum had
already detached.

Who is eligible comes from the ability's targetRule; which of them comes from the driver — the
keyboard reads Player.intendedTarget, a policy picks its own, and a Cadence asks its unit's
Targeting, which is now a preference with a memory per target rule rather than a Task filling a
slot. A unit with no Targeting cannot choose at all, and its Cadence says so rather than beating
in silence.

selectedTarget survives as player UI state only. Clicking a frame moves nothing but the highlight
and the keyboard's aim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A bot whose whole output is one spell measures that spell's availability, not play.
`panic` idled while Flash Heal was down, banking the mana it was meant to waste, so a
cooldown meant to punish it read as a buff: 75% wins on three wolves at a 5s cooldown
against 0% at none. With Heal to fall to, the same cell is 14% (200 seeds, ±5) and the
non-monotonic peak is gone. idle, triage, renew and shield are unchanged at every value.

Closes #41.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SuhjpYUuA1GR9KhKZC5AYm
…ivate helpers

Every test that spawned or killed a node grew its own microtask drain: three
private flush()es, two step()s and two settle()s, one of which flushed twice
because a death chains a second hop. Guessing the number is how a test passes
by luck. One settle() in test-setup yields to a macrotask, which drains the
whole queue including whatever is queued while it drains.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes #56
Closes #52

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SuhjpYUuA1GR9KhKZC5AYm
TinyWolf*2, WolfShaman was 0% for every bot, renew timing out 87% of the time. The issue called it
a throughput problem wearing a targeting problem's clothes. It is the other way round, and the
sweep says so: nearly doubling the tank's damage changed nothing, halving the shaman's health
changed nothing at all, and with Mend switched off the party cleared all three in 88s. Nothing was
wrong with the pack. Nobody was ever attacking the healer.

The tank took the first live enemy and stayed there, so the shaman — spawned last — was reached
only once every wolf was dead, and no wolf could die while it was being mended. prefer.healerFirst
breaks that by going for whatever carries a healing-tagged ability. It reads the tag rather than the
class, so a unit becomes a priority target by being given a heal and in no other way, and with
nobody to single out it is exactly the prefer.first it replaced — which is why every other row of
the grid sits where it did.

That alone only moved the stalemate: the shaman mends itself, 8.4 hps against the tank's 8.7 dps,
and parked at 87% health instead of a wolf at 90%. The two numbers had landed on top of each other
by accident, so the fight was binary — at Mend 55 it is still 0%, at 40 it is 70%. MendCadence goes
8s to 16s to put daylight between them without shaving Mend, which stays a heal big enough to be
worth racing and a 2500ms cast worth interrupting once something can.

At 200 seeds the pack now reads triage 74% ±6, renew 100% — the same fight as TinyWolf*3, which is
the honest limit of this: no longer broken, not yet a fight that asks anything a wolf pack doesn't.
That wants party damage (#44).

Closes #51

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SuhjpYUuA1GR9KhKZC5AYm
@oskarrough
oskarrough marked this pull request as ready for review July 29, 2026 07:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant