v0.5.18-dev · 2026-07-27
Say the Number
A playtest-report pass. Every item here came from Joe playing a 3v3 and reading the match log afterwards. The theme is stats that were working while the interface quietly said otherwise — the hardest kind of bug to report, because the game looks broken in a place where nothing is.
Brave and Faith are actually rolled offline
Offline practice and skirmish matches never rolled Brave, Faith or zodiac. The offline unit factory derived all three from the unit's id:
brave: 60 + (id % 20),
faith: 50 + (id % 25),
zodiac: zodiacs[id % 12],
So every offline 3v3 dealt Brave 60, 61, 62, 63, 64, 65 and Faith 50–55 in
id order, on every map, in every match, with the zodiac wheel stepping one
sign per unit. Joe reported it as "mostly only 0-5 # differences in rolls" —
which is exactly what a six-unit window on a + id ramp looks like.
The server had rolled these correctly since Phase 2. Only offline never did.
That is the online/offline split this project has now paid for five times
(evade 0 in the server's unit factory, the Movement slot twice, MDEF), so the
formula no longer exists in two places: stat_roll::roll_stats is the single
authority and both factories call it. Only the seeding differs, and
deliberately — offline seeds from the match seed so replays and undo
reproduce exactly, the server seeds from the OS.
Rolls now span the designed 45–90 band, the account-level floor bonus and the
Valorous/Devout lean both reach the offline path for the first time (the lean
was already being recorded on the unit and then ignored, because the thing
meant to consume it did not exist here). cargo test --test brave_faith_rolls roster_table -- --nocapture prints the live spread.
+5 Brave is visible
Cheer, Valor Song and the Orator's whole talk kit were applying their ±5 correctly the entire time — Brave scales every physical hit, so the damage really was going up. What they never did was say so, and that cost three separate lies on screen:
- The unit card's BRV number stayed at its roster value all match. The client mirrors Brave from the roster and updates it from events, and no event carried a Brave number.
- A permanent blank badge appeared over the unit. These arms faked a
StatusApplied { status: "brave_up", duration: 0 }, butbrave_upis not a registry status (correctly — nothing about it ticks or expires), so the client added a status entry nothing would ever remove, with no glyph to draw and tinted red, because a missed category lookup falls back to "debuff". A buff, rendered as a debuff, forever. - The hover forecast read "Cheer → Squire inflicts brave_up".
GameEvent::MoraleChanged { unit_id, stat, delta, new_value } replaces the
marker and carries the resulting value, so the client sets the number instead
of guessing at it. Shifts now show a "BRV +5" popup, refresh the unit
card, and print Brave rises (+5 → 67) in the log. Singing at an ally already
at 100 reports a delta of 0 and says "already at 100" rather than
announcing a +5 that moved nothing.
The four copy-pasted engine arms are one shift_morale applier now, in the
same spirit as v0.5.15's restore_hp: the caller states the intent, the
applier states the outcome.
Battle-log fixes from the same session
- Facing no longer claims a bonus on magic.
calc_magic_damagetakes no facing argument — spells don't care which way you're looking, per FFT — but the log printed "from BEHIND ×1.5" for every backstabbing spell anyway. The log has the proof side by side: the same Arcanist hit the same Squire for 24 from the front and 24 from behind, one turn apart, under the same Magic Up, while the story line claimed a 50% bonus on the second. Magic hits now read "from behind" with no multiplier. (Facing still matters to magic — it cuts the target's evade — it just doesn't hit harder.) - Slow stopped announcing an empty stat break. It emitted
StatBroken { stat: "SP", reduction_pct: 0 }— an event whose own payload says nothing happened. Slow scales CT gain, it does not reduce the SP stat, so there is no percentage to report. The client was already throwing the line away; now the event isn't sent. - Action states aren't afflictions. "Ninja is afflicted by Defending" is
an odd thing to read about a unit that chose to guard. The v0.5.16
statecategory now narrates as "Ninja is Defending". - No more turns for corpses. A unit killed by its own poison tick at turn
start got a
>>> TURN START: Squire (id 4, hp 0/113)banner. The engine auto-ends that unit's turn in the same event batch and the offline client read theTurnStartwithout theTurnEndthat supersedes it — the same half-read as the v0.5.15 turn-authority bug. Harmless in Joe's log because the corpse was an AI unit; on the player's own side it stops the clock on a unit that cannot act until they work out they must press WAIT.
You can finally mute someone in a lobby
chat_panel.gd shipped in v0.5.15 with a set_muted() that nothing
anywhere called. Its own comment said "called by a host screen that offers
a mute control", and neither host screen offered one — so the only way to be
muted in a lobby was to have been muted in a previous battle, through the
shared muted.json. The filtering worked perfectly and could never be
switched on.
Click a speaker's name in lobby or wait-room chat to mute them. An "n muted / UNMUTE ALL" row appears while anyone is muted, and it has to exist: muting hides that player's future lines, so their name is no longer in the box to click again. The control lives on the panel rather than on the two lobby screens, for the reason the file's header already gives — one implementation behind however many doors.
Mute stays shared with the in-battle scoreboard in both directions now.
Fixed alongside it: the panel escaped message bodies against BBCode but
not display names, which arrive over the same wire and are equally
player-supplied. A player called [color=red]Griefer recoloured everyone's
chat box. The clickable name meta carries a speaker index, never the name,
so the tag payload can't be crafted either.
Guard nets
tests/brave_faith_rolls.rs (6) and tests/morale_shifts.rs (7), plus
stat_roll's own 6. Verified by reverting: restoring the + id formula fails
4 of the 6 roll tests — and the one that still passes is the band check
(45 ≤ brave ≤ 90), which 60–65 satisfies comfortably. The bug was never that
the numbers were illegal; it was that they never moved, and a test has to
assert the thing that was actually wrong.
tools/smoke_chat_mute.tscn (13) drives the real panel through its real
render path; reverting the chat fix fails 6 of them.
Not built: voice chat
Asked for, and dropped after weighing it. Godot 4 exposes no Opus encoder to GDScript, so a self-hosted relay means shipping a hand-written codec (8 kHz µ-law, ~64 kbps per speaker) plus a second transport for lobbies, which have no WebSocket at all. Against playtesters who are already in a Discord voice channel together, that is a large, hard-to-test surface for something the group has solved. Revisit if playtests start drawing people from outside the Discord.