v0.5.19-dev · 2026-07-27
Show Your Work
The follow-up list from v0.5.18, worked top to bottom. Two hardening items with real gameplay consequences, three that make the combat maths legible, and two that stop a decided fight costing a click per unit per round.
The engine finally checks range
It never did. Every gate in execute_ability interrogated the caster —
alive, knows the ability, has the MP, isn't a toad — and not one looked at
where the command was pointing. The only thing that had ever enforced reach
was the client's red tile highlight, which is a drawing, not a rule.
Offline that cost a mis-click. Online it meant the authoritative server would
resolve a spell aimed at the far corner of the board for any client that
asked. RejectReason::OutOfRange { range, distance } carries both numbers,
because "out of range" alone doesn't say how much closer to walk.
The gate reads combat::ability_reach, which is now also what the client
paints and what the AI aims by — the AI had its own copy of the rule, and
it had already fallen behind (it never learned about v0.5.13's rolled Jump
range). Three answers to "how far can this go?" is a disagreement that costs
the AI its turn.
An out-of-range cast is a refusal, not a whiff: nothing is spent.
Cheer has a budget
Brave multiplies every physical hit, and talk skills had no limit but the 5–100 clamp — so a Squire could spend the battle singing and arrive at Brave 100, an investment that keeps paying while only costing early turns. Joe's log has one casting Cheer four times in twenty-one turns.
MORALE_SHIFT_BUDGET caps talk at ±15 per stat per match — three casts.
A budget rather than a lower ceiling, deliberately: a ceiling would silently
do nothing for exactly the units that rolled well, and "why did my song not
work on my best unit" is worse than a stated allowance everyone shares.
It is net, so an enemy Intimidate re-opens the headroom your Cheer spent. Two Orators arguing over one soldier is a fight worth having. The AI knows about the budget too, so it stops singing at someone who can't be moved.
Why is this number 27?
Hovering a target now shows the multipliers the hit actually collects:
Attack → Arcanist 21–27 DMG Hit 84%
zodiac ×1.25 · brave ×0.65 · their DEF ×0.67
Green above 1.0, red below. Every zodiac roll in Joe's log fired silently — you could play a whole match without learning the wheel exists.
Each term is read from the same combat::*_multiplier function the
resolver multiplies by, never re-derived in the UI. That rule is the whole
point: the battle log spent eight releases printing "from BEHIND ×1.5" on
magic hits precisely because the narration owned its own idea of the formula.
Magic passes no facing at all, and its absence there is load-bearing.
The battle log gained the Brave term too (brave ×0.65), so "is my +5 Brave
actually doing anything?" is a question the log can now answer by itself.
The last placeholder glyphs are gone
The charge hourglass, the death clock and the crystal were still Label3D
text. v0.5.17 gave the 38 statuses real art and left these three behind with
a note; this finishes the job through the same pipeline, with the death clock
printing its count beside the skull.
smoke_overhead_markers (26 checks) counts ink rather than trusting that
a file imported — ThorVG drops SVG features it can't parse without erroring,
leaving a texture that loads and renders nothing. It also compares glyphs by
an 8×8 shape signature rather than by total coverage: the hourglass and
the skull land within 0.004 of each other on ink while looking nothing alike,
so a coverage test would fail two good drawings and pass two identical ones.
Fast-forward (offline)
F, or the ▶▶ button. Speeds the bots up and auto-waits your own units on
turns that have nothing in them — Joe's log runs ~15 consecutive turns of
nothing but Wait while two death clocks tick down.
"Nothing in them" is ai::has_nothing_to_do, which runs the same decide
the bots play by, so it can never skip a turn the game itself thought was
worth taking. And the moment one of your units does have something to do,
fast-forward switches itself off and says why. That auto-disengage is what
makes an auto-wait safe to ship.
Offline only, on purpose: online there is a turn timer, teammates waiting, and a server that owns the turn.
Per-ability breakdown on the results board
The open item on ResultsBoard since v0.5.3. Each unit row now carries a
third line beside its rolled kit and gear — what it actually landed:
✦ Attack ×7 · Dual Strike ×3 · Venom Dart ×2
Counts landed uses: the tally sits beside AbilityUsed, which the whiff rule
already withholds when a cast reached nobody. Match History shows it too,
since both doors render the same board.
Guard nets
ability_range (9), morale_budget (7), ability_usage (5),
nothing_to_do (5), smoke_overhead_markers (26). Engine 41 suites and
server 21, all green.
Three existing tests were casting out of range and had to be repositioned —
a monk aiming eight tiles away, a mage five, a knight six. Each was fine
while nothing checked geometry, and each is now the refusal it always
described. One documents a real limitation rather than asserting past it:
decide bails to Wait as soon as the visible-enemy list is empty, so with
every enemy down or Invisible the fast-forward reads a healer's turn as empty
even with a dying ally in reach. Bounded and acceptable, and now written
down.