Skip to content
Shattered Crowns

v0.5.21-dev · 2026-08-01

Two Brains

A bug-and-calculations pass over a v0.5.20 playtest log. Six findings, and the two biggest were both the same shape: a second implementation of something the engine already knew how to do.

The offline AI was a second AI

network_manager._run_ai_turn was ~135 lines of GDScript under a header claiming it "mirrors the engine's Silver AI so offline and online behave the same". It did not, and had not for a long time:

  • It moved one tile per turn. It only ever considered the four adjacent squares, so a unit's Move stat was irrelevant to it. In the log: eight AI moves, every one exactly one tile, while the human walked four in a turn.
  • It never used a charged abilityct_cost > 0 was skipped outright. A summoner holding 163 MP took two turns in the whole match and spent both stepping one tile sideways. No summons, no Jump, no real magic, offline.
  • It ignored roughly 18 of the ~48 ability types, matching ab.type against a hardcoded string list. Among the missing: apply_buff, apply_debuff and damage_and_debuff — the three generic forms that exist precisely so new content needs no code edit. Everything added since v0.5.6 was invisible to it.

It also targeted the closest enemy where the engine targets the weakest, never learned the accuracy ranking added in v0.5.15, and had never heard of Invisible.

The engine AI is now the only AI. ai::decide_with_budget takes a TurnBudget saying which slots remain, so one call answers both halves of a turn, and ScEngine.ai_decide exposes it to the client. AI commands travel the same path player commands do, so slot tracking, the match log and undo all behave identically for both.

The server had the mirror-image bug: it ran exactly one decide per turn and then ended it, so online bots moved or attacked and never both — on the authoritative path, in every match with an AI seat or a disconnected player.

And the fast-forward never worked. ai::has_nothing_to_do matched only Wait, while its own doc comment promised "Wait or a self-Defend". Since defend is a universal action every unit holds, the AI's "nothing worth doing" answer is a self-Defend — so the check returned false for every unit with an action left, and v0.5.19's fast-forward auto-waited nobody and disengaged on the first turn with "…has something to do".

Red was winning the coin toss every match

Both unit factories seeded the clock with ct: (id * 7) % 30. Units are created blue-first, so that expression is a fixed per-team handicap:

mode blue red
1v1 0 7
2v2 7 35
3v3 21 54
4v4 42 64

Red opened better in every match up to 4v4, forever, and opening turn order was identical every time. 3v3 is the launch quickmatch queue. Opening CT is rolled with Brave/Faith/zodiac now — same band, so the pace of the opening is unchanged, and explicitly not affected by the account level bonus, because going first is not a stat you level into.

Damage shows its working

DamageDealt carried three multipliers out of roughly twelve, which made a battle log impossible to check against itself. Two cases from the log:

  • The same dragoon hit the same chemist for 72 and then 118, with identical zodiac and Brave printed. Facing explains 1.25 of the 1.64× — the rest was height, which appeared nowhere.
  • An Oil'd monk took a doubled Flamebrand, narrated as "zodiac ×0.75, faith ×0.53". The ×2 that a whole setup turn had been spent to buy went unmentioned — and was missing from the hover forecast too.

combat::damage_factors now names every term the resolver applied, and the event and the forecast call the same function so they cannot disagree. Fixed on the way: the Drain arm reported zodiac_mult: 1.0 while its damage genuinely collects the zodiac wheel.

Smaller things

  • Aiming a single-target ability at your own side now says so. Casting Insult on your own mage was legal, in range, hit nobody, and produced nothing — no event, no line, no popup, action slot gone. It emits CommandRejected{NoSuchTarget}. An AoE centred on an ally is still a whiff, because dropping a blast on your own front-liner to catch the enemies behind them is legitimate aiming.
  • The match log was stamped v0.5.15-dev for five releases. Two version constants sat side by side under a comment saying "bump both on each release"; only one got bumped. The label is derived from the version now.
  • The match log records MP. It had none anywhere, so regen, ability costs, the whiff refund and Chakra were all unverifiable after the fact.
  • Charging no longer narrates "(99 turns)" — that is a sentinel, not a countdown.
  • Corrected a stale check_match_end note in CLAUDE.md: it does not hold the match open for revivable bodies, deliberately.

Known, not fixed

A Fire-elemental physical ability's element is inert: physical hits take their element from the equipped weapon, so dragon_fist's authored Element::Fire never reaches the Oil check or the weakness check. Surfaced by writing the damage-breakdown tests. Left alone because it is a balance decision, not a defect.