Skip to content

updated dependencies to match the installation requirements for smods and lovely version - #481

Merged
V-rtualized merged 1 commit into
Balatro-Multiplayer:devfrom
lilaclila:patch-1
Jul 16, 2026
Merged

updated dependencies to match the installation requirements for smods and lovely version#481
V-rtualized merged 1 commit into
Balatro-Multiplayer:devfrom
lilaclila:patch-1

Conversation

@lilaclila

Copy link
Copy Markdown
Contributor

No description provided.

@V-rtualized
V-rtualized merged commit 00ea4a6 into Balatro-Multiplayer:dev Jul 16, 2026
V-rtualized added a commit that referenced this pull request Jul 16, 2026
* chore(release): bump version to 0.4.1~DEV

* fixes (#462)

* Update standard.lua

WSOB Ruleset lets go

* Add WSOB as tournament ruleset; bump version (#470)

* feat(rulesets): add WSOB tournament ruleset; restore standard layer

The "WSOB Ruleset lets go" change had gutted the shared `standard`
layer, which silently rebalanced every standard-composed ruleset
(Blitz, Traditional, Ranked, SmallWorld, Speedlatro, Chaos). Revert
standard.lua to its pre-WSOB form and move those settings into a
dedicated WSOB ruleset instead.

- rulesets/wsob.lua: near-vanilla tournament pool (multiplayer_content
  off, only Hanging Chad/Bloodstone/Glass reworked, Justice banned),
  gated on the ranked layer for version locking
- objects/enhancements/mp_glass.lua: extend Glass rework to "wsob"
- en-us.lua: k_wsob name + description
- ruleset_selection.lua: wire WSOB into the Tournaments tab

* refactor(ui): drop dead ruleset_buttons_data

Orphaned since the tabbed-ruleset refactor (#419), which switched every
consumer to `rulesets_tabs` but left this file-local defined and unused.
Zero references; pure dead allocation.

* chore(release): bump version to 0.4.3~DEV

* feat(mutators): turn challenge effects into a wall of stackable run modifiers (#471)

* Extract challenge effects into toggleable mutator layers + Wraith rework

Move the runtime effects from the eeeee, balancing_act (score
instability), polymorph_spam, and shared_pockets challenges into
layers/, gated on their existing G.GAME.modifiers.mp_* flags. Each
layer sets its flag from on_apply_bans at run start, so the effect can
be toggled as a mutator on any ruleset; the challenges keep their
rules.custom and now share one hook implementation (deduped). Surface
each on the mutator wall.

Add a wraith_rework layer: new c_mp_wraith consumable (random Uncommon
joker + $5, vs vanilla's random Rare + money to $0), gated via
reworked_consumables with the vanilla c_wraith banned_silent.

Also a stylua normalization pass touched bacon/planet_tycoon.

* fix broken wraith

* Wire up custom ruleset editor Play button (Path 1)

Custom tab now launches a run with a chosen base ruleset + the mutators
wall's modifier stack. Maps content sets (Vanilla/Standard/Sandbox/
Experimental) to base rulesets, sets the ruleset directly without routing
through apply_default_modifiers (which would wipe MP.MODIFIERS), then
LoadReworks + the normal start flow. Modifiers sync to the guest via the
existing modifier_layers serialization.

Custom card bans are gated behind a coming-soon pill (not yet threaded into
ApplyBans or network sync). Removed the COMING SOON ribbon.

* Remove coming-soon line from mutators wall

Drop the 'coming soon · Reward Draft · Rubber Band · Score Tax' footer and
the unused SOON table that fed it. Stub layers in mutator_stubs.lua are
untouched.

* Add No Red Seals mutator layer

New no_red_seals layer: bans Deja Vu and wraps Card:set_seal to refuse Red
assignments (RNG-neutral, so both clients no-op the same seal and stay
synced; the slot just goes sealless). Wired into the HAZARDS column of the
mutators wall alongside No Easy Money / No Uncommons.

* Disable not-yet-working mutators + stub custom editor on lobby path

Mark Inflation, No Interest, Discard Tax, Frugal, and Blind Poker as
coming-soon: greyed/un-toggleable cells with a "Coming soon." hover, and
skipped by the Randomize / Blind Random rolls so they can never activate.

Gate the custom ruleset editor to a coming-soon panel on the lobby (mp)
path; sp/practice keep the full editor.

* Disable Spartan and Pricey Packs mutators (not yet working)

* Surface the three stub mutators on the wall as coming-soon cells

Reward Draft, Rubber Band and Score Tax (the unwired layers from
mutator_stubs.lua) now appear as disabled "Coming soon." cells instead of
being invisible.

* Disable Heavy Pockets + No Takebacks; keep desc on coming-soon tooltips

Disabled mutator cells now show their full description with a trailing
"(Coming soon)" line instead of replacing the text outright.

* Disable Cash Ceiling mutator (not yet working)

* Strip LLM-flavored comment noise from mutator layers

Drop the seam/contract/MP-safe explanatory essays and fake file:line
citations; keep the human voice. Also removes the dormant, unreferenced
sticker_shop layer.

* Add credit badges to mutator wall tooltips

Reuse the MULTIPLAYER credit-badge look (float DynaText pill) from
mod_badges.lua as a standalone builder, dropped into the cell tooltip via
create_popup_UIBox_tooltip's filler hook so it only shows on hover. Cells
opt in with a credits = { art/idea/code } field. Rotation skips the
MULTIPLAYER header — one credit shows static, multiple rotate.

Seeded credits for the cells we know: Kars (idea) on Inflation and No Easy
Money, plus a few code credits.

* Casjb/full replay (#473)

* feat(replay): dual-stream deterministic action log (MP.RLOG)

Add a fully-recreatable action logging system. Every state-affecting
player action emits two aligned streams from one record() call:

- Carbon-copy (replay) stream: positional opcodes (e.g. "buy 1 2") with
  no card names, so it is indiscriminate and replays across mods. Written
  to a dedicated ".carbon" sidecar next to the Lovely log (NOT the mod
  folder, so version hotswaps don't disturb it).
- Human stream: mirrored into the carbon file; the player-facing Lovely
  log is left byte-for-byte unchanged so the website parser is undisturbed.

Both streams are hashed at game end (joker_hash) and submitted to the
server via the new submitLogHashes action for cheap tamper detection.

Coverage: buy/open_pack/voucher, reroll, sell, use, play, discard,
pack_pick/skip, select/skip blind, ready, joker/hand reorder, the random
ante_key, and net_* opponent effects (phantom/magnet/pizza/asteroid).
Instrumentation extends existing overrides; reorder uses a Preview-
independent debounced CardArea:update detector.

This is the logging system only; the replay runner and server-side hash
storage are follow-ups.

Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua
(both pass on Lua 5.4).

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* refactor(replay): emit both streams into the Lovely log, drop .carbon file

Per design change: keep the carbon (replay) and human-readable streams in the
same Lovely log, separated by prefix, instead of a dedicated .carbon sidecar.

- Carbon stream now logs under the "MP_RLOG:" prefix (MANIFEST / action lines /
  CHK trailer); the human stream keeps the existing "Client sent message:" prefix.
- record() is now the sole emitter of both lines, so the instrumented overrides
  (buy/sell/reroll/use) no longer log the human line themselves.
- Remove the .carbon file writer, path derivation, flush(), and the per-round
  flush call. Both stream buffers are still hashed at end_run and re-derive from
  the log by prefix.
- Rework the tests to capture sendTraceMessage output (no file) and update docs.

Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua pass on Lua 5.4.

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* feat(replay): ship the full carbon log to the server, not just its hash

end_run now accumulates the full carbon block (manifest + action lines + END +
CHK) and sends it as a `log` field on submitLogHashes, so the server can store a
complete viewable/replayable record of every game without the player handing
over their log. Hashing is unchanged (still over the action/human buffers).

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* feat(serialization): guard str_decode_and_unpack against zip-bomb payloads

Reject an encoded payload larger than MAX_ENCODED_BYTES (32 KB) before any
base64/gzip work, so a crafted magnetResponse / receiveEndGameJokers /
receiveNemesisDeck payload can't balloon to GBs and OOM the receiving client.
A legitimate saved joker / nemesis deck is a few KB; both callers already bail
cleanly on the nil return. Defense-in-depth — the relay also caps message size.

Adds tests/test_serialization_guard.lua.

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* chore(mod): bump version to 0.4.3 for the replay-log pre-release build

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* fix(replay): crash-proof area_enum (table index is nil on shop buy)

area_enum built a lookup table keyed by the CardArea globals, but several of
them are nil depending on game state (G.pack_cards only exists while a booster
is open, G.shop_* only in the shop). A table literal with a nil key throws
'table index is nil', so any buy/sell/use while one of those areas was absent
crashed the game — reproducibly on the first shop purchase. Compare the area
against each global directly instead (a live area vs a nil global is just
false), which is crash-safe and returns the same enum.

Commit message generated with Claude Code

* feat(replay): stream carbon lines live to the server in batches

While a game plays, MP.RLOG now batches its carbon lines and flushes them to the
server (new streamLogLines action) every ~25 lines or ~2s, under a per-game id
generated in begin_run. So a crashed/abandoned game still leaves a partial
record server-side; on a clean end, submit_log_hashes carries the same game id
so the server swaps the live stream for the complete hashed package. Flushing
falls back to the line-count trigger when love.timer is unavailable (tests), and
no-ops cleanly when there's no transport. Adds tests/test_rlog_stream.lua.

Commit message generated with Claude Code

* Hide opponent score until first hand played (#475)

* Show ??? for opponent score until first hand of a PvP blind

Display the opponent's score as "???" in the blind HUD until the local
player has played a hand in the current PvP blind, matching the server's
withholding of enemyInfo. Reset the stored opponent score at the start of
each blind so the first frame after playing shows 0 rather than the
previous blind's stale score.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LgfBstKG4gLVbu4JKUQFnn

* Add hide_score_until_played lobby option (default on for standard layer)

Expose the opponent-score hiding as a host-toggleable lobby option. It
defaults on only for standard-layer rulesets (start_lobby derives it from
MP.current_ruleset().standard) and off everywhere else; the baseline config
value is off. The blind HUD only masks the opponent score as "???" when the
option is enabled. The toggle lives in the lobby options tab, so it is
editable in non-forcing lobbies and locked at its default when a ruleset
forces options.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LgfBstKG4gLVbu4JKUQFnn

* Mask opponent hands as "?" until the first enemy info each blind

While the opponent's score is hidden, their hands count previously still
showed its stored value. Track an info_received flag on MP.GAME.enemy
(reset at blind start, set when an enemyInfo arrives) and drive a new
hands_text field, shown in place of the raw count. When the hide-score
option is on, the HUD shows "?" for opponent hands until the first enemy
info of the blind; otherwise it mirrors the real count as before. Ghost
replays mark info as received so replays are never masked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LgfBstKG4gLVbu4JKUQFnn

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Various fixes (#472)

* Timer: prevent old timer tick when ready + more correct check when can timer opponent

* TheOrder: fix SMODS seals desync

* Networking: try-catch for server messages + resolve channel once

* UI: fix ko-fi message display & crash

* CardAreas: fix crash in Shared Pockets + make shared area move in place instantly

* fix old timer not tick when you timer opponent

* Update Phantom Edition Text (#464)

The current phantom edition text is somewhat confusing, this looks to
make it clear what this edition does.

* feat: update vietnamese's localization (wip) (#468)

* Season 7 changese

* Nemesis timer display (#495)

* Update Multiplayer.json (#481)

* Fixed idol gg (#463)

* Fixed idol gg

* made change according to review by Toneblock

* Finalized sorting algo

* Timer, UI and Asteroid fixes (#482)

* 0.4.1 (#465)

* chore(release): bump version to 0.4.1~DEV

* fixes (#462)

* Update standard.lua

WSOB Ruleset lets go

---------

Co-authored-by: Stephen Kirk <me@thestephen.net>
Co-authored-by: SleepyG11 <35398172+SleepyG11@users.noreply.github.com>

* remove ~DEV for release

* Revert ruleset changes (0.4.2) (#466)

* Update standard.lua

* Update Multiplayer.json

* Casjb/full replay (#473)

* feat(replay): dual-stream deterministic action log (MP.RLOG)

Add a fully-recreatable action logging system. Every state-affecting
player action emits two aligned streams from one record() call:

- Carbon-copy (replay) stream: positional opcodes (e.g. "buy 1 2") with
  no card names, so it is indiscriminate and replays across mods. Written
  to a dedicated ".carbon" sidecar next to the Lovely log (NOT the mod
  folder, so version hotswaps don't disturb it).
- Human stream: mirrored into the carbon file; the player-facing Lovely
  log is left byte-for-byte unchanged so the website parser is undisturbed.

Both streams are hashed at game end (joker_hash) and submitted to the
server via the new submitLogHashes action for cheap tamper detection.

Coverage: buy/open_pack/voucher, reroll, sell, use, play, discard,
pack_pick/skip, select/skip blind, ready, joker/hand reorder, the random
ante_key, and net_* opponent effects (phantom/magnet/pizza/asteroid).
Instrumentation extends existing overrides; reorder uses a Preview-
independent debounced CardArea:update detector.

This is the logging system only; the replay runner and server-side hash
storage are follow-ups.

Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua
(both pass on Lua 5.4).

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* refactor(replay): emit both streams into the Lovely log, drop .carbon file

Per design change: keep the carbon (replay) and human-readable streams in the
same Lovely log, separated by prefix, instead of a dedicated .carbon sidecar.

- Carbon stream now logs under the "MP_RLOG:" prefix (MANIFEST / action lines /
  CHK trailer); the human stream keeps the existing "Client sent message:" prefix.
- record() is now the sole emitter of both lines, so the instrumented overrides
  (buy/sell/reroll/use) no longer log the human line themselves.
- Remove the .carbon file writer, path derivation, flush(), and the per-round
  flush call. Both stream buffers are still hashed at end_run and re-derive from
  the log by prefix.
- Rework the tests to capture sendTraceMessage output (no file) and update docs.

Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua pass on Lua 5.4.

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* feat(replay): ship the full carbon log to the server, not just its hash

end_run now accumulates the full carbon block (manifest + action lines + END +
CHK) and sends it as a `log` field on submitLogHashes, so the server can store a
complete viewable/replayable record of every game without the player handing
over their log. Hashing is unchanged (still over the action/human buffers).

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* feat(serialization): guard str_decode_and_unpack against zip-bomb payloads

Reject an encoded payload larger than MAX_ENCODED_BYTES (32 KB) before any
base64/gzip work, so a crafted magnetResponse / receiveEndGameJokers /
receiveNemesisDeck payload can't balloon to GBs and OOM the receiving client.
A legitimate saved joker / nemesis deck is a few KB; both callers already bail
cleanly on the nil return. Defense-in-depth — the relay also caps message size.

Adds tests/test_serialization_guard.lua.

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* chore(mod): bump version to 0.4.3 for the replay-log pre-release build

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* fix(replay): crash-proof area_enum (table index is nil on shop buy)

area_enum built a lookup table keyed by the CardArea globals, but several of
them are nil depending on game state (G.pack_cards only exists while a booster
is open, G.shop_* only in the shop). A table literal with a nil key throws
'table index is nil', so any buy/sell/use while one of those areas was absent
crashed the game — reproducibly on the first shop purchase. Compare the area
against each global directly instead (a live area vs a nil global is just
false), which is crash-safe and returns the same enum.

Commit message generated with Claude Code

* feat(replay): stream carbon lines live to the server in batches

While a game plays, MP.RLOG now batches its carbon lines and flushes them to the
server (new streamLogLines action) every ~25 lines or ~2s, under a per-game id
generated in begin_run. So a crashed/abandoned game still leaves a partial
record server-side; on a clean end, submit_log_hashes carries the same game id
so the server swaps the live stream for the complete hashed package. Flushing
falls back to the line-count trigger when love.timer is unavailable (tests), and
no-ops cleanly when there's no transport. Adds tests/test_rlog_stream.lua.

Commit message generated with Claude Code

* 0.5.0 Merge (#476)

* chore(release): bump version to 0.4.1~DEV

* fixes (#462)

* Update standard.lua

WSOB Ruleset lets go

* Add WSOB as tournament ruleset; bump version (#470)

* feat(rulesets): add WSOB tournament ruleset; restore standard layer

The "WSOB Ruleset lets go" change had gutted the shared `standard`
layer, which silently rebalanced every standard-composed ruleset
(Blitz, Traditional, Ranked, SmallWorld, Speedlatro, Chaos). Revert
standard.lua to its pre-WSOB form and move those settings into a
dedicated WSOB ruleset instead.

- rulesets/wsob.lua: near-vanilla tournament pool (multiplayer_content
  off, only Hanging Chad/Bloodstone/Glass reworked, Justice banned),
  gated on the ranked layer for version locking
- objects/enhancements/mp_glass.lua: extend Glass rework to "wsob"
- en-us.lua: k_wsob name + description
- ruleset_selection.lua: wire WSOB into the Tournaments tab

* refactor(ui): drop dead ruleset_buttons_data

Orphaned since the tabbed-ruleset refactor (#419), which switched every
consumer to `rulesets_tabs` but left this file-local defined and unused.
Zero references; pure dead allocation.

* chore(release): bump version to 0.4.3~DEV

* feat(mutators): turn challenge effects into a wall of stackable run modifiers (#471)

* Extract challenge effects into toggleable mutator layers + Wraith rework

Move the runtime effects from the eeeee, balancing_act (score
instability), polymorph_spam, and shared_pockets challenges into
layers/, gated on their existing G.GAME.modifiers.mp_* flags. Each
layer sets its flag from on_apply_bans at run start, so the effect can
be toggled as a mutator on any ruleset; the challenges keep their
rules.custom and now share one hook implementation (deduped). Surface
each on the mutator wall.

Add a wraith_rework layer: new c_mp_wraith consumable (random Uncommon
joker + $5, vs vanilla's random Rare + money to $0), gated via
reworked_consumables with the vanilla c_wraith banned_silent.

Also a stylua normalization pass touched bacon/planet_tycoon.

* fix broken wraith

* Wire up custom ruleset editor Play button (Path 1)

Custom tab now launches a run with a chosen base ruleset + the mutators
wall's modifier stack. Maps content sets (Vanilla/Standard/Sandbox/
Experimental) to base rulesets, sets the ruleset directly without routing
through apply_default_modifiers (which would wipe MP.MODIFIERS), then
LoadReworks + the normal start flow. Modifiers sync to the guest via the
existing modifier_layers serialization.

Custom card bans are gated behind a coming-soon pill (not yet threaded into
ApplyBans or network sync). Removed the COMING SOON ribbon.

* Remove coming-soon line from mutators wall

Drop the 'coming soon · Reward Draft · Rubber Band · Score Tax' footer and
the unused SOON table that fed it. Stub layers in mutator_stubs.lua are
untouched.

* Add No Red Seals mutator layer

New no_red_seals layer: bans Deja Vu and wraps Card:set_seal to refuse Red
assignments (RNG-neutral, so both clients no-op the same seal and stay
synced; the slot just goes sealless). Wired into the HAZARDS column of the
mutators wall alongside No Easy Money / No Uncommons.

* Disable not-yet-working mutators + stub custom editor on lobby path

Mark Inflation, No Interest, Discard Tax, Frugal, and Blind Poker as
coming-soon: greyed/un-toggleable cells with a "Coming soon." hover, and
skipped by the Randomize / Blind Random rolls so they can never activate.

Gate the custom ruleset editor to a coming-soon panel on the lobby (mp)
path; sp/practice keep the full editor.

* Disable Spartan and Pricey Packs mutators (not yet working)

* Surface the three stub mutators on the wall as coming-soon cells

Reward Draft, Rubber Band and Score Tax (the unwired layers from
mutator_stubs.lua) now appear as disabled "Coming soon." cells instead of
being invisible.

* Disable Heavy Pockets + No Takebacks; keep desc on coming-soon tooltips

Disabled mutator cells now show their full description with a trailing
"(Coming soon)" line instead of replacing the text outright.

* Disable Cash Ceiling mutator (not yet working)

* Strip LLM-flavored comment noise from mutator layers

Drop the seam/contract/MP-safe explanatory essays and fake file:line
citations; keep the human voice. Also removes the dormant, unreferenced
sticker_shop layer.

* Add credit badges to mutator wall tooltips

Reuse the MULTIPLAYER credit-badge look (float DynaText pill) from
mod_badges.lua as a standalone builder, dropped into the cell tooltip via
create_popup_UIBox_tooltip's filler hook so it only shows on hover. Cells
opt in with a credits = { art/idea/code } field. Rotation skips the
MULTIPLAYER header — one credit shows static, multiple rotate.

Seeded credits for the cells we know: Kars (idea) on Inflation and No Easy
Money, plus a few code credits.

* Casjb/full replay (#473)

* feat(replay): dual-stream deterministic action log (MP.RLOG)

Add a fully-recreatable action logging system. Every state-affecting
player action emits two aligned streams from one record() call:

- Carbon-copy (replay) stream: positional opcodes (e.g. "buy 1 2") with
  no card names, so it is indiscriminate and replays across mods. Written
  to a dedicated ".carbon" sidecar next to the Lovely log (NOT the mod
  folder, so version hotswaps don't disturb it).
- Human stream: mirrored into the carbon file; the player-facing Lovely
  log is left byte-for-byte unchanged so the website parser is undisturbed.

Both streams are hashed at game end (joker_hash) and submitted to the
server via the new submitLogHashes action for cheap tamper detection.

Coverage: buy/open_pack/voucher, reroll, sell, use, play, discard,
pack_pick/skip, select/skip blind, ready, joker/hand reorder, the random
ante_key, and net_* opponent effects (phantom/magnet/pizza/asteroid).
Instrumentation extends existing overrides; reorder uses a Preview-
independent debounced CardArea:update detector.

This is the logging system only; the replay runner and server-side hash
storage are follow-ups.

Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua
(both pass on Lua 5.4).

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* refactor(replay): emit both streams into the Lovely log, drop .carbon file

Per design change: keep the carbon (replay) and human-readable streams in the
same Lovely log, separated by prefix, instead of a dedicated .carbon sidecar.

- Carbon stream now logs under the "MP_RLOG:" prefix (MANIFEST / action lines /
  CHK trailer); the human stream keeps the existing "Client sent message:" prefix.
- record() is now the sole emitter of both lines, so the instrumented overrides
  (buy/sell/reroll/use) no longer log the human line themselves.
- Remove the .carbon file writer, path derivation, flush(), and the per-round
  flush call. Both stream buffers are still hashed at end_run and re-derive from
  the log by prefix.
- Rework the tests to capture sendTraceMessage output (no file) and update docs.

Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua pass on Lua 5.4.

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* feat(replay): ship the full carbon log to the server, not just its hash

end_run now accumulates the full carbon block (manifest + action lines + END +
CHK) and sends it as a `log` field on submitLogHashes, so the server can store a
complete viewable/replayable record of every game without the player handing
over their log. Hashing is unchanged (still over the action/human buffers).

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* feat(serialization): guard str_decode_and_unpack against zip-bomb payloads

Reject an encoded payload larger than MAX_ENCODED_BYTES (32 KB) before any
base64/gzip work, so a crafted magnetResponse / receiveEndGameJokers /
receiveNemesisDeck payload can't balloon to GBs and OOM the receiving client.
A legitimate saved joker / nemesis deck is a few KB; both callers already bail
cleanly on the nil return. Defense-in-depth — the relay also caps message size.

Adds tests/test_serialization_guard.lua.

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* chore(mod): bump version to 0.4.3 for the replay-log pre-release build

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* fix(replay): crash-proof area_enum (table index is nil on shop buy)

area_enum built a lookup table keyed by the CardArea globals, but several of
them are nil depending on game state (G.pack_cards only exists while a booster
is open, G.shop_* only in the shop). A table literal with a nil key throws
'table index is nil', so any buy/sell/use while one of those areas was absent
crashed the game — reproducibly on the first shop purchase. Compare the area
against each global directly instead (a live area vs a nil global is just
false), which is crash-safe and returns the same enum.

Commit message generated with Claude Code

* feat(replay): stream carbon lines live to the server in batches

While a game plays, MP.RLOG now batches its carbon lines and flushes them to the
server (new streamLogLines action) every ~25 lines or ~2s, under a per-game id
generated in begin_run. So a crashed/abandoned game still leaves a partial
record server-side; on a clean end, submit_log_hashes carries the same game id
so the server swaps the live stream for the complete hashed package. Flushing
falls back to the line-count trigger when love.timer is unavailable (tests), and
no-ops cleanly when there's no transport. Adds tests/test_rlog_stream.lua.

Commit message generated with Claude Code

* Hide opponent score until first hand played (#475)

* Show ??? for opponent score until first hand of a PvP blind

Display the opponent's score as "???" in the blind HUD until the local
player has played a hand in the current PvP blind, matching the server's
withholding of enemyInfo. Reset the stored opponent score at the start of
each blind so the first frame after playing shows 0 rather than the
previous blind's stale score.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LgfBstKG4gLVbu4JKUQFnn

* Add hide_score_until_played lobby option (default on for standard layer)

Expose the opponent-score hiding as a host-toggleable lobby option. It
defaults on only for standard-layer rulesets (start_lobby derives it from
MP.current_ruleset().standard) and off everywhere else; the baseline config
value is off. The blind HUD only masks the opponent score as "???" when the
option is enabled. The toggle lives in the lobby options tab, so it is
editable in non-forcing lobbies and locked at its default when a ruleset
forces options.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LgfBstKG4gLVbu4JKUQFnn

* Mask opponent hands as "?" until the first enemy info each blind

While the opponent's score is hidden, their hands count previously still
showed its stored value. Track an info_received flag on MP.GAME.enemy
(reset at blind start, set when an enemyInfo arrives) and drive a new
hands_text field, shown in place of the raw count. When the hide-score
option is on, the HUD shows "?" for opponent hands until the first enemy
info of the blind; otherwise it mirrors the real count as before. Ghost
replays mark info as received so replays are never masked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LgfBstKG4gLVbu4JKUQFnn

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Various fixes (#472)

* Timer: prevent old timer tick when ready + more correct check when can timer opponent

* TheOrder: fix SMODS seals desync

* Networking: try-catch for server messages + resolve channel once

* UI: fix ko-fi message display & crash

* CardAreas: fix crash in Shared Pockets + make shared area move in place instantly

* fix old timer not tick when you timer opponent

* Update Phantom Edition Text (#464)

The current phantom edition text is somewhat confusing, this looks to
make it clear what this edition does.

* feat: update vietnamese's localization (wip) (#468)

* Season 7 changese

---------

Co-authored-by: Stephen Kirk <me@thestephen.net>
Co-authored-by: SleepyG11 <35398172+SleepyG11@users.noreply.github.com>
Co-authored-by: Fantom-Balatro <fantombalatro@gmail.com>
Co-authored-by: Casper JB <casper_jb@icloud.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: EchoHeli <emiliano.dorantest@gmail.com>
Co-authored-by: Trần Quốc Lân <62930566+minotour4869@users.noreply.github.com>

* Add PvP validation to ante timer actions (#477)

* Fix life loss from PvP timer firing after PvP has ended

After endPvP, the opponent's startAnteTimer message could re-arm
nemesis_timer_started, causing the timer to tick in the old-timer
path and costing an extra life. Add a post_pvp guard that blocks
timer starts until the player readies for the next blind.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

* Revert "Fix life loss from PvP timer firing after PvP has ended"

This reverts commit 1099127.

* Fix PvP timer causing life loss after PvP ends

Tag startAnteTimer with isPvP when pressed during a PvP boss blind.
The receiving client ignores isPvP timer starts if it's no longer in
PvP, preventing the race condition where a PvP timer press arrives
after endPvP has already cleared the PvP state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

* Use end_pvp flag instead of is_pvp_boss to gate PvP timer starts

is_pvp_boss() stays true briefly after PvP ends until the game state
transitions. Check MP.GAME.end_pvp (set by endPvP handler) so the
guard catches the race window where PvP is over but the blind hasn't
changed yet.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

* Also ignore PvP timer starts when player has 0 hands left

If hands are exhausted the PvP round is effectively over even before
endPvP arrives, so reject stale PvP timer presses in that state too.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Update version from 0.5.0 to 0.5.1

* Pause timer when opponent disconnects (#478)

* Pause timer for both players when opponent disconnects

Clear both timer_started and nemesis_timer_started on
enemyDisconnected so the timer stops ticking for the remaining
player. The timer stays paused after reconnection and must be
manually restarted.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

* Also pause timer for the reconnecting player

Clear timer_started and nemesis_timer_started on rejoinedLobby so
the player who disconnected doesn't have their timer resume
immediately on reconnect.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

* Pause timer on reconnecting (disconnecting player's side)

The game loop keeps running while disconnected, so the timer keeps
ticking. Pause it in action_reconnecting when the connection drops,
not just on rejoinedLobby which is too late.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Fix PvP timer toggle when it shouldn't

* just in case

* PvP + hidden score fixes (#479)

* Fix PvP timer toggle when it shouldn't

* just in case

* Return blind select sound

* More strict timer checks

* Get Logs and Open Parser buttons in game over screen

* Add Log Parser and Open Logs in mod config

* Add "mods' and "collection' button to lobby

* always display remaining hands

* server-side pvp timer

* automatic pvp timer

* PvP timer 60+10

* Prevent asteroid go into negative levels

* update version, I guess

* prevent timer during game over/win

* use real timer for updating opponent's score

---------

Co-authored-by: Fantom-Balatro <fantombalatro@gmail.com>
Co-authored-by: Stephen Kirk <me@thestephen.net>
Co-authored-by: Casper JB <casper_jb@icloud.com>
Co-authored-by: Connor Mills <connor@virtualized.dev>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: EchoHeli <emiliano.dorantest@gmail.com>
Co-authored-by: Trần Quốc Lân <62930566+minotour4869@users.noreply.github.com>

---------

Co-authored-by: Stephen Kirk <me@thestephen.net>
Co-authored-by: SleepyG11 <35398172+SleepyG11@users.noreply.github.com>
Co-authored-by: Fantom-Balatro <fantombalatro@gmail.com>
Co-authored-by: Casper JB <casper_jb@icloud.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: EchoHeli <emiliano.dorantest@gmail.com>
Co-authored-by: Trần Quốc Lân <62930566+minotour4869@users.noreply.github.com>
Co-authored-by: Lilo <thrasherglitches@gmail.com>
Co-authored-by: 12problems <74747315+12problems@users.noreply.github.com>
V-rtualized added a commit that referenced this pull request Jul 30, 2026
* chore(release): bump version to 0.4.1~DEV

* fixes (#462)

* Update standard.lua

WSOB Ruleset lets go

* Add WSOB as tournament ruleset; bump version (#470)

* feat(rulesets): add WSOB tournament ruleset; restore standard layer

The "WSOB Ruleset lets go" change had gutted the shared `standard`
layer, which silently rebalanced every standard-composed ruleset
(Blitz, Traditional, Ranked, SmallWorld, Speedlatro, Chaos). Revert
standard.lua to its pre-WSOB form and move those settings into a
dedicated WSOB ruleset instead.

- rulesets/wsob.lua: near-vanilla tournament pool (multiplayer_content
  off, only Hanging Chad/Bloodstone/Glass reworked, Justice banned),
  gated on the ranked layer for version locking
- objects/enhancements/mp_glass.lua: extend Glass rework to "wsob"
- en-us.lua: k_wsob name + description
- ruleset_selection.lua: wire WSOB into the Tournaments tab

* refactor(ui): drop dead ruleset_buttons_data

Orphaned since the tabbed-ruleset refactor (#419), which switched every
consumer to `rulesets_tabs` but left this file-local defined and unused.
Zero references; pure dead allocation.

* chore(release): bump version to 0.4.3~DEV

* feat(mutators): turn challenge effects into a wall of stackable run modifiers (#471)

* Extract challenge effects into toggleable mutator layers + Wraith rework

Move the runtime effects from the eeeee, balancing_act (score
instability), polymorph_spam, and shared_pockets challenges into
layers/, gated on their existing G.GAME.modifiers.mp_* flags. Each
layer sets its flag from on_apply_bans at run start, so the effect can
be toggled as a mutator on any ruleset; the challenges keep their
rules.custom and now share one hook implementation (deduped). Surface
each on the mutator wall.

Add a wraith_rework layer: new c_mp_wraith consumable (random Uncommon
joker + $5, vs vanilla's random Rare + money to $0), gated via
reworked_consumables with the vanilla c_wraith banned_silent.

Also a stylua normalization pass touched bacon/planet_tycoon.

* fix broken wraith

* Wire up custom ruleset editor Play button (Path 1)

Custom tab now launches a run with a chosen base ruleset + the mutators
wall's modifier stack. Maps content sets (Vanilla/Standard/Sandbox/
Experimental) to base rulesets, sets the ruleset directly without routing
through apply_default_modifiers (which would wipe MP.MODIFIERS), then
LoadReworks + the normal start flow. Modifiers sync to the guest via the
existing modifier_layers serialization.

Custom card bans are gated behind a coming-soon pill (not yet threaded into
ApplyBans or network sync). Removed the COMING SOON ribbon.

* Remove coming-soon line from mutators wall

Drop the 'coming soon · Reward Draft · Rubber Band · Score Tax' footer and
the unused SOON table that fed it. Stub layers in mutator_stubs.lua are
untouched.

* Add No Red Seals mutator layer

New no_red_seals layer: bans Deja Vu and wraps Card:set_seal to refuse Red
assignments (RNG-neutral, so both clients no-op the same seal and stay
synced; the slot just goes sealless). Wired into the HAZARDS column of the
mutators wall alongside No Easy Money / No Uncommons.

* Disable not-yet-working mutators + stub custom editor on lobby path

Mark Inflation, No Interest, Discard Tax, Frugal, and Blind Poker as
coming-soon: greyed/un-toggleable cells with a "Coming soon." hover, and
skipped by the Randomize / Blind Random rolls so they can never activate.

Gate the custom ruleset editor to a coming-soon panel on the lobby (mp)
path; sp/practice keep the full editor.

* Disable Spartan and Pricey Packs mutators (not yet working)

* Surface the three stub mutators on the wall as coming-soon cells

Reward Draft, Rubber Band and Score Tax (the unwired layers from
mutator_stubs.lua) now appear as disabled "Coming soon." cells instead of
being invisible.

* Disable Heavy Pockets + No Takebacks; keep desc on coming-soon tooltips

Disabled mutator cells now show their full description with a trailing
"(Coming soon)" line instead of replacing the text outright.

* Disable Cash Ceiling mutator (not yet working)

* Strip LLM-flavored comment noise from mutator layers

Drop the seam/contract/MP-safe explanatory essays and fake file:line
citations; keep the human voice. Also removes the dormant, unreferenced
sticker_shop layer.

* Add credit badges to mutator wall tooltips

Reuse the MULTIPLAYER credit-badge look (float DynaText pill) from
mod_badges.lua as a standalone builder, dropped into the cell tooltip via
create_popup_UIBox_tooltip's filler hook so it only shows on hover. Cells
opt in with a credits = { art/idea/code } field. Rotation skips the
MULTIPLAYER header — one credit shows static, multiple rotate.

Seeded credits for the cells we know: Kars (idea) on Inflation and No Easy
Money, plus a few code credits.

* Casjb/full replay (#473)

* feat(replay): dual-stream deterministic action log (MP.RLOG)

Add a fully-recreatable action logging system. Every state-affecting
player action emits two aligned streams from one record() call:

- Carbon-copy (replay) stream: positional opcodes (e.g. "buy 1 2") with
  no card names, so it is indiscriminate and replays across mods. Written
  to a dedicated ".carbon" sidecar next to the Lovely log (NOT the mod
  folder, so version hotswaps don't disturb it).
- Human stream: mirrored into the carbon file; the player-facing Lovely
  log is left byte-for-byte unchanged so the website parser is undisturbed.

Both streams are hashed at game end (joker_hash) and submitted to the
server via the new submitLogHashes action for cheap tamper detection.

Coverage: buy/open_pack/voucher, reroll, sell, use, play, discard,
pack_pick/skip, select/skip blind, ready, joker/hand reorder, the random
ante_key, and net_* opponent effects (phantom/magnet/pizza/asteroid).
Instrumentation extends existing overrides; reorder uses a Preview-
independent debounced CardArea:update detector.

This is the logging system only; the replay runner and server-side hash
storage are follow-ups.

Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua
(both pass on Lua 5.4).

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* refactor(replay): emit both streams into the Lovely log, drop .carbon file

Per design change: keep the carbon (replay) and human-readable streams in the
same Lovely log, separated by prefix, instead of a dedicated .carbon sidecar.

- Carbon stream now logs under the "MP_RLOG:" prefix (MANIFEST / action lines /
  CHK trailer); the human stream keeps the existing "Client sent message:" prefix.
- record() is now the sole emitter of both lines, so the instrumented overrides
  (buy/sell/reroll/use) no longer log the human line themselves.
- Remove the .carbon file writer, path derivation, flush(), and the per-round
  flush call. Both stream buffers are still hashed at end_run and re-derive from
  the log by prefix.
- Rework the tests to capture sendTraceMessage output (no file) and update docs.

Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua pass on Lua 5.4.

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* feat(replay): ship the full carbon log to the server, not just its hash

end_run now accumulates the full carbon block (manifest + action lines + END +
CHK) and sends it as a `log` field on submitLogHashes, so the server can store a
complete viewable/replayable record of every game without the player handing
over their log. Hashing is unchanged (still over the action/human buffers).

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* feat(serialization): guard str_decode_and_unpack against zip-bomb payloads

Reject an encoded payload larger than MAX_ENCODED_BYTES (32 KB) before any
base64/gzip work, so a crafted magnetResponse / receiveEndGameJokers /
receiveNemesisDeck payload can't balloon to GBs and OOM the receiving client.
A legitimate saved joker / nemesis deck is a few KB; both callers already bail
cleanly on the nil return. Defense-in-depth — the relay also caps message size.

Adds tests/test_serialization_guard.lua.

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* chore(mod): bump version to 0.4.3 for the replay-log pre-release build

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* fix(replay): crash-proof area_enum (table index is nil on shop buy)

area_enum built a lookup table keyed by the CardArea globals, but several of
them are nil depending on game state (G.pack_cards only exists while a booster
is open, G.shop_* only in the shop). A table literal with a nil key throws
'table index is nil', so any buy/sell/use while one of those areas was absent
crashed the game — reproducibly on the first shop purchase. Compare the area
against each global directly instead (a live area vs a nil global is just
false), which is crash-safe and returns the same enum.

Commit message generated with Claude Code

* feat(replay): stream carbon lines live to the server in batches

While a game plays, MP.RLOG now batches its carbon lines and flushes them to the
server (new streamLogLines action) every ~25 lines or ~2s, under a per-game id
generated in begin_run. So a crashed/abandoned game still leaves a partial
record server-side; on a clean end, submit_log_hashes carries the same game id
so the server swaps the live stream for the complete hashed package. Flushing
falls back to the line-count trigger when love.timer is unavailable (tests), and
no-ops cleanly when there's no transport. Adds tests/test_rlog_stream.lua.

Commit message generated with Claude Code

* Hide opponent score until first hand played (#475)

* Show ??? for opponent score until first hand of a PvP blind

Display the opponent's score as "???" in the blind HUD until the local
player has played a hand in the current PvP blind, matching the server's
withholding of enemyInfo. Reset the stored opponent score at the start of
each blind so the first frame after playing shows 0 rather than the
previous blind's stale score.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LgfBstKG4gLVbu4JKUQFnn

* Add hide_score_until_played lobby option (default on for standard layer)

Expose the opponent-score hiding as a host-toggleable lobby option. It
defaults on only for standard-layer rulesets (start_lobby derives it from
MP.current_ruleset().standard) and off everywhere else; the baseline config
value is off. The blind HUD only masks the opponent score as "???" when the
option is enabled. The toggle lives in the lobby options tab, so it is
editable in non-forcing lobbies and locked at its default when a ruleset
forces options.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LgfBstKG4gLVbu4JKUQFnn

* Mask opponent hands as "?" until the first enemy info each blind

While the opponent's score is hidden, their hands count previously still
showed its stored value. Track an info_received flag on MP.GAME.enemy
(reset at blind start, set when an enemyInfo arrives) and drive a new
hands_text field, shown in place of the raw count. When the hide-score
option is on, the HUD shows "?" for opponent hands until the first enemy
info of the blind; otherwise it mirrors the real count as before. Ghost
replays mark info as received so replays are never masked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LgfBstKG4gLVbu4JKUQFnn

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Various fixes (#472)

* Timer: prevent old timer tick when ready + more correct check when can timer opponent

* TheOrder: fix SMODS seals desync

* Networking: try-catch for server messages + resolve channel once

* UI: fix ko-fi message display & crash

* CardAreas: fix crash in Shared Pockets + make shared area move in place instantly

* fix old timer not tick when you timer opponent

* Update Phantom Edition Text (#464)

The current phantom edition text is somewhat confusing, this looks to
make it clear what this edition does.

* feat: update vietnamese's localization (wip) (#468)

* Season 7 changese

* Nemesis timer display (#495)

* Update Multiplayer.json (#481)

* Fixed idol gg (#463)

* Fixed idol gg

* made change according to review by Toneblock

* Finalized sorting algo

* Timer, UI and Asteroid fixes (#482)

* 0.4.1 (#465)

* chore(release): bump version to 0.4.1~DEV

* fixes (#462)

* Update standard.lua

WSOB Ruleset lets go

---------

Co-authored-by: Stephen Kirk <me@thestephen.net>
Co-authored-by: SleepyG11 <35398172+SleepyG11@users.noreply.github.com>

* remove ~DEV for release

* Revert ruleset changes (0.4.2) (#466)

* Update standard.lua

* Update Multiplayer.json

* Casjb/full replay (#473)

* feat(replay): dual-stream deterministic action log (MP.RLOG)

Add a fully-recreatable action logging system. Every state-affecting
player action emits two aligned streams from one record() call:

- Carbon-copy (replay) stream: positional opcodes (e.g. "buy 1 2") with
  no card names, so it is indiscriminate and replays across mods. Written
  to a dedicated ".carbon" sidecar next to the Lovely log (NOT the mod
  folder, so version hotswaps don't disturb it).
- Human stream: mirrored into the carbon file; the player-facing Lovely
  log is left byte-for-byte unchanged so the website parser is undisturbed.

Both streams are hashed at game end (joker_hash) and submitted to the
server via the new submitLogHashes action for cheap tamper detection.

Coverage: buy/open_pack/voucher, reroll, sell, use, play, discard,
pack_pick/skip, select/skip blind, ready, joker/hand reorder, the random
ante_key, and net_* opponent effects (phantom/magnet/pizza/asteroid).
Instrumentation extends existing overrides; reorder uses a Preview-
independent debounced CardArea:update detector.

This is the logging system only; the replay runner and server-side hash
storage are follow-ups.

Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua
(both pass on Lua 5.4).

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* refactor(replay): emit both streams into the Lovely log, drop .carbon file

Per design change: keep the carbon (replay) and human-readable streams in the
same Lovely log, separated by prefix, instead of a dedicated .carbon sidecar.

- Carbon stream now logs under the "MP_RLOG:" prefix (MANIFEST / action lines /
  CHK trailer); the human stream keeps the existing "Client sent message:" prefix.
- record() is now the sole emitter of both lines, so the instrumented overrides
  (buy/sell/reroll/use) no longer log the human line themselves.
- Remove the .carbon file writer, path derivation, flush(), and the per-round
  flush call. Both stream buffers are still hashed at end_run and re-derive from
  the log by prefix.
- Rework the tests to capture sendTraceMessage output (no file) and update docs.

Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua pass on Lua 5.4.

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* feat(replay): ship the full carbon log to the server, not just its hash

end_run now accumulates the full carbon block (manifest + action lines + END +
CHK) and sends it as a `log` field on submitLogHashes, so the server can store a
complete viewable/replayable record of every game without the player handing
over their log. Hashing is unchanged (still over the action/human buffers).

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* feat(serialization): guard str_decode_and_unpack against zip-bomb payloads

Reject an encoded payload larger than MAX_ENCODED_BYTES (32 KB) before any
base64/gzip work, so a crafted magnetResponse / receiveEndGameJokers /
receiveNemesisDeck payload can't balloon to GBs and OOM the receiving client.
A legitimate saved joker / nemesis deck is a few KB; both callers already bail
cleanly on the nil return. Defense-in-depth — the relay also caps message size.

Adds tests/test_serialization_guard.lua.

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* chore(mod): bump version to 0.4.3 for the replay-log pre-release build

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* fix(replay): crash-proof area_enum (table index is nil on shop buy)

area_enum built a lookup table keyed by the CardArea globals, but several of
them are nil depending on game state (G.pack_cards only exists while a booster
is open, G.shop_* only in the shop). A table literal with a nil key throws
'table index is nil', so any buy/sell/use while one of those areas was absent
crashed the game — reproducibly on the first shop purchase. Compare the area
against each global directly instead (a live area vs a nil global is just
false), which is crash-safe and returns the same enum.

Commit message generated with Claude Code

* feat(replay): stream carbon lines live to the server in batches

While a game plays, MP.RLOG now batches its carbon lines and flushes them to the
server (new streamLogLines action) every ~25 lines or ~2s, under a per-game id
generated in begin_run. So a crashed/abandoned game still leaves a partial
record server-side; on a clean end, submit_log_hashes carries the same game id
so the server swaps the live stream for the complete hashed package. Flushing
falls back to the line-count trigger when love.timer is unavailable (tests), and
no-ops cleanly when there's no transport. Adds tests/test_rlog_stream.lua.

Commit message generated with Claude Code

* 0.5.0 Merge (#476)

* chore(release): bump version to 0.4.1~DEV

* fixes (#462)

* Update standard.lua

WSOB Ruleset lets go

* Add WSOB as tournament ruleset; bump version (#470)

* feat(rulesets): add WSOB tournament ruleset; restore standard layer

The "WSOB Ruleset lets go" change had gutted the shared `standard`
layer, which silently rebalanced every standard-composed ruleset
(Blitz, Traditional, Ranked, SmallWorld, Speedlatro, Chaos). Revert
standard.lua to its pre-WSOB form and move those settings into a
dedicated WSOB ruleset instead.

- rulesets/wsob.lua: near-vanilla tournament pool (multiplayer_content
  off, only Hanging Chad/Bloodstone/Glass reworked, Justice banned),
  gated on the ranked layer for version locking
- objects/enhancements/mp_glass.lua: extend Glass rework to "wsob"
- en-us.lua: k_wsob name + description
- ruleset_selection.lua: wire WSOB into the Tournaments tab

* refactor(ui): drop dead ruleset_buttons_data

Orphaned since the tabbed-ruleset refactor (#419), which switched every
consumer to `rulesets_tabs` but left this file-local defined and unused.
Zero references; pure dead allocation.

* chore(release): bump version to 0.4.3~DEV

* feat(mutators): turn challenge effects into a wall of stackable run modifiers (#471)

* Extract challenge effects into toggleable mutator layers + Wraith rework

Move the runtime effects from the eeeee, balancing_act (score
instability), polymorph_spam, and shared_pockets challenges into
layers/, gated on their existing G.GAME.modifiers.mp_* flags. Each
layer sets its flag from on_apply_bans at run start, so the effect can
be toggled as a mutator on any ruleset; the challenges keep their
rules.custom and now share one hook implementation (deduped). Surface
each on the mutator wall.

Add a wraith_rework layer: new c_mp_wraith consumable (random Uncommon
joker + $5, vs vanilla's random Rare + money to $0), gated via
reworked_consumables with the vanilla c_wraith banned_silent.

Also a stylua normalization pass touched bacon/planet_tycoon.

* fix broken wraith

* Wire up custom ruleset editor Play button (Path 1)

Custom tab now launches a run with a chosen base ruleset + the mutators
wall's modifier stack. Maps content sets (Vanilla/Standard/Sandbox/
Experimental) to base rulesets, sets the ruleset directly without routing
through apply_default_modifiers (which would wipe MP.MODIFIERS), then
LoadReworks + the normal start flow. Modifiers sync to the guest via the
existing modifier_layers serialization.

Custom card bans are gated behind a coming-soon pill (not yet threaded into
ApplyBans or network sync). Removed the COMING SOON ribbon.

* Remove coming-soon line from mutators wall

Drop the 'coming soon · Reward Draft · Rubber Band · Score Tax' footer and
the unused SOON table that fed it. Stub layers in mutator_stubs.lua are
untouched.

* Add No Red Seals mutator layer

New no_red_seals layer: bans Deja Vu and wraps Card:set_seal to refuse Red
assignments (RNG-neutral, so both clients no-op the same seal and stay
synced; the slot just goes sealless). Wired into the HAZARDS column of the
mutators wall alongside No Easy Money / No Uncommons.

* Disable not-yet-working mutators + stub custom editor on lobby path

Mark Inflation, No Interest, Discard Tax, Frugal, and Blind Poker as
coming-soon: greyed/un-toggleable cells with a "Coming soon." hover, and
skipped by the Randomize / Blind Random rolls so they can never activate.

Gate the custom ruleset editor to a coming-soon panel on the lobby (mp)
path; sp/practice keep the full editor.

* Disable Spartan and Pricey Packs mutators (not yet working)

* Surface the three stub mutators on the wall as coming-soon cells

Reward Draft, Rubber Band and Score Tax (the unwired layers from
mutator_stubs.lua) now appear as disabled "Coming soon." cells instead of
being invisible.

* Disable Heavy Pockets + No Takebacks; keep desc on coming-soon tooltips

Disabled mutator cells now show their full description with a trailing
"(Coming soon)" line instead of replacing the text outright.

* Disable Cash Ceiling mutator (not yet working)

* Strip LLM-flavored comment noise from mutator layers

Drop the seam/contract/MP-safe explanatory essays and fake file:line
citations; keep the human voice. Also removes the dormant, unreferenced
sticker_shop layer.

* Add credit badges to mutator wall tooltips

Reuse the MULTIPLAYER credit-badge look (float DynaText pill) from
mod_badges.lua as a standalone builder, dropped into the cell tooltip via
create_popup_UIBox_tooltip's filler hook so it only shows on hover. Cells
opt in with a credits = { art/idea/code } field. Rotation skips the
MULTIPLAYER header — one credit shows static, multiple rotate.

Seeded credits for the cells we know: Kars (idea) on Inflation and No Easy
Money, plus a few code credits.

* Casjb/full replay (#473)

* feat(replay): dual-stream deterministic action log (MP.RLOG)

Add a fully-recreatable action logging system. Every state-affecting
player action emits two aligned streams from one record() call:

- Carbon-copy (replay) stream: positional opcodes (e.g. "buy 1 2") with
  no card names, so it is indiscriminate and replays across mods. Written
  to a dedicated ".carbon" sidecar next to the Lovely log (NOT the mod
  folder, so version hotswaps don't disturb it).
- Human stream: mirrored into the carbon file; the player-facing Lovely
  log is left byte-for-byte unchanged so the website parser is undisturbed.

Both streams are hashed at game end (joker_hash) and submitted to the
server via the new submitLogHashes action for cheap tamper detection.

Coverage: buy/open_pack/voucher, reroll, sell, use, play, discard,
pack_pick/skip, select/skip blind, ready, joker/hand reorder, the random
ante_key, and net_* opponent effects (phantom/magnet/pizza/asteroid).
Instrumentation extends existing overrides; reorder uses a Preview-
independent debounced CardArea:update detector.

This is the logging system only; the replay runner and server-side hash
storage are follow-ups.

Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua
(both pass on Lua 5.4).

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* refactor(replay): emit both streams into the Lovely log, drop .carbon file

Per design change: keep the carbon (replay) and human-readable streams in the
same Lovely log, separated by prefix, instead of a dedicated .carbon sidecar.

- Carbon stream now logs under the "MP_RLOG:" prefix (MANIFEST / action lines /
  CHK trailer); the human stream keeps the existing "Client sent message:" prefix.
- record() is now the sole emitter of both lines, so the instrumented overrides
  (buy/sell/reroll/use) no longer log the human line themselves.
- Remove the .carbon file writer, path derivation, flush(), and the per-round
  flush call. Both stream buffers are still hashed at end_run and re-derive from
  the log by prefix.
- Rework the tests to capture sendTraceMessage output (no file) and update docs.

Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua pass on Lua 5.4.

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* feat(replay): ship the full carbon log to the server, not just its hash

end_run now accumulates the full carbon block (manifest + action lines + END +
CHK) and sends it as a `log` field on submitLogHashes, so the server can store a
complete viewable/replayable record of every game without the player handing
over their log. Hashing is unchanged (still over the action/human buffers).

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* feat(serialization): guard str_decode_and_unpack against zip-bomb payloads

Reject an encoded payload larger than MAX_ENCODED_BYTES (32 KB) before any
base64/gzip work, so a crafted magnetResponse / receiveEndGameJokers /
receiveNemesisDeck payload can't balloon to GBs and OOM the receiving client.
A legitimate saved joker / nemesis deck is a few KB; both callers already bail
cleanly on the nil return. Defense-in-depth — the relay also caps message size.

Adds tests/test_serialization_guard.lua.

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* chore(mod): bump version to 0.4.3 for the replay-log pre-release build

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* fix(replay): crash-proof area_enum (table index is nil on shop buy)

area_enum built a lookup table keyed by the CardArea globals, but several of
them are nil depending on game state (G.pack_cards only exists while a booster
is open, G.shop_* only in the shop). A table literal with a nil key throws
'table index is nil', so any buy/sell/use while one of those areas was absent
crashed the game — reproducibly on the first shop purchase. Compare the area
against each global directly instead (a live area vs a nil global is just
false), which is crash-safe and returns the same enum.

Commit message generated with Claude Code

* feat(replay): stream carbon lines live to the server in batches

While a game plays, MP.RLOG now batches its carbon lines and flushes them to the
server (new streamLogLines action) every ~25 lines or ~2s, under a per-game id
generated in begin_run. So a crashed/abandoned game still leaves a partial
record server-side; on a clean end, submit_log_hashes carries the same game id
so the server swaps the live stream for the complete hashed package. Flushing
falls back to the line-count trigger when love.timer is unavailable (tests), and
no-ops cleanly when there's no transport. Adds tests/test_rlog_stream.lua.

Commit message generated with Claude Code

* Hide opponent score until first hand played (#475)

* Show ??? for opponent score until first hand of a PvP blind

Display the opponent's score as "???" in the blind HUD until the local
player has played a hand in the current PvP blind, matching the server's
withholding of enemyInfo. Reset the stored opponent score at the start of
each blind so the first frame after playing shows 0 rather than the
previous blind's stale score.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LgfBstKG4gLVbu4JKUQFnn

* Add hide_score_until_played lobby option (default on for standard layer)

Expose the opponent-score hiding as a host-toggleable lobby option. It
defaults on only for standard-layer rulesets (start_lobby derives it from
MP.current_ruleset().standard) and off everywhere else; the baseline config
value is off. The blind HUD only masks the opponent score as "???" when the
option is enabled. The toggle lives in the lobby options tab, so it is
editable in non-forcing lobbies and locked at its default when a ruleset
forces options.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LgfBstKG4gLVbu4JKUQFnn

* Mask opponent hands as "?" until the first enemy info each blind

While the opponent's score is hidden, their hands count previously still
showed its stored value. Track an info_received flag on MP.GAME.enemy
(reset at blind start, set when an enemyInfo arrives) and drive a new
hands_text field, shown in place of the raw count. When the hide-score
option is on, the HUD shows "?" for opponent hands until the first enemy
info of the blind; otherwise it mirrors the real count as before. Ghost
replays mark info as received so replays are never masked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LgfBstKG4gLVbu4JKUQFnn

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Various fixes (#472)

* Timer: prevent old timer tick when ready + more correct check when can timer opponent

* TheOrder: fix SMODS seals desync

* Networking: try-catch for server messages + resolve channel once

* UI: fix ko-fi message display & crash

* CardAreas: fix crash in Shared Pockets + make shared area move in place instantly

* fix old timer not tick when you timer opponent

* Update Phantom Edition Text (#464)

The current phantom edition text is somewhat confusing, this looks to
make it clear what this edition does.

* feat: update vietnamese's localization (wip) (#468)

* Season 7 changese

---------

Co-authored-by: Stephen Kirk <me@thestephen.net>
Co-authored-by: SleepyG11 <35398172+SleepyG11@users.noreply.github.com>
Co-authored-by: Fantom-Balatro <fantombalatro@gmail.com>
Co-authored-by: Casper JB <casper_jb@icloud.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: EchoHeli <emiliano.dorantest@gmail.com>
Co-authored-by: Trần Quốc Lân <62930566+minotour4869@users.noreply.github.com>

* Add PvP validation to ante timer actions (#477)

* Fix life loss from PvP timer firing after PvP has ended

After endPvP, the opponent's startAnteTimer message could re-arm
nemesis_timer_started, causing the timer to tick in the old-timer
path and costing an extra life. Add a post_pvp guard that blocks
timer starts until the player readies for the next blind.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

* Revert "Fix life loss from PvP timer firing after PvP has ended"

This reverts commit 1099127.

* Fix PvP timer causing life loss after PvP ends

Tag startAnteTimer with isPvP when pressed during a PvP boss blind.
The receiving client ignores isPvP timer starts if it's no longer in
PvP, preventing the race condition where a PvP timer press arrives
after endPvP has already cleared the PvP state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

* Use end_pvp flag instead of is_pvp_boss to gate PvP timer starts

is_pvp_boss() stays true briefly after PvP ends until the game state
transitions. Check MP.GAME.end_pvp (set by endPvP handler) so the
guard catches the race window where PvP is over but the blind hasn't
changed yet.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

* Also ignore PvP timer starts when player has 0 hands left

If hands are exhausted the PvP round is effectively over even before
endPvP arrives, so reject stale PvP timer presses in that state too.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Update version from 0.5.0 to 0.5.1

* Pause timer when opponent disconnects (#478)

* Pause timer for both players when opponent disconnects

Clear both timer_started and nemesis_timer_started on
enemyDisconnected so the timer stops ticking for the remaining
player. The timer stays paused after reconnection and must be
manually restarted.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

* Also pause timer for the reconnecting player

Clear timer_started and nemesis_timer_started on rejoinedLobby so
the player who disconnected doesn't have their timer resume
immediately on reconnect.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

* Pause timer on reconnecting (disconnecting player's side)

The game loop keeps running while disconnected, so the timer keeps
ticking. Pause it in action_reconnecting when the connection drops,
not just on rejoinedLobby which is too late.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Fix PvP timer toggle when it shouldn't

* just in case

* PvP + hidden score fixes (#479)

* Fix PvP timer toggle when it shouldn't

* just in case

* Return blind select sound

* More strict timer checks

* Get Logs and Open Parser buttons in game over screen

* Add Log Parser and Open Logs in mod config

* Add "mods' and "collection' button to lobby

* always display remaining hands

* server-side pvp timer

* automatic pvp timer

* PvP timer 60+10

* Prevent asteroid go into negative levels

* update version, I guess

* prevent timer during game over/win

* use real timer for updating opponent's score

---------

Co-authored-by: Fantom-Balatro <fantombalatro@gmail.com>
Co-authored-by: Stephen Kirk <me@thestephen.net>
Co-authored-by: Casper JB <casper_jb@icloud.com>
Co-authored-by: Connor Mills <connor@virtualized.dev>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: EchoHeli <emiliano.dorantest@gmail.com>
Co-authored-by: Trần Quốc Lân <62930566+minotour4869@users.noreply.github.com>

* Bump

* MLBa ruleset: add bloodstone rework, idol TheOrder, hide enemy location, timer threshold (#499)

* MLBa ruleset: add bloodstone rework, idol TheOrder, hide enemy location, timer threshold

- Enable reworked bloodstone (j_mp_bloodstone) and silently ban vanilla bloodstone
- Gate idol RNG normalization with MP.is_major_league_ruleset()
- Add enemy_location_disabled config to hide opponent location HUD
- Add timer_display_threshold: delay startAnteTimer network message until
  timer crosses threshold; presser sees full countdown, opponent sees static
  threshold value until the message arrives
- Update snapshot test for majorleague ruleset shape

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MuDBRvAF5nx9htEHCW2WqZ

* Suppress enemy skip sound when enemy_location_disabled is true

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MuDBRvAF5nx9htEHCW2WqZ

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Update Major League description with new rules

* Fix timer threshold: remove HUD display cap, show real timer value (#513)

The HUD was capping the displayed timer at the threshold value (180)
before anyone pressed the timer button, making skip bonuses invisible.
The threshold should only control when the startAnteTimer network
message is sent — the local player always sees their real timer value.


Claude-Session: https://claude.ai/code/session_01MuDBRvAF5nx9htEHCW2WqZ

Co-authored-by: Claude <noreply@anthropic.com>

* pause_ante_timer handles timer threshold

* MLBa: remove life forgiveness, hide skips with timer threshold (#514)

* Fix timer threshold: remove HUD display cap, show real timer value

The HUD was capping the displayed timer at the threshold value (180)
before anyone pressed the timer button, making skip bonuses invisible.
The threshold should only control when the startAnteTimer network
message is sent — the local player always sees their real timer value.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MuDBRvAF5nx9htEHCW2WqZ

* MLBa: remove first life forgiveness, hide skips in lobby info

- Set timer_forgiveness to 0 (no free timer failures)
- Hide skip count in lobby info menu when timer_display_threshold is
  set, since skip count leaks information about the opponent's
  accumulated time bonus

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MuDBRvAF5nx9htEHCW2WqZ

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Retract time for skipping (#512)

* Consume & restore timer advanced better implementation

* matching skips implementation (old timer only)

* adjustments for making it consistent

* Disable matching skip timer when timer_display_threshold is set (#515)

The matching skip system (restore/consume timer based on skip
difference) conflicts with the timer threshold — the threshold
hides skip advantage by delaying startAnteTimer, but the matching
system reveals it by adjusting both players' timers symmetrically.


Claude-Session: https://claude.ai/code/session_01MuDBRvAF5nx9htEHCW2WqZ

Co-authored-by: Claude <noreply@anthropic.com>

* Fix threshold skip: add time locally, ignore enemy skips (#516)

When timer_display_threshold is set, the local player's skip should
still increase their timer (they see it go above 180). Only the
matching/retraction from enemy skips is suppressed — the opponent
learns nothing until the timer crosses the threshold.


Claude-Session: https://claude.ai/code/session_01MuDBRvAF5nx9htEHCW2WqZ

Co-authored-by: Claude <noreply@anthropic.com>

* Clamp nemesis timer hover display to threshold (#517)

The hover tooltip on the timer shows the opponent's real timer value
from dataSync. When timer_display_threshold is set, cap the displayed
value so it doesn't leak the opponent's skip bonus.


Claude-Session: https://claude.ai/code/session_01MuDBRvAF5nx9htEHCW2WqZ

Co-authored-by: Claude <noreply@anthropic.com>

---------

Co-authored-by: Stephen Kirk <me@thestephen.net>
Co-authored-by: SleepyG11 <35398172+SleepyG11@users.noreply.github.com>
Co-authored-by: Fantom-Balatro <fantombalatro@gmail.com>
Co-authored-by: Casper JB <casper_jb@icloud.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: EchoHeli <emiliano.dorantest@gmail.com>
Co-authored-by: Trần Quốc Lân <62930566+minotour4869@users.noreply.github.com>
Co-authored-by: Lilo <thrasherglitches@gmail.com>
Co-authored-by: 12problems <74747315+12problems@users.noreply.github.com>
V-rtualized added a commit that referenced this pull request Aug 1, 2026
* 0.4.1 (#465)

* chore(release): bump version to 0.4.1~DEV

* fixes (#462)

* Update standard.lua

WSOB Ruleset lets go

---------

Co-authored-by: Stephen Kirk <me@thestephen.net>
Co-authored-by: SleepyG11 <35398172+SleepyG11@users.noreply.github.com>

* remove ~DEV for release

* Revert ruleset changes (0.4.2) (#466)

* Update standard.lua

* Update Multiplayer.json

* Casjb/full replay (#473)

* feat(replay): dual-stream deterministic action log (MP.RLOG)

Add a fully-recreatable action logging system. Every state-affecting
player action emits two aligned streams from one record() call:

- Carbon-copy (replay) stream: positional opcodes (e.g. "buy 1 2") with
  no card names, so it is indiscriminate and replays across mods. Written
  to a dedicated ".carbon" sidecar next to the Lovely log (NOT the mod
  folder, so version hotswaps don't disturb it).
- Human stream: mirrored into the carbon file; the player-facing Lovely
  log is left byte-for-byte unchanged so the website parser is undisturbed.

Both streams are hashed at game end (joker_hash) and submitted to the
server via the new submitLogHashes action for cheap tamper detection.

Coverage: buy/open_pack/voucher, reroll, sell, use, play, discard,
pack_pick/skip, select/skip blind, ready, joker/hand reorder, the random
ante_key, and net_* opponent effects (phantom/magnet/pizza/asteroid).
Instrumentation extends existing overrides; reorder uses a Preview-
independent debounced CardArea:update detector.

This is the logging system only; the replay runner and server-side hash
storage are follow-ups.

Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua
(both pass on Lua 5.4).

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* refactor(replay): emit both streams into the Lovely log, drop .carbon file

Per design change: keep the carbon (replay) and human-readable streams in the
same Lovely log, separated by prefix, instead of a dedicated .carbon sidecar.

- Carbon stream now logs under the "MP_RLOG:" prefix (MANIFEST / action lines /
  CHK trailer); the human stream keeps the existing "Client sent message:" prefix.
- record() is now the sole emitter of both lines, so the instrumented overrides
  (buy/sell/reroll/use) no longer log the human line themselves.
- Remove the .carbon file writer, path derivation, flush(), and the per-round
  flush call. Both stream buffers are still hashed at end_run and re-derive from
  the log by prefix.
- Rework the tests to capture sendTraceMessage output (no file) and update docs.

Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua pass on Lua 5.4.

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* feat(replay): ship the full carbon log to the server, not just its hash

end_run now accumulates the full carbon block (manifest + action lines + END +
CHK) and sends it as a `log` field on submitLogHashes, so the server can store a
complete viewable/replayable record of every game without the player handing
over their log. Hashing is unchanged (still over the action/human buffers).

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* feat(serialization): guard str_decode_and_unpack against zip-bomb payloads

Reject an encoded payload larger than MAX_ENCODED_BYTES (32 KB) before any
base64/gzip work, so a crafted magnetResponse / receiveEndGameJokers /
receiveNemesisDeck payload can't balloon to GBs and OOM the receiving client.
A legitimate saved joker / nemesis deck is a few KB; both callers already bail
cleanly on the nil return. Defense-in-depth — the relay also caps message size.

Adds tests/test_serialization_guard.lua.

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* chore(mod): bump version to 0.4.3 for the replay-log pre-release build

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* fix(replay): crash-proof area_enum (table index is nil on shop buy)

area_enum built a lookup table keyed by the CardArea globals, but several of
them are nil depending on game state (G.pack_cards only exists while a booster
is open, G.shop_* only in the shop). A table literal with a nil key throws
'table index is nil', so any buy/sell/use while one of those areas was absent
crashed the game — reproducibly on the first shop purchase. Compare the area
against each global directly instead (a live area vs a nil global is just
false), which is crash-safe and returns the same enum.

Commit message generated with Claude Code

* feat(replay): stream carbon lines live to the server in batches

While a game plays, MP.RLOG now batches its carbon lines and flushes them to the
server (new streamLogLines action) every ~25 lines or ~2s, under a per-game id
generated in begin_run. So a crashed/abandoned game still leaves a partial
record server-side; on a clean end, submit_log_hashes carries the same game id
so the server swaps the live stream for the complete hashed package. Flushing
falls back to the line-count trigger when love.timer is unavailable (tests), and
no-ops cleanly when there's no transport. Adds tests/test_rlog_stream.lua.

Commit message generated with Claude Code

* 0.5.0 Merge (#476)

* chore(release): bump version to 0.4.1~DEV

* fixes (#462)

* Update standard.lua

WSOB Ruleset lets go

* Add WSOB as tournament ruleset; bump version (#470)

* feat(rulesets): add WSOB tournament ruleset; restore standard layer

The "WSOB Ruleset lets go" change had gutted the shared `standard`
layer, which silently rebalanced every standard-composed ruleset
(Blitz, Traditional, Ranked, SmallWorld, Speedlatro, Chaos). Revert
standard.lua to its pre-WSOB form and move those settings into a
dedicated WSOB ruleset instead.

- rulesets/wsob.lua: near-vanilla tournament pool (multiplayer_content
  off, only Hanging Chad/Bloodstone/Glass reworked, Justice banned),
  gated on the ranked layer for version locking
- objects/enhancements/mp_glass.lua: extend Glass rework to "wsob"
- en-us.lua: k_wsob name + description
- ruleset_selection.lua: wire WSOB into the Tournaments tab

* refactor(ui): drop dead ruleset_buttons_data

Orphaned since the tabbed-ruleset refactor (#419), which switched every
consumer to `rulesets_tabs` but left this file-local defined and unused.
Zero references; pure dead allocation.

* chore(release): bump version to 0.4.3~DEV

* feat(mutators): turn challenge effects into a wall of stackable run modifiers (#471)

* Extract challenge effects into toggleable mutator layers + Wraith rework

Move the runtime effects from the eeeee, balancing_act (score
instability), polymorph_spam, and shared_pockets challenges into
layers/, gated on their existing G.GAME.modifiers.mp_* flags. Each
layer sets its flag from on_apply_bans at run start, so the effect can
be toggled as a mutator on any ruleset; the challenges keep their
rules.custom and now share one hook implementation (deduped). Surface
each on the mutator wall.

Add a wraith_rework layer: new c_mp_wraith consumable (random Uncommon
joker + $5, vs vanilla's random Rare + money to $0), gated via
reworked_consumables with the vanilla c_wraith banned_silent.

Also a stylua normalization pass touched bacon/planet_tycoon.

* fix broken wraith

* Wire up custom ruleset editor Play button (Path 1)

Custom tab now launches a run with a chosen base ruleset + the mutators
wall's modifier stack. Maps content sets (Vanilla/Standard/Sandbox/
Experimental) to base rulesets, sets the ruleset directly without routing
through apply_default_modifiers (which would wipe MP.MODIFIERS), then
LoadReworks + the normal start flow. Modifiers sync to the guest via the
existing modifier_layers serialization.

Custom card bans are gated behind a coming-soon pill (not yet threaded into
ApplyBans or network sync). Removed the COMING SOON ribbon.

* Remove coming-soon line from mutators wall

Drop the 'coming soon · Reward Draft · Rubber Band · Score Tax' footer and
the unused SOON table that fed it. Stub layers in mutator_stubs.lua are
untouched.

* Add No Red Seals mutator layer

New no_red_seals layer: bans Deja Vu and wraps Card:set_seal to refuse Red
assignments (RNG-neutral, so both clients no-op the same seal and stay
synced; the slot just goes sealless). Wired into the HAZARDS column of the
mutators wall alongside No Easy Money / No Uncommons.

* Disable not-yet-working mutators + stub custom editor on lobby path

Mark Inflation, No Interest, Discard Tax, Frugal, and Blind Poker as
coming-soon: greyed/un-toggleable cells with a "Coming soon." hover, and
skipped by the Randomize / Blind Random rolls so they can never activate.

Gate the custom ruleset editor to a coming-soon panel on the lobby (mp)
path; sp/practice keep the full editor.

* Disable Spartan and Pricey Packs mutators (not yet working)

* Surface the three stub mutators on the wall as coming-soon cells

Reward Draft, Rubber Band and Score Tax (the unwired layers from
mutator_stubs.lua) now appear as disabled "Coming soon." cells instead of
being invisible.

* Disable Heavy Pockets + No Takebacks; keep desc on coming-soon tooltips

Disabled mutator cells now show their full description with a trailing
"(Coming soon)" line instead of replacing the text outright.

* Disable Cash Ceiling mutator (not yet working)

* Strip LLM-flavored comment noise from mutator layers

Drop the seam/contract/MP-safe explanatory essays and fake file:line
citations; keep the human voice. Also removes the dormant, unreferenced
sticker_shop layer.

* Add credit badges to mutator wall tooltips

Reuse the MULTIPLAYER credit-badge look (float DynaText pill) from
mod_badges.lua as a standalone builder, dropped into the cell tooltip via
create_popup_UIBox_tooltip's filler hook so it only shows on hover. Cells
opt in with a credits = { art/idea/code } field. Rotation skips the
MULTIPLAYER header — one credit shows static, multiple rotate.

Seeded credits for the cells we know: Kars (idea) on Inflation and No Easy
Money, plus a few code credits.

* Casjb/full replay (#473)

* feat(replay): dual-stream deterministic action log (MP.RLOG)

Add a fully-recreatable action logging system. Every state-affecting
player action emits two aligned streams from one record() call:

- Carbon-copy (replay) stream: positional opcodes (e.g. "buy 1 2") with
  no card names, so it is indiscriminate and replays across mods. Written
  to a dedicated ".carbon" sidecar next to the Lovely log (NOT the mod
  folder, so version hotswaps don't disturb it).
- Human stream: mirrored into the carbon file; the player-facing Lovely
  log is left byte-for-byte unchanged so the website parser is undisturbed.

Both streams are hashed at game end (joker_hash) and submitted to the
server via the new submitLogHashes action for cheap tamper detection.

Coverage: buy/open_pack/voucher, reroll, sell, use, play, discard,
pack_pick/skip, select/skip blind, ready, joker/hand reorder, the random
ante_key, and net_* opponent effects (phantom/magnet/pizza/asteroid).
Instrumentation extends existing overrides; reorder uses a Preview-
independent debounced CardArea:update detector.

This is the logging system only; the replay runner and server-side hash
storage are follow-ups.

Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua
(both pass on Lua 5.4).

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* refactor(replay): emit both streams into the Lovely log, drop .carbon file

Per design change: keep the carbon (replay) and human-readable streams in the
same Lovely log, separated by prefix, instead of a dedicated .carbon sidecar.

- Carbon stream now logs under the "MP_RLOG:" prefix (MANIFEST / action lines /
  CHK trailer); the human stream keeps the existing "Client sent message:" prefix.
- record() is now the sole emitter of both lines, so the instrumented overrides
  (buy/sell/reroll/use) no longer log the human line themselves.
- Remove the .carbon file writer, path derivation, flush(), and the per-round
  flush call. Both stream buffers are still hashed at end_run and re-derive from
  the log by prefix.
- Rework the tests to capture sendTraceMessage output (no file) and update docs.

Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua pass on Lua 5.4.

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* feat(replay): ship the full carbon log to the server, not just its hash

end_run now accumulates the full carbon block (manifest + action lines + END +
CHK) and sends it as a `log` field on submitLogHashes, so the server can store a
complete viewable/replayable record of every game without the player handing
over their log. Hashing is unchanged (still over the action/human buffers).

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* feat(serialization): guard str_decode_and_unpack against zip-bomb payloads

Reject an encoded payload larger than MAX_ENCODED_BYTES (32 KB) before any
base64/gzip work, so a crafted magnetResponse / receiveEndGameJokers /
receiveNemesisDeck payload can't balloon to GBs and OOM the receiving client.
A legitimate saved joker / nemesis deck is a few KB; both callers already bail
cleanly on the nil return. Defense-in-depth — the relay also caps message size.

Adds tests/test_serialization_guard.lua.

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* chore(mod): bump version to 0.4.3 for the replay-log pre-release build

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* fix(replay): crash-proof area_enum (table index is nil on shop buy)

area_enum built a lookup table keyed by the CardArea globals, but several of
them are nil depending on game state (G.pack_cards only exists while a booster
is open, G.shop_* only in the shop). A table literal with a nil key throws
'table index is nil', so any buy/sell/use while one of those areas was absent
crashed the game — reproducibly on the first shop purchase. Compare the area
against each global directly instead (a live area vs a nil global is just
false), which is crash-safe and returns the same enum.

Commit message generated with Claude Code

* feat(replay): stream carbon lines live to the server in batches

While a game plays, MP.RLOG now batches its carbon lines and flushes them to the
server (new streamLogLines action) every ~25 lines or ~2s, under a per-game id
generated in begin_run. So a crashed/abandoned game still leaves a partial
record server-side; on a clean end, submit_log_hashes carries the same game id
so the server swaps the live stream for the complete hashed package. Flushing
falls back to the line-count trigger when love.timer is unavailable (tests), and
no-ops cleanly when there's no transport. Adds tests/test_rlog_stream.lua.

Commit message generated with Claude Code

* Hide opponent score until first hand played (#475)

* Show ??? for opponent score until first hand of a PvP blind

Display the opponent's score as "???" in the blind HUD until the local
player has played a hand in the current PvP blind, matching the server's
withholding of enemyInfo. Reset the stored opponent score at the start of
each blind so the first frame after playing shows 0 rather than the
previous blind's stale score.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LgfBstKG4gLVbu4JKUQFnn

* Add hide_score_until_played lobby option (default on for standard layer)

Expose the opponent-score hiding as a host-toggleable lobby option. It
defaults on only for standard-layer rulesets (start_lobby derives it from
MP.current_ruleset().standard) and off everywhere else; the baseline config
value is off. The blind HUD only masks the opponent score as "???" when the
option is enabled. The toggle lives in the lobby options tab, so it is
editable in non-forcing lobbies and locked at its default when a ruleset
forces options.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LgfBstKG4gLVbu4JKUQFnn

* Mask opponent hands as "?" until the first enemy info each blind

While the opponent's score is hidden, their hands count previously still
showed its stored value. Track an info_received flag on MP.GAME.enemy
(reset at blind start, set when an enemyInfo arrives) and drive a new
hands_text field, shown in place of the raw count. When the hide-score
option is on, the HUD shows "?" for opponent hands until the first enemy
info of the blind; otherwise it mirrors the real count as before. Ghost
replays mark info as received so replays are never masked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LgfBstKG4gLVbu4JKUQFnn

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Various fixes (#472)

* Timer: prevent old timer tick when ready + more correct check when can timer opponent

* TheOrder: fix SMODS seals desync

* Networking: try-catch for server messages + resolve channel once

* UI: fix ko-fi message display & crash

* CardAreas: fix crash in Shared Pockets + make shared area move in place instantly

* fix old timer not tick when you timer opponent

* Update Phantom Edition Text (#464)

The current phantom edition text is somewhat confusing, this looks to
make it clear what this edition does.

* feat: update vietnamese's localization (wip) (#468)

* Season 7 changese

---------

Co-authored-by: Stephen Kirk <me@thestephen.net>
Co-authored-by: SleepyG11 <35398172+SleepyG11@users.noreply.github.com>
Co-authored-by: Fantom-Balatro <fantombalatro@gmail.com>
Co-authored-by: Casper JB <casper_jb@icloud.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: EchoHeli <emiliano.dorantest@gmail.com>
Co-authored-by: Trần Quốc Lân <62930566+minotour4869@users.noreply.github.com>

* Add PvP validation to ante timer actions (#477)

* Fix life loss from PvP timer firing after PvP has ended

After endPvP, the opponent's startAnteTimer message could re-arm
nemesis_timer_started, causing the timer to tick in the old-timer
path and costing an extra life. Add a post_pvp guard that blocks
timer starts until the player readies for the next blind.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

* Revert "Fix life loss from PvP timer firing after PvP has ended"

This reverts commit 1099127ee244ead313314ed62d7662175d9007d7.

* Fix PvP timer causing life loss after PvP ends

Tag startAnteTimer with isPvP when pressed during a PvP boss blind.
The receiving client ignores isPvP timer starts if it's no longer in
PvP, preventing the race condition where a PvP timer press arrives
after endPvP has already cleared the PvP state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

* Use end_pvp flag instead of is_pvp_boss to gate PvP timer starts

is_pvp_boss() stays true briefly after PvP ends until the game state
transitions. Check MP.GAME.end_pvp (set by endPvP handler) so the
guard catches the race window where PvP is over but the blind hasn't
changed yet.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

* Also ignore PvP timer starts when player has 0 hands left

If hands are exhausted the PvP round is effectively over even before
endPvP arrives, so reject stale PvP timer presses in that state too.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Update version from 0.5.0 to 0.5.1

* Pause timer when opponent disconnects (#478)

* Pause timer for both players when opponent disconnects

Clear both timer_started and nemesis_timer_started on
enemyDisconnected so the timer stops ticking for the remaining
player. The timer stays paused after reconnection and must be
manually restarted.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

* Also pause timer for the reconnecting player

Clear timer_started and nemesis_timer_started on rejoinedLobby so
the player who disconnected doesn't have their timer resume
immediately on reconnect.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

* Pause timer on reconnecting (disconnecting player's side)

The game loop keeps running while disconnected, so the timer keeps
ticking. Pause it in action_reconnecting when the connection drops,
not just on rejoinedLobby which is too late.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

---------

Co-authored-by: Claude <noreply@anthropic.com>

* PvP + hidden score fixes (#479)

* Fix PvP timer toggle when it shouldn't

* just in case

* v0.5.2 (#498)

* chore(release): bump version to 0.4.1~DEV

* fixes (#462)

* Update standard.lua

WSOB Ruleset lets go

* Add WSOB as tournament ruleset; bump version (#470)

* feat(rulesets): add WSOB tournament ruleset; restore standard layer

The "WSOB Ruleset lets go" change had gutted the shared `standard`
layer, which silently rebalanced every standard-composed ruleset
(Blitz, Traditional, Ranked, SmallWorld, Speedlatro, Chaos). Revert
standard.lua to its pre-WSOB form and move those settings into a
dedicated WSOB ruleset instead.

- rulesets/wsob.lua: near-vanilla tournament pool (multiplayer_content
  off, only Hanging Chad/Bloodstone/Glass reworked, Justice banned),
  gated on the ranked layer for version locking
- objects/enhancements/mp_glass.lua: extend Glass rework to "wsob"
- en-us.lua: k_wsob name + description
- ruleset_selection.lua: wire WSOB into the Tournaments tab

* refactor(ui): drop dead ruleset_buttons_data

Orphaned since the tabbed-ruleset refactor (#419), which switched every
consumer to `rulesets_tabs` but left this file-local defined and unused.
Zero references; pure dead allocation.

* chore(release): bump version to 0.4.3~DEV

* feat(mutators): turn challenge effects into a wall of stackable run modifiers (#471)

* Extract challenge effects into toggleable mutator layers + Wraith rework

Move the runtime effects from the eeeee, balancing_act (score
instability), polymorph_spam, and shared_pockets challenges into
layers/, gated on their existing G.GAME.modifiers.mp_* flags. Each
layer sets its flag from on_apply_bans at run start, so the effect can
be toggled as a mutator on any ruleset; the challenges keep their
rules.custom and now share one hook implementation (deduped). Surface
each on the mutator wall.

Add a wraith_rework layer: new c_mp_wraith consumable (random Uncommon
joker + $5, vs vanilla's random Rare + money to $0), gated via
reworked_consumables with the vanilla c_wraith banned_silent.

Also a stylua normalization pass touched bacon/planet_tycoon.

* fix broken wraith

* Wire up custom ruleset editor Play button (Path 1)

Custom tab now launches a run with a chosen base ruleset + the mutators
wall's modifier stack. Maps content sets (Vanilla/Standard/Sandbox/
Experimental) to base rulesets, sets the ruleset directly without routing
through apply_default_modifiers (which would wipe MP.MODIFIERS), then
LoadReworks + the normal start flow. Modifiers sync to the guest via the
existing modifier_layers serialization.

Custom card bans are gated behind a coming-soon pill (not yet threaded into
ApplyBans or network sync). Removed the COMING SOON ribbon.

* Remove coming-soon line from mutators wall

Drop the 'coming soon · Reward Draft · Rubber Band · Score Tax' footer and
the unused SOON table that fed it. Stub layers in mutator_stubs.lua are
untouched.

* Add No Red Seals mutator layer

New no_red_seals layer: bans Deja Vu and wraps Card:set_seal to refuse Red
assignments (RNG-neutral, so both clients no-op the same seal and stay
synced; the slot just goes sealless). Wired into the HAZARDS column of the
mutators wall alongside No Easy Money / No Uncommons.

* Disable not-yet-working mutators + stub custom editor on lobby path

Mark Inflation, No Interest, Discard Tax, Frugal, and Blind Poker as
coming-soon: greyed/un-toggleable cells with a "Coming soon." hover, and
skipped by the Randomize / Blind Random rolls so they can never activate.

Gate the custom ruleset editor to a coming-soon panel on the lobby (mp)
path; sp/practice keep the full editor.

* Disable Spartan and Pricey Packs mutators (not yet working)

* Surface the three stub mutators on the wall as coming-soon cells

Reward Draft, Rubber Band and Score Tax (the unwired layers from
mutator_stubs.lua) now appear as disabled "Coming soon." cells instead of
being invisible.

* Disable Heavy Pockets + No Takebacks; keep desc on coming-soon tooltips

Disabled mutator cells now show their full description with a trailing
"(Coming soon)" line instead of replacing the text outright.

* Disable Cash Ceiling mutator (not yet working)

* Strip LLM-flavored comment noise from mutator layers

Drop the seam/contract/MP-safe explanatory essays and fake file:line
citations; keep the human voice. Also removes the dormant, unreferenced
sticker_shop layer.

* Add credit badges to mutator wall tooltips

Reuse the MULTIPLAYER credit-badge look (float DynaText pill) from
mod_badges.lua as a standalone builder, dropped into the cell tooltip via
create_popup_UIBox_tooltip's filler hook so it only shows on hover. Cells
opt in with a credits = { art/idea/code } field. Rotation skips the
MULTIPLAYER header — one credit shows static, multiple rotate.

Seeded credits for the cells we know: Kars (idea) on Inflation and No Easy
Money, plus a few code credits.

* Casjb/full replay (#473)

* feat(replay): dual-stream deterministic action log (MP.RLOG)

Add a fully-recreatable action logging system. Every state-affecting
player action emits two aligned streams from one record() call:

- Carbon-copy (replay) stream: positional opcodes (e.g. "buy 1 2") with
  no card names, so it is indiscriminate and replays across mods. Written
  to a dedicated ".carbon" sidecar next to the Lovely log (NOT the mod
  folder, so version hotswaps don't disturb it).
- Human stream: mirrored into the carbon file; the player-facing Lovely
  log is left byte-for-byte unchanged so the website parser is undisturbed.

Both streams are hashed at game end (joker_hash) and submitted to the
server via the new submitLogHashes action for cheap tamper detection.

Coverage: buy/open_pack/voucher, reroll, sell, use, play, discard,
pack_pick/skip, select/skip blind, ready, joker/hand reorder, the random
ante_key, and net_* opponent effects (phantom/magnet/pizza/asteroid).
Instrumentation extends existing overrides; reorder uses a Preview-
independent debounced CardArea:update detector.

This is the logging system only; the replay runner and server-side hash
storage are follow-ups.

Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua
(both pass on Lua 5.4).

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* refactor(replay): emit both streams into the Lovely log, drop .carbon file

Per design change: keep the carbon (replay) and human-readable streams in the
same Lovely log, separated by prefix, instead of a dedicated .carbon sidecar.

- Carbon stream now logs under the "MP_RLOG:" prefix (MANIFEST / action lines /
  CHK trailer); the human stream keeps the existing "Client sent message:" prefix.
- record() is now the sole emitter of both lines, so the instrumented overrides
  (buy/sell/reroll/use) no longer log the human line themselves.
- Remove the .carbon file writer, path derivation, flush(), and the per-round
  flush call. Both stream buffers are still hashed at end_run and re-derive from
  the log by prefix.
- Rework the tests to capture sendTraceMessage output (no file) and update docs.

Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua pass on Lua 5.4.

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* feat(replay): ship the full carbon log to the server, not just its hash

end_run now accumulates the full carbon block (manifest + action lines + END +
CHK) and sends it as a `log` field on submitLogHashes, so the server can store a
complete viewable/replayable record of every game without the player handing
over their log. Hashing is unchanged (still over the action/human buffers).

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* feat(serialization): guard str_decode_and_unpack against zip-bomb payloads

Reject an encoded payload larger than MAX_ENCODED_BYTES (32 KB) before any
base64/gzip work, so a crafted magnetResponse / receiveEndGameJokers /
receiveNemesisDeck payload can't balloon to GBs and OOM the receiving client.
A legitimate saved joker / nemesis deck is a few KB; both callers already bail
cleanly on the nil return. Defense-in-depth — the relay also caps message size.

Adds tests/test_serialization_guard.lua.

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* chore(mod): bump version to 0.4.3 for the replay-log pre-release build

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* fix(replay): crash-proof area_enum (table index is nil on shop buy)

area_enum built a lookup table keyed by the CardArea globals, but several of
them are nil depending on game state (G.pack_cards only exists while a booster
is open, G.shop_* only in the shop). A table literal with a nil key throws
'table index is nil', so any buy/sell/use while one of those areas was absent
crashed the game — reproducibly on the first shop purchase. Compare the area
against each global directly instead (a live area vs a nil global is just
false), which is crash-safe and returns the same enum.

Commit message generated with Claude Code

* feat(replay): stream carbon lines live to the server in batches

While a game plays, MP.RLOG now batches its carbon lines and flushes them to the
server (new streamLogLines action) every ~25 lines or ~2s, under a per-game id
generated in begin_run. So a crashed/abandoned game still leaves a partial
record server-side; on a clean end, submit_log_hashes carries the same game id
so the server swaps the live stream for the complete hashed package. Flushing
falls back to the line-count trigger when love.timer is unavailable (tests), and
no-ops cleanly when there's no transport. Adds tests/test_rlog_stream.lua.

Commit message generated with Claude Code

* Hide opponent score until first hand played (#475)

* Show ??? for opponent score until first hand of a PvP blind

Display the opponent's score as "???" in the blind HUD until the local
player has played a hand in the current PvP blind, matching the server's
withholding of enemyInfo. Reset the stored opponent score at the start of
each blind so the first frame after playing shows 0 rather than the
previous blind's stale score.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LgfBstKG4gLVbu4JKUQFnn

* Add hide_score_until_played lobby option (default on for standard layer)

Expose the opponent-score hiding as a host-toggleable lobby option. It
defaults on only for standard-layer rulesets (start_lobby derives it from
MP.current_ruleset().standard) and off everywhere else; the baseline config
value is off. The blind HUD only masks the opponent score as "???" when the
option is enabled. The toggle lives in the lobby options tab, so it is
editable in non-forcing lobbies and locked at its default when a ruleset
forces options.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LgfBstKG4gLVbu4JKUQFnn

* Mask opponent hands as "?" until the first enemy info each blind

While the opponent's score is hidden, their hands count previously still
showed its stored value. Track an info_received flag on MP.GAME.enemy
(reset at blind start, set when an enemyInfo arrives) and drive a new
hands_text field, shown in place of the raw count. When the hide-score
option is on, the HUD shows "?" for opponent hands until the first enemy
info of the blind; otherwise it mirrors the real count as before. Ghost
replays mark info as received so replays are never masked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LgfBstKG4gLVbu4JKUQFnn

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Various fixes (#472)

* Timer: prevent old timer tick when ready + more correct check when can timer opponent

* TheOrder: fix SMODS seals desync

* Networking: try-catch for server messages + resolve channel once

* UI: fix ko-fi message display & crash

* CardAreas: fix crash in Shared Pockets + make shared area move in place instantly

* fix old timer not tick when you timer opponent

* Update Phantom Edition Text (#464)

The current phantom edition text is somewhat confusing, this looks to
make it clear what this edition does.

* feat: update vietnamese's localization (wip) (#468)

* Season 7 changese

* Nemesis timer display (#495)

* Update Multiplayer.json (#481)

* Fixed idol gg (#463)

* Fixed idol gg

* made change according to review by Toneblock

* Finalized sorting algo

* Timer, UI and Asteroid fixes (#482)

* 0.4.1 (#465)

* chore(release): bump version to 0.4.1~DEV

* fixes (#462)

* Update standard.lua

WSOB Ruleset lets go

---------

Co-authored-by: Stephen Kirk <me@thestephen.net>
Co-authored-by: SleepyG11 <35398172+SleepyG11@users.noreply.github.com>

* remove ~DEV for release

* Revert ruleset changes (0.4.2) (#466)

* Update standard.lua

* Update Multiplayer.json

* Casjb/full replay (#473)

* feat(replay): dual-stream deterministic action log (MP.RLOG)

Add a fully-recreatable action logging system. Every state-affecting
player action emits two aligned streams from one record() call:

- Carbon-copy (replay) stream: positional opcodes (e.g. "buy 1 2") with
  no card names, so it is indiscriminate and replays across mods. Written
  to a dedicated ".carbon" sidecar next to the Lovely log (NOT the mod
  folder, so version hotswaps don't disturb it).
- Human stream: mirrored into the carbon file; the player-facing Lovely
  log is left byte-for-byte unchanged so the website parser is undisturbed.

Both streams are hashed at game end (joker_hash) and submitted to the
server via the new submitLogHashes action for cheap tamper detection.

Coverage: buy/open_pack/voucher, reroll, sell, use, play, discard,
pack_pick/skip, select/skip blind, ready, joker/hand reorder, the random
ante_key, and net_* opponent effects (phantom/magnet/pizza/asteroid).
Instrumentation extends existing overrides; reorder uses a Preview-
independent debounced CardArea:update detector.

This is the logging system only; the replay runner and server-side hash
storage are follow-ups.

Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua
(both pass on Lua 5.4).

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* refactor(replay): emit both streams into the Lovely log, drop .carbon file

Per design change: keep the carbon (replay) and human-readable streams in the
same Lovely log, separated by prefix, instead of a dedicated .carbon sidecar.

- Carbon stream now logs under the "MP_RLOG:" prefix (MANIFEST / action lines /
  CHK trailer); the human stream keeps the existing "Client sent message:" prefix.
- record() is now the sole emitter of both lines, so the instrumented overrides
  (buy/sell/reroll/use) no longer log the human line themselves.
- Remove the .carbon file writer, path derivation, flush(), and the per-round
  flush call. Both stream buffers are still hashed at end_run and re-derive from
  the log by prefix.
- Rework the tests to capture sendTraceMessage output (no file) and update docs.

Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua pass on Lua 5.4.

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* feat(replay): ship the full carbon log to the server, not just its hash

end_run now accumulates the full carbon block (manifest + action lines + END +
CHK) and sends it as a `log` field on submitLogHashes, so the server can store a
complete viewable/replayable record of every game without the player handing
over their log. Hashing is unchanged (still over the action/human buffers).

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* feat(serialization): guard str_decode_and_unpack against zip-bomb payloads

Reject an encoded payload larger than MAX_ENCODED_BYTES (32 KB) before any
base64/gzip work, so a crafted magnetResponse / receiveEndGameJokers /
receiveNemesisDeck payload can't balloon to GBs and OOM the receiving client.
A legitimate saved joker / nemesis deck is a few KB; both callers already bail
cleanly on the nil return. Defense-in-depth — the relay also caps message size.

Adds tests/test_serialization_guard.lua.

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* chore(mod): bump version to 0.4.3 for the replay-log pre-release build

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* fix(replay): crash-proof area_enum (table index is nil on shop buy)

area_enum built a lookup table keyed by the CardArea globals, but several of
them are nil depending on game state (G.pack_cards only exists while a booster
is open, G.shop_* only in the shop). A table literal with a nil key throws
'table index is nil', so any buy/sell/use while one of those areas was absent
crashed the game — reproducibly on the first shop purchase. Compare the area
against each global directly instead (a live area vs a nil global is just
false), which is crash-safe and returns the same enum.

Commit message generated with Claude Code

* feat(replay): stream carbon lines live to the server in batches

While a game plays, MP.RLOG now batches its carbon lines and flushes them to the
server (new streamLogLines action) every ~25 lines or ~2s, under a per-game id
generated in begin_run. So a crashed/abandoned game still leaves a partial
record server-side; on a clean end, submit_log_hashes carries the same game id
so the server swaps the live stream for the complete hashed package. Flushing
falls back to the line-count trigger when love.timer is unavailable (tests), and
no-ops cleanly when there's no transport. Adds tests/test_rlog_stream.lua.

Commit message generated with Claude Code

* 0.5.0 Merge (#476)

* chore(release): bump version to 0.4.1~DEV

* fixes (#462)

* Update standard.lua

WSOB Ruleset lets go

* Add WSOB as tournament ruleset; bump version (#470)

* feat(rulesets): add WSOB tournament ruleset; restore standard layer

The "WSOB Ruleset lets go" change had gutted the shared `standard`
layer, which silently rebalanced every standard-composed ruleset
(Blitz, Traditional, Ranked, SmallWorld, Speedlatro, Chaos). Revert
standard.lua to its pre-WSOB form and move those settings into a
dedicated WSOB ruleset instead.

- rulesets/wsob.lua: near-vanilla tournament pool (multiplayer_content
  off, only Hanging Chad/Bloodstone/Glass reworked, Justice banned),
  gated on the ranked layer for version locking
- objects/enhancements/mp_glass.lua: extend Glass rework to "wsob"
- en-us.lua: k_wsob name + description
- ruleset_selection.lua: wire WSOB into the Tournaments tab

* refactor(ui): drop dead ruleset_buttons_data

Orphaned since the tabbed-ruleset refactor (#419), which switched every
consumer to `rulesets_tabs` but left this file-local defined and unused.
Zero references; pure dead allocation.

* chore(release): bump version to 0.4.3~DEV

* feat(mutators): turn challenge effects into a wall of stackable run modifiers (#471)

* Extract challenge effects into toggleable mutator layers + Wraith rework

Move the runtime effects from the eeeee, balancing_act (score
instability), polymorph_spam, and shared_pockets challenges into
layers/, gated on their existing G.GAME.modifiers.mp_* flags. Each
layer sets its flag from on_apply_bans at run start, so the effect can
be toggled as a mutator on any ruleset; the challenges keep their
rules.custom and now share one hook implementation (deduped). Surface
each on the mutator wall.

Add a wraith_rework layer: new c_mp_wraith consumable (random Uncommon
joker + $5, vs vanilla's random Rare + money to $0), gated via
reworked_consumables with the vanilla c_wraith banned_silent.

Also a stylua normalization pass touched bacon/planet_tycoon.

* fix broken wraith

* Wire up custom ruleset editor Play button (Path 1)

Custom tab now launches a run with a chosen base ruleset + the mutators
wall's modifier stack. Maps content sets (Vanilla/Standard/Sandbox/
Experimental) to base rulesets, sets the ruleset directly without routing
through apply_default_modifiers (which would wipe MP.MODIFIERS), then
LoadReworks + the normal start flow. Modifiers sync to the guest via the
existing modifier_layers serialization.

Custom card bans are gated behind a coming-soon pill (not yet threaded into
ApplyBans or network sync). Removed the COMING SOON ribbon.

* Remove coming-soon line from mutators wall

Drop the 'coming soon · Reward Draft · Rubber Band · Score Tax' footer and
the unused SOON table that fed it. Stub layers in mutator_stubs.lua are
untouched.

* Add No Red Seals mutator layer

New no_red_seals layer: bans Deja Vu and wraps Card:set_seal to refuse Red
assignments (RNG-neutral, so both clients no-op the same seal and stay
synced; the slot just goes sealless). Wired into the HAZARDS column of the
mutators wall alongside No Easy Money / No Uncommons.

* Disable not-yet-working mutators + stub custom editor on lobby path

Mark Inflation, No Interest, Discard Tax, Frugal, and Blind Poker as
coming-soon: greyed/un-toggleable cells with a "Coming soon." hover, and
skipped by the Randomize / Blind Random rolls so they can never activate.

Gate the custom ruleset editor to a coming-soon panel on the lobby (mp)
path; sp/practice keep the full editor.

* Disable Spartan and Pricey Packs mutators (not yet working)

* Surface the three stub mutators on the wall as coming-soon cells

Reward Draft, Rubber Band and Score Tax (the unwired layers from
mutator_stubs.lua) now appear as disabled "Coming soon." cells instead of
being invisible.

* Disable Heavy Pockets + No Takebacks; keep desc on coming-soon tooltips

Disabled mutator cells now show their full description with a trailing
"(Coming soon)" line instead of replacing the text outright.

* Disable Cash Ceiling mutator (not yet working)

* Strip LLM-flavored comment noise from mutator layers

Drop the seam/contract/MP-safe explanatory essays and fake file:line
citations; keep the human voice. Also removes the dormant, unreferenced
sticker_shop layer.

* Add credit badges to mutator wall tooltips

Reuse the MULTIPLAYER credit-badge look (float DynaText pill) from
mod_badges.lua as a standalone builder, dropped into the cell tooltip via
create_popup_UIBox_tooltip's filler hook so it only shows on hover. Cells
opt in with a credits = { art/idea/code } field. Rotation skips the
MULTIPLAYER header — one credit shows static, multiple rotate.

Seeded credits for the cells we know: Kars (idea) on Inflation and No Easy
Money, plus a few code credits.

* Casjb/full replay (#473)

* feat(replay): dual-stream deterministic action log (MP.RLOG)

Add a fully-recreatable action logging system. Every state-affecting
player action emits two aligned streams from one record() call:

- Carbon-copy (replay) stream: positional opcodes (e.g. "buy 1 2") with
  no card names, so it is indiscriminate and replays across mods. Written
  to a dedicated ".carbon" sidecar next to the Lovely log (NOT the mod
  folder, so version hotswaps don't disturb it).
- Human stream: mirrored into the carbon file; the player-facing Lovely
  log is left byte-for-byte unchanged so the website parser is undisturbed.

Both streams are hashed at game end (joker_hash) and submitted to the
server via the new submitLogHashes action for cheap tamper detection.

Coverage: buy/open_pack/voucher, reroll, sell, use, play, discard,
pack_pick/skip, select/skip blind, ready, joker/hand reorder, the random
ante_key, and net_* opponent effects (phantom/magnet/pizza/asteroid).
Instrumentation extends existing overrides; reorder uses a Preview-
independent debounced CardArea:update detector.

This is the logging system only; the replay runner and server-side hash
storage are follow-ups.

Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua
(both pass on Lua 5.4).

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* refactor(replay): emit both streams into the Lovely log, drop .carbon file

Per design change: keep the carbon (replay) and human-readable streams in the
same Lovely log, separated by prefix, instead of a dedicated .carbon sidecar.

- Carbon stream now logs under the "MP_RLOG:" prefix (MANIFEST / action lines /
  CHK trailer); the human stream keeps the existing "Client sent message:" prefix.
- record() is now the sole emitter of both lines, so the instrumented overrides
  (buy/sell/reroll/use) no longer log the human line themselves.
- Remove the .carbon file writer, path derivation, flush(), and the per-round
  flush call. Both stream buffers are still hashed at end_run and re-derive from
  the log by prefix.
- Rework the tests to capture sendTraceMessage output (no file) and update docs.

Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua pass on Lua 5.4.

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* feat(replay): ship the full carbon log to the server, not just its hash

end_run now accumulates the full carbon block (manifest + action lines + END +
CHK) and sends it as a `log` field on submitLogHashes, so the server can store a
complete viewable/replayable record of every game without the player handing
over their log. Hashing is unchanged (still over the action/human buffers).

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* feat(serialization): guard str_decode_and_unpack against zip-bomb payloads

Reject an encoded payload larger than MAX_ENCODED_BYTES (32 KB) before any
base64/gzip work, so a crafted magnetResponse / receiveEndGameJokers /
receiveNemesisDeck payload can't balloon to GBs and OOM the receiving client.
A legitimate saved joker / nemesis deck is a few KB; both callers already bail
cleanly on the nil return. Defense-in-depth — the relay also caps message size.

Adds tests/test_serialization_guard.lua.

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* chore(mod): bump version to 0.4.3 for the replay-log pre-release build

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* fix(replay): crash-proof area_enum (table index is nil on shop buy)

area_enum built a lookup table keyed by the CardArea globals, but several of
them are nil depending on game state (G.pack_cards only exists while a booster
is open, G.shop_* only in the shop). A table literal with a nil key throws
'table index is nil', so any buy/sell/use while one of those areas was absent
crashed the game — reproducibly on the first shop purchase. Compare the area
against each global directly instead (a live area vs a nil global is just
false), which is crash-safe and returns the same enum.

Commit message generated with Claude Code

* feat(replay): stream carbon lines live to the server in batches

While a game plays, MP.RLOG now batches its carbon lines and flushes them to the
server (new streamLogLines action) every ~25 lines or ~2s, under a per-game id
generated in begin_run. So a crashed/abandoned game still leaves a partial
record server-side; on a clean end, submit_log_hashes carries the same game id
so the server swaps the live stream for the complete hashed package. Flushing
falls back to the line-count trigger when love.timer is unavailable (tests), and
no-ops cleanly when there's no transport. Adds tests/test_rlog_stream.lua.

Commit message generated with Claude Code

* Hide opponent score until first hand played (#475)

* Show ??? for opponent score until first hand of a PvP blind

Display the opponent's score as "???" in the blind HUD until the local
player has played a hand in the current PvP blind, matching the server's
withholding of enemyInfo. Reset the stored opponent score at the start of
each blind so the first frame after playing shows 0 rather than the
previous blind's stale score.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LgfBstKG4gLVbu4JKUQFnn

* Add hide_score_until_played lobby option (default on for standard layer)

Expose the opponent-score hiding as a host-toggleable lobby option. It
defaults on only for standard-layer rulesets (start_lobby derives it from
MP.current_ruleset().standard) and off everywhere else; the baseline config
value is off. The blind HUD only masks the opponent score as "???" when the
option is enabled. The toggle lives in the lobby options tab, so it is
editable in non-forcing lobbies and locked at its default when a ruleset
forces options.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LgfBstKG4gLVbu4JKUQFnn

* Mask opponent hands as "?" until the first enemy info each blind

While the opponent's score is hidden, their hands count previously still
showed its stored value. Track an info_received flag on MP.GAME.enemy
(reset at blind start, set when an enemyInfo arrives) and drive a new
hands_text field, shown in place of the raw count. When the hide-score
option is on, the HUD shows "?" for opponent hands until the first enemy
info of the blind; otherwise it mirrors the real count as before. Ghost
replays mark info as received so replays are never masked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LgfBstKG4gLVbu4JKUQFnn

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Various fixes (#472)

* Timer: prevent old timer tick when ready + more correct check when can timer opponent

* TheOrder: fix SMODS seals desync

* Networking: try-catch for server messages + resolve channel once

* UI: fix ko-fi message display & crash

* CardAreas: fix crash in Shared Pockets + make shared area move in place instantly

* fix old timer not tick when you timer opponent

* Update Phantom Edition Text (#464)

The current phantom edition text is somewhat confusing, this looks to
make it clear what this edition does.

* feat: update vietnamese's localization (wip) (#468)

* Season 7 changese

---------

Co-authored-by: Stephen Kirk <me@thestephen.net>
Co-authored-by: SleepyG11 <35398172+SleepyG11@users.noreply.github.com>
Co-authored-by: Fantom-Balatro <fantombalatro@gmail.com>
Co-authored-by: Casper JB <casper_jb@icloud.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: EchoHeli <emiliano.dorantest@gmail.com>
Co-authored-by: Trần Quốc Lân <62930566+minotour4869@users.noreply.github.com>

* Add PvP validation to ante timer actions (#477)

* Fix life loss from PvP timer firing after PvP has ended

After endPvP, the opponent's startAnteTimer message could re-arm
nemesis_timer_started, causing the timer to tick in the old-timer
path and costing an extra life. Add a post_pvp guard that blocks
timer starts until the player readies for the next blind.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

* Revert "Fix life loss from PvP timer firing after PvP has ended"

This reverts commit 1099127ee244ead313314ed62d7662175d9007d7.

* Fix PvP timer causing life loss after PvP ends

Tag startAnteTimer with isPvP when pressed during a PvP boss blind.
The receiving client ignores isPvP timer starts if it's no longer in
PvP, preventing the race condition where a PvP timer press arrives
after endPvP has already cleared the PvP state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

* Use end_pvp flag instead of is_pvp_boss to gate PvP timer starts

is_pvp_boss() stays true briefly after PvP ends until the game state
transitions. Check MP.GAME.end_pvp (set by endPvP handler) so the
guard catches the race window where PvP is over but the blind hasn't
changed yet.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

* Also ignore PvP timer starts when player has 0 hands left

If hands are exhausted the PvP round is effectively over even before
endPvP arrives, so reject stale PvP timer presses in that state too.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Update version from 0.5.0 to 0.5.1

* Pause timer when opponent disconnects (#478)

* Pause timer for both players when opponent disconnects

Clear both timer_started and nemesis_timer_started on
enemyDisconnected so the timer stops ticking for the remaining
player. The timer stays paused after reconnection and must be
manually restarted.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

* Also pause timer for the reconnecting player

Clear timer_started and nemesis_timer_started on rejoinedLobby so
the player who disconnected doesn't have their timer resume
immediately on reconnect.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

* Pause timer on reconnecting (disconnecting player's side)

The game loop keeps running while disconnected, so the timer keeps
ticking. Pause it in action_reconnecting when the connection drops,
not just on rejoinedLobby which is too late.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Fix PvP timer toggle when it shouldn't

* just in case

* PvP + hidden score fixes (#479)

* Fix PvP timer toggle when it shouldn't

* just in case

* Return blind select sound

* More strict timer checks

* Get Logs and Open Parser buttons in game over screen

* Add Log Parser and Open Logs in mod config

* Add "mods' and "collection' button to lobby

* always display remaining hands

* server-side pvp timer

* automatic pvp timer

* PvP timer 60+10

* Prevent asteroid go into negative levels

* update version, I guess

* prevent timer during game over/win

* use real timer for updating opponent's score

---------

Co-authored-by: Fantom-Balatro <fantombalatro@gmail.com>
Co-authored-by: Stephen Kirk <me@thestephen.net>
Co-authored-by: Casper JB <casper_jb@icloud.com>
Co-authored-by: Connor Mills <connor@virtualized.dev>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: EchoHeli <emiliano.dorantest@gmail.com>
Co-authored-by: Trần Quốc Lân <62930566+minotour4869@users.noreply.github.com>

---------

Co-authored-by: Stephen Kirk <me@thestephen.net>
Co-authored-by: SleepyG11 <35398172+SleepyG11@users.noreply.github.com>
Co-authored-by: Fantom-Balatro <fantombalatro@gmail.com>
Co-authored-by: Casper JB <casper_jb@icloud.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: EchoHeli <emiliano.dorantest@gmail.com>
Co-authored-by: Trần Quốc Lân <62930566+minotour4869@users.noreply.github.com>
Co-authored-by: Lilo <thrasherglitches@gmail.com>
Co-authored-by: 12problems <74747315+12problems@users.noreply.github.com>

* feat(the-order): log each idol hit for the website log parser (#511)

The Order weights the idol card by how many copies of each card are in the
deck, but nothing about that reaches the player after the fact. Log the hit
so the website log parser can show which card won, the value that was
rolled, and how likely that card was.

Emitted once per selection, right after the winning card is stored:

  IDOL_ROLL::<base64 of
    {"roll":0.3536,"winner":"TC","cards":["AS1","AH1","KS4"]}>

`cards` are compact "<rank><suit><count>" tokens (T = 10) in the same order
the weighted threshold walk uses, so the reader can reconstruct the exact
distribution the roll was made against. base64 keeps it to a single opaque
token in the log. Logging only — no game state or RNG is touched.

* v0.5.3 (#518)

* chore(release): bump version to 0.4.1~DEV

* fixes (#462)

* Update standard.lua

WSOB Ruleset lets go

* Add WSOB as tournament ruleset; bump version (#470)

* feat(rulesets): add WSOB tournament ruleset; restore standard layer

The "WSOB Ruleset lets go" change had gutted the shared `standard`
layer, which silently rebalanced every standard-composed ruleset
(Blitz, Traditional, Ranked, SmallWorld, Speedlatro, Chaos). Revert
standard.lua to its pre-WSOB form and move those settings into a
dedicated WSOB ruleset instead.

- rulesets/wsob.lua: near-vanilla tournament pool (multiplayer_content
  off, only Hanging Chad/Bloodstone/Glass reworked, Justice banned),
  gated on the ranked layer for version locking
- objects/enhancements/mp_glass.lua: extend Glass rework to "wsob"
- en-us.lua: k_wsob name + description
- ruleset_selection.lua: wire WSOB into the Tournaments tab

* refactor(ui): drop dead ruleset_buttons_data

Orphaned since the tabbed-ruleset refactor (#419), which switched every
consumer to `rulesets_tabs` but left this file-local defined and unused.
Zero references; pure dead allocation.

* chore(release): bump version to 0.4.3~DEV

* feat(mutators): turn challenge effects into a wall of stackable run modifiers (#471)

* Extract challenge effects into toggleable mutator layers + Wraith rework

Move the runtime effects from the eeeee, balancing_act (score
instability), polymorph_spam, and shared_pockets challenges into
layers/, gated on their existing G.GAME.modifiers.mp_* flags. Each
layer sets its flag from on_apply_bans at run start, so the effect can
be toggled as a mutator on any ruleset; the challenges keep their
rules.custom and now share one hook implementation (deduped). Surface
each on the mutator wall.

Add a wraith_rework layer: new c_mp_wraith consumable (random Uncommon
joker + $5, vs vanilla's random Rare + money to $0), gated via
reworked_consumables with the vanilla c_wraith banned_silent.

Also a stylua normalization pass touched bacon/planet_tycoon.

* fix broken wraith

* Wire up custom ruleset editor Play button (Path 1)

Custom tab now launches a run with a chosen base ruleset + the mutators
wall's modifier stack. Maps content sets (Vanilla/Standard/Sandbox/
Experimental) to base rulesets, sets the ruleset directly without routing
through apply_default_modifiers (which would wipe MP.MODIFIERS), then
LoadReworks + the normal start flow. Modifiers sync to the guest via the
existing modifier_layers serialization.

Custom card bans are gated behind a coming-soon pill (not yet threaded into
ApplyBans or network sync). Removed the COMING SOON ribbon.

* Remove coming-soon line from mutators wall

Drop the 'coming soon · Reward Draft · Rubber Band · Score Tax' footer and
the unused SOON table that fed it. Stub layers in mutator_stubs.lua are
untouched.

* Add No Red Seals mutator layer

New no_red_seals layer: bans Deja Vu and wraps Card:set_seal to refuse Red
assignments (RNG-neutral, so both clients no-op the same seal and stay
synced; the slot just goes sealless). Wired into the HAZARDS column of the
mutators wall alongside No Easy Money / No Uncommons.

* Disable not-yet-working mutators + stub custom editor on lobby path

Mark Inflation, No Interest, Discard Tax, Frugal, and Blind Poker as
coming-soon: greyed/un-toggleable cells with a "Coming soon." hover, and
skipped by the Randomize / Blind Random rolls so they can never activate.

Gate the custom ruleset editor to a coming-soon panel on the lobby (mp)
path; sp/practice keep the full editor.

* Disable Spartan and Pricey Packs mutators (not yet working)

* Surface the three stub mutators on the wall as coming-soon cells

Reward Draft, Rubber Band and Score Tax (the unwired layers from
mutator_stubs.lua) now appear as disabled "Coming soon." cells instead of
being invisible.

* Disable Heavy Pockets + No Takebacks; keep desc on coming-soon tooltips

Disabled mutator cells now show their full description with a trailing
"(Coming soon)" line instead of replacing the text outright.

* Disable Cash Ceiling mutator (not yet working)

* Strip LLM-flavored comment noise from mutator layers

Drop the seam/contract/MP-safe explanatory essays and fake file:line
citations; keep the human voice. Also removes the dormant, unreferenced
sticker_shop layer.

* Add credit badges to mutator wall tooltips

Reuse the MULTIPLAYER credit-badge look (float DynaText pill) from
mod_badges.lua as a standalone builder, dropped into the cell tooltip via
create_popup_UIBox_tooltip's filler hook so it only shows on hover. Cells
opt in with a credits = { art/idea/code } field. Rotation skips the
MULTIPLAYER header — one credit shows static, multiple rotate.

Seeded credits for the cells we know: Kars (idea) on Inflation and No Easy
Money, plus a few code credits.

* Casjb/full replay (#473)

* feat(replay): dual-stream deterministic action log (MP.RLOG)

Add a fully-recreatable action logging system. Every state-affecting
player action emits two aligned streams from one record() call:

- Carbon-copy (replay) stream: positional opcodes (e.g. "buy 1 2") with
  no card names, so it is indiscriminate and replays across mods. Written
  to a dedicated ".carbon" sidecar next to the Lovely log (NOT the mod
  folder, so version hotswaps don't disturb it).
- Human stream: mirrored into the carbon file; the player-facing Lovely
  log is left byte-for-byte unchanged so the website parser is undisturbed.

Both streams are hashed at game end (joker_hash) and submitted to the
server via the new submitLogHashes action for cheap tamper detection.

Coverage: buy/open_pack/voucher, reroll, sell, use, play, discard,
pack_pick/skip, select/skip blind, ready, joker/hand reorder, the random
ante_key, and net_* opponent effects (phantom/magnet/pizza/asteroid).
Instrumentation extends existing overrides; reorder uses a Preview-
independent debounced CardArea:update detector.

This is the logging system only; the replay runner and server-side hash
storage are follow-ups.

Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua
(both pass on Lua 5.4).

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* refactor(replay): emit both streams into the Lovely log, drop .carbon file

Per design change: keep the carbon (replay) and human-readable streams in the
same Lovely log, separated by prefix, instead of a dedicated .carbon sidecar.

- Carbon stream now logs under the "MP_RLOG:" prefix (MANIFEST / action lines /
  CHK trailer); the human stream keeps the existing "Client sent message:" prefix.
- record() is now the sole emitter of both lines, so the instrumented overrides
  (buy/sell/reroll/use) no longer log the human line themselves.
- Remove the .carbon file writer, path derivation, flush(), and the per-round
  flush call. Both stream buffers are still hashed at end_run and re-derive from
  the log by prefix.
- Rework the tests to capture sendTraceMessage output (no file) and update docs.

Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua pass on Lua 5.4.

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* feat(replay): ship the full carbon log to the server, not just its hash

end_run now accumulates the full carbon block (manifest + action lines + END +
CHK) and sends it as a `log` field on submitLogHashes, so the server can store a
complete viewable/replayable record of every game without the player handing
over their log. Hashing is unchanged (still over the action/human buffers).

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* feat(serialization): guard str_decode_and_unpack against zip-bomb payloads

Reject an encoded payload larger than MAX_ENCODED_BYTES (32 KB) before any
base64/gzip work, so a crafted magnetResponse / receiveEndGameJokers /
receiveNemesisDeck payload can't balloon to GBs and OOM the receiving client.
A legitimate saved joker / nemesis deck is a few KB; both callers already bail
cleanly on the nil return. Defense-in-depth — the relay also caps message size.

Adds tests/test_serialization_guard.lua.

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* chore(mod): bump version to 0.4.3 for the replay-log pre-release build

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* fix(replay): crash-proof area_enum (table index is nil on shop buy)

area_enum built a lookup table keyed by the CardArea globals, but several of
them are nil depending on game state (G.pack_…
V-rtualized added a commit that referenced this pull request Aug 1, 2026
* 0.4.1 (#465)

* chore(release): bump version to 0.4.1~DEV

* fixes (#462)

* Update standard.lua

WSOB Ruleset lets go

---------

Co-authored-by: Stephen Kirk <me@thestephen.net>
Co-authored-by: SleepyG11 <35398172+SleepyG11@users.noreply.github.com>

* remove ~DEV for release

* Revert ruleset changes (0.4.2) (#466)

* Update standard.lua

* Update Multiplayer.json

* Casjb/full replay (#473)

* feat(replay): dual-stream deterministic action log (MP.RLOG)

Add a fully-recreatable action logging system. Every state-affecting
player action emits two aligned streams from one record() call:

- Carbon-copy (replay) stream: positional opcodes (e.g. "buy 1 2") with
  no card names, so it is indiscriminate and replays across mods. Written
  to a dedicated ".carbon" sidecar next to the Lovely log (NOT the mod
  folder, so version hotswaps don't disturb it).
- Human stream: mirrored into the carbon file; the player-facing Lovely
  log is left byte-for-byte unchanged so the website parser is undisturbed.

Both streams are hashed at game end (joker_hash) and submitted to the
server via the new submitLogHashes action for cheap tamper detection.

Coverage: buy/open_pack/voucher, reroll, sell, use, play, discard,
pack_pick/skip, select/skip blind, ready, joker/hand reorder, the random
ante_key, and net_* opponent effects (phantom/magnet/pizza/asteroid).
Instrumentation extends existing overrides; reorder uses a Preview-
independent debounced CardArea:update detector.

This is the logging system only; the replay runner and server-side hash
storage are follow-ups.

Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua
(both pass on Lua 5.4).

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* refactor(replay): emit both streams into the Lovely log, drop .carbon file

Per design change: keep the carbon (replay) and human-readable streams in the
same Lovely log, separated by prefix, instead of a dedicated .carbon sidecar.

- Carbon stream now logs under the "MP_RLOG:" prefix (MANIFEST / action lines /
  CHK trailer); the human stream keeps the existing "Client sent message:" prefix.
- record() is now the sole emitter of both lines, so the instrumented overrides
  (buy/sell/reroll/use) no longer log the human line themselves.
- Remove the .carbon file writer, path derivation, flush(), and the per-round
  flush call. Both stream buffers are still hashed at end_run and re-derive from
  the log by prefix.
- Rework the tests to capture sendTraceMessage output (no file) and update docs.

Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua pass on Lua 5.4.

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* feat(replay): ship the full carbon log to the server, not just its hash

end_run now accumulates the full carbon block (manifest + action lines + END +
CHK) and sends it as a `log` field on submitLogHashes, so the server can store a
complete viewable/replayable record of every game without the player handing
over their log. Hashing is unchanged (still over the action/human buffers).

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* feat(serialization): guard str_decode_and_unpack against zip-bomb payloads

Reject an encoded payload larger than MAX_ENCODED_BYTES (32 KB) before any
base64/gzip work, so a crafted magnetResponse / receiveEndGameJokers /
receiveNemesisDeck payload can't balloon to GBs and OOM the receiving client.
A legitimate saved joker / nemesis deck is a few KB; both callers already bail
cleanly on the nil return. Defense-in-depth — the relay also caps message size.

Adds tests/test_serialization_guard.lua.

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* chore(mod): bump version to 0.4.3 for the replay-log pre-release build

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* fix(replay): crash-proof area_enum (table index is nil on shop buy)

area_enum built a lookup table keyed by the CardArea globals, but several of
them are nil depending on game state (G.pack_cards only exists while a booster
is open, G.shop_* only in the shop). A table literal with a nil key throws
'table index is nil', so any buy/sell/use while one of those areas was absent
crashed the game — reproducibly on the first shop purchase. Compare the area
against each global directly instead (a live area vs a nil global is just
false), which is crash-safe and returns the same enum.

Commit message generated with Claude Code

* feat(replay): stream carbon lines live to the server in batches

While a game plays, MP.RLOG now batches its carbon lines and flushes them to the
server (new streamLogLines action) every ~25 lines or ~2s, under a per-game id
generated in begin_run. So a crashed/abandoned game still leaves a partial
record server-side; on a clean end, submit_log_hashes carries the same game id
so the server swaps the live stream for the complete hashed package. Flushing
falls back to the line-count trigger when love.timer is unavailable (tests), and
no-ops cleanly when there's no transport. Adds tests/test_rlog_stream.lua.

Commit message generated with Claude Code

* 0.5.0 Merge (#476)

* chore(release): bump version to 0.4.1~DEV

* fixes (#462)

* Update standard.lua

WSOB Ruleset lets go

* Add WSOB as tournament ruleset; bump version (#470)

* feat(rulesets): add WSOB tournament ruleset; restore standard layer

The "WSOB Ruleset lets go" change had gutted the shared `standard`
layer, which silently rebalanced every standard-composed ruleset
(Blitz, Traditional, Ranked, SmallWorld, Speedlatro, Chaos). Revert
standard.lua to its pre-WSOB form and move those settings into a
dedicated WSOB ruleset instead.

- rulesets/wsob.lua: near-vanilla tournament pool (multiplayer_content
  off, only Hanging Chad/Bloodstone/Glass reworked, Justice banned),
  gated on the ranked layer for version locking
- objects/enhancements/mp_glass.lua: extend Glass rework to "wsob"
- en-us.lua: k_wsob name + description
- ruleset_selection.lua: wire WSOB into the Tournaments tab

* refactor(ui): drop dead ruleset_buttons_data

Orphaned since the tabbed-ruleset refactor (#419), which switched every
consumer to `rulesets_tabs` but left this file-local defined and unused.
Zero references; pure dead allocation.

* chore(release): bump version to 0.4.3~DEV

* feat(mutators): turn challenge effects into a wall of stackable run modifiers (#471)

* Extract challenge effects into toggleable mutator layers + Wraith rework

Move the runtime effects from the eeeee, balancing_act (score
instability), polymorph_spam, and shared_pockets challenges into
layers/, gated on their existing G.GAME.modifiers.mp_* flags. Each
layer sets its flag from on_apply_bans at run start, so the effect can
be toggled as a mutator on any ruleset; the challenges keep their
rules.custom and now share one hook implementation (deduped). Surface
each on the mutator wall.

Add a wraith_rework layer: new c_mp_wraith consumable (random Uncommon
joker + $5, vs vanilla's random Rare + money to $0), gated via
reworked_consumables with the vanilla c_wraith banned_silent.

Also a stylua normalization pass touched bacon/planet_tycoon.

* fix broken wraith

* Wire up custom ruleset editor Play button (Path 1)

Custom tab now launches a run with a chosen base ruleset + the mutators
wall's modifier stack. Maps content sets (Vanilla/Standard/Sandbox/
Experimental) to base rulesets, sets the ruleset directly without routing
through apply_default_modifiers (which would wipe MP.MODIFIERS), then
LoadReworks + the normal start flow. Modifiers sync to the guest via the
existing modifier_layers serialization.

Custom card bans are gated behind a coming-soon pill (not yet threaded into
ApplyBans or network sync). Removed the COMING SOON ribbon.

* Remove coming-soon line from mutators wall

Drop the 'coming soon · Reward Draft · Rubber Band · Score Tax' footer and
the unused SOON table that fed it. Stub layers in mutator_stubs.lua are
untouched.

* Add No Red Seals mutator layer

New no_red_seals layer: bans Deja Vu and wraps Card:set_seal to refuse Red
assignments (RNG-neutral, so both clients no-op the same seal and stay
synced; the slot just goes sealless). Wired into the HAZARDS column of the
mutators wall alongside No Easy Money / No Uncommons.

* Disable not-yet-working mutators + stub custom editor on lobby path

Mark Inflation, No Interest, Discard Tax, Frugal, and Blind Poker as
coming-soon: greyed/un-toggleable cells with a "Coming soon." hover, and
skipped by the Randomize / Blind Random rolls so they can never activate.

Gate the custom ruleset editor to a coming-soon panel on the lobby (mp)
path; sp/practice keep the full editor.

* Disable Spartan and Pricey Packs mutators (not yet working)

* Surface the three stub mutators on the wall as coming-soon cells

Reward Draft, Rubber Band and Score Tax (the unwired layers from
mutator_stubs.lua) now appear as disabled "Coming soon." cells instead of
being invisible.

* Disable Heavy Pockets + No Takebacks; keep desc on coming-soon tooltips

Disabled mutator cells now show their full description with a trailing
"(Coming soon)" line instead of replacing the text outright.

* Disable Cash Ceiling mutator (not yet working)

* Strip LLM-flavored comment noise from mutator layers

Drop the seam/contract/MP-safe explanatory essays and fake file:line
citations; keep the human voice. Also removes the dormant, unreferenced
sticker_shop layer.

* Add credit badges to mutator wall tooltips

Reuse the MULTIPLAYER credit-badge look (float DynaText pill) from
mod_badges.lua as a standalone builder, dropped into the cell tooltip via
create_popup_UIBox_tooltip's filler hook so it only shows on hover. Cells
opt in with a credits = { art/idea/code } field. Rotation skips the
MULTIPLAYER header — one credit shows static, multiple rotate.

Seeded credits for the cells we know: Kars (idea) on Inflation and No Easy
Money, plus a few code credits.

* Casjb/full replay (#473)

* feat(replay): dual-stream deterministic action log (MP.RLOG)

Add a fully-recreatable action logging system. Every state-affecting
player action emits two aligned streams from one record() call:

- Carbon-copy (replay) stream: positional opcodes (e.g. "buy 1 2") with
  no card names, so it is indiscriminate and replays across mods. Written
  to a dedicated ".carbon" sidecar next to the Lovely log (NOT the mod
  folder, so version hotswaps don't disturb it).
- Human stream: mirrored into the carbon file; the player-facing Lovely
  log is left byte-for-byte unchanged so the website parser is undisturbed.

Both streams are hashed at game end (joker_hash) and submitted to the
server via the new submitLogHashes action for cheap tamper detection.

Coverage: buy/open_pack/voucher, reroll, sell, use, play, discard,
pack_pick/skip, select/skip blind, ready, joker/hand reorder, the random
ante_key, and net_* opponent effects (phantom/magnet/pizza/asteroid).
Instrumentation extends existing overrides; reorder uses a Preview-
independent debounced CardArea:update detector.

This is the logging system only; the replay runner and server-side hash
storage are follow-ups.

Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua
(both pass on Lua 5.4).

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* refactor(replay): emit both streams into the Lovely log, drop .carbon file

Per design change: keep the carbon (replay) and human-readable streams in the
same Lovely log, separated by prefix, instead of a dedicated .carbon sidecar.

- Carbon stream now logs under the "MP_RLOG:" prefix (MANIFEST / action lines /
  CHK trailer); the human stream keeps the existing "Client sent message:" prefix.
- record() is now the sole emitter of both lines, so the instrumented overrides
  (buy/sell/reroll/use) no longer log the human line themselves.
- Remove the .carbon file writer, path derivation, flush(), and the per-round
  flush call. Both stream buffers are still hashed at end_run and re-derive from
  the log by prefix.
- Rework the tests to capture sendTraceMessage output (no file) and update docs.

Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua pass on Lua 5.4.

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* feat(replay): ship the full carbon log to the server, not just its hash

end_run now accumulates the full carbon block (manifest + action lines + END +
CHK) and sends it as a `log` field on submitLogHashes, so the server can store a
complete viewable/replayable record of every game without the player handing
over their log. Hashing is unchanged (still over the action/human buffers).

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* feat(serialization): guard str_decode_and_unpack against zip-bomb payloads

Reject an encoded payload larger than MAX_ENCODED_BYTES (32 KB) before any
base64/gzip work, so a crafted magnetResponse / receiveEndGameJokers /
receiveNemesisDeck payload can't balloon to GBs and OOM the receiving client.
A legitimate saved joker / nemesis deck is a few KB; both callers already bail
cleanly on the nil return. Defense-in-depth — the relay also caps message size.

Adds tests/test_serialization_guard.lua.

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* chore(mod): bump version to 0.4.3 for the replay-log pre-release build

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* fix(replay): crash-proof area_enum (table index is nil on shop buy)

area_enum built a lookup table keyed by the CardArea globals, but several of
them are nil depending on game state (G.pack_cards only exists while a booster
is open, G.shop_* only in the shop). A table literal with a nil key throws
'table index is nil', so any buy/sell/use while one of those areas was absent
crashed the game — reproducibly on the first shop purchase. Compare the area
against each global directly instead (a live area vs a nil global is just
false), which is crash-safe and returns the same enum.

Commit message generated with Claude Code

* feat(replay): stream carbon lines live to the server in batches

While a game plays, MP.RLOG now batches its carbon lines and flushes them to the
server (new streamLogLines action) every ~25 lines or ~2s, under a per-game id
generated in begin_run. So a crashed/abandoned game still leaves a partial
record server-side; on a clean end, submit_log_hashes carries the same game id
so the server swaps the live stream for the complete hashed package. Flushing
falls back to the line-count trigger when love.timer is unavailable (tests), and
no-ops cleanly when there's no transport. Adds tests/test_rlog_stream.lua.

Commit message generated with Claude Code

* Hide opponent score until first hand played (#475)

* Show ??? for opponent score until first hand of a PvP blind

Display the opponent's score as "???" in the blind HUD until the local
player has played a hand in the current PvP blind, matching the server's
withholding of enemyInfo. Reset the stored opponent score at the start of
each blind so the first frame after playing shows 0 rather than the
previous blind's stale score.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LgfBstKG4gLVbu4JKUQFnn

* Add hide_score_until_played lobby option (default on for standard layer)

Expose the opponent-score hiding as a host-toggleable lobby option. It
defaults on only for standard-layer rulesets (start_lobby derives it from
MP.current_ruleset().standard) and off everywhere else; the baseline config
value is off. The blind HUD only masks the opponent score as "???" when the
option is enabled. The toggle lives in the lobby options tab, so it is
editable in non-forcing lobbies and locked at its default when a ruleset
forces options.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LgfBstKG4gLVbu4JKUQFnn

* Mask opponent hands as "?" until the first enemy info each blind

While the opponent's score is hidden, their hands count previously still
showed its stored value. Track an info_received flag on MP.GAME.enemy
(reset at blind start, set when an enemyInfo arrives) and drive a new
hands_text field, shown in place of the raw count. When the hide-score
option is on, the HUD shows "?" for opponent hands until the first enemy
info of the blind; otherwise it mirrors the real count as before. Ghost
replays mark info as received so replays are never masked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LgfBstKG4gLVbu4JKUQFnn

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Various fixes (#472)

* Timer: prevent old timer tick when ready + more correct check when can timer opponent

* TheOrder: fix SMODS seals desync

* Networking: try-catch for server messages + resolve channel once

* UI: fix ko-fi message display & crash

* CardAreas: fix crash in Shared Pockets + make shared area move in place instantly

* fix old timer not tick when you timer opponent

* Update Phantom Edition Text (#464)

The current phantom edition text is somewhat confusing, this looks to
make it clear what this edition does.

* feat: update vietnamese's localization (wip) (#468)

* Season 7 changese

---------

Co-authored-by: Stephen Kirk <me@thestephen.net>
Co-authored-by: SleepyG11 <35398172+SleepyG11@users.noreply.github.com>
Co-authored-by: Fantom-Balatro <fantombalatro@gmail.com>
Co-authored-by: Casper JB <casper_jb@icloud.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: EchoHeli <emiliano.dorantest@gmail.com>
Co-authored-by: Trần Quốc Lân <62930566+minotour4869@users.noreply.github.com>

* Add PvP validation to ante timer actions (#477)

* Fix life loss from PvP timer firing after PvP has ended

After endPvP, the opponent's startAnteTimer message could re-arm
nemesis_timer_started, causing the timer to tick in the old-timer
path and costing an extra life. Add a post_pvp guard that blocks
timer starts until the player readies for the next blind.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

* Revert "Fix life loss from PvP timer firing after PvP has ended"

This reverts commit 1099127ee244ead313314ed62d7662175d9007d7.

* Fix PvP timer causing life loss after PvP ends

Tag startAnteTimer with isPvP when pressed during a PvP boss blind.
The receiving client ignores isPvP timer starts if it's no longer in
PvP, preventing the race condition where a PvP timer press arrives
after endPvP has already cleared the PvP state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

* Use end_pvp flag instead of is_pvp_boss to gate PvP timer starts

is_pvp_boss() stays true briefly after PvP ends until the game state
transitions. Check MP.GAME.end_pvp (set by endPvP handler) so the
guard catches the race window where PvP is over but the blind hasn't
changed yet.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

* Also ignore PvP timer starts when player has 0 hands left

If hands are exhausted the PvP round is effectively over even before
endPvP arrives, so reject stale PvP timer presses in that state too.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Update version from 0.5.0 to 0.5.1

* Pause timer when opponent disconnects (#478)

* Pause timer for both players when opponent disconnects

Clear both timer_started and nemesis_timer_started on
enemyDisconnected so the timer stops ticking for the remaining
player. The timer stays paused after reconnection and must be
manually restarted.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

* Also pause timer for the reconnecting player

Clear timer_started and nemesis_timer_started on rejoinedLobby so
the player who disconnected doesn't have their timer resume
immediately on reconnect.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

* Pause timer on reconnecting (disconnecting player's side)

The game loop keeps running while disconnected, so the timer keeps
ticking. Pause it in action_reconnecting when the connection drops,
not just on rejoinedLobby which is too late.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

---------

Co-authored-by: Claude <noreply@anthropic.com>

* PvP + hidden score fixes (#479)

* Fix PvP timer toggle when it shouldn't

* just in case

* v0.5.2 (#498)

* chore(release): bump version to 0.4.1~DEV

* fixes (#462)

* Update standard.lua

WSOB Ruleset lets go

* Add WSOB as tournament ruleset; bump version (#470)

* feat(rulesets): add WSOB tournament ruleset; restore standard layer

The "WSOB Ruleset lets go" change had gutted the shared `standard`
layer, which silently rebalanced every standard-composed ruleset
(Blitz, Traditional, Ranked, SmallWorld, Speedlatro, Chaos). Revert
standard.lua to its pre-WSOB form and move those settings into a
dedicated WSOB ruleset instead.

- rulesets/wsob.lua: near-vanilla tournament pool (multiplayer_content
  off, only Hanging Chad/Bloodstone/Glass reworked, Justice banned),
  gated on the ranked layer for version locking
- objects/enhancements/mp_glass.lua: extend Glass rework to "wsob"
- en-us.lua: k_wsob name + description
- ruleset_selection.lua: wire WSOB into the Tournaments tab

* refactor(ui): drop dead ruleset_buttons_data

Orphaned since the tabbed-ruleset refactor (#419), which switched every
consumer to `rulesets_tabs` but left this file-local defined and unused.
Zero references; pure dead allocation.

* chore(release): bump version to 0.4.3~DEV

* feat(mutators): turn challenge effects into a wall of stackable run modifiers (#471)

* Extract challenge effects into toggleable mutator layers + Wraith rework

Move the runtime effects from the eeeee, balancing_act (score
instability), polymorph_spam, and shared_pockets challenges into
layers/, gated on their existing G.GAME.modifiers.mp_* flags. Each
layer sets its flag from on_apply_bans at run start, so the effect can
be toggled as a mutator on any ruleset; the challenges keep their
rules.custom and now share one hook implementation (deduped). Surface
each on the mutator wall.

Add a wraith_rework layer: new c_mp_wraith consumable (random Uncommon
joker + $5, vs vanilla's random Rare + money to $0), gated via
reworked_consumables with the vanilla c_wraith banned_silent.

Also a stylua normalization pass touched bacon/planet_tycoon.

* fix broken wraith

* Wire up custom ruleset editor Play button (Path 1)

Custom tab now launches a run with a chosen base ruleset + the mutators
wall's modifier stack. Maps content sets (Vanilla/Standard/Sandbox/
Experimental) to base rulesets, sets the ruleset directly without routing
through apply_default_modifiers (which would wipe MP.MODIFIERS), then
LoadReworks + the normal start flow. Modifiers sync to the guest via the
existing modifier_layers serialization.

Custom card bans are gated behind a coming-soon pill (not yet threaded into
ApplyBans or network sync). Removed the COMING SOON ribbon.

* Remove coming-soon line from mutators wall

Drop the 'coming soon · Reward Draft · Rubber Band · Score Tax' footer and
the unused SOON table that fed it. Stub layers in mutator_stubs.lua are
untouched.

* Add No Red Seals mutator layer

New no_red_seals layer: bans Deja Vu and wraps Card:set_seal to refuse Red
assignments (RNG-neutral, so both clients no-op the same seal and stay
synced; the slot just goes sealless). Wired into the HAZARDS column of the
mutators wall alongside No Easy Money / No Uncommons.

* Disable not-yet-working mutators + stub custom editor on lobby path

Mark Inflation, No Interest, Discard Tax, Frugal, and Blind Poker as
coming-soon: greyed/un-toggleable cells with a "Coming soon." hover, and
skipped by the Randomize / Blind Random rolls so they can never activate.

Gate the custom ruleset editor to a coming-soon panel on the lobby (mp)
path; sp/practice keep the full editor.

* Disable Spartan and Pricey Packs mutators (not yet working)

* Surface the three stub mutators on the wall as coming-soon cells

Reward Draft, Rubber Band and Score Tax (the unwired layers from
mutator_stubs.lua) now appear as disabled "Coming soon." cells instead of
being invisible.

* Disable Heavy Pockets + No Takebacks; keep desc on coming-soon tooltips

Disabled mutator cells now show their full description with a trailing
"(Coming soon)" line instead of replacing the text outright.

* Disable Cash Ceiling mutator (not yet working)

* Strip LLM-flavored comment noise from mutator layers

Drop the seam/contract/MP-safe explanatory essays and fake file:line
citations; keep the human voice. Also removes the dormant, unreferenced
sticker_shop layer.

* Add credit badges to mutator wall tooltips

Reuse the MULTIPLAYER credit-badge look (float DynaText pill) from
mod_badges.lua as a standalone builder, dropped into the cell tooltip via
create_popup_UIBox_tooltip's filler hook so it only shows on hover. Cells
opt in with a credits = { art/idea/code } field. Rotation skips the
MULTIPLAYER header — one credit shows static, multiple rotate.

Seeded credits for the cells we know: Kars (idea) on Inflation and No Easy
Money, plus a few code credits.

* Casjb/full replay (#473)

* feat(replay): dual-stream deterministic action log (MP.RLOG)

Add a fully-recreatable action logging system. Every state-affecting
player action emits two aligned streams from one record() call:

- Carbon-copy (replay) stream: positional opcodes (e.g. "buy 1 2") with
  no card names, so it is indiscriminate and replays across mods. Written
  to a dedicated ".carbon" sidecar next to the Lovely log (NOT the mod
  folder, so version hotswaps don't disturb it).
- Human stream: mirrored into the carbon file; the player-facing Lovely
  log is left byte-for-byte unchanged so the website parser is undisturbed.

Both streams are hashed at game end (joker_hash) and submitted to the
server via the new submitLogHashes action for cheap tamper detection.

Coverage: buy/open_pack/voucher, reroll, sell, use, play, discard,
pack_pick/skip, select/skip blind, ready, joker/hand reorder, the random
ante_key, and net_* opponent effects (phantom/magnet/pizza/asteroid).
Instrumentation extends existing overrides; reorder uses a Preview-
independent debounced CardArea:update detector.

This is the logging system only; the replay runner and server-side hash
storage are follow-ups.

Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua
(both pass on Lua 5.4).

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* refactor(replay): emit both streams into the Lovely log, drop .carbon file

Per design change: keep the carbon (replay) and human-readable streams in the
same Lovely log, separated by prefix, instead of a dedicated .carbon sidecar.

- Carbon stream now logs under the "MP_RLOG:" prefix (MANIFEST / action lines /
  CHK trailer); the human stream keeps the existing "Client sent message:" prefix.
- record() is now the sole emitter of both lines, so the instrumented overrides
  (buy/sell/reroll/use) no longer log the human line themselves.
- Remove the .carbon file writer, path derivation, flush(), and the per-round
  flush call. Both stream buffers are still hashed at end_run and re-derive from
  the log by prefix.
- Rework the tests to capture sendTraceMessage output (no file) and update docs.

Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua pass on Lua 5.4.

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* feat(replay): ship the full carbon log to the server, not just its hash

end_run now accumulates the full carbon block (manifest + action lines + END +
CHK) and sends it as a `log` field on submitLogHashes, so the server can store a
complete viewable/replayable record of every game without the player handing
over their log. Hashing is unchanged (still over the action/human buffers).

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* feat(serialization): guard str_decode_and_unpack against zip-bomb payloads

Reject an encoded payload larger than MAX_ENCODED_BYTES (32 KB) before any
base64/gzip work, so a crafted magnetResponse / receiveEndGameJokers /
receiveNemesisDeck payload can't balloon to GBs and OOM the receiving client.
A legitimate saved joker / nemesis deck is a few KB; both callers already bail
cleanly on the nil return. Defense-in-depth — the relay also caps message size.

Adds tests/test_serialization_guard.lua.

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* chore(mod): bump version to 0.4.3 for the replay-log pre-release build

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* fix(replay): crash-proof area_enum (table index is nil on shop buy)

area_enum built a lookup table keyed by the CardArea globals, but several of
them are nil depending on game state (G.pack_cards only exists while a booster
is open, G.shop_* only in the shop). A table literal with a nil key throws
'table index is nil', so any buy/sell/use while one of those areas was absent
crashed the game — reproducibly on the first shop purchase. Compare the area
against each global directly instead (a live area vs a nil global is just
false), which is crash-safe and returns the same enum.

Commit message generated with Claude Code

* feat(replay): stream carbon lines live to the server in batches

While a game plays, MP.RLOG now batches its carbon lines and flushes them to the
server (new streamLogLines action) every ~25 lines or ~2s, under a per-game id
generated in begin_run. So a crashed/abandoned game still leaves a partial
record server-side; on a clean end, submit_log_hashes carries the same game id
so the server swaps the live stream for the complete hashed package. Flushing
falls back to the line-count trigger when love.timer is unavailable (tests), and
no-ops cleanly when there's no transport. Adds tests/test_rlog_stream.lua.

Commit message generated with Claude Code

* Hide opponent score until first hand played (#475)

* Show ??? for opponent score until first hand of a PvP blind

Display the opponent's score as "???" in the blind HUD until the local
player has played a hand in the current PvP blind, matching the server's
withholding of enemyInfo. Reset the stored opponent score at the start of
each blind so the first frame after playing shows 0 rather than the
previous blind's stale score.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LgfBstKG4gLVbu4JKUQFnn

* Add hide_score_until_played lobby option (default on for standard layer)

Expose the opponent-score hiding as a host-toggleable lobby option. It
defaults on only for standard-layer rulesets (start_lobby derives it from
MP.current_ruleset().standard) and off everywhere else; the baseline config
value is off. The blind HUD only masks the opponent score as "???" when the
option is enabled. The toggle lives in the lobby options tab, so it is
editable in non-forcing lobbies and locked at its default when a ruleset
forces options.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LgfBstKG4gLVbu4JKUQFnn

* Mask opponent hands as "?" until the first enemy info each blind

While the opponent's score is hidden, their hands count previously still
showed its stored value. Track an info_received flag on MP.GAME.enemy
(reset at blind start, set when an enemyInfo arrives) and drive a new
hands_text field, shown in place of the raw count. When the hide-score
option is on, the HUD shows "?" for opponent hands until the first enemy
info of the blind; otherwise it mirrors the real count as before. Ghost
replays mark info as received so replays are never masked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LgfBstKG4gLVbu4JKUQFnn

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Various fixes (#472)

* Timer: prevent old timer tick when ready + more correct check when can timer opponent

* TheOrder: fix SMODS seals desync

* Networking: try-catch for server messages + resolve channel once

* UI: fix ko-fi message display & crash

* CardAreas: fix crash in Shared Pockets + make shared area move in place instantly

* fix old timer not tick when you timer opponent

* Update Phantom Edition Text (#464)

The current phantom edition text is somewhat confusing, this looks to
make it clear what this edition does.

* feat: update vietnamese's localization (wip) (#468)

* Season 7 changese

* Nemesis timer display (#495)

* Update Multiplayer.json (#481)

* Fixed idol gg (#463)

* Fixed idol gg

* made change according to review by Toneblock

* Finalized sorting algo

* Timer, UI and Asteroid fixes (#482)

* 0.4.1 (#465)

* chore(release): bump version to 0.4.1~DEV

* fixes (#462)

* Update standard.lua

WSOB Ruleset lets go

---------

Co-authored-by: Stephen Kirk <me@thestephen.net>
Co-authored-by: SleepyG11 <35398172+SleepyG11@users.noreply.github.com>

* remove ~DEV for release

* Revert ruleset changes (0.4.2) (#466)

* Update standard.lua

* Update Multiplayer.json

* Casjb/full replay (#473)

* feat(replay): dual-stream deterministic action log (MP.RLOG)

Add a fully-recreatable action logging system. Every state-affecting
player action emits two aligned streams from one record() call:

- Carbon-copy (replay) stream: positional opcodes (e.g. "buy 1 2") with
  no card names, so it is indiscriminate and replays across mods. Written
  to a dedicated ".carbon" sidecar next to the Lovely log (NOT the mod
  folder, so version hotswaps don't disturb it).
- Human stream: mirrored into the carbon file; the player-facing Lovely
  log is left byte-for-byte unchanged so the website parser is undisturbed.

Both streams are hashed at game end (joker_hash) and submitted to the
server via the new submitLogHashes action for cheap tamper detection.

Coverage: buy/open_pack/voucher, reroll, sell, use, play, discard,
pack_pick/skip, select/skip blind, ready, joker/hand reorder, the random
ante_key, and net_* opponent effects (phantom/magnet/pizza/asteroid).
Instrumentation extends existing overrides; reorder uses a Preview-
independent debounced CardArea:update detector.

This is the logging system only; the replay runner and server-side hash
storage are follow-ups.

Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua
(both pass on Lua 5.4).

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* refactor(replay): emit both streams into the Lovely log, drop .carbon file

Per design change: keep the carbon (replay) and human-readable streams in the
same Lovely log, separated by prefix, instead of a dedicated .carbon sidecar.

- Carbon stream now logs under the "MP_RLOG:" prefix (MANIFEST / action lines /
  CHK trailer); the human stream keeps the existing "Client sent message:" prefix.
- record() is now the sole emitter of both lines, so the instrumented overrides
  (buy/sell/reroll/use) no longer log the human line themselves.
- Remove the .carbon file writer, path derivation, flush(), and the per-round
  flush call. Both stream buffers are still hashed at end_run and re-derive from
  the log by prefix.
- Rework the tests to capture sendTraceMessage output (no file) and update docs.

Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua pass on Lua 5.4.

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* feat(replay): ship the full carbon log to the server, not just its hash

end_run now accumulates the full carbon block (manifest + action lines + END +
CHK) and sends it as a `log` field on submitLogHashes, so the server can store a
complete viewable/replayable record of every game without the player handing
over their log. Hashing is unchanged (still over the action/human buffers).

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* feat(serialization): guard str_decode_and_unpack against zip-bomb payloads

Reject an encoded payload larger than MAX_ENCODED_BYTES (32 KB) before any
base64/gzip work, so a crafted magnetResponse / receiveEndGameJokers /
receiveNemesisDeck payload can't balloon to GBs and OOM the receiving client.
A legitimate saved joker / nemesis deck is a few KB; both callers already bail
cleanly on the nil return. Defense-in-depth — the relay also caps message size.

Adds tests/test_serialization_guard.lua.

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* chore(mod): bump version to 0.4.3 for the replay-log pre-release build

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* fix(replay): crash-proof area_enum (table index is nil on shop buy)

area_enum built a lookup table keyed by the CardArea globals, but several of
them are nil depending on game state (G.pack_cards only exists while a booster
is open, G.shop_* only in the shop). A table literal with a nil key throws
'table index is nil', so any buy/sell/use while one of those areas was absent
crashed the game — reproducibly on the first shop purchase. Compare the area
against each global directly instead (a live area vs a nil global is just
false), which is crash-safe and returns the same enum.

Commit message generated with Claude Code

* feat(replay): stream carbon lines live to the server in batches

While a game plays, MP.RLOG now batches its carbon lines and flushes them to the
server (new streamLogLines action) every ~25 lines or ~2s, under a per-game id
generated in begin_run. So a crashed/abandoned game still leaves a partial
record server-side; on a clean end, submit_log_hashes carries the same game id
so the server swaps the live stream for the complete hashed package. Flushing
falls back to the line-count trigger when love.timer is unavailable (tests), and
no-ops cleanly when there's no transport. Adds tests/test_rlog_stream.lua.

Commit message generated with Claude Code

* 0.5.0 Merge (#476)

* chore(release): bump version to 0.4.1~DEV

* fixes (#462)

* Update standard.lua

WSOB Ruleset lets go

* Add WSOB as tournament ruleset; bump version (#470)

* feat(rulesets): add WSOB tournament ruleset; restore standard layer

The "WSOB Ruleset lets go" change had gutted the shared `standard`
layer, which silently rebalanced every standard-composed ruleset
(Blitz, Traditional, Ranked, SmallWorld, Speedlatro, Chaos). Revert
standard.lua to its pre-WSOB form and move those settings into a
dedicated WSOB ruleset instead.

- rulesets/wsob.lua: near-vanilla tournament pool (multiplayer_content
  off, only Hanging Chad/Bloodstone/Glass reworked, Justice banned),
  gated on the ranked layer for version locking
- objects/enhancements/mp_glass.lua: extend Glass rework to "wsob"
- en-us.lua: k_wsob name + description
- ruleset_selection.lua: wire WSOB into the Tournaments tab

* refactor(ui): drop dead ruleset_buttons_data

Orphaned since the tabbed-ruleset refactor (#419), which switched every
consumer to `rulesets_tabs` but left this file-local defined and unused.
Zero references; pure dead allocation.

* chore(release): bump version to 0.4.3~DEV

* feat(mutators): turn challenge effects into a wall of stackable run modifiers (#471)

* Extract challenge effects into toggleable mutator layers + Wraith rework

Move the runtime effects from the eeeee, balancing_act (score
instability), polymorph_spam, and shared_pockets challenges into
layers/, gated on their existing G.GAME.modifiers.mp_* flags. Each
layer sets its flag from on_apply_bans at run start, so the effect can
be toggled as a mutator on any ruleset; the challenges keep their
rules.custom and now share one hook implementation (deduped). Surface
each on the mutator wall.

Add a wraith_rework layer: new c_mp_wraith consumable (random Uncommon
joker + $5, vs vanilla's random Rare + money to $0), gated via
reworked_consumables with the vanilla c_wraith banned_silent.

Also a stylua normalization pass touched bacon/planet_tycoon.

* fix broken wraith

* Wire up custom ruleset editor Play button (Path 1)

Custom tab now launches a run with a chosen base ruleset + the mutators
wall's modifier stack. Maps content sets (Vanilla/Standard/Sandbox/
Experimental) to base rulesets, sets the ruleset directly without routing
through apply_default_modifiers (which would wipe MP.MODIFIERS), then
LoadReworks + the normal start flow. Modifiers sync to the guest via the
existing modifier_layers serialization.

Custom card bans are gated behind a coming-soon pill (not yet threaded into
ApplyBans or network sync). Removed the COMING SOON ribbon.

* Remove coming-soon line from mutators wall

Drop the 'coming soon · Reward Draft · Rubber Band · Score Tax' footer and
the unused SOON table that fed it. Stub layers in mutator_stubs.lua are
untouched.

* Add No Red Seals mutator layer

New no_red_seals layer: bans Deja Vu and wraps Card:set_seal to refuse Red
assignments (RNG-neutral, so both clients no-op the same seal and stay
synced; the slot just goes sealless). Wired into the HAZARDS column of the
mutators wall alongside No Easy Money / No Uncommons.

* Disable not-yet-working mutators + stub custom editor on lobby path

Mark Inflation, No Interest, Discard Tax, Frugal, and Blind Poker as
coming-soon: greyed/un-toggleable cells with a "Coming soon." hover, and
skipped by the Randomize / Blind Random rolls so they can never activate.

Gate the custom ruleset editor to a coming-soon panel on the lobby (mp)
path; sp/practice keep the full editor.

* Disable Spartan and Pricey Packs mutators (not yet working)

* Surface the three stub mutators on the wall as coming-soon cells

Reward Draft, Rubber Band and Score Tax (the unwired layers from
mutator_stubs.lua) now appear as disabled "Coming soon." cells instead of
being invisible.

* Disable Heavy Pockets + No Takebacks; keep desc on coming-soon tooltips

Disabled mutator cells now show their full description with a trailing
"(Coming soon)" line instead of replacing the text outright.

* Disable Cash Ceiling mutator (not yet working)

* Strip LLM-flavored comment noise from mutator layers

Drop the seam/contract/MP-safe explanatory essays and fake file:line
citations; keep the human voice. Also removes the dormant, unreferenced
sticker_shop layer.

* Add credit badges to mutator wall tooltips

Reuse the MULTIPLAYER credit-badge look (float DynaText pill) from
mod_badges.lua as a standalone builder, dropped into the cell tooltip via
create_popup_UIBox_tooltip's filler hook so it only shows on hover. Cells
opt in with a credits = { art/idea/code } field. Rotation skips the
MULTIPLAYER header — one credit shows static, multiple rotate.

Seeded credits for the cells we know: Kars (idea) on Inflation and No Easy
Money, plus a few code credits.

* Casjb/full replay (#473)

* feat(replay): dual-stream deterministic action log (MP.RLOG)

Add a fully-recreatable action logging system. Every state-affecting
player action emits two aligned streams from one record() call:

- Carbon-copy (replay) stream: positional opcodes (e.g. "buy 1 2") with
  no card names, so it is indiscriminate and replays across mods. Written
  to a dedicated ".carbon" sidecar next to the Lovely log (NOT the mod
  folder, so version hotswaps don't disturb it).
- Human stream: mirrored into the carbon file; the player-facing Lovely
  log is left byte-for-byte unchanged so the website parser is undisturbed.

Both streams are hashed at game end (joker_hash) and submitted to the
server via the new submitLogHashes action for cheap tamper detection.

Coverage: buy/open_pack/voucher, reroll, sell, use, play, discard,
pack_pick/skip, select/skip blind, ready, joker/hand reorder, the random
ante_key, and net_* opponent effects (phantom/magnet/pizza/asteroid).
Instrumentation extends existing overrides; reorder uses a Preview-
independent debounced CardArea:update detector.

This is the logging system only; the replay runner and server-side hash
storage are follow-ups.

Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua
(both pass on Lua 5.4).

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* refactor(replay): emit both streams into the Lovely log, drop .carbon file

Per design change: keep the carbon (replay) and human-readable streams in the
same Lovely log, separated by prefix, instead of a dedicated .carbon sidecar.

- Carbon stream now logs under the "MP_RLOG:" prefix (MANIFEST / action lines /
  CHK trailer); the human stream keeps the existing "Client sent message:" prefix.
- record() is now the sole emitter of both lines, so the instrumented overrides
  (buy/sell/reroll/use) no longer log the human line themselves.
- Remove the .carbon file writer, path derivation, flush(), and the per-round
  flush call. Both stream buffers are still hashed at end_run and re-derive from
  the log by prefix.
- Rework the tests to capture sendTraceMessage output (no file) and update docs.

Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua pass on Lua 5.4.

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* feat(replay): ship the full carbon log to the server, not just its hash

end_run now accumulates the full carbon block (manifest + action lines + END +
CHK) and sends it as a `log` field on submitLogHashes, so the server can store a
complete viewable/replayable record of every game without the player handing
over their log. Hashing is unchanged (still over the action/human buffers).

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* feat(serialization): guard str_decode_and_unpack against zip-bomb payloads

Reject an encoded payload larger than MAX_ENCODED_BYTES (32 KB) before any
base64/gzip work, so a crafted magnetResponse / receiveEndGameJokers /
receiveNemesisDeck payload can't balloon to GBs and OOM the receiving client.
A legitimate saved joker / nemesis deck is a few KB; both callers already bail
cleanly on the nil return. Defense-in-depth — the relay also caps message size.

Adds tests/test_serialization_guard.lua.

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* chore(mod): bump version to 0.4.3 for the replay-log pre-release build

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* fix(replay): crash-proof area_enum (table index is nil on shop buy)

area_enum built a lookup table keyed by the CardArea globals, but several of
them are nil depending on game state (G.pack_cards only exists while a booster
is open, G.shop_* only in the shop). A table literal with a nil key throws
'table index is nil', so any buy/sell/use while one of those areas was absent
crashed the game — reproducibly on the first shop purchase. Compare the area
against each global directly instead (a live area vs a nil global is just
false), which is crash-safe and returns the same enum.

Commit message generated with Claude Code

* feat(replay): stream carbon lines live to the server in batches

While a game plays, MP.RLOG now batches its carbon lines and flushes them to the
server (new streamLogLines action) every ~25 lines or ~2s, under a per-game id
generated in begin_run. So a crashed/abandoned game still leaves a partial
record server-side; on a clean end, submit_log_hashes carries the same game id
so the server swaps the live stream for the complete hashed package. Flushing
falls back to the line-count trigger when love.timer is unavailable (tests), and
no-ops cleanly when there's no transport. Adds tests/test_rlog_stream.lua.

Commit message generated with Claude Code

* Hide opponent score until first hand played (#475)

* Show ??? for opponent score until first hand of a PvP blind

Display the opponent's score as "???" in the blind HUD until the local
player has played a hand in the current PvP blind, matching the server's
withholding of enemyInfo. Reset the stored opponent score at the start of
each blind so the first frame after playing shows 0 rather than the
previous blind's stale score.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LgfBstKG4gLVbu4JKUQFnn

* Add hide_score_until_played lobby option (default on for standard layer)

Expose the opponent-score hiding as a host-toggleable lobby option. It
defaults on only for standard-layer rulesets (start_lobby derives it from
MP.current_ruleset().standard) and off everywhere else; the baseline config
value is off. The blind HUD only masks the opponent score as "???" when the
option is enabled. The toggle lives in the lobby options tab, so it is
editable in non-forcing lobbies and locked at its default when a ruleset
forces options.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LgfBstKG4gLVbu4JKUQFnn

* Mask opponent hands as "?" until the first enemy info each blind

While the opponent's score is hidden, their hands count previously still
showed its stored value. Track an info_received flag on MP.GAME.enemy
(reset at blind start, set when an enemyInfo arrives) and drive a new
hands_text field, shown in place of the raw count. When the hide-score
option is on, the HUD shows "?" for opponent hands until the first enemy
info of the blind; otherwise it mirrors the real count as before. Ghost
replays mark info as received so replays are never masked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LgfBstKG4gLVbu4JKUQFnn

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Various fixes (#472)

* Timer: prevent old timer tick when ready + more correct check when can timer opponent

* TheOrder: fix SMODS seals desync

* Networking: try-catch for server messages + resolve channel once

* UI: fix ko-fi message display & crash

* CardAreas: fix crash in Shared Pockets + make shared area move in place instantly

* fix old timer not tick when you timer opponent

* Update Phantom Edition Text (#464)

The current phantom edition text is somewhat confusing, this looks to
make it clear what this edition does.

* feat: update vietnamese's localization (wip) (#468)

* Season 7 changese

---------

Co-authored-by: Stephen Kirk <me@thestephen.net>
Co-authored-by: SleepyG11 <35398172+SleepyG11@users.noreply.github.com>
Co-authored-by: Fantom-Balatro <fantombalatro@gmail.com>
Co-authored-by: Casper JB <casper_jb@icloud.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: EchoHeli <emiliano.dorantest@gmail.com>
Co-authored-by: Trần Quốc Lân <62930566+minotour4869@users.noreply.github.com>

* Add PvP validation to ante timer actions (#477)

* Fix life loss from PvP timer firing after PvP has ended

After endPvP, the opponent's startAnteTimer message could re-arm
nemesis_timer_started, causing the timer to tick in the old-timer
path and costing an extra life. Add a post_pvp guard that blocks
timer starts until the player readies for the next blind.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

* Revert "Fix life loss from PvP timer firing after PvP has ended"

This reverts commit 1099127ee244ead313314ed62d7662175d9007d7.

* Fix PvP timer causing life loss after PvP ends

Tag startAnteTimer with isPvP when pressed during a PvP boss blind.
The receiving client ignores isPvP timer starts if it's no longer in
PvP, preventing the race condition where a PvP timer press arrives
after endPvP has already cleared the PvP state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

* Use end_pvp flag instead of is_pvp_boss to gate PvP timer starts

is_pvp_boss() stays true briefly after PvP ends until the game state
transitions. Check MP.GAME.end_pvp (set by endPvP handler) so the
guard catches the race window where PvP is over but the blind hasn't
changed yet.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

* Also ignore PvP timer starts when player has 0 hands left

If hands are exhausted the PvP round is effectively over even before
endPvP arrives, so reject stale PvP timer presses in that state too.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Update version from 0.5.0 to 0.5.1

* Pause timer when opponent disconnects (#478)

* Pause timer for both players when opponent disconnects

Clear both timer_started and nemesis_timer_started on
enemyDisconnected so the timer stops ticking for the remaining
player. The timer stays paused after reconnection and must be
manually restarted.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

* Also pause timer for the reconnecting player

Clear timer_started and nemesis_timer_started on rejoinedLobby so
the player who disconnected doesn't have their timer resume
immediately on reconnect.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

* Pause timer on reconnecting (disconnecting player's side)

The game loop keeps running while disconnected, so the timer keeps
ticking. Pause it in action_reconnecting when the connection drops,
not just on rejoinedLobby which is too late.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kQrzLNB5kdL18tfVP4gfj

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Fix PvP timer toggle when it shouldn't

* just in case

* PvP + hidden score fixes (#479)

* Fix PvP timer toggle when it shouldn't

* just in case

* Return blind select sound

* More strict timer checks

* Get Logs and Open Parser buttons in game over screen

* Add Log Parser and Open Logs in mod config

* Add "mods' and "collection' button to lobby

* always display remaining hands

* server-side pvp timer

* automatic pvp timer

* PvP timer 60+10

* Prevent asteroid go into negative levels

* update version, I guess

* prevent timer during game over/win

* use real timer for updating opponent's score

---------

Co-authored-by: Fantom-Balatro <fantombalatro@gmail.com>
Co-authored-by: Stephen Kirk <me@thestephen.net>
Co-authored-by: Casper JB <casper_jb@icloud.com>
Co-authored-by: Connor Mills <connor@virtualized.dev>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: EchoHeli <emiliano.dorantest@gmail.com>
Co-authored-by: Trần Quốc Lân <62930566+minotour4869@users.noreply.github.com>

---------

Co-authored-by: Stephen Kirk <me@thestephen.net>
Co-authored-by: SleepyG11 <35398172+SleepyG11@users.noreply.github.com>
Co-authored-by: Fantom-Balatro <fantombalatro@gmail.com>
Co-authored-by: Casper JB <casper_jb@icloud.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: EchoHeli <emiliano.dorantest@gmail.com>
Co-authored-by: Trần Quốc Lân <62930566+minotour4869@users.noreply.github.com>
Co-authored-by: Lilo <thrasherglitches@gmail.com>
Co-authored-by: 12problems <74747315+12problems@users.noreply.github.com>

* feat(the-order): log each idol hit for the website log parser (#511)

The Order weights the idol card by how many copies of each card are in the
deck, but nothing about that reaches the player after the fact. Log the hit
so the website log parser can show which card won, the value that was
rolled, and how likely that card was.

Emitted once per selection, right after the winning card is stored:

  IDOL_ROLL::<base64 of
    {"roll":0.3536,"winner":"TC","cards":["AS1","AH1","KS4"]}>

`cards` are compact "<rank><suit><count>" tokens (T = 10) in the same order
the weighted threshold walk uses, so the reader can reconstruct the exact
distribution the roll was made against. base64 keeps it to a single opaque
token in the log. Logging only — no game state or RNG is touched.

* v0.5.3 (#518)

* chore(release): bump version to 0.4.1~DEV

* fixes (#462)

* Update standard.lua

WSOB Ruleset lets go

* Add WSOB as tournament ruleset; bump version (#470)

* feat(rulesets): add WSOB tournament ruleset; restore standard layer

The "WSOB Ruleset lets go" change had gutted the shared `standard`
layer, which silently rebalanced every standard-composed ruleset
(Blitz, Traditional, Ranked, SmallWorld, Speedlatro, Chaos). Revert
standard.lua to its pre-WSOB form and move those settings into a
dedicated WSOB ruleset instead.

- rulesets/wsob.lua: near-vanilla tournament pool (multiplayer_content
  off, only Hanging Chad/Bloodstone/Glass reworked, Justice banned),
  gated on the ranked layer for version locking
- objects/enhancements/mp_glass.lua: extend Glass rework to "wsob"
- en-us.lua: k_wsob name + description
- ruleset_selection.lua: wire WSOB into the Tournaments tab

* refactor(ui): drop dead ruleset_buttons_data

Orphaned since the tabbed-ruleset refactor (#419), which switched every
consumer to `rulesets_tabs` but left this file-local defined and unused.
Zero references; pure dead allocation.

* chore(release): bump version to 0.4.3~DEV

* feat(mutators): turn challenge effects into a wall of stackable run modifiers (#471)

* Extract challenge effects into toggleable mutator layers + Wraith rework

Move the runtime effects from the eeeee, balancing_act (score
instability), polymorph_spam, and shared_pockets challenges into
layers/, gated on their existing G.GAME.modifiers.mp_* flags. Each
layer sets its flag from on_apply_bans at run start, so the effect can
be toggled as a mutator on any ruleset; the challenges keep their
rules.custom and now share one hook implementation (deduped). Surface
each on the mutator wall.

Add a wraith_rework layer: new c_mp_wraith consumable (random Uncommon
joker + $5, vs vanilla's random Rare + money to $0), gated via
reworked_consumables with the vanilla c_wraith banned_silent.

Also a stylua normalization pass touched bacon/planet_tycoon.

* fix broken wraith

* Wire up custom ruleset editor Play button (Path 1)

Custom tab now launches a run with a chosen base ruleset + the mutators
wall's modifier stack. Maps content sets (Vanilla/Standard/Sandbox/
Experimental) to base rulesets, sets the ruleset directly without routing
through apply_default_modifiers (which would wipe MP.MODIFIERS), then
LoadReworks + the normal start flow. Modifiers sync to the guest via the
existing modifier_layers serialization.

Custom card bans are gated behind a coming-soon pill (not yet threaded into
ApplyBans or network sync). Removed the COMING SOON ribbon.

* Remove coming-soon line from mutators wall

Drop the 'coming soon · Reward Draft · Rubber Band · Score Tax' footer and
the unused SOON table that fed it. Stub layers in mutator_stubs.lua are
untouched.

* Add No Red Seals mutator layer

New no_red_seals layer: bans Deja Vu and wraps Card:set_seal to refuse Red
assignments (RNG-neutral, so both clients no-op the same seal and stay
synced; the slot just goes sealless). Wired into the HAZARDS column of the
mutators wall alongside No Easy Money / No Uncommons.

* Disable not-yet-working mutators + stub custom editor on lobby path

Mark Inflation, No Interest, Discard Tax, Frugal, and Blind Poker as
coming-soon: greyed/un-toggleable cells with a "Coming soon." hover, and
skipped by the Randomize / Blind Random rolls so they can never activate.

Gate the custom ruleset editor to a coming-soon panel on the lobby (mp)
path; sp/practice keep the full editor.

* Disable Spartan and Pricey Packs mutators (not yet working)

* Surface the three stub mutators on the wall as coming-soon cells

Reward Draft, Rubber Band and Score Tax (the unwired layers from
mutator_stubs.lua) now appear as disabled "Coming soon." cells instead of
being invisible.

* Disable Heavy Pockets + No Takebacks; keep desc on coming-soon tooltips

Disabled mutator cells now show their full description with a trailing
"(Coming soon)" line instead of replacing the text outright.

* Disable Cash Ceiling mutator (not yet working)

* Strip LLM-flavored comment noise from mutator layers

Drop the seam/contract/MP-safe explanatory essays and fake file:line
citations; keep the human voice. Also removes the dormant, unreferenced
sticker_shop layer.

* Add credit badges to mutator wall tooltips

Reuse the MULTIPLAYER credit-badge look (float DynaText pill) from
mod_badges.lua as a standalone builder, dropped into the cell tooltip via
create_popup_UIBox_tooltip's filler hook so it only shows on hover. Cells
opt in with a credits = { art/idea/code } field. Rotation skips the
MULTIPLAYER header — one credit shows static, multiple rotate.

Seeded credits for the cells we know: Kars (idea) on Inflation and No Easy
Money, plus a few code credits.

* Casjb/full replay (#473)

* feat(replay): dual-stream deterministic action log (MP.RLOG)

Add a fully-recreatable action logging system. Every state-affecting
player action emits two aligned streams from one record() call:

- Carbon-copy (replay) stream: positional opcodes (e.g. "buy 1 2") with
  no card names, so it is indiscriminate and replays across mods. Written
  to a dedicated ".carbon" sidecar next to the Lovely log (NOT the mod
  folder, so version hotswaps don't disturb it).
- Human stream: mirrored into the carbon file; the player-facing Lovely
  log is left byte-for-byte unchanged so the website parser is undisturbed.

Both streams are hashed at game end (joker_hash) and submitted to the
server via the new submitLogHashes action for cheap tamper detection.

Coverage: buy/open_pack/voucher, reroll, sell, use, play, discard,
pack_pick/skip, select/skip blind, ready, joker/hand reorder, the random
ante_key, and net_* opponent effects (phantom/magnet/pizza/asteroid).
Instrumentation extends existing overrides; reorder uses a Preview-
independent debounced CardArea:update detector.

This is the logging system only; the replay runner and server-side hash
storage are follow-ups.

Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua
(both pass on Lua 5.4).

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* refactor(replay): emit both streams into the Lovely log, drop .carbon file

Per design change: keep the carbon (replay) and human-readable streams in the
same Lovely log, separated by prefix, instead of a dedicated .carbon sidecar.

- Carbon stream now logs under the "MP_RLOG:" prefix (MANIFEST / action lines /
  CHK trailer); the human stream keeps the existing "Client sent message:" prefix.
- record() is now the sole emitter of both lines, so the instrumented overrides
  (buy/sell/reroll/use) no longer log the human line themselves.
- Remove the .carbon file writer, path derivation, flush(), and the per-round
  flush call. Both stream buffers are still hashed at end_run and re-derive from
  the log by prefix.
- Rework the tests to capture sendTraceMessage output (no file) and update docs.

Tests: tests/test_rlog_roundtrip.lua, tests/test_rlog_checksum.lua pass on Lua 5.4.

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* feat(replay): ship the full carbon log to the server, not just its hash

end_run now accumulates the full carbon block (manifest + action lines + END +
CHK) and sends it as a `log` field on submitLogHashes, so the server can store a
complete viewable/replayable record of every game without the player handing
over their log. Hashing is unchanged (still over the action/human buffers).

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* feat(serialization): guard str_decode_and_unpack against zip-bomb payloads

Reject an encoded payload larger than MAX_ENCODED_BYTES (32 KB) before any
base64/gzip work, so a crafted magnetResponse / receiveEndGameJokers /
receiveNemesisDeck payload can't balloon to GBs and OOM the receiving client.
A legitimate saved joker / nemesis deck is a few KB; both callers already bail
cleanly on the nil return. Defense-in-depth — the relay also caps message size.

Adds tests/test_serialization_guard.lua.

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* chore(mod): bump version to 0.4.3 for the replay-log pre-release build

🤖 Commit message generated by Claude Code (https://claude.com/claude-code)

* fix(replay): crash-proof area_enum (table index is nil on shop buy)

area_enum built a lookup table keyed by the CardArea globals, but several of
them are nil depending on game state (G.pack_ca…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants