Skip to content
Shattered Crowns

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 }, but brave_up is 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_damage takes 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 state category 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 the TurnStart without the TurnEnd that 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.