diff --git a/Multiplayer.json b/Multiplayer.json index 09d60caf..933a3626 100644 --- a/Multiplayer.json +++ b/Multiplayer.json @@ -12,7 +12,7 @@ "priority": 10000000, "badge_colour": "AC3232", "badge_text_colour": "FFFFFF", - "version": "0.4.2", + "version": "0.4.3", "dependencies": [ "Steamodded (>=1.0.0~BETA-1221a)", "Lovely (>=0.8)", diff --git a/compatibility/Preview/CorePreview.lua b/compatibility/Preview/CorePreview.lua index ee84cffe..0a2fa08e 100644 --- a/compatibility/Preview/CorePreview.lua +++ b/compatibility/Preview/CorePreview.lua @@ -130,9 +130,17 @@ end local orig_discard = G.FUNCS.discard_cards_from_highlighted function G.FUNCS.discard_cards_from_highlighted(e, is_hook_blind) + -- Carbon: capture which hand slots are discarded BEFORE orig_discard consumes + -- them. is_hook_blind means a programmatic discard (blind effect), not a click. + local discarded = (not is_hook_blind) and MP.UTILS.highlighted_hand_indices() or nil orig_discard(e, is_hook_blind) - if not is_hook_blind then FN.PRE.stop_current_coroutine() end + if not is_hook_blind then + if discarded and #discarded > 0 then + MP.RLOG.record("discard", { discarded }, "action:discard,cards:" .. table.concat(discarded, ".")) + end + FN.PRE.stop_current_coroutine() + end end -- USER INTERFACE ADVICE: diff --git a/lib/card_utils.lua b/lib/card_utils.lua index dbb3fe3b..0adcaf88 100644 --- a/lib/card_utils.lua +++ b/lib/card_utils.lua @@ -45,6 +45,87 @@ function MP.UTILS.joker_to_string(card) return joker_string end +-- Stable area enum for the carbon replay stream. The int identifies WHICH +-- CardArea a positional index refers to, independent of card identity/name. +MP.UTILS.AREA = { + shop_jokers = 1, + shop_booster = 2, + shop_vouchers = 3, + jokers = 4, + consumeables = 5, + hand = 6, + pack_cards = 7, +} + +-- Map a live CardArea object to its stable AREA enum int (or nil if unknown). +function MP.UTILS.area_enum(area) + if not area or not G then return nil end + -- Compare directly instead of building a lookup table keyed by the CardArea + -- globals: 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), and a + -- table literal with a nil key throws "table index is nil". Comparing a + -- live area against a nil global is simply false, so this is crash-safe. + if area == G.shop_jokers then return MP.UTILS.AREA.shop_jokers end + if area == G.shop_booster then return MP.UTILS.AREA.shop_booster end + if area == G.shop_vouchers then return MP.UTILS.AREA.shop_vouchers end + if area == G.jokers then return MP.UTILS.AREA.jokers end + if area == G.consumeables then return MP.UTILS.AREA.consumeables end + if area == G.hand then return MP.UTILS.AREA.hand end + if area == G.pack_cards then return MP.UTILS.AREA.pack_cards end + return nil +end + +-- 1-based index of a card within its CardArea's card list. `area` defaults to +-- card.area. Returns nil if the card is not found. This positional index is the +-- deterministic reference used by the carbon stream (never card.sort_id, which +-- is a per-run counter that won't match across a re-simulation). +function MP.UTILS.index_in_area(card, area) + area = area or (card and card.area) + if not card or not area or not area.cards then return nil end + for i = 1, #area.cards do + if area.cards[i] == card then return i end + end + return nil +end + +-- 1-based G.hand indices of the currently highlighted cards, ascending. Shared +-- by play/discard/consumable-target instrumentation so every hand reference in +-- the carbon stream is a deterministic positional index list. +function MP.UTILS.highlighted_hand_indices() + local out = {} + if not (G and G.hand and G.hand.highlighted) then return out end + for _, c in ipairs(G.hand.highlighted) do + local i = MP.UTILS.index_in_area(c, G.hand) + if i then out[#out + 1] = i end + end + table.sort(out) + return out +end + +-- Given the previous order (a list of card sort_ids) and the current cards, +-- return the new order expressed as a list of the cards' PREVIOUS 1-based +-- indices -- i.e. the permutation a replay applies to reproduce the reorder. +-- Returns nil if it is not a pure reorder (the card set changed) or if nothing +-- moved. Referencing previous indices (not sort_id) keeps the carbon stream +-- positional and replayable. +function MP.UTILS.reorder_permutation(old_ids, cards) + if not old_ids or not cards or #cards == 0 or #old_ids ~= #cards then return nil end + local pos = {} + for i = 1, #old_ids do + pos[old_ids[i]] = i + end + local perm = {} + local changed = false + for j = 1, #cards do + local oi = pos[cards[j].sort_id] + if not oi then return nil end -- a card is new/removed: not a pure reorder + perm[j] = oi + if oi ~= j then changed = true end + end + if not changed then return nil end + return perm +end + -- ??? seems to be dead code function MP.UTILS.get_joker(key) if not G.jokers or not G.jokers.cards then return nil end diff --git a/lib/replay_log.lua b/lib/replay_log.lua new file mode 100644 index 00000000..2f6ad645 --- /dev/null +++ b/lib/replay_log.lua @@ -0,0 +1,228 @@ +-- Replay Log (MP.RLOG): dual-stream, deterministic, fully-recreatable action log. +-- +-- Two streams are emitted from the SAME instrumentation points so they stay +-- event-for-event aligned (shared monotonic sequence number). Both go into the +-- ordinary Lovely log, distinguished only by a line prefix so parsers know what +-- to read -- there is no separate file. +-- +-- 1. Carbon-copy (replay) stream -- prefix "MP_RLOG:". Positional, no names: +-- "MP_RLOG: 5 buy 1 2" means "buy shop area 1, slot 2". Indiscriminate, so +-- modded content is just "slot N" and replays across mods for free. This is +-- the only truly replayable stream. The block is framed by a MANIFEST +-- header and an END + CHK trailer (also under the MP_RLOG: prefix). +-- +-- 2. Human-readable stream -- prefix "Client sent message:" (the existing +-- format the website parser already reads). "Client sent message: action: +-- boughtCardFromShop,card:Blueprint,cost:4". +-- +-- record() is the single emitter for both lines, so the per-action overrides no +-- longer log the human line themselves -- they pass the payload to record(). +-- +-- At end_run both streams are hashed and the hashes are sent to the server, so +-- tamper-checking later is a cheap hash comparison instead of a line-by-line +-- diff. The carbon stream re-derives cleanly from the log by its prefix. NOTE: +-- MP.UTILS.joker_hash (Adler-style) catches casual edits but is NOT collision- +-- resistant -- a motivated forger could edit and fix the hash. The robust +-- defenses (server sees the lines live, or full re-simulation) are future work; +-- this hash is the intended cheap first pass. + +local RLOG = {} +MP.RLOG = RLOG + +RLOG.CARBON_PREFIX = "MP_RLOG:" -- positional / replay stream +RLOG.HUMAN_PREFIX = "Client sent message:" -- human-readable stream (website-compatible) + +-- Required manifest keys; begin_run warns if any are missing. +RLOG.REQUIRED_MANIFEST_KEYS = { "seed", "ruleset", "gamemode", "deck", "stake" } + +RLOG._seq = 0 +RLOG._carbon_buffer = {} -- the action "MP_RLOG: ..." lines, hashed at end +RLOG._carbon_full = {} -- the full carbon block (manifest + actions + END + CHK), sent to the server +RLOG._human_buffer = {} -- the "Client sent message: ..." lines, hashed at end +RLOG._run_active = false +RLOG._manifest = nil +RLOG._force_active = false -- test hook: bypass the lobby gate + +-- Live streaming: carbon lines are pushed to the server in batches as the game +-- plays, so a crashed/abandoned game still leaves a partial record server-side. +-- On a clean end the server swaps that partial for the full hashed package. +RLOG._game_id = nil -- per-game grouping key, generated in begin_run +RLOG._pending = {} -- carbon lines buffered since the last flush +RLOG._last_flush = 0 -- love.timer.getTime() of the last flush (0 if unavailable) +RLOG.STREAM_FLUSH_LINES = 25 -- flush once this many lines are buffered, or... +RLOG.STREAM_FLUSH_SECS = 2 -- ...this many seconds have passed since the last flush + +------------------------------------------------------------------------------- +-- Gate +------------------------------------------------------------------------------- + +-- Only real multiplayer games log. Ghost playback, practice, and the preview +-- simulation have no lobby code, so they never emit. +function RLOG.is_active() + if RLOG._force_active then return true end + if not (MP.LOBBY and MP.LOBBY.code) then return false end + if MP.GHOST and MP.GHOST.is_active and MP.GHOST.is_active() then return false end + return true +end + +------------------------------------------------------------------------------- +-- Internal helpers +------------------------------------------------------------------------------- + +-- Format an opcode's args into the positional arg string. +-- Each token is either a scalar -> "1" or a list -> dot-joined "1.3.5". +-- A bare scalar/string is treated as a single token. +local function fmt_args(args) + if args == nil then return "" end + if type(args) ~= "table" then return tostring(args) end + local parts = {} + for _, tok in ipairs(args) do + if type(tok) == "table" then + local sub = {} + for _, v in ipairs(tok) do + sub[#sub + 1] = tostring(v) + end + parts[#parts + 1] = table.concat(sub, ".") + else + parts[#parts + 1] = tostring(tok) + end + end + return table.concat(parts, " ") +end + +local function emit(msg) + sendTraceMessage(msg, "MULTIPLAYER") +end + +-- Per-game grouping key for the live stream. Lets the server group a game's +-- streamed lines and delete them once the final package lands. +local function new_game_id(manifest) + local lobby = (manifest and manifest.lobby_code) or "nolobby" + local who = (manifest and manifest.player) or "?" + return string.format("%s-%s-%d-%d", tostring(lobby), tostring(who), os.time(), math.random(100000, 999999)) +end + +-- Best-effort wall clock for flush pacing; 0 when love.timer is unavailable +-- (e.g. under the headless test harness), in which case flushing falls back to +-- the line-count trigger plus the end-of-run flush. +local function stream_now() + if love and love.timer and love.timer.getTime then return love.timer.getTime() end + return 0 +end + +-- Send any buffered carbon lines to the server as one batch. No-ops cleanly if +-- there's no transport yet (e.g. tests) -- the lines are still kept in the full +-- carbon block submitted at end_run. +function RLOG.flush() + if #RLOG._pending == 0 then return end + if not (RLOG._game_id and MP.ACTIONS and MP.ACTIONS.stream_log_lines) then return end + local batch = RLOG._pending + RLOG._pending = {} + RLOG._last_flush = stream_now() + MP.ACTIONS.stream_log_lines(RLOG._game_id, batch) +end + +-- Flush once the batch is big enough or enough time has elapsed since the last. +local function maybe_flush() + if #RLOG._pending >= RLOG.STREAM_FLUSH_LINES then + RLOG.flush() + elseif (stream_now() - RLOG._last_flush) >= RLOG.STREAM_FLUSH_SECS then + RLOG.flush() + end +end + +-- Emit a carbon-stream line: tee to the Lovely log, accumulate it into the full +-- block we ship to the server at end_run, AND queue it for live streaming. +local function emit_carbon(msg) + RLOG._carbon_full[#RLOG._carbon_full + 1] = msg + RLOG._pending[#RLOG._pending + 1] = msg + sendTraceMessage(msg, "MULTIPLAYER") + maybe_flush() +end + +------------------------------------------------------------------------------- +-- Public API +------------------------------------------------------------------------------- + +-- Record one state-affecting action. Emits the carbon (positional) line and, +-- when a human payload is provided, the mirrored human line -- both into the +-- Lovely log with the same sequence number. +-- opcode : string, e.g. "buy" +-- args : nil | scalar | list of tokens (scalar or sub-list); see fmt_args +-- human : nil | string payload in the existing "action:key,..." format +function RLOG.record(opcode, args, human) + if not RLOG.is_active() or not RLOG._run_active then return end + + RLOG._seq = RLOG._seq + 1 + local seq = RLOG._seq + + local argstr = fmt_args(args) + local cline = RLOG.CARBON_PREFIX .. " " .. seq .. " " .. opcode .. (argstr ~= "" and (" " .. argstr) or "") + RLOG._carbon_buffer[#RLOG._carbon_buffer + 1] = cline + emit_carbon(cline) + + if human ~= nil and human ~= "" then + local hline = RLOG.HUMAN_PREFIX .. " " .. human + RLOG._human_buffer[#RLOG._human_buffer + 1] = hline + emit(hline) + end +end + +-- Start a new game's block: reset counters/buffers and emit the manifest header. +function RLOG.begin_run(manifest) + manifest = manifest or {} + + for _, key in ipairs(RLOG.REQUIRED_MANIFEST_KEYS) do + if manifest[key] == nil then + sendWarnMessage("RLOG: manifest missing required key '" .. key .. "'", "MULTIPLAYER") + end + end + + RLOG._seq = 0 + RLOG._carbon_buffer = {} + RLOG._carbon_full = {} + RLOG._human_buffer = {} + RLOG._manifest = manifest + RLOG._run_active = true + + -- Open the live stream for this game: fresh id + empty batch buffer. + RLOG._game_id = new_game_id(manifest) + manifest.game_id = RLOG._game_id + RLOG._pending = {} + RLOG._last_flush = stream_now() + + local json = require("json") + emit_carbon(RLOG.CARBON_PREFIX .. " MANIFEST " .. json.encode(manifest)) +end + +-- Close the current game's block: emit the END line, hash each stream, emit the +-- CHK trailer, and submit the hashes plus the full carbon block to the server. +function RLOG.end_run(outcome) + if not RLOG._run_active then return end + + local json = require("json") + emit_carbon(RLOG.CARBON_PREFIX .. " END " .. json.encode(outcome or {})) + + local carbon_str = table.concat(RLOG._carbon_buffer, "\n") + local human_str = table.concat(RLOG._human_buffer, "\n") + local carbon_hash = MP.UTILS.joker_hash(carbon_str) + local human_hash = MP.UTILS.joker_hash(human_str) + local bytes = #carbon_str + #human_str + + emit_carbon(string.format("%s CHK v1 carbon=%s human=%s bytes=%d", RLOG.CARBON_PREFIX, carbon_hash, human_hash, bytes)) + + -- Push any remaining streamed lines (incl. END + CHK) before the final + -- package, so an oversized/rejected package still leaves a complete stream. + RLOG.flush() + + if MP.ACTIONS and MP.ACTIONS.submit_log_hashes then + -- The full carbon block (manifest + actions + END + CHK) so the server + -- keeps the complete viewable/replayable log, not just its hash. The + -- game_id lets the server drop this game's live stream in favour of it. + local carbon_log = table.concat(RLOG._carbon_full, "\n") + MP.ACTIONS.submit_log_hashes(carbon_hash, human_hash, RLOG._manifest and RLOG._manifest.seed, carbon_log, RLOG._game_id) + end + + RLOG._run_active = false + return carbon_hash, human_hash +end diff --git a/lib/serialization.lua b/lib/serialization.lua index 42856d38..ac5b628c 100644 --- a/lib/serialization.lua +++ b/lib/serialization.lua @@ -44,8 +44,20 @@ function MP.UTILS.str_pack_and_encode(data) return str_encoded end +-- A legitimately serialized object (a saved joker, a nemesis deck) is at most a +-- few KB once gzipped and base64-encoded. Reject anything far larger BEFORE we +-- spend CPU decoding/decompressing it: this is the client-side defense against +-- zip-bomb payloads relayed through actions like magnetResponse / +-- receiveEndGameJokers / receiveNemesisDeck. The relay also caps message size, +-- so this is defense-in-depth. +MP.UTILS.MAX_ENCODED_BYTES = 32 * 1024 + function MP.UTILS.str_decode_and_unpack(str) local success, str_decoded, str_decompressed, str_unpacked + if type(str) ~= "string" then return nil, "expected string payload" end + if #str > MP.UTILS.MAX_ENCODED_BYTES then + return nil, string.format("payload too large (%d > %d bytes)", #str, MP.UTILS.MAX_ENCODED_BYTES) + end success, str_decoded = pcall(love.data.decode, "string", "base64", str) if not success then return nil, str_decoded end success, str_decompressed = pcall(love.data.decompress, "string", "gzip", str_decoded) diff --git a/networking/action_handlers.lua b/networking/action_handlers.lua index c84ef6eb..679e9a20 100644 --- a/networking/action_handlers.lua +++ b/networking/action_handlers.lua @@ -271,6 +271,33 @@ local function action_start_game(p) if not MP.LOBBY.config.different_seeds and MP.LOBBY.config.custom_seed ~= "random" then seed = MP.LOBBY.config.custom_seed end + + -- Open a new replay-log block for this game with everything needed to + -- reconstruct it deterministically later. Uses the resolved seed. + MP.RLOG.begin_run({ + seed = seed, + stake = stake, + deck = MP.LOBBY.config.back, + sleeve = MP.LOBBY.config.sleeve, + challenge = MP.LOBBY.config.challenge, + ruleset = MP.LOBBY.config.ruleset, + gamemode = MP.LOBBY.config.gamemode, + modifier_layers = MP.LOBBY.config.modifier_layers, + lobby_config = MP.LOBBY.config, + the_order_enabled = MP.should_use_the_order(), + different_seeds = MP.LOBBY.config.different_seeds, + mod_version = SMODS.Mods["Multiplayer"] and SMODS.Mods["Multiplayer"].version, + mod_hash = MP.MOD_STRING, + smods_version = MP.SMODS_VERSION, + lovely_version = MP.REQUIRED_LOVELY_VERSION, + lobby_code = MP.LOBBY.code, + is_host = MP.LOBBY.is_host, + player = MP.LOBBY.username, + opponent = (MP.LOBBY.is_host and MP.LOBBY.guest and MP.LOBBY.guest.username) + or (MP.LOBBY.host and MP.LOBBY.host.username), + start_ts = os.date("%Y-%m-%dT%H:%M:%S%z"), + }) + G.FUNCS.lobby_start_run(nil, { seed = seed, stake = stake }) MP.LOBBY.ready_to_start = false end @@ -401,6 +428,7 @@ local function action_stop_game() MP.UI.update_connection_status() MP.reset_game_states() end + MP.RLOG.end_run({ result = "stop" }) MP.UTILS.emit_log_checksum() end @@ -445,6 +473,7 @@ local function action_win_game() MP.nemesis_deck_received = false MP.GAME.won = true MP.STATS.record_match(true) + MP.RLOG.end_run({ result = "win" }) MP.UTILS.log_mem_debug_messages() MP.UTILS.emit_log_checksum() win_game() @@ -458,6 +487,7 @@ local function action_lose_game() MP.STATS.record_match(false) G.STATE_COMPLETE = false G.STATE = G.STATES.GAME_OVER + MP.RLOG.end_run({ result = "loss" }) MP.UTILS.log_mem_debug_messages() MP.UTILS.emit_log_checksum() end @@ -527,6 +557,9 @@ end local function action_send_phantom(p) local key = p.key + -- Carbon: exogenous opponent effect. Keyed by content (not a board index), + -- logged in received order so a solo re-sim reproduces it faithfully. + MP.RLOG.record("net_phantom_add", key, "action:netPhantomAdd,key:" .. tostring(key)) local menu = G.OVERLAY_MENU -- we are spoofing a menu here, which disables duplicate protection G.OVERLAY_MENU = G.OVERLAY_MENU or true local new_card = create_card("Joker", MP.shared, false, nil, nil, nil, key) @@ -537,6 +570,7 @@ local function action_send_phantom(p) end local function action_remove_phantom(p) + MP.RLOG.record("net_phantom_remove", p.key, "action:netPhantomRemove,key:" .. tostring(p.key)) local card = MP.UTILS.get_phantom_joker(p.key) if card then card:remove_from_deck() @@ -610,10 +644,14 @@ local function action_version() MP.ACTIONS.version() end -local action_asteroid = action_asteroid +local action_asteroid_ref = action_asteroid or function() if MP.UI.show_asteroid_hand_level_up then MP.UI.show_asteroid_hand_level_up() end end +local function action_asteroid(p) + MP.RLOG.record("net_asteroid", nil, "action:netAsteroid") + return action_asteroid_ref(p) +end local function action_sold_joker() -- HACK: this action is being sent when any card is being sold, since Taxes is now reworked @@ -631,6 +669,7 @@ end local function action_eat_pizza(p) local discards = p.whole -- rename to "discards" when possible + MP.RLOG.record("net_pizza", discards, "action:netPizza,discards:" .. tostring(discards)) MP.GAME.pizza_discards = MP.GAME.pizza_discards + discards G.GAME.round_resets.discards = G.GAME.round_resets.discards + discards ease_discard(discards) @@ -641,6 +680,7 @@ local function action_spent_last_shop(p) end local function action_magnet() + MP.RLOG.record("net_magnet", nil, "action:netMagnet") local card = nil for _, v in pairs(G.jokers.cards) do if not card or v.sell_cost > card.sell_cost then card = v end @@ -1275,6 +1315,32 @@ function MP.ACTIONS.sync_client() }) end +-- Live carbon-log stream: batches of "MP_RLOG: ..." lines pushed while the game +-- is in progress, keyed by game_id so the server can group them and drop them +-- once the final package below lands. See lib/replay_log.lua (MP.RLOG). +function MP.ACTIONS.stream_log_lines(game_id, lines) + Client.send({ + action = "streamLogLines", + gameId = game_id, + lines = lines, + }) +end + +-- End-of-game replay-log fingerprints. The server stores these (keyed by +-- lobby + seed + game) so a presented log can later be re-hashed and compared +-- without a line-by-line diff, and uses game_id to delete the live stream in +-- favour of this complete package. See lib/replay_log.lua (MP.RLOG). +function MP.ACTIONS.submit_log_hashes(carbon, human, seed, log, game_id) + Client.send({ + action = "submitLogHashes", + carbon = carbon, + human = human, + seed = seed, + log = log, + gameId = game_id, + }) +end + function MP.ACTIONS.modded(modId, modAction, params, target) local msg = { action = "moddedAction", diff --git a/overrides/game.lua b/overrides/game.lua index 47fbe745..26379398 100644 --- a/overrides/game.lua +++ b/overrides/game.lua @@ -15,20 +15,23 @@ end local sell_card_ref = Card.sell_card function Card:sell_card() if self.ability and self.ability.name then - sendTraceMessage( - string.format("Client sent message: action:soldCard,card:%s", self.ability.name), - "MULTIPLAYER" - ) + -- record() emits both the carbon line and the human "Client sent message:" + -- line. Sell is positional by area + slot, captured before the card leaves + -- its area. Area distinguishes selling a joker (4) from a consumable (5). + local human = string.format("action:soldCard,card:%s", self.ability.name) + local area = MP.UTILS.area_enum(self.area) + local idx = MP.UTILS.index_in_area(self) + if area and idx then MP.RLOG.record("sell", { area, idx }, human) end end return sell_card_ref(self) end local reroll_shop_ref = G.FUNCS.reroll_shop function G.FUNCS.reroll_shop(e) - sendTraceMessage( - string.format("Client sent message: action:rerollShop,cost:%s", G.GAME.current_round.reroll_cost), - "MULTIPLAYER" - ) + -- Reroll has no positional target; the shop contents it produces are + -- deterministic from the seed, so the bare opcode is enough to replay. + -- record() emits both the carbon line and the human "Client sent message:". + MP.RLOG.record("reroll", nil, string.format("action:rerollShop,cost:%s", G.GAME.current_round.reroll_cost)) -- Update reroll stats if in a multiplayer game if MP.LOBBY.code and MP.GAME.stats then @@ -43,21 +46,49 @@ local buy_from_shop_ref = G.FUNCS.buy_from_shop function G.FUNCS.buy_from_shop(e) local c1 = e.config.ref_table if c1 and c1:is(Card) then - sendTraceMessage( - string.format("Client sent message: action:boughtCardFromShop,card:%s,cost:%s", c1.ability.name, c1.cost), - "MULTIPLAYER" - ) + -- record() emits both the carbon line and the human "Client sent message:" + -- line. Buy is positional by shop area + slot, captured before the card + -- leaves the shop. Booster packs and vouchers get distinct opcodes since + -- they branch the game differently, but all reference an area + slot. + local human = string.format("action:boughtCardFromShop,card:%s,cost:%s", c1.ability.name, c1.cost) + local area = MP.UTILS.area_enum(c1.area) + local idx = MP.UTILS.index_in_area(c1) + if area and idx then + local opcode = "buy" + local set = c1.ability and c1.ability.set + if set == "Booster" then + opcode = "open_pack" + elseif set == "Voucher" then + opcode = "voucher" + end + MP.RLOG.record(opcode, { area, idx }, human) + end end return buy_from_shop_ref(e) end local use_card_ref = G.FUNCS.use_card function G.FUNCS.use_card(e, mute, nosave) - if e.config and e.config.ref_table and e.config.ref_table.ability and e.config.ref_table.ability.name then - sendTraceMessage( - string.format("Client sent message: action:usedCard,card:%s", e.config.ref_table.ability.name), - "MULTIPLAYER" - ) + local card = e.config and e.config.ref_table + if card and card.ability and card.ability.name then + -- record() emits both the carbon line and the human "Client sent message:". + local human = string.format("action:usedCard,card:%s", card.ability.name) + -- Pack picks share this hook (a picked card lives in G.pack_cards) but get + -- their own opcode. Both reference a slot plus any highlighted hand targets + -- (e.g. a Tarot from an Arcana pack applied to selected cards). + if card.area == (G and G.pack_cards) then + local idx = MP.UTILS.index_in_area(card, G.pack_cards) + if idx then + local targets = MP.UTILS.highlighted_hand_indices() + MP.RLOG.record("pack_pick", (#targets > 0) and { idx, targets } or { idx }, human) + end + else + local idx = MP.UTILS.index_in_area(card) + if idx then + local targets = MP.UTILS.highlighted_hand_indices() + MP.RLOG.record("use", (#targets > 0) and { idx, targets } or { idx }, human) + end + end end return use_card_ref(e, mute, nosave) end @@ -71,3 +102,58 @@ G.FUNCS.evaluate_round = function() end evaluate_round_ref() end + +-- Carbon: skipping a booster pack. +if G.FUNCS.skip_booster then + local skip_booster_ref = G.FUNCS.skip_booster + function G.FUNCS.skip_booster(e) + MP.RLOG.record("pack_skip", 0, "action:skipPack") + return skip_booster_ref(e) + end +end + +-- Carbon: joker / hand reordering (drag-drop). There is no discrete base-game +-- callback for a reorder, so we diff each area's card order on update. This is +-- intentionally independent of the Preview integration (which has its own, +-- Preview-gated order tracker) so reorders are always logged. Detection is +-- debounced until no card in the area is mid-drag, so one drag emits one event, +-- and reorder_permutation only fires on a pure permutation (the card set is +-- unchanged) -- draws, plays and discards change the set and are ignored here. +local function rlog_reorder_area(cardarea) + if cardarea == G.jokers then return MP.UTILS.AREA.jokers end + if cardarea == G.hand then return MP.UTILS.AREA.hand end + return nil +end + +local function rlog_area_dragging(cardarea) + for _, c in ipairs(cardarea.cards) do + if c.states and c.states.drag and c.states.drag.is then return true end + end + return false +end + +local cardarea_update_ref = CardArea.update +function CardArea:update(dt) + cardarea_update_ref(self, dt) + + -- Cheap area check first (runs for every CardArea every frame); only the + -- joker/hand areas do any further work, and only during a live MP game. + local area_id = rlog_reorder_area(self) + if not area_id or not self.cards or #self.cards == 0 then return end + if not (MP.RLOG and MP.RLOG.is_active()) then return end + if rlog_area_dragging(self) then return end -- wait for the drag to settle + + local cur = {} + for i = 1, #self.cards do + cur[i] = self.cards[i].sort_id + end + local prev = self._rlog_order + self._rlog_order = cur + + if prev and #prev == #cur then + local perm = MP.UTILS.reorder_permutation(prev, self.cards) + if perm then + MP.RLOG.record("reorder", { area_id, perm }, "action:reorder,area:" .. area_id) + end + end +end diff --git a/tests/readme.md b/tests/readme.md index 9ccaebfc..8ade6ff3 100644 --- a/tests/readme.md +++ b/tests/readme.md @@ -32,3 +32,38 @@ After `capture`, review the diff in `tests/ruleset_snapshot.lua` before committi - Function bodies (only whether a function is defined) - Runtime behavior (ApplyBans hook chains, smallworld cull logic, speedlatro timer) - Rework center definitions (`MP.ReworkCenter` calls) + +## Replay Log (MP.RLOG) + +`test_rlog_roundtrip.lua` and `test_rlog_checksum.lua` exercise the dual-stream +replay logger (`lib/replay_log.lua`). They stub the game globals, capture the +lines it emits to the Lovely log, and assert on them — no files are written. + +Both streams live in the ordinary Lovely log, distinguished by prefix: +- **Carbon (positional/replay):** `MP_RLOG:` — e.g. `MP_RLOG: 5 buy 1 2`, plus + `MP_RLOG: MANIFEST {...}` and the `MP_RLOG: CHK v1 carbon=… human=… bytes=…` + trailer. +- **Human-readable:** `Client sent message:` — the existing website-parser + format, e.g. `Client sent message: action:boughtCardFromShop,card:Blueprint,cost:4`. + +```bash +lua tests/test_rlog_roundtrip.lua # streams well-formed + hashes round-trip +lua tests/test_rlog_checksum.lua # editing one opcode changes the stored hash +``` + +`test_rlog_roundtrip.lua` asserts: `MP_RLOG: MANIFEST` header + `END`/`CHK` +trailer; carbon action lines with a gapless, monotonic sequence; positional args +including ordered index-lists (e.g. `play 1.3.5.7.8`, `use 1 2.4`) intact; a +paired `Client sent message:` line per action; and `CHK` per-stream hashes that +equal a recompute over the captured lines and match what `submit_log_hashes` +sends. + +`test_rlog_checksum.lua` confirms the `CHK` carbon hash equals a hash of the +carbon stream (re-extracted by prefix) and that tampering with one opcode +changes it. + +### Manual end-to-end check + +Play one real multiplayer match, then open the Lovely log. Filter the `MP_RLOG:` +(positional) and `Client sent message:` (human) lines and confirm they mirror +each action event-for-event. diff --git a/tests/test_rlog_checksum.lua b/tests/test_rlog_checksum.lua new file mode 100644 index 00000000..f6be1664 --- /dev/null +++ b/tests/test_rlog_checksum.lua @@ -0,0 +1,70 @@ +--[[ + Replay-log (MP.RLOG) tamper-detection test. + + Verifies the CHK trailer's carbon hash equals a hash over the carbon + (positional) stream as re-extracted from the log by its "MP_RLOG:" prefix, and + that editing a single opcode changes the hash -- i.e. the cheap end-of-game + fingerprint catches a tampered log. (joker_hash is not collision-resistant; + this guards against casual edits, not a determined forger who also fixes the + hash -- see lib/replay_log.lua.) + + Run from the repo root: + lua tests/test_rlog_checksum.lua +]] + +package.loaded["json"] = { + encode = function() + return "{}" + end, +} + +local captured = {} +function sendTraceMessage(msg) + captured[#captured + 1] = msg +end +function sendWarnMessage() end + +MP = { + LOBBY = { code = "TEST" }, + ACTIONS = {}, + UTILS = { + joker_hash = function(s) + local a, b = 1, 0 + for i = 1, #s do + a = (a + s:byte(i)) % 65521 + b = (b + a) % 65521 + end + return string.format("%08x", b * 65536 + a) + end, + }, +} + +dofile("lib/replay_log.lua") +local RLOG = assert(MP.RLOG, "MP.RLOG not defined after load") + +RLOG.begin_run({ seed = "S", ruleset = "r", gamemode = "g", deck = "d", stake = 1 }) +RLOG.record("buy", { 1, 1 }, "action:boughtCardFromShop,card:X,cost:1") +RLOG.record("sell", { 4, 2 }, "action:soldCard,card:Y") +local carbon_hash = RLOG.end_run({ result = "stop" }) + +-- Re-extract the carbon (positional) stream from the log by prefix: action +-- lines are "MP_RLOG: ...", excluding MANIFEST/END/CHK. This is the domain. +local carbon_lines = {} +local chk_carbon +for _, l in ipairs(captured) do + if l:match("^MP_RLOG: %d") then + carbon_lines[#carbon_lines + 1] = l + end + chk_carbon = l:match("^MP_RLOG: CHK v1 carbon=(%x+)") or chk_carbon +end + +local original = table.concat(carbon_lines, "\n") +assert(chk_carbon == carbon_hash, "CHK trailer carbon hash != end_run return") +assert(MP.UTILS.joker_hash(original) == carbon_hash, "CHK carbon hash must equal hash of the carbon stream") + +-- Tamper: buying slot 1 instead becomes slot 2. The hash must change. +local tampered = original:gsub("1 buy 1 1", "1 buy 1 2", 1) +assert(tampered ~= original, "tamper precondition failed (pattern not found)") +assert(MP.UTILS.joker_hash(tampered) ~= carbon_hash, "tampered stream must not match the stored hash") + +print("test_rlog_checksum: OK") diff --git a/tests/test_rlog_roundtrip.lua b/tests/test_rlog_roundtrip.lua new file mode 100644 index 00000000..7525e718 --- /dev/null +++ b/tests/test_rlog_roundtrip.lua @@ -0,0 +1,120 @@ +--[[ + Replay-log (MP.RLOG) round-trip test. + + Drives lib/replay_log.lua with stubbed globals, captures the lines it emits to + the Lovely log, and asserts the dual stream is well-formed: a MANIFEST header + and END + CHK trailer under the "MP_RLOG:" carbon prefix; carbon action lines + with a gapless monotonic sequence; positional args (including ordered index- + lists) intact; a paired human "Client sent message:" line per action; and CHK + per-stream hashes that equal a recompute over the captured lines and match + what was submitted to the server. + + Run from the repo root: + lua tests/test_rlog_roundtrip.lua +]] + +package.loaded["json"] = { + encode = function() + return "{}" + end, +} + +local captured = {} +function sendTraceMessage(msg) + captured[#captured + 1] = msg +end +function sendWarnMessage() end + +local submitted +MP = { + LOBBY = { code = "TEST" }, + ACTIONS = { + submit_log_hashes = function(c, h, seed, log) + submitted = { carbon = c, human = h, seed = seed, log = log } + end, + }, + UTILS = { + -- Real Adler-style hash from lib/crypto.lua so the domain matches prod. + joker_hash = function(s) + local a, b = 1, 0 + for i = 1, #s do + a = (a + s:byte(i)) % 65521 + b = (b + a) % 65521 + end + return string.format("%08x", b * 65536 + a) + end, + }, +} + +dofile("lib/replay_log.lua") +local RLOG = assert(MP.RLOG, "MP.RLOG not defined after load") + +-- ─── Drive a run ──────────────────────────────────────────────────────────── + +RLOG.begin_run({ seed = "ABCD", ruleset = "r", gamemode = "g", deck = "Red Deck", stake = 1 }) +RLOG.record("select_blind", 0, "action:selectBlind,blind:bl_small") +RLOG.record("buy", { 1, 2 }, "action:boughtCardFromShop,card:Blueprint,cost:4") +RLOG.record("play", { { 1, 3, 5, 7, 8 } }, "action:play,cards:1.3.5.7.8") +RLOG.record("use", { 1, { 2, 4 } }, "action:usedCard,card:The Tower") +RLOG.record("reroll", nil, "action:rerollShop,cost:5") +local carbon_hash, human_hash = RLOG.end_run({ result = "win" }) + +-- ─── Parse the captured log lines ─────────────────────────────────────────── + +assert(captured[1]:match("^MP_RLOG: MANIFEST {"), "first line must be MANIFEST, got: " .. tostring(captured[1])) +assert(captured[#captured - 1]:match("^MP_RLOG: END {"), "penultimate must be END, got: " .. tostring(captured[#captured - 1])) +assert( + captured[#captured]:match("^MP_RLOG: CHK v1 carbon=%x+ human=%x+ bytes=%d+$"), + "bad CHK: " .. tostring(captured[#captured]) +) + +local A = {} -- seq -> arg string +local carbon_lines, human_lines = {}, {} -- in-order full lines (the hash domains) +local last_seq = 0 +for _, l in ipairs(captured) do + local s, rest = l:match("^MP_RLOG: (%d+) (.+)$") + if s then + s = tonumber(s) + A[s] = rest + carbon_lines[#carbon_lines + 1] = l + assert(s == last_seq + 1, "carbon sequence not gapless/monotonic at " .. s) + last_seq = s + end + local payload = l:match("^Client sent message: (.+)$") + if payload then + human_lines[#human_lines + 1] = l + end +end + +-- ─── Assertions ───────────────────────────────────────────────────────────── + +assert(A[1] == "select_blind 0", "A1=" .. tostring(A[1])) +assert(A[2] == "buy 1 2", "A2=" .. tostring(A[2])) +assert(A[3] == "play 1.3.5.7.8", "A3=" .. tostring(A[3])) -- ordered index-list preserved +assert(A[4] == "use 1 2.4", "A4=" .. tostring(A[4])) -- target index-list preserved +assert(A[5] == "reroll", "A5=" .. tostring(A[5])) -- nil args -> bare opcode +assert(A[6] == nil, "unexpected extra carbon action line") + +assert(#human_lines == 5, "expected 5 human lines, got " .. #human_lines) +assert(human_lines[2] == "Client sent message: action:boughtCardFromShop,card:Blueprint,cost:4", "H2=" .. human_lines[2]) + +-- Hash domains: CHK values must equal a recompute over the in-order carbon / +-- human lines (exactly what gets re-extracted from a log by prefix). +local chk_carbon, chk_human = captured[#captured]:match("carbon=(%x+) human=(%x+)") +assert(chk_carbon == MP.UTILS.joker_hash(table.concat(carbon_lines, "\n")), "carbon hash domain mismatch") +assert(chk_human == MP.UTILS.joker_hash(table.concat(human_lines, "\n")), "human hash domain mismatch") +assert(carbon_hash == chk_carbon and human_hash == chk_human, "end_run return != CHK trailer") + +-- The same hashes are what we send to the server, with the seed for keying. +assert(submitted, "hashes not submitted to server") +assert(submitted.carbon == carbon_hash and submitted.human == human_hash, "submitted hashes mismatch") +assert(submitted.seed == "ABCD", "seed not forwarded to server") + +-- The full carbon block (manifest + actions + END + CHK) is shipped to the server +-- so it keeps the whole viewable/replayable log, not just the hash. +assert(submitted.log, "carbon log not sent to server") +assert(submitted.log:match("MP_RLOG: MANIFEST {"), "sent log missing manifest line") +assert(submitted.log:match("MP_RLOG: 2 buy 1 2"), "sent log missing action lines") +assert(submitted.log:match("MP_RLOG: CHK v1 "), "sent log missing CHK trailer") + +print("test_rlog_roundtrip: OK") diff --git a/tests/test_rlog_stream.lua b/tests/test_rlog_stream.lua new file mode 100644 index 00000000..6b681d56 --- /dev/null +++ b/tests/test_rlog_stream.lua @@ -0,0 +1,97 @@ +--[[ + Replay-log (MP.RLOG) live-stream test. + + Verifies the in-game streaming layer: carbon lines are flushed to the server + in batches under a stable per-game id, every carbon line is streamed (manifest + + actions + END + CHK), and the final submit_log_hashes carries the same + game id (so the server can swap the live stream for the complete package). + + Uses a tiny flush threshold to force several batches. love.timer is absent, so + flushing falls back to the line-count trigger plus the end-of-run flush. + + Run from the repo root: + lua tests/test_rlog_stream.lua +]] + +package.loaded["json"] = { + encode = function() + return "{}" + end, +} + +local traced = {} +function sendTraceMessage(msg) + traced[#traced + 1] = msg +end +function sendWarnMessage() end + +local streamed = {} -- flat list of every streamed line, in order +local stream_game_ids = {} -- game id seen on each batch +local submitted +MP = { + LOBBY = { code = "TEST" }, + ACTIONS = { + stream_log_lines = function(game_id, lines) + stream_game_ids[#stream_game_ids + 1] = game_id + for _, l in ipairs(lines) do + streamed[#streamed + 1] = l + end + end, + submit_log_hashes = function(carbon, human, seed, log, game_id) + submitted = { carbon = carbon, human = human, seed = seed, log = log, game_id = game_id } + end, + }, + UTILS = { + joker_hash = function(s) + local a, b = 1, 0 + for i = 1, #s do + a = (a + s:byte(i)) % 65521 + b = (b + a) % 65521 + end + return string.format("%08x", b * 65536 + a) + end, + }, +} + +dofile("lib/replay_log.lua") +local RLOG = assert(MP.RLOG, "MP.RLOG not defined after load") +RLOG.STREAM_FLUSH_LINES = 2 -- force a flush every couple of lines +RLOG.STREAM_FLUSH_SECS = 1e9 -- disable the time-based trigger for the test + +RLOG.begin_run({ seed = "S", ruleset = "r", gamemode = "g", deck = "d", stake = 1 }) +RLOG.record("buy", { 1, 1 }, "action:boughtCardFromShop,card:X,cost:1") +RLOG.record("sell", { 4, 2 }, "action:soldCard,card:Y") +RLOG.record("reroll", nil, "action:rerollShop,cost:5") +RLOG.end_run({ result = "stop" }) + +-- ── Game id: present, stable across batches, matches the final submit ──────── +assert(#stream_game_ids > 0, "no live batches were streamed") +local gid = stream_game_ids[1] +assert(type(gid) == "string" and #gid > 0, "game id missing/empty") +for _, g in ipairs(stream_game_ids) do + assert(g == gid, "game id changed between batches") +end +assert(submitted, "submit_log_hashes was not called") +assert(submitted.game_id == gid, "final submit did not carry the streamed game id") + +-- ── Batching actually happened (threshold of 2 ⇒ multiple flushes) ─────────── +assert(#stream_game_ids >= 2, "expected multiple batches with flush-lines=2") + +-- ── Every carbon line was streamed, in order (manifest + actions + END + CHK) ─ +local carbon_traced = {} +for _, m in ipairs(traced) do + if m:match("^MP_RLOG:") then + carbon_traced[#carbon_traced + 1] = m + end +end +assert( + #streamed == #carbon_traced, + string.format("streamed %d lines but carbon block has %d", #streamed, #carbon_traced) +) +for i = 1, #carbon_traced do + assert(streamed[i] == carbon_traced[i], "streamed line mismatch at index " .. i) +end +assert(carbon_traced[1]:match("^MP_RLOG: MANIFEST "), "first streamed line must be the manifest") +assert(carbon_traced[#carbon_traced]:match("^MP_RLOG: CHK "), "last streamed line must be the CHK trailer") + +print("test_rlog_stream: OK") diff --git a/tests/test_serialization_guard.lua b/tests/test_serialization_guard.lua new file mode 100644 index 00000000..9c9d2794 --- /dev/null +++ b/tests/test_serialization_guard.lua @@ -0,0 +1,84 @@ +--[[ + Serialization zip-bomb guard test. + + Exercises MP.UTILS.str_decode_and_unpack's size guard (lib/serialization.lua): + an over-large encoded payload must be rejected BEFORE any base64/gzip work, a + non-string must be rejected, and a normal small payload must still round-trip. + + love.data and STR_PACK are stubbed with identity codecs so the test runs under + plain Lua (the guard logic is what we're covering, not love's real codecs). + + Run from the repo root: + lua tests/test_serialization_guard.lua +]] + +MP = { UTILS = {} } + +-- Track whether the heavy decode path was entered, so we can prove the guard +-- short-circuits before spending any CPU on an oversized payload. +local decode_calls = 0 + +love = { + data = { + decode = function(_container, _fmt, s) + decode_calls = decode_calls + 1 + return s + end, + decompress = function(_container, _fmt, s) + return s + end, + compress = function(_container, _fmt, s) + return s + end, + encode = function(_container, _fmt, s) + return s + end, + }, +} + +-- Minimal STR_PACK for a flat table of string/number values -> "return { ... }". +function STR_PACK(data) + local parts = {} + for k, v in pairs(data) do + local key = string.format("[%q]", k) + local val = type(v) == "number" and tostring(v) or string.format("%q", v) + parts[#parts + 1] = key .. "=" .. val + end + return "return {" .. table.concat(parts, ",") .. "}" +end + +dofile("lib/serialization.lua") + +local failures = 0 +local function check(name, cond) + if cond then + print("ok - " .. name) + else + failures = failures + 1 + print("FAIL - " .. name) + end +end + +-- ─── 1. Oversized payload is rejected before any decode work ────────────────── +decode_calls = 0 +local big = string.rep("A", MP.UTILS.MAX_ENCODED_BYTES + 1) +local res, err = MP.UTILS.str_decode_and_unpack(big) +check("oversized payload returns nil", res == nil) +check("oversized payload reports 'too large'", type(err) == "string" and err:find("too large") ~= nil) +check("oversized payload never reached love.data.decode", decode_calls == 0) + +-- ─── 2. Non-string payload is rejected ─────────────────────────────────────── +local res2 = MP.UTILS.str_decode_and_unpack({ not_a = "string" }) +check("non-string payload returns nil", res2 == nil) + +-- ─── 3. A normal small payload still round-trips ───────────────────────────── +local original = { name = "Blueprint", cost = 10 } +local encoded = MP.UTILS.str_pack_and_encode(original) +check("normal payload is under the cap", #encoded <= MP.UTILS.MAX_ENCODED_BYTES) +local decoded = MP.UTILS.str_decode_and_unpack(encoded) +check("normal payload round-trips", type(decoded) == "table" and decoded.name == "Blueprint" and decoded.cost == 10) + +if failures > 0 then + error(failures .. " check(s) failed") +end +print("\nAll serialization guard checks passed.") diff --git a/ui/game/functions.lua b/ui/game/functions.lua index 6c2bd225..cd018ac6 100644 --- a/ui/game/functions.lua +++ b/ui/game/functions.lua @@ -15,6 +15,7 @@ function G.FUNCS.mp_toggle_ready(e) sendTraceMessage("Toggling Ready", "MULTIPLAYER") MP.GAME.ready_blind = not MP.GAME.ready_blind MP.GAME.ready_blind_text = MP.GAME.ready_blind and localize("b_unready") or localize("b_ready") + MP.RLOG.record("ready_blind", MP.GAME.ready_blind and 1 or 0) MP.GAME.pvp_reached = true @@ -62,6 +63,14 @@ function G.FUNCS.select_blind(e) if MP.is_mp_or_ghost() then MP.GAME.ante_key = tostring(math.random()) if not MP.GHOST.is_active() then + -- Carbon: log the freshly-rolled (non-deterministic) ante_key first so + -- a replay can restore it, then the blind selection itself. + MP.RLOG.record("set_ante_key", MP.GAME.ante_key) + MP.RLOG.record( + "select_blind", + 0, + string.format("action:selectBlind,blind:%s", tostring(e.config.ref_table.key or e.config.ref_table.name)) + ) MP.ACTIONS.play_hand(0, G.GAME.round_resets.hands) MP.ACTIONS.new_round() MP.ACTIONS.set_location("loc_playing", (e.config.ref_table.key or e.config.ref_table.name)) @@ -87,6 +96,7 @@ G.FUNCS.skip_blind = function(e) end MP.ACTIONS.skip(G.GAME.skips) + MP.RLOG.record("skip_blind", 0, "action:skipBlind") --Update the furthest blind local temp_furthest_blind = 0 diff --git a/ui/game/timer.lua b/ui/game/timer.lua index 8665cc76..cc5bac32 100644 --- a/ui/game/timer.lua +++ b/ui/game/timer.lua @@ -404,7 +404,12 @@ end local old_play = G.FUNCS.play_cards_from_highlighted function G.FUNCS.play_cards_from_highlighted(...) + -- Carbon: capture which hand slots are played BEFORE old_play consumes them. + local played = MP.UTILS.highlighted_hand_indices() old_play(...) + if #played > 0 then + MP.RLOG.record("play", { played }, "action:play,cards:" .. table.concat(played, ".")) + end if G.play and G.play.cards[1] then return end if MP.LOBBY.code and MP.LOBBY.config.timer and not MP.GAME.timer_consumed then if MP.is_pvp_boss() then