Skip to content
Shattered Crowns

v0.5.15-dev · 2026-07-26

Say Something

Three things that couldn't speak. Magic defence existed as a number nobody read, the engine refused commands without saying why, and there was no way to talk to anyone until the battle had already started.

Fixed — MDEF was inert for the entire life of the project (combat.rs)

UnitStats.mdef shipped with the first version of the engine. recalc_stats derived it from MA, twenty-odd items granted it, the Codex printed it on every equipment card — and no damage path had ever read the field. Every point of magic defence in the game was a tooltip. A Spellward Shield's mdef 8 did exactly nothing.

This is the fourth instance of this repo's signature failure: registry data shipped ahead of the code meant to consume it, rendered by a UI that can't tell the difference (equipment and the Movement slot in v0.5.8, evade in v0.5.10).

calc_magic_damage now mitigates on MDEF through the same curve DEF uses on the physical side — one shared mitigation_factor, one constant, because the two stats are mirror images across the roster:

DEF MDEF takes physical takes magic
Sworn Knight 9 2 69% 91%
Iron Fist 10 3 67% 87%
Arcanist 2 8 91% 71%
Eidolon Caller 2 9 91% 69%

The knight who is hardest to cut is the easiest to burn, and the summoner is the exact reverse. That inversion is the design justification for the stat existing at all; sharing one curve is what makes it read as a single idea rather than two systems that happen to point opposite ways. The full table prints from cargo test mdef_curve_table -- --nocapture.

Balance note: magic damage falls roughly 20% against a defended target. That number wants a look in the next pass — it was never tuned against a live MDEF because there has never been one.

Also removed a dead if block in the same function: an inverted element-weakness check the original author noticed was backwards, left as an empty husk, and rewrote correctly underneath.

Added — the engine now says WHY it refused a command (CommandRejected)

Every gate in process_command and execute_ability used to refuse by returning an empty event vector. The action slot was spent, nothing reached the log, and the player was told nothing at all. This is how a monk with 3 MP asking for a 10 MP Chakra produced the playtest report "Chakra is broken" — the truth was "you have no mana", and the only way anyone found that out was by reading the match log.

The server had been papering over it by listing every plausible cause in one string ("out of range, not enough MP, or blocked by a status"), because it genuinely could not tell which had happened.

New GameEvent::CommandRejected { unit_id, action, reason } with a RejectReason naming the real cause — InsufficientMp { needed, have } (both numbers, because "not enough MP" still leaves you guessing how short you were), Silenced, CannotAct, CannotMove, NotLearned, NoSuchTarget, CasterUnavailable, UnknownAbility. The server reports that instead of guessing, and the battle-log narrator gains a real line.

An empty batch now means exactly one thing — a legal cast that touched nobody — so the whiff path and the refusal path are finally distinguishable.

Fixed a real bug found on the way: aiming at a unit that had ceased to exist deducted the MP and then bailed on the target lookup, charging full price for a cast that never happened. It refunds now.

Fixed — Unbreakable only covered half of what it promised

Unbreakable ("Armor Break and Weapon Break never land on you") shipped in v0.5.6, when those arts did nothing but apply a −30% status. v0.5.9 gave the same abilities a second half that destroys the item outright, and taught only the damage path about it. From then until now, an Unbreakable samurai shrugged off the stat penalty and lost the armour anyway — the more painful half, silently unprotected, behind a tooltip saying otherwise.

This is FFT's Safeguard, which is why no separate safeguard support is registered; the guard test that used to demand its absence said the blocker was "gear destruction doesn't exist", and that note had been stale since v0.5.9.

Added — text chat in lobbies and matchmaking wait rooms

In-match chat has ridden the battle WebSocket since v0.5.3. Everything before a match had none, which is backwards: the lobby is where people actually need to talk, and the quickmatch queue is the longest wait in the game with nothing at all to look at.

Lobbies are REST-polled and have no socket, so this is a second transport for the same feature. One room abstraction keyed by an opaque id:

Room Who's in it
lobby:<lobby_id> the friend lobby
queue:<mode>:<scheme> everyone in the same quickmatch queue

The queue room is keyed by exactly what the server keys its queue on, so the people you can talk to are the people you can be matched with.

Delivery piggybacks polling the clients already do — GET /api/chat/poll returns only what's new, so the lobby folds chat into its existing cadence with no second timer. A joiner with no cursor gets the retained history instead of an empty box. Joining a lobby and starting a match post system lines, so a waiting host sees arrivals where they'd be replying to them.

Not persisted: lobby chat has no value after the lobby ends, and writing it to disk would turn an ephemeral room into a moderation surface with retention questions attached. Reports still work — messages carry the player id the existing /api/report flow uses, and mute shares user://muted.json with the in-battle panel, so muting someone in a battle keeps them muted in the next lobby.

Fixed — the AI never learned that attacks can miss (ai/mod.rs)

The AI ranked its options by raw power from the day it was written. That was correct right up until v0.5.10 made attacks capable of missing — and then nothing was updated, so for two releases it preferred a 40%-accuracy haymaker over a reliable strike that would have done more damage on average. Accuracy became a real number and the one piece of code choosing between abilities never learned to read it.

Ranking is now by expected value. Two stages, each using what it actually knows: nominal_accuracy ranks the kit before a target is picked (an axe user should reach for a 100% ability over an 80% one), then expected_value picks the target, which folds in evade and facing — so an AI that flanks is also an AI that becomes more willing to swing. Both route through the same combat::*_hit_chance the resolver rolls and the preview displays, so the AI cannot form its own opinion about how likely a swing is.

Fixed — the archer-walks-into-melee fix had only ever applied to one AI tier

v0.5.8 taught the AI that the basic Attack takes its reach from the weapon's class rather than the ability row (authored at 1). It was written as a closure inside decide_bronze and never added to decide_silver — which is what AIDifficulty::Silver runs, what the server actually uses, and the fallback for Gold/Diamond/Master too.

So the fix landed in the one tier nothing uses. Every real match since has had archers closing to punching distance to fire a five-tile bow, behind a CLAUDE.md note saying it was fixed. reach is a shared free function now: it can't be added to one caller and forgotten in the other.

The guard test for this was vacuous on its first attempt and caught by reverting the fix and watching it still pass — the Windstrider fields job abilities with range 4-5 authored on their own rows, so the AI shot from distance without ever consulting the weapon. It proves nothing unless the unit's only option is the range-1 attack row.

Added — quickmatch offers an AI escape hatch, and never takes it for you

/api/matchmake returned queued: true unconditionally when the queue was short, and there was no other path out — no timeout, no AI fill, no background sweep. A 3v3-single queue needs SIX people. The honest description of the old behaviour on a playtest population is "the FIND MATCH button spins until you press Back."

This first shipped as an automatic fill: 90 seconds elapsed, match starts, bots take the empty seats. Joe rejected that, and the reasoning is the whole point of a matchmaker — bots are not nearly as fun as people, so being handed one silently is a downgrade dressed up as a match. Waiting longer for a human has to stay the default.

So the timeout unlocks an offer instead of an outcome. After 90 seconds the longest-waiting player — the one who has actually paid the wait — gets a START WITH AI OPPONENTS button, and nothing happens until they press it. Only the head of the queue is offered it: letting any waiter trigger it would mean someone who arrived ten seconds ago could end the search for the player who has been there two minutes. Both conditions are re-checked server-side, so the button appearing is a UI hint rather than authorisation.

Humans present when the option is taken get split across both sides — two people who queued for PvP should end up facing each other with bot teammates, not stacked together beating up bots. And a real human arriving while the offer is open still simply matches, bots forgotten.

The tests caught a real bug in the underlying fix. queue_matchmaking is queue-and-poll, the client re-POSTs every 2 seconds, and the handler stamped queued_at: now on every one of those — so nobody could ever accumulate any wait and the threshold would have been unreachable in production while looking correct in review. The same bug had been quietly corrupting queue-position ordering, which had degenerated to "whoever polled least recently."

Fixed — the CT-tie turn-order mismatch, and its consequence offline

Joe's log 1 at t=150.67: the engine announced unit 3's turn, the client banner said unit 2, and unit 2's commands were accepted. Three faults in one line.

Two authorities on one question. The engine picked with max_by_key(|u| u.ct), which returns the LAST maximum on a tie; the offline client re-derived it with ct > highest_ct, which keeps the FIRST. On a tie they chose opposite ends of the roster. The client's scan also ignored airborne, which the engine excludes — so a mid-Jump dragoon could be announced by the banner and never started by the engine.

The client no longer computes this at all. TurnStart was already in the tick's event batch and simply never read.

The tie-break is now stated: highest CT, then lowest unit id. The old behaviour was deterministic but inherited from a combinator's documented edge case, and a rule two implementations read differently deserves writing down.

The commands should never have been accepted. The server has gated on active_unit forever; offline had no such gate, because engine::process_command deliberately doesn't check it. Offline gates now too.

Added — healing done, and the overheal bug underneath it

The results board tracked damage only, so a dedicated healer finished every match looking like it had contributed nothing. There's a HEAL column now, on both the unit rows and the per-player line.

Adding it surfaced a real defect: three of the five heal sites reported the ROLLED amount rather than the HP actually restored. A 60-power heal on a unit at 95/100 announced 60 and restored 5. That number is not cosmetic — it drives the floating popups, the battle log, and rewards::MatchTally, which observes these events to settle EXP and Crowns, so overheal has been inflating real payouts. FullRestore had it right the whole time and said so in a comment ("so damage popups and reward tallies stay honest"); the arms around it never followed.

Healing counts self-heals: a player reading "Healing 340" expects it to be every point they put back, and silently excluding Chakra would make the number wrong in a way nothing on screen explains.

Fixed — two more accounting bugs found sweeping for the first one

An MP drain was announcing itself as an HP heal. mana_leech emitted HealApplied — an HP event — so the battle log narrated "recovers N HP", the floating popup showed a heal, and rewards::MatchTally counted siphoned mana as healing when settling EXP and Crowns. MpRestored has existed since v0.5.10 for exactly this.

The same arm also capped in only one direction. Its own comment reasons carefully about the target's cap — "draining 40 MP off a target holding 12 must move 12, not 40, or the caster mints MP" — and then reports what left the target rather than what the caster received, so a siphon into a full pool claimed MP it never gained.

Regen credited its healing to nobody. The poison branch directly above it has always credited its damage to whoever applied the DOT; the regen branch never got the mirror, so a support caster's Regen ticked all match and appeared in no one's healing column.

Fixed — Shell did nothing against drains, and Demi couldn't wake a sleeper

Two more from the same sweep, both found by counting call sites rather than by reading code.

Three apply_damage sites, two applications of the Protect/Shell multiplier. The drain arm deliberately skips crit, facing, zodiac and reactions — reasonable, those are rolls and counter-play — and damage_taken_multiplier got swept in with them. So Shell, whose entire job is reducing incoming magic, did nothing at all against the Mystic's signature kit while reducing every other spell in the game by a third.

Demi wrote hp by hand and never ran break_on_damage_statuses, so a Demi could take half a sleeping unit's health without disturbing it. This is the same defect the poison DOT had until v0.5.10 — and CLAUDE.md recorded that DOT as "the one damage site in the game" that could not wake a sleeper. It was two; the audit that produced that sentence only looked at sites that route through apply_damage.

Fixed — seven ability types the AI would never use

Found by enumerating AbilityType and checking each variant against the AI's match lists. Seven were absent, so the engine resolved them, the Codex rendered them, and no bot ever threw one:

BuffAtk (the Squire's Accumulate), Cleanse, Steal, and — the bad one — BuffFaith, DebuffBrave, DebuffFaith and InflictCharm, which are the Orator's entire kit. An AI mediator fielded nothing it was willing to use and simply walked around basic-attacking.

All seven are wired now: BuffAtk joins the buff table it always belonged in, Cleanse gets a branch that finds an ally carrying a Debuff-category status, BuffFaith shares BuffBrave's heuristic, and the four enemy-targeting types join the damage/debuff pool (with charm deduped, and the Brave/Faith shifts deliberately not deduped — they are instant ±5s that stack, so re-applying is the point).

The guard test is written against real registry abilities rather than the enum, because what matters is whether a bot holding the ability throws it, not whether a variant name appears in a match arm.

Fixed — the hover forecast called fourteen working abilities "No Effect"

preview_ability dispatched on AbilityType and ended in _ => {}, which leaves kind at "none" — and the client renders "none" as "No Effect." Fourteen types fell through it: both drains, DamageAndDebuff (the knight's sword arts), Dispel, Cleanse, InflictRandomDebuff, InflictWithFaith, InflictCharm, Steal's ally-side siblings, and the Orator's whole Brave/Faith set.

The worst were ApplyBuff/ApplyDebuff. Those were introduced in v0.5.6 precisely so new status content could be "a pure jobs.rs data row" that the engine and AI understand for free — and the forecast was never taught, so every ability added the recommended way advertised itself as useless.

All fourteen forecast properly now: drains show a damage range and their hit chance, faith-gated afflictions carry their faith-roll odds, the generic forms name the status they actually apply, and the strip/steal/random effects read as a plain ability→target line rather than promising a specific outcome.

The catch-all is gone, so the match is exhaustive and the compiler now refuses a new AbilityType until the forecast handles it — a better guarantee than any test.

Fixed — the exported build had been stamping a four-version-old number

project.godot's config/version still read 0.5.10 while the in-game label was at v0.5.14. That field feeds the Windows exe's file properties, so every build since has identified itself wrongly to anything reading them.