July26 - #57
Conversation
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.
Deploying with
|
| 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 |
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
|
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.
|
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
Letting the vibes loose.