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.