diff --git a/README.md b/README.md index 1d5581a..b994f07 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,9 @@ The generated userscript uses `@run-at document-start` and `@inject-into page` s If the page context does not expose `WebSocket` at the first document-start tick, the helper retries hook installation every 250 ms until it succeeds. The helper also passively checks read-only page decode hooks for older JS/Laya builds: `net.MessageWrapper.decodeMessage` when the Mahjong Soul client exposes it, and `Laya.EventDispatcher.event` when decoded game actions are dispatched through the page runtime. These hooks observe already-decoded visible fields without mutating messages or event payloads. On Unity WebGL builds they are marked as not applicable instead of being treated as the current capture path. The debug panel shows install attempts, whether `WebSocket` is currently available, whether client decode or page dispatch is hooked/not applicable, and the binary sample size used for capture exports. Increase `Binary sample bytes` before collecting a new capture if replay diagnostics report truncated raw binary samples. -Current `https://game.maj-soul.com/1/` builds load as Unity WebGL rather than the older JS/Laya runtime. In that case the debug panel's `Runtime` line should show `Unity WebGL detected`; `client decode` and `page dispatch` are not the expected path. Seeing `.lq.ActionPrototype` action names but no usable action payload fields means the helper has reached the passive raw layer, but state restoration still needs a Unity runtime decoded hook or an action payload decoder. The helper passively observes `createUnityInstance` so captures can confirm whether the Unity instance and Module are visible for the next decoder-mapping step. Newer captures also include a bounded, read-only shape summary of Unity instance/Module property names and function names; this is meant for choosing the next decoded-message hook without dumping memory or calling runtime functions. +Current `https://game.maj-soul.com/1/` builds load as Unity WebGL rather than the older JS/Laya runtime. In that case the debug panel's `Runtime` line should show `Unity WebGL detected`; `client decode` and `page dispatch` are not the expected path. The parser now handles the current position-dependent XOR transform used by Unity `ActionPrototype.data`, but still reports an action as unmapped when the decrypted bytes fail complete protobuf validation or action-specific field/value checks. The helper also passively observes `createUnityInstance` and exports a bounded, read-only shape summary of Unity instance/Module property and function names for diagnosing future client changes without dumping memory or calling runtime functions. -The current Unity field mapping includes narrow decoders for the short encoded `ActionDiscardTile` payload shape and two `ActionDealTile` shapes observed on `game.maj-soul.com/1/`, allowing replay to recover discard seat/tile/tsumogiri, draw seat, and self-draw tile for many actions. It intentionally does not guess long discard payloads, new-round hand data, meld details, or score changes until those encodings are mapped from real samples. +The old fixed-offset short-payload guesses have been replaced with one length-aware WebGL XOR decoder followed by ordinary protobuf field decoding. Regression fixtures cover observed discard/draw payloads and a captured 232-byte `ActionNewRound` sample that recovers round metadata, 13 hand tiles, four scores, dora, riichi sticks, and wall count. Unknown action schemas remain visible in diagnostics instead of being accepted merely because random bytes resemble protobuf. The debug panel also shows how many WebSocket instances were created after the hook installed and the most recent socket URLs. This helps distinguish an installation problem from a page state where the game client has not opened live traffic yet. @@ -57,7 +57,7 @@ npm run verify Core modules live under `src/` and are tested independently from the userscript shell. `majsoul-helper.user.js` is generated from `src/main.js` and the source modules by `npm run build`; edit `src/` first, then rebuild the Tampermonkey file. -The test suite includes core algorithm tests, protocol parser tests, capture replay tests, and jsdom overlay tests. Overlay tests assert that realtime advice is off by default, manual input can trigger analysis, and debug copy actions remain available. +The current suite contains 317 automated tests covering core algorithms, protocol parsing, capture replay, generated userscript behavior, and the jsdom overlay. Overlay tests assert that realtime advice is off by default, manual input can trigger analysis, and capture copy/download actions remain available. Use `npm run audit` to see requirement-level status for the original goal; it stays incomplete until a real Mahjong Soul page capture has matching page metadata, overlay live state, replayed `gameState`, and `acceptance.readyForRealPageMvp`. Use `npm run smoke` after building to run a command-line preflight of the generated userscript hook, parser, overlay, sanitized capture export, and live `gameState` update path. Use `npm run import-capture -- path/to/downloaded-capture.json` to copy a browser-downloaded overlay export into `captures/capture-real.json` without hand-renaming; with no source path it searches the default Downloads folder for the newest `majsoul-helper-capture*.json`. The importer may print non-blocking `Import notice` lines when the file is copied but lacks overlay snapshots, Mahjong Soul page metadata, ready preflight/safety data, or inbound raw WebSocket traffic. Use `npm run capture-doctor -- captures/capture-real.json` for the first single-file diagnosis after exporting a real sample; it prints replay readiness, real-page proof gaps, page/preflight/hook/event-buffer/safety status, truncation counts, traffic/action summaries, state-update status, live/replay mismatches, and next-step recommendations without opening the full JSON. Use `npm run validate-captures` after saving real-page exports under `captures/` to batch-check replay readiness, or add `-- --summary` for a concise triage view before reading full JSON. Use `npm run real-page-gate` for final acceptance; it runs `validate-captures --require-real-page-ready` and `goal-audit --strict` against `captures/`. Use `npm run verify` before sharing changes. It runs build, MVP checks, smoke preflight, tests, userscript syntax checks, the requirement audit, replay strict fixture validation, and capture batch validation against fixtures. @@ -73,17 +73,17 @@ Hook diagnostics include WebSocket constructor static-property copy counts, whet The debug panel includes `Self-test`, which runs a local parser/diagnostics check without creating WebSocket connections, sending messages, recording fake traffic, or updating `gameState`. Use it after installation to confirm the overlay, adapter, and parser are wired before relying on live page samples. -The debug panel also shows a live `MVP gate` that mirrors the important replay acceptance checks: raw inbound traffic, decoded binary envelopes, ActionPrototype names, parsed draw/discard seats, state updates for hand/round/draw/discards/dora/scores/visible tiles/current turn, and clear warnings. When optional events appear, such as melds, claimed discards, `ActionAnGangAddGang` kan events, riichi, or round ends, their seat and state-update checks are promoted into the live gate too. Kan samples also require a known kan type, a four-tile visible kan meld, and own kan tile removal from hand/`drawnTile` when applicable. Treat missing gate items as the next thing to fix or resample before trusting realtime state restoration. A separate `Real-page preflight` line checks whether the current page looks like Mahjong Soul, capture is running, the hook is installed, live snapshots will be exported, the live MVP gate is complete, the retained event buffer has not dropped earlier traffic, no samples are truncated, no capture errors occurred, realtime advice is off, manual input is clear, and the no-automation/no-message-mutation boundary is recorded; when something is missing it shows a next-step hint. After export, run `npm run capture-doctor -- captures/capture-real.json` first, then `npm run real-page-gate`, which repeats the strict real-page checks and confirms replay/live-state agreement through the audit. +The debug panel also shows a live `MVP gate` that mirrors the important replay acceptance checks: raw inbound traffic, decoded binary envelopes, ActionPrototype names, parsed draw/discard seats, state updates for hand/round/draw/discards/dora/scores/visible tiles/current turn, and clear warnings. When optional events appear, such as melds, claimed discards, `ActionAnGangAddGang` kan events, riichi, or round ends, their seat and state-update checks are promoted into the live gate too. Kan samples also require a known kan type, a four-tile visible kan meld, and own kan tile removal from hand/`drawnTile` when applicable. Treat missing gate items as the next thing to fix or resample before trusting realtime state restoration. If action steps regress without a decoded `ActionNewRound`, the helper clears stale round state and reports a partial-state warning instead of merging two rounds. A separate `Real-page preflight` line checks whether the current page looks like Mahjong Soul, capture is running, the hook is installed, live snapshots will be exported, the live MVP gate is complete, the retained event buffer has not dropped earlier traffic, no samples are truncated, no capture errors occurred, realtime advice is off, manual input is clear, and the no-automation/no-message-mutation boundary is recorded; when something is missing it shows a next-step hint. After export, run `npm run capture-doctor -- captures/capture-real.json` first, then `npm run real-page-gate`, which repeats the strict real-page checks and confirms replay/live-state agreement through the audit. -The debug panel also includes a capture limit input. The default retained event buffer is 10000 events, and changing it also changes the adapter's retained event buffer and is saved locally. Older saved values below 10000 are automatically upgraded so real-page samples are less likely to drop early round traffic. `Copy capture` and `Download capture` export only the most recent N helper events, which keeps real-page samples smaller and easier to share. +The debug panel also includes a capture limit input. The default retained event buffer is 10000 events, and changing it also changes the adapter's retained event buffer and is saved locally. Older saved values below 10000 are automatically upgraded so real-page samples are less likely to drop early round traffic. `Copy capture` and `Download capture` export only the most recent N helper events. Download uses a dedicated button and keeps its object URL alive long enough for the browser to accept the file, avoiding page-level click cancellation and premature URL cleanup. If the browser denies clipboard access, the overlay shows a selectable fallback text area containing the same JSON. ## Current MVP Notes -The WebSocket adapter records realtime inbound/outbound message summaries and exposes them in the debug panel. It also performs conservative parsing for readable JSON messages whose names clearly match known round/tile actions. On JS/Laya client builds, it can also observe `net.MessageWrapper.decodeMessage` or `Laya.EventDispatcher.event` after the page has decoded a message; this records only a sanitized decoded-message summary plus standardized visible game events. On the current Unity WebGL build, those globals may never appear, so raw ActionPrototype names can be visible while the inner action payload remains encoded or unmapped. +The WebSocket adapter records realtime inbound/outbound message summaries and exposes them in the debug panel. It also performs conservative parsing for readable JSON messages whose names clearly match known round/tile actions. On JS/Laya client builds, it can observe `net.MessageWrapper.decodeMessage` or `Laya.EventDispatcher.event` after the page has decoded a message. On the current Unity WebGL build those globals may never appear, so the normal path is raw `ActionPrototype` capture, the WebGL XOR transform, strict protobuf validation, and schema-based field decoding. -For binary frames, the helper parses the public outer Liqi-style envelope shape: frame type byte (`Notify`, `Request`, `Response`), request id when present, protobuf length-delimited method name, and a bounded payload hex sample. It recognizes `.lq.ActionPrototype`, extracts the inner `Action*` name, and conservatively reads simple visible fields for discard/draw-style actions when they are present. If the binary method itself is a direct `.lq.Action*` method, the helper also treats that method payload as an action payload for the same conservative field extraction and diagnostics. +For binary frames, the helper parses the public outer Liqi-style envelope shape: frame type byte (`Notify`, `Request`, `Response`), request id when present, protobuf length-delimited method name, and a bounded payload hex sample. It recognizes `.lq.ActionPrototype`, extracts the inner `Action*` name, tries the validated WebGL XOR candidate, and then reads visible fields by protobuf field id. If the binary method itself is a direct `.lq.Action*` method, the helper treats that method payload as an action payload for the same conservative field extraction and diagnostics. The currently mapped binary `Action*` field numbers are aligned with the public generated Liqi schema for the visible state fields used by the MVP: `ActionNewRound` round metadata, starting hand, dora indicators, packed scores, riichi sticks, and wall count; `ActionDealTile` seat/tile/wall count/doras plus nested `LiQiSuccess`; `ActionDiscardTile` seat/tile/riichi/moqie/doras; `ActionChiPengGang` seat/type/tiles plus nested `LiQiSuccess`; `ActionAnGangAddGang` seat/type/tile/doras; `ActionBaBei` as a visible north reveal; `ActionLiqiSuccess` seat/score/riichi sticks; `ActionHule`/`RecordHule` packed `scores` field 5, nested `GameEnd.scores` fallback field 6, and doras field 7; `ActionLiuJu` nested `LiQiSuccess` plus `GameEnd.scores`; and `ActionNoTile`/`RecordNoTile` nested `NoTileScoreInfo` seat/score/doras fields. Dora-like action names without a dedicated mapping conservatively use tile-like payload strings as standard `dora` events. `ActionAnGangAddGang` type `2` is treated as added kan and upgrades an existing visible triplet when present; type `3` is treated as concealed kan and expanded to four visible tiles. Nested or direct `LiQiSuccess` is emitted as a standard `riichi` event so the state can update riichi flags, riichi sticks, and the declaring player's visible score. Direct replay-oriented `RecordNewRound`, `RecordDealTile`, `RecordDiscardTile`, `RecordChiPengGang`, `RecordAnGangAddGang`, `RecordBaBei`, `RecordHule`, `RecordLiuJu`, and `RecordNoTile` methods are mapped to the same standard event model for training/review workflows. @@ -93,15 +93,15 @@ Full gameplay protobuf decoding is still intentionally incremental. Unknown or i Decoded client objects are also normalized against the Mahjong Soul replay field contract used by mjai-reviewer-style log conversion. In particular, `RecordNewRound`/`ActionNewRound` can use `chang`, `ju`, `ben`, `liqibang`, `scores`, `dora`/`doras`, `tiles`, `tehais`, `hands`, or `tiles0` through `tiles3`; when `selfSeat`/`seat`/`actor` is available, that seat's hand becomes the standard `tiles` field while all-seat hands remain visible as `seatHands`. -The request/response id is decoded as little-endian, matching the public majsoul_wrapper protocol notes. Action payloads are read by protobuf field id for the currently supported visible fields instead of positional guessing. +The request/response id is decoded as little-endian, matching the public majsoul_wrapper protocol notes. WebGL action decryption uses a payload-length- and byte-position-dependent XOR stream; the candidate is accepted only when the full byte sequence is a valid protobuf message and the named action contains plausible required fields. Action values are then read by protobuf field id instead of positional guessing. ## Capturing Real Page Samples -1. Install `majsoul-helper.user.js` in Tampermonkey and confirm the overlay title shows `Majsoul Helper v0.2.13`. +1. Install `majsoul-helper.user.js` in Tampermonkey and confirm the overlay title shows `Majsoul Helper v0.3.0`. 2. Open Mahjong Soul and enter a non-ranked or training-friendly room. 3. Keep realtime advice off unless explicitly testing it. 4. Use the overlay debug panel to confirm `raw_message` entries are appearing. - The `Install` line should read `installed`, `v0.2.13`, and the `Runtime` line should identify whether the page is JS/Laya or Unity WebGL. On older JS/Laya builds, either `client decode hooked` or `page dispatch hooked` is the best signal that decoded visible fields are available. On current Unity WebGL builds, expect the legacy decode hooks to become `not-applicable-unity`; if the capture health says Unity Action names are captured but payload fields are encoded or unmapped, export the capture and treat the next task as finding a Unity runtime decoded hook or action payload decoder. Binary sample bytes default to 65536 in current builds. + The `Install` line should read `installed`, `v0.3.0`, and the `Runtime` line should identify whether the page is JS/Laya or Unity WebGL. On older JS/Laya builds, either `client decode hooked` or `page dispatch hooked` is the best signal that decoded visible fields are available. On current Unity WebGL builds, expect the legacy decode hooks to become `not-applicable-unity`; `payloadCodec: majsoul-webgl-xor-v1` identifies action bytes accepted by the current decoder. If an action remains under `unmappedUnityPayloads`, export that sample for schema mapping instead of adding positional guesses. Binary sample bytes default to 65536 in current builds. 5. Click `Download capture` for a local JSON file, or `Copy capture` if you prefer the clipboard path. 6. Import the downloaded file with `npm run import-capture -- path/to/majsoul-helper-capture.json`, or run `npm run import-capture` to use the newest matching file in your Downloads folder. 7. Run `npm run capture-doctor -- captures/capture-real.json` for a compact first diagnosis, then run `npm run real-page-gate` for the strict final real-page check. @@ -172,12 +172,13 @@ Implemented and locally verified: - Shanten, ukeire, candidate discard analysis, red-five normalization, and state updates are covered by tests. - WebSocket send/message hooks record traffic summaries and pass outbound payloads through unchanged. - Readable JSON and Liqi-style binary ActionPrototype samples can be parsed conservatively into standard events. +- Current Unity WebGL `ActionPrototype.data` can be XOR-decoded after strict wire/schema validation; the real 232-byte `ActionNewRound` regression fixture restores hand, scores, dora, and round metadata. - Offline capture replay can turn copied debug samples into parsed events and a final gameState. - Safety tests guard against auto-clicking, auto-discard helpers, anti-detection behavior, and WebSocket payload mutation. Still requires real Mahjong Soul page validation: -- Confirm Tampermonkey page injection captures live `raw_message` entries and records the current page runtime in capture diagnostics. -- On Unity WebGL builds, map a decoded Unity runtime hook or implement an action payload decoder so `ActionDealTile`, `ActionDiscardTile`, `ActionNewRound`, and related actions produce seats, tiles, scores, dora, and hand fields. -- Collect a fresh real capture after the Unity decode path or field mapping change and verify new round, hand, draw, discard, call, riichi, dora, round end, win, and draw-game events. +- A manual browser session has confirmed userscript installation, Unity detection, and live inbound WebSocket capture; this observation is not a saved real-page acceptance artifact. +- Collect a fresh full-round export with the current decoder and verify new round, hand, draw, discard, and every optional action actually observed in that sample. - Validate automatic gameState restoration against an actual non-ranked/training-friendly table. +- Keep the real-page gate incomplete until `capture-doctor`, replay, `liveStateSnapshotMatches`, and `npm run real-page-gate` agree on an imported capture. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9ef3783..14c92c7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -19,16 +19,21 @@ ├── src/ │ ├── main.js │ ├── adapter/ +│ │ ├── captureReplay.js │ │ ├── majsoulAdapter.js -│ │ └── messageParser.js +│ │ ├── messageParser.js +│ │ └── protobuf.js │ ├── core/ │ │ ├── analyzer.js +│ │ ├── config.js +│ │ ├── eventDiagnostics.js │ │ ├── events.js │ │ ├── gameState.js │ │ ├── realPageReadiness.js │ │ ├── shanten.js │ │ ├── tile.js -│ │ └── ukeire.js +│ │ ├── ukeire.js +│ │ └── version.js │ └── ui/ │ ├── overlay.js │ └── styles.js @@ -51,12 +56,17 @@ | --- | --- | | `src/main.js` | Compose adapter, state, analyzer, overlay, config, and global debug handle. | | `src/adapter/majsoulAdapter.js` | Observe WebSocket/runtime traffic, buffer capture samples, invoke parser, export diagnostics. | -| `src/adapter/messageParser.js` | Convert raw/decoded Mahjong Soul messages into standardized helper events. | +| `src/adapter/captureReplay.js` | Replay exported samples, de-duplicate live/raw events, and summarize captures. | +| `src/adapter/messageParser.js` | Decode Liqi envelopes, validate/decrypt Unity WebGL action payloads, and emit standardized helper events. | +| `src/adapter/protobuf.js` | Bounds-checked protobuf wire parsing and field access helpers. | +| `src/core/config.js` | Version and normalize persistent capture settings. | +| `src/core/eventDiagnostics.js` | Shared live/offline event ordering, state diagnostics, and MVP acceptance gate. | | `src/core/events.js` | Define allowed standardized event types. | | `src/core/gameState.js` | Apply events, maintain normalized visible state, emit consistency warnings. | | `src/core/tile.js` | Parse/normalize/display tiles and convert to/from 34-index representation. | | `src/core/shanten.js` | Calculate standard, seven-pairs, and thirteen-orphans shanten. | | `src/core/ukeire.js` | Calculate effective tile types and remaining counts. | +| `src/core/version.js` | Single runtime helper version used by source and build validation. | | `src/core/analyzer.js` | Analyze current hand and discard candidates. | | `src/core/realPageReadiness.js` | Define safety, preflight, and capture verification criteria. | | `src/ui/overlay.js` | Render overlay controls, state, analysis, debug events, and capture export. | @@ -76,7 +86,8 @@ flowchart TD D --> H["WebSocket + Unity runtime observation"] H --> I["Raw samples and diagnostics"] H --> J["messageParser"] - J --> K["Standardized events"] + J --> O["Validated WebGL XOR + protobuf fields"] + O --> K["Standardized events"] K --> E E --> L["Visible state + warnings"] L --> F @@ -107,6 +118,7 @@ flowchart TD ```mermaid flowchart LR Parser["messageParser"] --> Events["events"] + Parser --> Protobuf["protobuf"] Parser --> Tile["tile"] Adapter["majsoulAdapter"] --> Parser Main["main"] --> Adapter diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 012a370..59f5ef5 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,34 @@ # CHANGELOG.md +## v0.3.0 + +### Changed + +- Live overlay and offline replay now share one event-diagnostics and MVP acceptance implementation, including inbound-traffic proof. +- Event ordering prefers monotonic `eventId`, preventing same-millisecond draw/discard actions from being evaluated backwards. +- `GameState` now tracks an explicit non-zero `selfSeat`; all-seat replay hands no longer silently default to seat 0, dealer hands can be inferred from 14 opening tiles, and private live draws can identify non-dealers. +- Hidden opponent draws now update public turn, wall, and dora state without inventing a private tile. +- Capture replay and protobuf wire parsing were split into focused modules; duplicated readiness logic was removed. +- Capture configuration now has a one-time migration version, so old undersized defaults are upgraded once while later user choices persist. +- Overlay teardown removes adapter listeners, DOM, styles, and object URLs during userscript upgrades. +- Unity `ActionPrototype.data` now uses one payload-length- and position-dependent XOR decoder instead of three fixed-offset short-payload guesses. Decoded candidates must consume as valid protobuf and pass named-action field/value checks. +- A captured 232-byte `ActionNewRound` fixture now verifies 13 hand tiles, four scores, dora, round metadata, riichi sticks, and wall count; the observed draw/discard fixtures now use their correctly decoded seats and wall counts. +- Build tooling now declares esbuild directly, uses Vitest 4, supports Node 25 Web Storage behavior, reports zero npm audit vulnerabilities, and runs 317 automated tests. + +### Fixed + +- Seat-wind calculation now uses both self seat and dealer seat. +- Red-five discard/meld diagnostics compare normalized tiles. +- Replay and live gates no longer drift on optional calls, kans, riichi, round ends, or current turn. +- `Download capture` now uses an explicit programmatic download and delays object-URL cleanup, so a page capture-phase click listener or premature revocation cannot silently cancel the JSON download. +- A regressed action step without a decoded round start now invalidates stale round state instead of merging two rounds into an impossible hand. +- Capture import no longer mistakes bounded envelope/action diagnostic previews for truncation of the retained raw WebSocket sample. + +### Known Issues + +- A manual Unity session reached the live `20/20` MVP gate and `15/15` preflight and exported successfully, but the saved sample crossed a paused round boundary and is intentionally rejected by strict replay acceptance. A clean uninterrupted export is still required. +- Action schemas not yet represented by observed regression samples may still appear under `unmappedUnityPayloads` and must not be guessed. + ## v0.2.13 ### Changed @@ -47,13 +76,3 @@ - Raised default capture limits to support larger live samples. - Realtime discard-candidate advice is explicitly opt-in and disabled by default. - Debug exports now include live state, runtime diagnostics, safety settings, and readiness information. - -### Known Issues - -- Full real-page game-state restoration is not complete. -- Unity `ActionNewRound` payloads are not yet decoded enough to recover initial hand, dora, scores, seat, and round metadata reliably. -- Several longer Unity action payloads remain encoded or unmapped. -- Current local ignored capture evidence is diagnostic-only and not ready for real-page MVP acceptance. -- Batch capture validation and direct replay disagree on the stale local capture's readiness. -- Large parser/adapter/overlay modules should be split once decoder behavior stabilizes. -- Some user-facing text contains mojibake around punctuation examples. diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index 3822734..b5e1e18 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -2,119 +2,74 @@ ## Current Version -- Project version: `0.2.13`. +- Project version: `0.3.0`. - Generated userscript: `majsoul-helper.user.js`. - Main source entry: `src/main.js`. - Target site: `https://game.maj-soul.com/1/`. ## One-Line State -The project has a working modular helper shell, analysis engine, debug overlay, WebSocket/Unity capture layer, and partial Unity action decoding, but full real-page game-state restoration is not complete because several Unity WebGL payloads remain encoded or unmapped. - -## Implemented Features - -- Tampermonkey userscript generation and install metadata. -- Draggable, collapsible overlay with training/review warning. -- Manual hand input for shanten, ukeire, and discard-candidate analysis. -- Realtime discard advice toggle is present and off by default. -- Normalized tile utilities with red-five support. -- Shanten calculation: - - standard 4 melds + pair - - seven pairs - - thirteen orphans - - open-meld standard handling -- Ukeire calculation over 34 tile types with visible-tile exclusion. -- Candidate discard analysis sorted by after-discard shanten and ukeire. -- Standardized `GameState` model and event application. -- WebSocket observation without outbound message mutation. -- Raw text/binary/blob capture with bounded sample bytes and event limits. -- Unity WebGL runtime diagnostics: - - loader observation - - `createUnityInstance` shape checks - - Unity/applicability markers -- Message parser for readable JSON, decoded objects, Liqi-style binary envelopes, and selected Unity encoded action payloads. -- Replay, capture doctor, capture validation, audit, smoke, and full verify scripts. -- Safety tests that reject autoplay/click/anti-cheat patterns. - -## Current Development Status - -- Local goal audit is not fully complete. -- `npm run audit` proves most static/MVP requirements but still reports real-page validation as needing capture evidence. -- `captures/capture-real.json` is an ignored local sample from helper `0.2.6`, not current `0.2.13`. -- The latest ignored live capture from helper `0.2.11` preserved 1227 events with no buffer drop and parsed draw/discard traffic, but still lacked decoded `ActionNewRound` hand, dora, score, and round metadata. -- Replay now reports unmapped Unity payload families explicitly, long encrypted `ActionDealTile` payloads no longer create false riichi state, stale live parsed events from replayable raw samples are skipped, and decoded Record-style NewRound objects understand the mjai-reviewer `tiles0..tiles3` field contract. - -## Architecture - -- `src/main.js` - - Initializes adapter, state, analyzer, and overlay. - - Persists safe UI/capture config in `localStorage`. - - Exposes `window.__majsoulHelper` for debug. -- `src/adapter/majsoulAdapter.js` - - Installs WebSocket and runtime hooks. - - Records raw samples and parsed events. - - Exports capture diagnostics. -- `src/adapter/messageParser.js` - - Converts raw/decoded traffic into standardized events. - - Contains current Unity payload decoder knowledge. -- `src/core/gameState.js` - - Applies standardized events. - - Produces visible state and consistency warnings. -- `src/core/analyzer.js` - - Runs shanten, ukeire, and discard simulation. -- `src/core/tile.js` - - Tile parsing, normalization, indices, dora mapping. -- `src/core/shanten.js` - - Shanten algorithms. -- `src/core/ukeire.js` - - Ukeire enumeration and remaining-count logic. -- `src/core/realPageReadiness.js` - - Real-page gate and safety/readiness checks. -- `src/ui/overlay.js` - - DOM rendering, controls, copy/download, debug panels. -- `src/ui/styles.js` - - Overlay CSS. -- `scripts/` - - Build, replay, import, validation, audit, smoke, and verification utilities. -- `tests/` - - Unit, integration, parser, UI, safety, build, docs, replay, and runtime tests. - -## Data Flow - -1. Tampermonkey injects the generated userscript at document start. -2. `src/main.js` initializes `MajsoulAdapter`, `GameState`, `Analyzer`, and `Overlay`. -3. `MajsoulAdapter` observes WebSocket/runtime traffic and stores bounded raw samples. -4. `messageParser` parses supported messages into normalized events. -5. `GameState.applyEvent()` updates hand/table state. -6. `Overlay` reads visible state and optional analyzer output. -7. Debug export includes raw capture, parsed events, live state, runtime diagnostics, safety settings, and readiness checks. - -## Known Issues - -- Full Unity WebGL state restoration is incomplete. - - `ActionNewRound` initial hand, dora, scores, and round metadata are not reliably decoded from current Unity payloads. - - Some longer `ActionDiscardTile`, `ActionDealTile`, `ActionChiPengGang`, `ActionAnGangAddGang`, `ActionHule`, and restore/sync payloads remain unmapped. -- The current blocker is mostly interpretation, not raw capture. WebSocket traffic and action names are visible; important payload fields are still encoded or unknown. -- `GameState.hand` is now treated as a decoded base hand; own draw/discard traffic without a decoded initial hand no longer invents a partial hand. Unity captures still need `ActionNewRound` decoding before full hand analysis can be trusted. -- Local ignored capture data is diagnostic-only. `captures/capture-real.json` is helper `0.2.6`; the latest user-supplied live capture was helper `0.2.11` and remains not ready. -- `npm run validate-captures -- --summary` reports the local capture as failed/not ready, while direct replay can produce diagnostics. This validation path needs investigation before being used as a release gate. -- `messageParser.js`, `majsoulAdapter.js`, and `overlay.js` are large modules with multiple responsibilities. Future decoder work risks regressions unless tested narrowly. -- Acceptance/readiness logic exists in both UI/runtime exports and replay/audit scripts, creating drift risk. -- Overlay render is mostly full re-render on updates. Event buffers are capped, but high-frequency live sessions could still expose UI performance issues. -- Some text/regex examples show mojibake around punctuation cleanup in hand parsing examples. Clean before user-facing release. -- Safety boundary tests are regex-based and may need tuning if legitimate code triggers false positives. - -## Current Priorities - -1. Capture a fresh `0.2.13` real-page session from round start with safe settings and large binary samples, then import/replay it. -2. Decode Unity payload fields for `ActionNewRound` first, especially initial hand, dora indicators, scores, seat, round, honba, riichi sticks, and wall count. -3. Use `diagnostics.unmappedUnityPayloads` from replay/doctor to choose the next action family instead of trusting accidental protobuf-looking bytes. -4. Make capture validation and replay agree on readiness/failure reasons. -5. Add regression tests for every newly decoded Unity action shape. -6. Keep no-automation safety boundaries intact while expanding live-state parsing. - -## Risk Assessment - -- Biggest product risk: without `ActionNewRound` and restore/sync decoding, the overlay cannot reliably know the player's full current hand after page refresh, reconnect, or mid-round start. -- Biggest engineering risk: overfitting decoders to one capture shape and silently producing plausible but wrong game state. -- Biggest compliance/safety risk: realtime advice could be misused. It is currently opt-in; keep it disabled by default and clearly labeled. +The local helper, analysis engine, capture/replay pipeline, shared acceptance gate, and safety checks are complete and verified; final real-page acceptance still needs a fresh `0.3.0` Unity WebGL capture because the repository intentionally contains no private live capture. + +## Implemented + +- Tampermonkey userscript generation and early page-context injection. +- Draggable/collapsible overlay with manual hand analysis and debug export. +- Standard, seven-pairs, and thirteen-orphans shanten. +- Ukeire and discard-candidate ranking with visible-tile exclusion. +- Normalized state for hand, self seat, draw, rivers, melds, dora, scores, round metadata, turn, riichi, and warnings. +- Explicit non-zero `selfSeat` handling: + - decoded self seat is preserved; + - all-seat replay hands never default to seat 0; + - a 14-tile opening hand identifies the dealer; + - a private live draw can identify a non-dealer. +- Hidden opponent draws update public turn, wall, and dora state without inventing a tile. +- Passive WebSocket, decoded JS/Laya, and Unity boot/runtime observation. +- Readable JSON, Liqi binary envelope, selected Action/Record protobuf, and the current length/position-dependent Unity WebGL action XOR transform. +- Strict decoded-candidate validation: the full payload must be valid protobuf and contain plausible schema fields for the named action before decoded values are trusted. +- A captured 232-byte `ActionNewRound` regression sample restores the 13-tile hand, four scores, dora, round metadata, riichi sticks, and wall count. +- Capture export, replay, de-duplication, doctor, validation, audit, smoke, and real-page gates. +- Browser-resilient capture downloads use an explicit download action and delayed object-URL cleanup. +- One shared live/offline MVP gate and one shared real-page readiness implementation. +- Versioned capture configuration migration that preserves later user choices. +- Clean overlay teardown during userscript upgrades. +- Node 25-compatible test launcher, declared esbuild dependency, current Vitest, 317 passing automated tests, and zero npm audit vulnerabilities. + +## Architecture State + +- `src/adapter/protobuf.js` owns protobuf wire parsing. +- `src/adapter/messageParser.js` owns Mahjong Soul event decoding. +- `src/adapter/majsoulAdapter.js` owns page observation and capture buffering. +- `src/adapter/captureReplay.js` owns offline replay and capture summaries. +- `src/core/gameState.js` owns normalized state transitions. +- `src/core/eventDiagnostics.js` owns event ordering, state diagnostics, and MVP checks. +- `src/core/realPageReadiness.js` owns export and real-page readiness rules. +- `src/core/config.js` owns persistent capture settings and migration. +- `src/ui/overlay.js` renders controls and delegates core decisions to shared modules. + +## Remaining External Validation + +The repository has no real capture under `captures/` that passes strict acceptance, so `npm run audit` correctly reports the real-page evidence requirement as incomplete. A manual browser session confirmed installation, Unity detection, live WebSocket traffic, real `ActionNewRound` decoding, a `20/20` live MVP gate, a `15/15` preflight, and successful JSON download. The exported sample later crossed a paused round boundary (action step `53` to `1` without a captured `ActionNewRound`), so replay now clears stale state and correctly rejects the sample. It is useful regression evidence, but not final acceptance evidence. + +A fresh real-page sample must prove: + +1. the `0.3.0` helper is installed in page context; +2. inbound live traffic is retained without truncation or buffer loss; +3. `ActionNewRound`, draw, discard, and any observed optional actions decode into correct standard events; +4. overlay state matches the visible table; +5. offline replay matches the exported live snapshot; +6. realtime advice is off and all no-automation/no-mutation safety flags are clean. + +## Known Protocol Risk + +The current XOR transform is decoded generically, but Unity action schemas may change and not every action family has an observed regression sample. Payloads that fail complete protobuf or named-action plausibility checks remain unmapped. Any new field mapping must be based on a sanitized real sample and receive a focused regression fixture before it can be trusted. + +## Next Action + +Install the generated `0.3.0` userscript, collect one safe session from round start, then run: + +```bash +npm run import-capture -- path/to/majsoul-helper-capture.json +npm run capture-doctor -- captures/capture-real.json +npm run real-page-gate +``` diff --git a/docs/TODO.md b/docs/TODO.md index 57bf85e..cefeb76 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,92 +1,44 @@ # TODO.md -## P0 - Must - -### Decode Unity `ActionNewRound` - -- Goal: recover initial hand, dora indicators, scores, seat, round, honba, riichi sticks, wall count, and round wind from real Unity payloads. -- Modules: `src/adapter/messageParser.js`, `src/core/gameState.js`, `tests/messageParser.test.js`, replay fixtures. -- Complexity: High. - -### Capture Fresh Current-Version Evidence - -- Goal: collect/import a safe `0.2.13` real-page capture from round start and use it as the main verification artifact. -- Modules: `scripts/import-capture.mjs`, `scripts/replay-capture.mjs`, `scripts/validate-captures.mjs`, `docs/real-page-sampling.md`. -- Complexity: Medium. - -### Fix Capture Validation Drift - -- Goal: make `npm run validate-captures -- --summary` and direct `npm run replay -- ` agree on readiness and failure causes. -- Modules: `scripts/validate-captures.mjs`, `scripts/replay-capture.mjs`, `scripts/capture-doctor.mjs`, tests. -- Complexity: Medium. - -### Prevent Partial-State Misleading UI - -- Goal: clearly distinguish "observed rivers/draws only" from "full reliable hand/table state" in overlay and exported diagnostics. -- Status: Partially improved in `0.2.11` through `0.2.13`; unknown starting hands no longer get synthesized from own draw/discard traffic, long encrypted deal payloads no longer synthesize false riichi state, replay skips stale live parsed events from raw samples that can be replayed, and decoded Record-style NewRound fields can restore `tiles0..tiles3` when a self seat is known. -- Modules: `src/core/gameState.js`, `src/ui/overlay.js`, `src/core/realPageReadiness.js`, tests. -- Complexity: Medium. - -## P1 - Important - -### Decode More Unity Action Shapes - -- Goal: map longer discard/deal variants, calls, closed/add kan, hule, exhaustive draw, game restore, and sync payloads. -- Modules: `src/adapter/messageParser.js`, `src/core/events.js`, `src/core/gameState.js`, replay tests. -- Complexity: High. - -### Add Decoder Fixture Corpus - -- Goal: create small sanitized byte fixtures for each known Unity action shape with expected standardized events. -- Modules: `tests/fixtures`, `tests/messageParser.test.js`, `tests/replayCapture.test.js`. -- Complexity: Medium. - -### Refactor Large Modules - -- Goal: split parser codecs, adapter capture plumbing, and overlay sections to reduce regression risk. -- Modules: `src/adapter/messageParser.js`, `src/adapter/majsoulAdapter.js`, `src/ui/overlay.js`. -- Complexity: Medium. - -### Align Readiness Gates - -- Goal: centralize real-page readiness criteria so overlay, replay, validate, and audit use the same logic. -- Modules: `src/core/realPageReadiness.js`, `scripts/goal-audit.mjs`, `scripts/replay-capture.mjs`, `src/ui/overlay.js`. -- Complexity: Medium. - -### Clean User-Facing Text Encoding - -- Goal: remove mojibake from hand-input examples and punctuation cleanup text. -- Modules: `README.md`, `src/core/tile.js`, `src/ui/overlay.js`, tests. -- Complexity: Low. - -## P2 - Optimization - -### Improve Overlay Performance - -- Goal: reduce full DOM re-render cost during high-frequency live capture. -- Modules: `src/ui/overlay.js`, `src/ui/styles.js`, UI tests. -- Complexity: Medium. - -### Better Capture Controls - -- Goal: make capture limit and binary sample byte inputs robust across Tampermonkey/browser quirks, including keyboard editing. -- Modules: `src/ui/overlay.js`, `src/main.js`, tests. -- Complexity: Low. - -### Add Developer Handoff Fixtures - -- Goal: commit sanitized minimal captures or synthetic replay fixtures that prove real-page parser behavior without leaking raw live data. -- Modules: `captures/README.md`, `tests/fixtures`, replay tests. -- Complexity: Medium. - -### Improve Documentation Workflow - -- Goal: document exact install/update/capture/replay loop for future agents and testers. -- Modules: `README.md`, `docs/real-page-sampling.md`, `docs/PROJECT_STATE.md`. -- Complexity: Low. - -### Add Analyzer Edge-Case Tests - -- Goal: broaden coverage for open hands, red-five duplicate visibility, dead-wall dora accounting, and invalid known tile counts. -- Modules: `src/core/analyzer.js`, `src/core/ukeire.js`, `tests/shanten.test.js`, `tests/ukeire.test.js`. -- Complexity: Low. +## P0 — Real-Page Acceptance + +### Capture Fresh `0.3.0` Evidence + +- Collect a safe Unity WebGL capture from round start with realtime advice off. +- Import it into the ignored `captures/` directory. +- Require `capture-doctor`, replay, and `real-page-gate` to agree. +- Confirm the replayed state matches both the exported live snapshot and visible table. + +This task requires browser evidence and must not be marked complete from synthetic fixtures. + +### Decode Only Observed Unmapped Unity Shapes + +- The current XOR transform and the observed `ActionNewRound`/draw/discard samples are mapped; start with whichever action remains in `diagnostics.unmappedUnityPayloads` in the next capture. +- Use `diagnostics.unmappedUnityPayloads` to select the next action family. +- Add a sanitized byte fixture and expected standard events for every new mapping. +- Do not infer fields from accidental protobuf-looking bytes. + +## P1 — After Real Capture + +- Map any observed longer deal/discard variants, calls, kans, wins, exhaustive draws, restore, or sync payloads. +- Add sanitized fixtures without committing private raw capture data. +- Measure overlay rendering during a long high-frequency session before optimizing it. + +## Completed in `0.3.0` + +- Unified live overlay and offline replay MVP acceptance rules. +- Centralized real-page readiness summaries. +- Corrected same-timestamp event ordering with `eventId`. +- Added explicit non-zero self-seat state handling. +- Stopped defaulting all-seat replay hands to seat 0. +- Preserved hidden opponent draw turn/wall/dora updates. +- Split protobuf parsing and capture replay out of oversized modules. +- Added versioned configuration migration and persistent user-selected limits. +- Added clean overlay teardown on userscript upgrade. +- Replaced fixed-offset Unity payload guesses with validated length/position-dependent XOR decoding. +- Added the captured 232-byte `ActionNewRound` regression fixture and corrected observed draw/discard expectations. +- Made capture downloads resilient to page click cancellation and premature object-URL revocation. +- Invalidated stale state when action steps regress across an uncaptured round boundary. +- Distinguished bounded diagnostic-preview truncation from actual raw WebSocket sample truncation during import. +- Reached 317 passing automated tests. +- Declared esbuild directly, upgraded Vitest, fixed Node 25 DOM tests, and cleared npm audit findings. diff --git a/docs/real-page-sampling.md b/docs/real-page-sampling.md index 6ed89fb..46b4f41 100644 --- a/docs/real-page-sampling.md +++ b/docs/real-page-sampling.md @@ -18,11 +18,11 @@ Use this checklist to collect a useful Mahjong Soul capture for protocol mapping ## In The Browser 1. Open Mahjong Soul web. -2. Confirm the `Majsoul Helper v0.2.13` overlay appears. If the title does not show `v0.2.13`, Tampermonkey is still running an older generated userscript. +2. Confirm the `Majsoul Helper v0.3.0` overlay appears. If the title does not show `v0.3.0`, Tampermonkey is still running an older generated userscript. 3. Open the overlay debug section and click `Self-test`. It should report `Self-test: ok`; this only checks local parser wiring and does not send WebSocket messages or modify `gameState`. 4. Join a safe testing room. -5. Confirm the `Install` line reads `installed`, `v0.2.13`, `capture running`, and `WebSocket available`. On older JS/Laya builds, either `client decode hooked` or `page dispatch hooked` after the game code has loaded is the best signal that decoded visible fields are available. On current Unity WebGL builds, those legacy hooks should be treated as `not-applicable-unity`; export anyway if `raw_message` entries and `ActionPrototype` names appear, because that capture identifies the next Unity hook or decoder target. -6. Check the `Runtime` and `Hooks` lines. `Runtime: Unity WebGL detected` means the old `net.MessageWrapper` and `Laya.EventDispatcher` hooks are not the current main path, even when capture is installed correctly. `loader observer on`, `createUnityInstance hooked`, and `resolved yes` mean the helper passively saw the Unity boot path and can report whether the Unity instance and Module are visible. The runtime line also reports read-only Unity instance/Module key and function counts; `capture-doctor` prints the bounded key/function names under `Runtime keys` so the next decoder hook can be chosen without dumping memory or calling runtime functions. `constructor`, `send`, and `addEventListener` should still read `ok`; the constructor static-property copy count should not show failures; and `prototype.constructor` should read `patched`. `onmessage` may use `accessor` or a fallback mode; if it reads `non-configurable`, continue sampling but pay close attention to whether inbound traffic appears. On current Mahjong Soul builds, raw `ActionPrototype.data` may not be plain protobuf even though the action name is visible. +5. Confirm the `Install` line reads `installed`, `v0.3.0`, `capture running`, and `WebSocket available`. On older JS/Laya builds, either `client decode hooked` or `page dispatch hooked` after the game code has loaded is the best signal that decoded visible fields are available. On current Unity WebGL builds those legacy hooks should be `not-applicable-unity`; look for `ActionPrototype` names and `payloadCodec: majsoul-webgl-xor-v1`, which means the raw action bytes passed the current XOR, complete-protobuf, and named-schema checks. +6. Check the `Runtime` and `Hooks` lines. `Runtime: Unity WebGL detected` means the old `net.MessageWrapper` and `Laya.EventDispatcher` hooks are not the current main path, even when capture is installed correctly. `loader observer on`, `createUnityInstance hooked`, and `resolved yes` mean the helper passively saw the Unity boot path and can report whether the Unity instance and Module are visible. The runtime line also reports read-only Unity instance/Module key and function counts; `capture-doctor` prints the bounded key/function names under `Runtime keys` without dumping memory or calling runtime functions. `constructor`, `send`, and `addEventListener` should still read `ok`; the constructor static-property copy count should not show failures; and `prototype.constructor` should read `patched`. `onmessage` may use `accessor` or a fallback mode; if it reads `non-configurable`, continue sampling but pay close attention to whether inbound traffic appears. Current raw `ActionPrototype.data` is transformed before protobuf decoding, so an action name alone is not evidence that its fields decoded. 7. Check the `sockets` count. If it remains `0`, reload the game client after the userscript is enabled and wait for Mahjong Soul to open its WebSocket. 8. Wait for `raw_message` entries. 9. Perform normal visible game actions only: draw, discard, call if the table naturally offers it, riichi if the test situation naturally reaches it. @@ -31,7 +31,9 @@ Use this checklist to collect a useful Mahjong Soul capture for protocol mapping 12. Watch the `Real-page preflight` line. It should reach `15/15` before export; if not, follow the next-step hint shown in the same line. It checks the Mahjong Soul page, hook/capture status, live snapshots, live MVP gate, retained event buffer, truncated samples, capture errors, and the safety snapshot used by the real-page gate. After export, run the displayed `capture-doctor` command first, then `npm run real-page-gate` because only replay and strict audit can confirm `liveStateSnapshotMatches=true` and the exported `liveSafetySettings` safety boundary. 13. Leave `Capture limit` at the 10000-event default. Current builds automatically upgrade older saved lower values before collecting real-page samples. 14. Leave `Binary sample bytes` at the 65536-byte default. Current builds automatically upgrade older saved lower values before collecting real-page samples. -15. Click `Download capture`, then import the downloaded JSON with `npm run import-capture -- path/to/majsoul-helper-capture.json`; if it is still in the default Downloads folder, `npm run import-capture` will pick the newest matching `majsoul-helper-capture*.json`. You can also use `Copy capture` and paste the JSON into a local file under `captures/`. +15. Click the `Download capture` button, then import the downloaded JSON with `npm run import-capture -- path/to/majsoul-helper-capture.json`; if it is still in the default Downloads folder, `npm run import-capture` will pick the newest matching `majsoul-helper-capture*.json`. The current button starts an explicit browser download and delays object-URL cleanup so page click handlers cannot cancel it. You can also use `Copy capture` and paste the JSON into a local file under `captures/`. + +Do not pause capture across a round boundary. If action steps regress without a captured `ActionNewRound`, the helper deliberately clears stale round state and the export cannot pass strict acceptance. Clear the debug buffer and start again at the next visible round start rather than combining partial rounds. ## Save And Replay @@ -41,7 +43,7 @@ Use this checklist to collect a useful Mahjong Soul capture for protocol mapping npm run import-capture -- path/to/majsoul-helper-capture.json ``` - The importer validates that the JSON has a helper capture `events` array, creates `captures/` when needed, and refuses to overwrite `captures/capture-real.json` unless you pass `--force` or choose another `--out` path. `Import notice` lines are non-blocking early warnings for files that were copied but lack overlay snapshots, Mahjong Soul page metadata, ready preflight/safety data, inbound raw WebSocket traffic, or show obvious sampling problems such as paused capture, dropped event-buffer entries, helper `capture_error` events, or truncated raw samples; still run the doctor and real-page gate after import. + The importer validates that the JSON has a helper capture `events` array, creates `captures/` when needed, and refuses to overwrite `captures/capture-real.json` unless you pass `--force` or choose another `--out` path. `Import notice` lines are non-blocking early warnings for files that were copied but lack overlay snapshots, Mahjong Soul page metadata, ready preflight/safety data, inbound raw WebSocket traffic, or show obvious sampling problems such as paused capture, dropped event-buffer entries, helper `capture_error` events, or truncated raw samples. Bounded envelope/action preview flags are diagnostic limits and do not by themselves mean the retained raw sample was truncated. Still run the doctor and real-page gate after import. 2. Run: @@ -91,19 +93,19 @@ Use this checklist to collect a useful Mahjong Soul capture for protocol mapping - `acceptance` - `gameState` -The overlay debug section also shows `Unparsed actions` and a compact `Action diagnostics` block while sampling live traffic. Treat those action names as the next parser mapping candidates after checking that raw capture samples are not truncated. In replay output, `actionDiagnostics` gives one compact sample per ActionPrototype name, including parse counts, debug-sample truncation flags, payload lengths, and grouped `actionPayloadFields`. `diagnostics.unmappedUnityPayloads` is more specific for current Unity captures: it groups action names whose raw `ActionPrototype` payloads were captured but still look encrypted or otherwise unmapped after excluding the known short draw/discard codecs. +The overlay debug section also shows `Unparsed actions` and a compact `Action diagnostics` block while sampling live traffic. Treat those action names as the next parser mapping candidates after checking that raw capture samples are not truncated. In replay output, `actionDiagnostics` gives one compact sample per ActionPrototype name, including parse counts, debug-sample truncation flags, payload lengths, and grouped `actionPayloadFields`. `diagnostics.unmappedUnityPayloads` is more specific for current Unity captures: it groups named action payloads for which the XOR candidate did not produce a complete protobuf message with plausible required fields for that action. When the browser delivers WebSocket data as `Blob`, captures include a small `blob` placeholder followed by an async `blob-arraybuffer` sample. The placeholder is not a truncation signal; replay diagnostics and parser mapping should use the `blob-arraybuffer` entry. -If `acceptance.missing` includes `drawTileSeatParsed` or `discardTileSeatParsed`, the parser found a draw/discard action but did not recover a valid seat number. First check whether `captureMetadata.helperDiagnostics.helperVersion` is `0.2.13`, whether `captureMetadata.helperDiagnostics.runtime.unityWebGL` is true, whether the Unity `createUnityInstance` observer resolved, whether `unityInstanceShape`/`unityModuleShape` include useful runtime entry points, and whether the capture has any `client_decode` events. If Unity WebGL is detected and old JS hooks are `not-applicable-unity`, do not keep recapturing the same flow expecting seats to appear; inspect `ActionDealTile` or `ActionDiscardTile` `payload.binaryEnvelope.actionPayloadSample` and find a Unity decoded hook or action payload decoder. If decoded events are present but seats are still missing, inspect the corresponding decoded payload summary or `payload.binaryEnvelope.actionPayloadSample` in replay output, then adjust field mapping before trusting any automatic table state. +If `acceptance.missing` includes `drawTileSeatParsed` or `discardTileSeatParsed`, the parser found a draw/discard action but did not recover a valid seat number. First check the helper version, Unity runtime flag, truncation diagnostics, and whether the action envelope has `actionPayloadCodec: majsoul-webgl-xor-v1`. If the codec is absent, inspect the raw `actionPayloadSample` and add a sanitized regression sample before changing the XOR/schema gate. If the codec is present but the seat is still absent, inspect decoded `actionPayloadFields` and correct the named protobuf field mapping before trusting automatic table state. -For v0.2.9 and later, short Unity `ActionDiscardTile` payloads can recover discard seat, tile, and tsumogiri when the payload matches the observed 14-byte encoded shape. Short Unity `ActionDealTile` payloads can recover draw seat, and the observed 24-byte self-draw shape can recover the drawn tile. If replay now shows hand, draw, discard, and visible-tile checks passing but still misses round, dora, score, or meld checks, the remaining work is mapping `ActionNewRound`, `ActionChiPengGang`, `ActionHule`, and longer draw/discard variants. +Version `0.3.0` replaces the former 6/14/24-byte fixed-offset guesses with one payload-length- and byte-position-dependent XOR transform. The observed discard and draw samples now decode through their protobuf fields, and a captured 232-byte `ActionNewRound` fixture restores a 13-tile hand, four scores, dora, round metadata, riichi sticks, and wall count. This fixture is parser evidence only; the strict real-page gate still requires a fresh overlay export whose live snapshot agrees with offline replay. If `acceptance.missing` includes `gameStateHandUpdated`, collect a sample that includes the start of a round or inspect `ActionNewRound` hand tile fields before treating the capture as a complete MVP state-restoration pass. -In `0.2.11` and later, own draw/discard traffic before a decoded initial hand is kept out of `gameState.hand`; this prevents replay from treating a few known self-drawn tiles as a complete base hand. In `0.2.12` and later, long encrypted deal payloads also cannot create riichi state unless the parser first recovers core deal fields such as seat, tile, wall count, or dora. +Own draw/discard traffic before a decoded initial hand is kept out of `gameState.hand`; this prevents replay from treating a few known self-drawn tiles as a complete base hand. A transformed action payload cannot update state unless it first passes complete protobuf and named-action plausibility checks, so random nested bytes cannot create false deal or riichi state. -In `0.2.13` and later, decoded `RecordNewRound`/`ActionNewRound` objects also understand the Mahjong Soul replay contract used by mjai-reviewer-style log conversion: `chang`, `ju`, `ben`, `liqibang`, `scores`, `dora`/`doras`, and either `tiles` or `tiles0` through `tiles3`. When `selfSeat`/`seat`/`actor` is available, replay uses that seat's hand as the standard visible `tiles` field and keeps the all-seat hands as `seatHands` for diagnostics. +Decoded `RecordNewRound`/`ActionNewRound` objects understand the Mahjong Soul replay contract used by mjai-reviewer-style log conversion: `chang`, `ju`, `ben`, `liqibang`, `scores`, `dora`/`doras`, and either `tiles` or `tiles0` through `tiles3`. Version `0.3.0` preserves `selfSeat`, never guesses seat 0 from all-seat hands, infers a dealer from a 14-tile opening hand, and can infer a non-dealer from a later private `ActionDealTile`. If `acceptance.missing` includes `gameStateRoundMetadataUpdated`, inspect `ActionNewRound` `chang`, `ju`, or round fields before trusting wind/round display. @@ -111,13 +113,13 @@ If `acceptance.missing` includes `gameStateDoraIndicatorsUpdated`, inspect `Acti If `acceptance.missing` includes `gameStateScoresUpdated`, inspect `ActionNewRound`, `ActionHule`, `ActionLiuJu`, or `ActionNoTile` score fields before trusting point display. -The current parser maps the visible MVP fields from the public generated Liqi schema: `ActionNewRound` uses `tiles` field 4, `dora` field 5, packed `scores` field 6, `liqibang` field 8, `left_tile_count` field 13, and `doras` field 14; `ActionDealTile` uses `doras` field 6 and nested `LiQiSuccess` field 5; `ActionDiscardTile` uses `is_liqi` field 3, `moqie` field 5, and `doras` field 8; `ActionChiPengGang` uses `seat` field 1, `type` field 2, and nested `LiQiSuccess` field 5; `ActionAnGangAddGang` uses `seat` field 1, `type` field 2, tile field 3, and `doras` field 6; `ActionLiqiSuccess` uses `seat` field 1, `score` field 2, and `liqibang` field 3; `ActionHule`/`RecordHule` use packed `scores` field 5, nested `GameEnd` fallback field 6 with packed `scores` field 1, and `doras` field 7; `ActionLiuJu` checks nested `GameEnd` field 2 for packed `scores` field 1 and nested `LiQiSuccess` field 5; `ActionNoTile`/`RecordNoTile` use `scores` field 3, then nested `NoTileScoreInfo.seat` field 1, `doras` field 6, and `score` field 7. Dora-like action names without a dedicated mapping use tile-like payload strings as standard `dora` events. If real captures disagree with these assumptions, trust the capture diagnostics and update tests with a sanitized fixture. +After the validated WebGL transform, the parser maps visible MVP fields from the public generated Liqi schema: `ActionNewRound` uses `tiles` field 4, `dora` field 5, packed `scores` field 6, `liqibang` field 8, `left_tile_count` field 13, and `doras` field 14; `ActionDealTile` uses `doras` field 6 and nested `LiQiSuccess` field 5; `ActionDiscardTile` uses `is_liqi` field 3, `moqie` field 5, and `doras` field 8; `ActionChiPengGang` uses `seat` field 1, `type` field 2, and nested `LiQiSuccess` field 5; `ActionAnGangAddGang` uses `seat` field 1, `type` field 2, tile field 3, and `doras` field 6; `ActionLiqiSuccess` uses `seat` field 1, `score` field 2, and `liqibang` field 3; `ActionHule`/`RecordHule` use packed `scores` field 5, nested `GameEnd` fallback field 6 with packed `scores` field 1, and `doras` field 7; `ActionLiuJu` checks nested `GameEnd` field 2 for packed `scores` field 1 and nested `LiQiSuccess` field 5; `ActionNoTile`/`RecordNoTile` use `scores` field 3, then nested `NoTileScoreInfo.seat` field 1, `doras` field 6, and `score` field 7. Dora-like action names without a dedicated mapping use tile-like payload strings as standard `dora` events. If real captures disagree with these assumptions, trust the capture diagnostics and update tests with a sanitized fixture. Replay-oriented direct methods are also normalized when captured: `RecordNewRound`, `RecordDealTile`, `RecordDiscardTile`, `RecordChiPengGang`, `RecordAnGangAddGang`, `RecordBaBei`, `RecordHule`, `RecordLiuJu`, and `RecordNoTile` map into the same standard round, draw, discard, meld, and round-end events. This is useful when validating training/review pages as well as live table traffic. If the capture contains `.lq.GameRestore`, or a `.lq.ResEnterGame`/`.lq.ResSyncGame` response with nested `game_restore`, replay should emit a `round_start` snapshot before any restored actions. Check that this snapshot restores hand, dora indicators, wall count, scores, rivers, melds, current turn, and riichi flags from the visible table. Nested `ActionPrototype` entries inside restore payloads are counted in `topActions`, `diagnostics.rawActionTotal`, and `actionDiagnostics`. -For field mapping, also inspect `payload.binaryEnvelope.actionPayloadFields`. It groups observed protobuf varints, printable strings, and tile-like strings by field number, which is usually faster and safer than reading the hex sample first. +For field mapping, also inspect `payload.binaryEnvelope.actionPayloadFields`. When `actionPayloadCodec` is present this summary comes from the validated decoded bytes, while `actionPayloadSample` remains the captured raw sample. The grouped varints, printable strings, and tile-like strings are usually faster and safer to inspect than raw hex. For unparsed action families, start with `actionDiagnostics[].sample.actionPayloadFields`. It is the same field grouping lifted into a top-level replay section, sorted so unparsed and high-count action names appear first. diff --git a/majsoul-helper.user.js b/majsoul-helper.user.js index 58e9523..365cc38 100644 --- a/majsoul-helper.user.js +++ b/majsoul-helper.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name Majsoul Helper MVP // @namespace https://local.majsoul-helper/ -// @version 0.2.13 +// @version 0.3.0 // @description Visible-state/debug helper for Mahjong Soul. No auto discard, no click automation, no message mutation. // @match *://*.mahjongsoul.com/* // @match *://mahjongsoul.game.yo-star.com/* @@ -13,6 +13,148 @@ // ==/UserScript== var MajsoulHelperBundle = (() => { + // src/adapter/protobuf.js + function toUint8Array(data) { + if (data instanceof ArrayBuffer) return new Uint8Array(data); + if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + return null; + } + function bytesToHex(bytes, max = bytes.length) { + return Array.from(bytes.slice(0, max), (byte) => byte.toString(16).padStart(2, "0")).join(" "); + } + function isPrintableShortText(text) { + return typeof text === "string" && /^[\x20-\x7E]{1,80}$/.test(text); + } + function groupValuesByField(entries, mapValue) { + const grouped = /* @__PURE__ */ new Map(); + for (const entry of entries) { + const value = mapValue(entry); + if (value === void 0 || value === null || value === "") continue; + if (!grouped.has(entry.field)) grouped.set(entry.field, []); + const values = grouped.get(entry.field); + if (values.length < 12) values.push(value); + } + return Array.from(grouped.entries()).sort((a, b) => a[0] - b[0]).map(([field, values]) => ({ field, values })); + } + function summarizePayloadFields(bytes, { isTileString = () => false } = {}) { + if (!bytes?.length) { + return { + varints: [], + strings: [], + tileStrings: [] + }; + } + const fields = parseProtobufEnvelope(bytes).fields; + return { + varints: groupValuesByField(fields.varints, (entry) => entry.value), + strings: groupValuesByField(fields.lengthDelimited, (entry) => isPrintableShortText(entry.text) ? entry.text : void 0), + tileStrings: groupValuesByField(fields.lengthDelimited, (entry) => isTileString(entry.text) ? entry.text : void 0) + }; + } + function decodeVarint(bytes, offset) { + let value = 0; + let multiplier = 1; + let cursor = offset; + while (cursor < bytes.length && cursor - offset < 10) { + const byte = bytes[cursor]; + value += (byte & 127) * multiplier; + if (!Number.isSafeInteger(value)) return null; + cursor += 1; + if ((byte & 128) === 0) return { value, offset: cursor }; + multiplier *= 128; + } + return null; + } + function decodeUtf8(bytes) { + try { + return new TextDecoder().decode(bytes); + } catch { + return ""; + } + } + function parseProtobufEnvelope(bytes) { + let offset = 0; + const lengthDelimited = []; + const varints = []; + while (offset < bytes.length) { + const tag = decodeVarint(bytes, offset); + if (!tag) break; + offset = tag.offset; + const field = tag.value >> 3; + const wireType = tag.value & 7; + if (wireType === 0) { + const value = decodeVarint(bytes, offset); + if (!value) break; + varints.push({ field, value: value.value }); + offset = value.offset; + continue; + } + if (wireType === 2) { + const length = decodeVarint(bytes, offset); + if (!length) break; + offset = length.offset; + if (!Number.isSafeInteger(length.value) || length.value < 0) break; + const end = offset + length.value; + if (!Number.isSafeInteger(end) || end < offset) break; + if (end > bytes.length) break; + const valueBytes = bytes.slice(offset, end); + lengthDelimited.push({ field, bytes: valueBytes, text: decodeUtf8(valueBytes) }); + offset = end; + continue; + } + if (wireType === 5) { + if (offset + 4 > bytes.length) break; + offset += 4; + continue; + } + if (wireType === 1) { + if (offset + 8 > bytes.length) break; + offset += 8; + continue; + } + break; + } + const methodEntry = lengthDelimited.find((entry) => /^\.?lq\./.test(entry.text)) || lengthDelimited.find((entry) => /Action|Req|Res|Notify|Lobby/.test(entry.text)); + const dataEntry = lengthDelimited.find((entry) => entry !== methodEntry && entry.bytes.length > 0); + return { + methodName: methodEntry?.text, + payloadBytes: dataEntry?.bytes || new Uint8Array(), + fields: { varints, lengthDelimited } + }; + } + function varintFields(fields, id) { + return fields.varints.filter((entry) => entry.field === id).map((entry) => entry.value); + } + function decodeVarintSequence(bytes = new Uint8Array()) { + const values = []; + let offset = 0; + while (offset < bytes.length) { + const decoded = decodeVarint(bytes, offset); + if (!decoded || decoded.offset <= offset) break; + values.push(decoded.value); + offset = decoded.offset; + } + return offset === bytes.length ? values : []; + } + function packedVarintFields(fields, id) { + return fields.lengthDelimited.filter((entry) => entry.field === id).flatMap((entry) => decodeVarintSequence(entry.bytes)); + } + function numericField(fields, id) { + return numericFields(fields, id)[0]; + } + function numericFields(fields, id) { + return [ + ...varintFields(fields, id), + ...packedVarintFields(fields, id) + ]; + } + function stringField(fields, id) { + return fields.lengthDelimited.find((entry) => entry.field === id && entry.text)?.text; + } + function stringFields(fields, id) { + return fields.lengthDelimited.filter((entry) => entry.field === id && entry.text).map((entry) => entry.text); + } + // src/adapter/messageParser.js var BINARY_ENVELOPE_SAMPLE_BYTES = 512; function readPath(value, path) { @@ -87,7 +229,7 @@ var MajsoulHelperBundle = (() => { if (directTiles !== void 0) return directTiles; const seat = normalizeSeatIndex(firstDefined(payload.selfSeat, payload.playerSeat, payload.actor, payload.who, payload.seat)); if (seat !== void 0 && seatHands?.[seat]?.length) return seatHands[seat]; - return seatHands?.[0] || []; + return []; } function readableRoundEndReason(payload = {}, sourceName = "") { const explicit = firstDefined(payload.reason, payload.roundEndReason, payload.endReason); @@ -216,6 +358,7 @@ var MajsoulHelperBundle = (() => { if (!payload || typeof payload !== "object") return {}; if (type === "round_start") { const seatHands = readableSeatHands(payload); + const selfSeat = normalizeSeatIndex(firstDefined(payload.selfSeat, payload.playerSeat, payload.actor, payload.who, payload.seat)); const chang = firstDefined(payload.chang, payload.round_index, payload.roundIndex); const ju = firstDefined(payload.ju, payload.ju_index, payload.juIndex, payload.dealer); const round = firstDefined( @@ -233,6 +376,7 @@ var MajsoulHelperBundle = (() => { riichiSticks: firstDefined(payload.riichiSticks, payload.lizhibang, payload.liqibang, payload.sticks, 0), roundWind: firstDefined(payload.roundWind, payload.changfeng, payload.round_wind), seatWind: firstDefined(payload.seatWind, payload.zifeng, payload.seat_wind), + ...selfSeat !== void 0 ? { selfSeat } : {}, scores: firstDefined(payload.scores, payload.points, payload.score), tiles: readableRoundTiles(payload, seatHands), doraIndicators: readableDoraIndicators(payload), @@ -241,8 +385,10 @@ var MajsoulHelperBundle = (() => { }; } if (type === "deal_hand") { + const selfSeat = normalizeSeatIndex(firstDefined(payload.selfSeat, payload.playerSeat, payload.actor, payload.who, payload.seat)); return { - tiles: firstDefined(payload.tiles, payload.hand, payload.handTiles, payload.qipai, payload.tehai, []) + tiles: firstDefined(payload.tiles, payload.hand, payload.handTiles, payload.qipai, payload.tehai, []), + ...selfSeat !== void 0 ? { selfSeat } : {} }; } if (type === "draw_tile") { @@ -321,127 +467,148 @@ var MajsoulHelperBundle = (() => { return null; } } - function toUint8Array(data) { - if (data instanceof ArrayBuffer) return new Uint8Array(data); - if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); - return null; - } - function bytesToHex(bytes, max = bytes.length) { - return Array.from(bytes.slice(0, max), (byte) => byte.toString(16).padStart(2, "0")).join(" "); - } - function isPrintableShortText(text) { - return typeof text === "string" && /^[\x20-\x7E]{1,80}$/.test(text); + var MAJSOUL_WEBGL_ACTION_XOR_KEYS = [132, 94, 78, 66, 57, 162, 31, 96, 28]; + var MAJSOUL_WEBGL_ACTION_CODEC = "majsoul-webgl-xor-v1"; + function isIntegerInRange(value, min, max) { + return Number.isInteger(value) && value >= min && value <= max; } - function groupValuesByField(entries, mapValue) { - const grouped = /* @__PURE__ */ new Map(); - for (const entry of entries) { - const value = mapValue(entry); - if (value === void 0 || value === null || value === "") continue; - if (!grouped.has(entry.field)) grouped.set(entry.field, []); - const values = grouped.get(entry.field); - if (values.length < 12) values.push(value); - } - return Array.from(grouped.entries()).sort((a, b) => a[0] - b[0]).map(([field, values]) => ({ field, values })); - } - function summarizePayloadFields(bytes) { - if (!bytes?.length) { - return { - varints: [], - strings: [], - tileStrings: [] - }; - } - const fields = parseProtobufEnvelope(bytes).fields; - return { - varints: groupValuesByField(fields.varints, (entry) => entry.value), - strings: groupValuesByField(fields.lengthDelimited, (entry) => isPrintableShortText(entry.text) ? entry.text : void 0), - tileStrings: groupValuesByField(fields.lengthDelimited, (entry) => tileLike(entry.text) ? entry.text : void 0) - }; - } - function decodeVarint(bytes, offset) { - let value = 0; - let multiplier = 1; - let cursor = offset; - while (cursor < bytes.length && cursor - offset < 10) { - const byte = bytes[cursor]; - value += (byte & 127) * multiplier; - if (!Number.isSafeInteger(value)) return null; - cursor += 1; - if ((byte & 128) === 0) return { value, offset: cursor }; - multiplier *= 128; - } - return null; - } - function decodeUtf8(bytes) { - try { - return new TextDecoder().decode(bytes); - } catch { - return ""; - } - } - function parseProtobufEnvelope(bytes) { + function isCompleteProtobufPayload(bytes) { + if (!bytes?.length) return false; let offset = 0; - const lengthDelimited = []; - const varints = []; + let fieldCount = 0; while (offset < bytes.length) { const tag = decodeVarint(bytes, offset); - if (!tag) break; + if (!tag || tag.offset <= offset) return false; offset = tag.offset; - const field = tag.value >> 3; + const field = Math.floor(tag.value / 8); const wireType = tag.value & 7; + if (!isIntegerInRange(field, 1, 536870911)) return false; if (wireType === 0) { const value = decodeVarint(bytes, offset); - if (!value) break; - varints.push({ field, value: value.value }); + if (!value || value.offset <= offset) return false; offset = value.offset; - continue; - } - if (wireType === 2) { + } else if (wireType === 1) { + if (offset + 8 > bytes.length) return false; + offset += 8; + } else if (wireType === 2) { const length = decodeVarint(bytes, offset); - if (!length) break; + if (!length || length.offset <= offset || !Number.isSafeInteger(length.value)) return false; offset = length.offset; - if (!Number.isSafeInteger(length.value) || length.value < 0) break; const end = offset + length.value; - if (!Number.isSafeInteger(end) || end < offset) break; - if (end > bytes.length) break; - const valueBytes = bytes.slice(offset, end); - lengthDelimited.push({ field, bytes: valueBytes, text: decodeUtf8(valueBytes) }); + if (!Number.isSafeInteger(end) || end < offset || end > bytes.length) return false; offset = end; - continue; - } - if (wireType === 5) { - if (offset + 4 > bytes.length) break; + } else if (wireType === 5) { + if (offset + 4 > bytes.length) return false; offset += 4; - continue; + } else { + return false; } - if (wireType === 1) { - if (offset + 8 > bytes.length) break; - offset += 8; - continue; + fieldCount += 1; + } + return offset === bytes.length && fieldCount > 0; + } + function decodeMajsoulWebglActionPayload(bytes) { + const decoded = Uint8Array.from(bytes || []); + const base = 23 ^ decoded.length; + for (let index = 0; index < decoded.length; index += 1) { + const key = MAJSOUL_WEBGL_ACTION_XOR_KEYS[index % MAJSOUL_WEBGL_ACTION_XOR_KEYS.length]; + decoded[index] ^= base + 5 * index + key & 255; + } + return decoded; + } + function optionalFieldsInRange(fields, ids, min, max) { + return ids.every((id) => { + const value = numericField(fields, id); + return value === void 0 || isIntegerInRange(value, min, max); + }); + } + function hasPlausibleActionPayload(actionName, fields) { + const name = String(actionName || "").split(".").pop(); + const seat = numericField(fields, 1); + const allTiles = fields.lengthDelimited.map((entry) => entry.text).filter(tileLike); + if (name === "ActionNewRound" || name === "RecordNewRound") { + const record = name === "RecordNewRound"; + const handTiles = tileStringFields(fields, record ? 7 : 4); + const scores = numericFields(fields, record ? 5 : 6); + const doraIndicators = [ + stringField(fields, record ? 4 : 5), + ...tileStringFields(fields, record ? 16 : 14) + ].filter(tileLike); + const leftTileCount = numericField(fields, record ? 15 : 13); + return isIntegerInRange(numericField(fields, 1), 0, 3) && isIntegerInRange(numericField(fields, 2), 0, 3) && isIntegerInRange(numericField(fields, 3), 0, 999) && [3, 4].includes(scores.length) && scores.every((score) => isIntegerInRange(score, 0, 1e7)) && [0, 13].includes(handTiles.length) && doraIndicators.length > 0 && isIntegerInRange(leftTileCount, 0, 136); + } + if (name === "ActionDiscardTile" || name === "RecordDiscardTile") { + return isIntegerInRange(seat, 0, 3) && tileLike(stringField(fields, 2)) && optionalFieldsInRange(fields, [3, 5, 9], 0, 1); + } + if (name === "ActionDealTile" || name === "RecordDealTile") { + const tile = stringField(fields, 2); + return isIntegerInRange(seat, 0, 3) && (tile === void 0 || tileLike(tile)) && isIntegerInRange(numericField(fields, 3), 0, 136); + } + if (name === "ActionChiPengGang" || name === "RecordChiPengGang") { + const meld = tileStringFields(fields, 3); + return isIntegerInRange(seat, 0, 3) && isIntegerInRange(numericField(fields, 2), 0, 3) && [3, 4].includes(meld.length); + } + if (name === "ActionAnGangAddGang" || name === "RecordAnGangAddGang") { + return isIntegerInRange(seat, 0, 3) && isIntegerInRange(numericField(fields, 2), 0, 3) && tileStringFields(fields, 3).length > 0; + } + if (name === "ActionBaBei" || name === "RecordBaBei") { + return isIntegerInRange(seat, 0, 3); + } + if (name === "ActionLiqi" || name === "RecordLiqi") { + return isIntegerInRange(seat, 0, 3) && isIntegerInRange(numericField(fields, 2), 1, 2); + } + if (name === "ActionLiqiSuccess" || name === "RecordLiqiSuccess") { + return isIntegerInRange(seat, 0, 3) && isIntegerInRange(numericField(fields, 2), 0, 1e7) && isIntegerInRange(numericField(fields, 3), 0, 100); + } + if (name === "ActionHule" || name === "RecordHule") { + return nestedPayloadEntries(fields, 1).length > 0 || numericFields(fields, 5).length >= 3 || tileStringFields(fields, 7).length > 0; + } + if (name === "ActionLiuJu" || name === "RecordLiuJu") { + return isIntegerInRange(numericField(fields, 1), 0, 10) || nestedPayloadEntries(fields, 2).length > 0 || tileStringFields(fields, 4).length > 0; + } + if (name === "ActionNoTile" || name === "RecordNoTile") { + return optionalFieldsInRange(fields, [1, 4], 0, 1) && nestedPayloadEntries(fields, 3).length > 0; + } + if (toEventType(name) === "dora") return allTiles.length > 0; + return false; + } + function selectActionPayloadBytes(actionName, bytes, { allowWebglXor = false } = {}) { + const rawBytes = bytes || new Uint8Array(); + const rawFields = parseProtobufEnvelope(rawBytes).fields; + if (allowWebglXor) { + const decodedBytes = decodeMajsoulWebglActionPayload(rawBytes); + if (isCompleteProtobufPayload(decodedBytes)) { + const decodedFields = parseProtobufEnvelope(decodedBytes).fields; + if (hasPlausibleActionPayload(actionName, decodedFields)) { + return { + bytes: decodedBytes, + fields: decodedFields, + payloadCodec: MAJSOUL_WEBGL_ACTION_CODEC + }; + } } - break; } - const methodEntry = lengthDelimited.find((entry) => /^\.?lq\./.test(entry.text)) || lengthDelimited.find((entry) => /Action|Req|Res|Notify|Lobby/.test(entry.text)); - const dataEntry = lengthDelimited.find((entry) => entry !== methodEntry && entry.bytes.length > 0); - return { - methodName: methodEntry?.text, - payloadBytes: dataEntry?.bytes || new Uint8Array(), - fields: { varints, lengthDelimited } - }; + if (isCompleteProtobufPayload(rawBytes) && hasPlausibleActionPayload(actionName, rawFields)) { + return { bytes: rawBytes, fields: rawFields, payloadCodec: void 0 }; + } + return { bytes: rawBytes, fields: rawFields, payloadCodec: void 0 }; } - function parseActionPrototype(payloadBytes) { + function parseActionPrototype(payloadBytes, { allowWebglXor = false } = {}) { if (!payloadBytes?.length) return null; const decoded = parseProtobufEnvelope(payloadBytes); - const actionEntry = decoded.fields.lengthDelimited.find((entry) => /^(Action|Record)[A-Za-z0-9_]+$/.test(entry.text)); + const actionEntry = decoded.fields.lengthDelimited.find((entry) => entry.field === 2 && /^(Action|Record)[A-Za-z0-9_]+$/.test(entry.text)); if (!actionEntry) return null; - const dataEntry = decoded.fields.lengthDelimited.find((entry) => entry !== actionEntry && entry.bytes.length > 0); + const dataEntry = decoded.fields.lengthDelimited.find((entry) => entry.field === 3); + const actionPayloadBytes = dataEntry?.bytes || new Uint8Array(); + const selectedPayload = selectActionPayloadBytes(actionEntry.text, actionPayloadBytes, { allowWebglXor }); return { - step: decoded.fields.varints[0]?.value, + step: numericField(decoded.fields, 1), actionName: actionEntry.text, - actionPayloadBytes: dataEntry?.bytes || new Uint8Array(), - actionPayloadSample: bytesToHex(dataEntry?.bytes || new Uint8Array(), BINARY_ENVELOPE_SAMPLE_BYTES), - actionPayloadTruncated: (dataEntry?.bytes.length || 0) > BINARY_ENVELOPE_SAMPLE_BYTES, - actionPayloadFields: summarizePayloadFields(dataEntry?.bytes || new Uint8Array()) + actionPayloadBytes, + actionPayloadSample: bytesToHex(actionPayloadBytes, BINARY_ENVELOPE_SAMPLE_BYTES), + actionPayloadTruncated: actionPayloadBytes.length > BINARY_ENVELOPE_SAMPLE_BYTES, + actionPayloadFields: summarizePayloadFields(selectedPayload.bytes, { isTileString: tileLike }), + actionPayloadCodec: selectedPayload.payloadCodec }; } function actionNameFromMethod(methodName) { @@ -451,81 +618,9 @@ var MajsoulHelperBundle = (() => { function tileLike(text) { return /^[0-9][mps]$|^[1-7]z$/.test(text); } - function varintFields(fields, id) { - return fields.varints.filter((entry) => entry.field === id).map((entry) => entry.value); - } - function decodeVarintSequence(bytes = new Uint8Array()) { - const values = []; - let offset = 0; - while (offset < bytes.length) { - const decoded = decodeVarint(bytes, offset); - if (!decoded || decoded.offset <= offset) break; - values.push(decoded.value); - offset = decoded.offset; - } - return offset === bytes.length ? values : []; - } - function packedVarintFields(fields, id) { - return fields.lengthDelimited.filter((entry) => entry.field === id).flatMap((entry) => decodeVarintSequence(entry.bytes)); - } - function numericField(fields, id) { - return numericFields(fields, id)[0]; - } - function numericFields(fields, id) { - return [ - ...varintFields(fields, id), - ...packedVarintFields(fields, id) - ]; - } - function stringField(fields, id) { - return fields.lengthDelimited.find((entry) => entry.field === id && entry.text)?.text; - } - function stringFields(fields, id) { - return fields.lengthDelimited.filter((entry) => entry.field === id && entry.text).map((entry) => entry.text); - } function tileStringFields(fields, id) { return stringFields(fields, id).filter(tileLike); } - function decodeUnityEncodedDiscardPayload(bytes) { - if (!bytes || bytes.length !== 14) return null; - const seat = bytes[1] ^ 126; - const tile = String.fromCharCode(bytes[4] ^ 102, bytes[5] ^ 212); - const tsumogiri = bytes[9] ^ 202; - if (!Number.isInteger(seat) || seat < 0 || seat > 3) return null; - if (!tileLike(tile)) return null; - if (![0, 1].includes(tsumogiri)) return null; - return { - seat, - tile, - tsumogiri: Boolean(tsumogiri), - isRiichi: false, - doraIndicators: [], - payloadCodec: "unity-xor-discard-short" - }; - } - function decodeUnityEncodedDealPayload(bytes) { - if (!bytes) return null; - if (bytes.length === 6) { - const seat = bytes[1] ^ 118; - if (!Number.isInteger(seat) || seat < 0 || seat > 3) return null; - return { - seat, - doraIndicators: [], - payloadCodec: "unity-xor-deal-seat-short" - }; - } - if (bytes.length === 24) { - const tile = String.fromCharCode(bytes[4] ^ 92, bytes[5] ^ 202); - if (!tileLike(tile)) return null; - return { - seat: 0, - tile, - doraIndicators: [], - payloadCodec: "unity-xor-deal-self-draw" - }; - } - return null; - } function nestedPayloadFields(fields, id) { return fields.lengthDelimited.filter((entry) => entry.field === id && entry.bytes?.length).map((entry) => parseProtobufEnvelope(entry.bytes).fields); } @@ -639,17 +734,21 @@ var MajsoulHelperBundle = (() => { actionPayloadLength: actionPayloadBytes.length, actionPayloadSample: bytesToHex(actionPayloadBytes, BINARY_ENVELOPE_SAMPLE_BYTES), actionPayloadTruncated: actionPayloadBytes.length > BINARY_ENVELOPE_SAMPLE_BYTES, - actionPayloadFields: summarizePayloadFields(actionPayloadBytes) + actionPayloadFields: summarizePayloadFields(actionPayloadBytes, { isTileString: tileLike }) }; } function parseRestoredActionEvents(baseEnvelope, restoreFields) { const events = []; for (const entry of nestedPayloadEntries(restoreFields, 2)) { - const actionPrototype = parseActionPrototype(entry.bytes); + const actionPrototype = parseActionPrototype(entry.bytes, { allowWebglXor: false }); if (!actionPrototype?.actionName) continue; const type = toEventType(actionPrototype.actionName); if (!type) continue; - const actionPayload = decodeSimpleActionPayload(actionPrototype.actionName, actionPrototype.actionPayloadBytes); + const actionPayload = decodeSimpleActionPayload( + actionPrototype.actionName, + actionPrototype.actionPayloadBytes, + { allowWebglXor: false } + ); const payload = { ...actionPayload, binaryEnvelope: actionPayloadEnvelope(baseEnvelope, actionPrototype, actionPrototype.actionPayloadBytes) @@ -705,7 +804,7 @@ var MajsoulHelperBundle = (() => { function extractGameRestoreActionNames(payloadBytes) { if (!payloadBytes?.length) return []; const fields = parseProtobufEnvelope(payloadBytes).fields; - return nestedPayloadEntries(fields, 2).map((entry) => parseActionPrototype(entry.bytes)?.actionName).filter(Boolean); + return nestedPayloadEntries(fields, 2).map((entry) => parseActionPrototype(entry.bytes, { allowWebglXor: false })?.actionName).filter(Boolean); } function extractRestoreActionNames(methodName, payloadBytes) { if (isGameRestoreMethod(methodName)) return extractGameRestoreActionNames(payloadBytes); @@ -715,59 +814,63 @@ var MajsoulHelperBundle = (() => { } return void 0; } - function decodeSimpleActionPayload(actionName, bytes) { + function decodeSimpleActionPayload(actionName, bytes, { allowWebglXor = false } = {}) { if (!actionName || !bytes?.length) return {}; - const decoded = parseProtobufEnvelope(bytes); - const fields = decoded.fields; + const selectedPayload = selectActionPayloadBytes(actionName, bytes, { allowWebglXor }); + const fields = selectedPayload.fields; + const codec = selectedPayload.payloadCodec ? { payloadCodec: selectedPayload.payloadCodec } : {}; const allTiles = fields.lengthDelimited.map((entry) => entry.text).filter(tileLike); if (actionName === "ActionDiscardTile" || actionName === "RecordDiscardTile") { - const decoded2 = { + const decoded = { seat: numericField(fields, 1), tile: stringField(fields, 2), tsumogiri: Boolean(numericField(fields, 5)), isRiichi: Boolean(firstDefined(numericField(fields, 3), numericField(fields, 9))), - doraIndicators: tileStringFields(fields, 8) + doraIndicators: tileStringFields(fields, 8), + ...codec }; - if (decoded2.seat !== void 0 || decoded2.tile || decoded2.doraIndicators.length) return decoded2; - return decodeUnityEncodedDiscardPayload(bytes) || decoded2; + return decoded; } if (actionName === "ActionDealTile" || actionName === "RecordDealTile") { - const decoded2 = { + const decoded = { seat: numericField(fields, 1), tile: stringField(fields, 2), leftTileCount: numericField(fields, 3), - doraIndicators: tileStringFields(fields, 6) + doraIndicators: tileStringFields(fields, 6), + ...codec }; - if (decoded2.seat !== void 0 || decoded2.tile || decoded2.leftTileCount !== void 0 || decoded2.doraIndicators.length) { + if (decoded.seat !== void 0 || decoded.tile || decoded.leftTileCount !== void 0 || decoded.doraIndicators.length) { const riichi = decodeLiQiSuccess(fields, 5); return { - ...decoded2, + ...decoded, ...riichi ? { riichi } : {} }; } - return decodeUnityEncodedDealPayload(bytes) || decoded2; + return decoded; } if (actionName === "ActionChiPengGang" || actionName === "RecordChiPengGang") { - const decoded2 = { + const decoded = { seat: numericField(fields, 1), type: numericField(fields, 2), - meld: tileStringFields(fields, 3) + meld: tileStringFields(fields, 3), + ...codec }; - if (decoded2.seat !== void 0 || decoded2.type !== void 0 || decoded2.meld.length) { + if (decoded.seat !== void 0 || decoded.type !== void 0 || decoded.meld.length) { const riichi = decodeLiQiSuccess(fields, 5); return { - ...decoded2, + ...decoded, ...riichi ? { riichi } : {} }; } - return decoded2; + return decoded; } if (actionName === "ActionAnGangAddGang" || actionName === "RecordAnGangAddGang") { return { seat: numericField(fields, 1), type: firstDefined(numericField(fields, 2), stringField(fields, 2)), doraIndicators: tileStringFields(fields, 6), - meld: tileStringFields(fields, 3) + meld: tileStringFields(fields, 3), + ...codec }; } if (actionName === "ActionBaBei" || actionName === "RecordBaBei") { @@ -776,20 +879,23 @@ var MajsoulHelperBundle = (() => { type: "babei", meld: ["4z"], tsumogiri: Boolean(actionName === "RecordBaBei" ? numericField(fields, 8) : numericField(fields, 9)), - doraIndicators: tileStringFields(fields, 6) + doraIndicators: tileStringFields(fields, 6), + ...codec }; } if (actionName === "ActionLiqi" || actionName === "RecordLiqi") { return { seat: numericField(fields, 1), - step: numericField(fields, 2) + step: numericField(fields, 2), + ...codec }; } if (actionName === "ActionLiqiSuccess" || actionName === "RecordLiqiSuccess") { return { seat: numericField(fields, 1), score: numericField(fields, 2), - riichiSticks: numericField(fields, 3) + riichiSticks: numericField(fields, 3), + ...codec }; } if (actionName === "ActionHule" || actionName === "RecordHule") { @@ -799,7 +905,8 @@ var MajsoulHelperBundle = (() => { reason: "hule", scores: scores.length ? scores : gameEndScores, tiles: allTiles, - doraIndicators: tileStringFields(fields, 7) + doraIndicators: tileStringFields(fields, 7), + ...codec }; } if (actionName === "ActionLiuJu" || actionName === "RecordLiuJu") { @@ -812,7 +919,8 @@ var MajsoulHelperBundle = (() => { tiles: tileStringFields(fields, 4).length ? tileStringFields(fields, 4) : allTiles, allPlayerTiles: tileStringFields(fields, 6), ...scores.length ? { scores } : {}, - ...riichi ? { riichi } : {} + ...riichi ? { riichi } : {}, + ...codec }; } if (actionName === "ActionNoTile" || actionName === "RecordNoTile") { @@ -824,13 +932,15 @@ var MajsoulHelperBundle = (() => { gameEnd: Boolean(numericField(fields, 4)), scores: scoreInfo.scores, doraIndicators: scoreInfo.doraIndicators, - tiles: allTiles + tiles: allTiles, + ...codec }; } if (toEventType(actionName) === "dora") { return { tile: allTiles[0], - doraIndicators: allTiles + doraIndicators: allTiles, + ...codec }; } if (actionName === "ActionNewRound") { @@ -850,7 +960,8 @@ var MajsoulHelperBundle = (() => { doraIndicators, scores: numericFields(fields, 6), tiles: handTiles.length ? handTiles : allTiles.filter((tile) => !doraIndicators.includes(tile)), - leftTileCount: numericField(fields, 13) + leftTileCount: numericField(fields, 13), + ...codec }; } if (actionName === "RecordNewRound") { @@ -869,7 +980,8 @@ var MajsoulHelperBundle = (() => { doraIndicators, scores: numericFields(fields, 5), tiles: tileStringFields(fields, 7), - leftTileCount: numericField(fields, 15) + leftTileCount: numericField(fields, 15), + ...codec }; } return {}; @@ -904,11 +1016,11 @@ var MajsoulHelperBundle = (() => { if (bytes.length <= messageOffset) return { frameType, frameTypeName, requestId: null }; const requestId = frameType === 1 ? null : bytes[1] | bytes[2] << 8; const envelope = parseProtobufEnvelope(bytes.slice(messageOffset)); - const actionPrototype = envelope.methodName === ".lq.ActionPrototype" ? parseActionPrototype(envelope.payloadBytes) : null; + const actionPrototype = envelope.methodName === ".lq.ActionPrototype" ? parseActionPrototype(envelope.payloadBytes, { allowWebglXor: true }) : null; const directActionName = actionPrototype ? null : actionNameFromMethod(envelope.methodName); const actionName = actionPrototype?.actionName || directActionName; const actionPayloadBytes = actionPrototype?.actionPayloadBytes || (directActionName ? envelope.payloadBytes : new Uint8Array()); - const actionPayloadFields = actionPrototype?.actionPayloadFields || (directActionName ? summarizePayloadFields(actionPayloadBytes) : void 0); + const actionPayloadFields = actionPrototype?.actionPayloadFields || (directActionName ? summarizePayloadFields(actionPayloadBytes, { isTileString: tileLike }) : void 0); const restoreActionNames = extractRestoreActionNames(envelope.methodName, envelope.payloadBytes); const publicEnvelope = { frameType, @@ -924,7 +1036,8 @@ var MajsoulHelperBundle = (() => { actionPayloadLength: actionName ? actionPayloadBytes.length : void 0, actionPayloadSample: actionName ? bytesToHex(actionPayloadBytes, BINARY_ENVELOPE_SAMPLE_BYTES) : void 0, actionPayloadTruncated: actionName ? actionPayloadBytes.length > BINARY_ENVELOPE_SAMPLE_BYTES : void 0, - actionPayloadFields + actionPayloadFields, + ...actionPrototype?.actionPayloadCodec ? { actionPayloadCodec: actionPrototype.actionPayloadCodec } : {} }; return { envelope: publicEnvelope, @@ -950,62 +1063,200 @@ var MajsoulHelperBundle = (() => { } return events; } - function parseDecodedMessage(data) { - const messages = Array.isArray(data) ? data : [data]; - const events = []; - for (const message of messages) { - const envelope = buildDecodedEnvelope(message); - if (!envelope) continue; - const type = toEventType(envelope.name); - if (!type) continue; - const payload = normalizePayload(type, envelope.payload, envelope.name); - events.push(...expandStandardEvents(type, { - ...payload, - binaryEnvelope: decodedBinaryEnvelope(envelope) - })); + function parseDecodedMessage(data) { + const messages = Array.isArray(data) ? data : [data]; + const events = []; + for (const message of messages) { + const envelope = buildDecodedEnvelope(message); + if (!envelope) continue; + const type = toEventType(envelope.name); + if (!type) continue; + const payload = normalizePayload(type, envelope.payload, envelope.name); + events.push(...expandStandardEvents(type, { + ...payload, + binaryEnvelope: decodedBinaryEnvelope(envelope) + })); + } + return events; + } + function parseBinaryMessage(data) { + const frame = parseBinaryFrame(data); + const envelope = frame?.envelope; + if (!envelope?.methodName) return []; + if (isGameRestoreMethod(envelope.methodName)) { + return parseGameRestoreMessage(envelope, frame.payloadBytes); + } + if (isGameRestoreCarrierMethod(envelope.methodName)) { + return parseGameRestoreCarrierMessage(envelope, frame.payloadBytes); + } + const actionName = envelope.actionName; + if (!actionName) return []; + const type = toEventType(actionName || envelope.methodName); + if (!type) return []; + const actionPayload = actionName ? decodeSimpleActionPayload(actionName, frame.actionPayloadBytes, { + allowWebglXor: envelope.methodName === ".lq.ActionPrototype" + }) : {}; + return expandStandardEvents(type, { + ...actionPayload, + binaryEnvelope: envelope + }); + } + + // src/core/events.js + var STANDARD_GAME_EVENT_TYPES = /* @__PURE__ */ new Set([ + "round_start", + "deal_hand", + "draw_tile", + "discard_tile", + "call_meld", + "riichi", + "dora", + "round_end" + ]); + function isStandardGameEvent(type) { + return STANDARD_GAME_EVENT_TYPES.has(type); + } + + // src/adapter/captureReplay.js + function hexToBytes(hex) { + if (!hex) return new Uint8Array(); + return new Uint8Array(hex.split(/\s+/).filter(Boolean).map((part) => Number.parseInt(part, 16))); + } + function summarizeCaptureEvents(events = []) { + const summary = { + totalEvents: events.length, + rawMessages: 0, + parsedEvents: 0, + diagnosticEvents: 0, + bySource: {}, + byKind: {}, + byMethodName: {}, + byActionName: {}, + byParsedType: {}, + byDiagnosticType: {}, + byUnparsedActionName: {} + }; + const rawActions = {}; + const parsedActions = {}; + for (const event of events) { + summary.bySource[event.source || "unknown"] = (summary.bySource[event.source || "unknown"] || 0) + 1; + if (event.type === "raw_message") { + summary.rawMessages += 1; + const payload = event.payload || {}; + const kind = payload.kind || "unknown"; + summary.byKind[kind] = (summary.byKind[kind] || 0) + 1; + const envelope = payloadEnvelope(payload); + const methodName = envelope?.methodName; + const actionNames = envelopeActionNames(envelope); + if (methodName) summary.byMethodName[methodName] = (summary.byMethodName[methodName] || 0) + 1; + for (const actionName of actionNames) { + summary.byActionName[actionName] = (summary.byActionName[actionName] || 0) + 1; + rawActions[actionName] = (rawActions[actionName] || 0) + 1; + } + } else if (isStandardGameEvent(event.type)) { + summary.parsedEvents += 1; + summary.byParsedType[event.type] = (summary.byParsedType[event.type] || 0) + 1; + const methodName = event.payload?.binaryEnvelope?.methodName; + const actionNames = [event.payload?.binaryEnvelope?.actionName].filter(Boolean); + if (methodName) summary.byMethodName[methodName] = (summary.byMethodName[methodName] || 0) + 1; + for (const actionName of actionNames) { + summary.byActionName[actionName] = (summary.byActionName[actionName] || 0) + 1; + parsedActions[actionName] = (parsedActions[actionName] || 0) + 1; + } + } else { + summary.diagnosticEvents += 1; + summary.byDiagnosticType[event.type || "unknown"] = (summary.byDiagnosticType[event.type || "unknown"] || 0) + 1; + } + } + for (const [actionName, count] of Object.entries(rawActions)) { + const missing = count - (parsedActions[actionName] || 0); + if (missing > 0) summary.byUnparsedActionName[actionName] = missing; + } + return summary; + } + function payloadEnvelope(payload = {}) { + if (payload.envelope) return payload.envelope; + if (!payload.sample || payload.kind === "text") return null; + try { + return parseBinaryEnvelope(hexToBytes(payload.sample)); + } catch { + return null; + } + } + function envelopeActionNames(envelope = {}) { + return [ + envelope?.actionName, + ...envelope?.restoreActionNames || [] + ].filter(Boolean); + } + + // src/core/config.js + var HELPER_CONFIG_KEY = "majsoul-helper-config"; + var HELPER_CONFIG_VERSION = 1; + var DEFAULT_BINARY_SAMPLE_BYTES = 65536; + var MIN_BINARY_SAMPLE_BYTES = 16; + var DEFAULT_MAX_EVENTS = 1e4; + var MAX_CAPTURE_EVENTS = 1e4; + function normalizeCaptureLimit(value, fallback = DEFAULT_MAX_EVENTS) { + return normalizeInteger(value, { fallback, min: 1, max: MAX_CAPTURE_EVENTS }); + } + function normalizeBinarySampleBytes(value, fallback = DEFAULT_BINARY_SAMPLE_BYTES) { + return normalizeInteger(value, { + fallback, + min: MIN_BINARY_SAMPLE_BYTES, + max: DEFAULT_BINARY_SAMPLE_BYTES + }); + } + function loadHelperConfig(storage = globalThis.localStorage) { + const stored = readStoredConfig(storage); + const config = migrateConfig(stored); + if (JSON.stringify(stored) !== JSON.stringify(config)) writeStoredConfig(storage, config); + return config; + } + function updateHelperConfig(patch, storage = globalThis.localStorage) { + const current = loadHelperConfig(storage); + const next = normalizeCurrentConfig({ ...current, ...patch, configVersion: HELPER_CONFIG_VERSION }); + writeStoredConfig(storage, next); + return next; + } + function migrateConfig(stored) { + if (stored.configVersion === HELPER_CONFIG_VERSION) return normalizeCurrentConfig(stored); + return normalizeCurrentConfig({ + ...stored, + configVersion: HELPER_CONFIG_VERSION, + captureLimit: Math.max(DEFAULT_MAX_EVENTS, normalizeCaptureLimit(stored.captureLimit)), + binarySampleBytes: Math.max(DEFAULT_BINARY_SAMPLE_BYTES, normalizeBinarySampleBytes(stored.binarySampleBytes)) + }); + } + function normalizeCurrentConfig(config) { + return { + ...config, + configVersion: HELPER_CONFIG_VERSION, + captureLimit: normalizeCaptureLimit(config.captureLimit), + binarySampleBytes: normalizeBinarySampleBytes(config.binarySampleBytes) + }; + } + function readStoredConfig(storage) { + try { + const parsed = JSON.parse(storage?.getItem?.(HELPER_CONFIG_KEY) || "{}"); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; } - return events; } - function parseBinaryMessage(data) { - const frame = parseBinaryFrame(data); - const envelope = frame?.envelope; - if (!envelope?.methodName) return []; - if (isGameRestoreMethod(envelope.methodName)) { - return parseGameRestoreMessage(envelope, frame.payloadBytes); - } - if (isGameRestoreCarrierMethod(envelope.methodName)) { - return parseGameRestoreCarrierMessage(envelope, frame.payloadBytes); + function writeStoredConfig(storage, config) { + try { + storage?.setItem?.(HELPER_CONFIG_KEY, JSON.stringify(config)); + } catch { } - const actionName = envelope.actionName; - if (!actionName) return []; - const type = toEventType(actionName || envelope.methodName); - if (!type) return []; - const actionPayload = actionName ? decodeSimpleActionPayload(actionName, frame.actionPayloadBytes) : {}; - return expandStandardEvents(type, { - ...actionPayload, - binaryEnvelope: envelope - }); } - - // src/core/events.js - var STANDARD_GAME_EVENT_TYPES = /* @__PURE__ */ new Set([ - "round_start", - "deal_hand", - "draw_tile", - "discard_tile", - "call_meld", - "riichi", - "dora", - "round_end" - ]); - function isStandardGameEvent(type) { - return STANDARD_GAME_EVENT_TYPES.has(type); + function normalizeInteger(value, { fallback, min, max }) { + const number = Number(value); + if (!Number.isFinite(number) || number <= 0) return fallback; + return Math.max(min, Math.min(max, Math.floor(number))); } // src/adapter/majsoulAdapter.js - var DEFAULT_BINARY_SAMPLE_BYTES = 65536; - var DEFAULT_MAX_EVENTS = 1e4; - var MAX_CAPTURE_EVENTS = 1e4; var RUNTIME_SHAPE_KEY_LIMIT = 40; var RUNTIME_SHAPE_ACCESSOR_LIMIT = 20; var SELF_TEST_DISCARD_SAMPLE = "01 0a 13 2e 6c 71 2e 41 63 74 69 6f 6e 50 72 6f 74 6f 74 79 70 65 12 1f 08 35 12 11 41 63 74 69 6f 6e 44 69 73 63 61 72 64 54 69 6c 65 1a 08 08 03 12 02 39 73 28 01"; @@ -2146,7 +2397,7 @@ var MajsoulHelperBundle = (() => { name: ".lq.ActionDealTile", data: { seat: 0, tile: "5m", leftTileCount: 55 } })); - const binaryBytes = hexToBytes(SELF_TEST_DISCARD_SAMPLE); + const binaryBytes = hexToBytes2(SELF_TEST_DISCARD_SAMPLE); const binaryEnvelope = parseBinaryEnvelope(binaryBytes); const binaryEvents = parseBinaryMessage(binaryBytes); const result = { @@ -2170,7 +2421,7 @@ var MajsoulHelperBundle = (() => { return result; } }; - function hexToBytes(hex) { + function hexToBytes2(hex) { if (!hex) return new Uint8Array(); return new Uint8Array(hex.split(/\s+/).filter(Boolean).map((part) => Number.parseInt(part, 16))); } @@ -2187,87 +2438,16 @@ var MajsoulHelperBundle = (() => { droppedBeforeRetained: oldestEventId === null ? 0 : Math.max(0, oldestEventId - 1) }; } - function summarizeCaptureEvents(events = []) { - const summary = { - totalEvents: events.length, - rawMessages: 0, - parsedEvents: 0, - diagnosticEvents: 0, - bySource: {}, - byKind: {}, - byMethodName: {}, - byActionName: {}, - byParsedType: {}, - byDiagnosticType: {}, - byUnparsedActionName: {} - }; - const rawActions = {}; - const parsedActions = {}; - for (const event of events) { - summary.bySource[event.source || "unknown"] = (summary.bySource[event.source || "unknown"] || 0) + 1; - if (event.type === "raw_message") { - summary.rawMessages += 1; - const payload = event.payload || {}; - const kind = payload.kind || "unknown"; - summary.byKind[kind] = (summary.byKind[kind] || 0) + 1; - const envelope = payloadEnvelope(payload); - const methodName = envelope?.methodName; - const actionNames = envelopeActionNames(envelope); - if (methodName) summary.byMethodName[methodName] = (summary.byMethodName[methodName] || 0) + 1; - for (const actionName of actionNames) { - summary.byActionName[actionName] = (summary.byActionName[actionName] || 0) + 1; - rawActions[actionName] = (rawActions[actionName] || 0) + 1; - } - } else if (isStandardGameEvent(event.type)) { - summary.parsedEvents += 1; - summary.byParsedType[event.type] = (summary.byParsedType[event.type] || 0) + 1; - const methodName = event.payload?.binaryEnvelope?.methodName; - const actionNames = [event.payload?.binaryEnvelope?.actionName].filter(Boolean); - if (methodName) summary.byMethodName[methodName] = (summary.byMethodName[methodName] || 0) + 1; - for (const actionName of actionNames) { - summary.byActionName[actionName] = (summary.byActionName[actionName] || 0) + 1; - parsedActions[actionName] = (parsedActions[actionName] || 0) + 1; - } - } else { - summary.diagnosticEvents += 1; - summary.byDiagnosticType[event.type || "unknown"] = (summary.byDiagnosticType[event.type || "unknown"] || 0) + 1; - } - } - for (const [actionName, count] of Object.entries(rawActions)) { - const missing = count - (parsedActions[actionName] || 0); - if (missing > 0) summary.byUnparsedActionName[actionName] = missing; - } - return summary; - } - function payloadEnvelope(payload = {}) { - if (payload.envelope) return payload.envelope; - if (!payload.sample || payload.kind === "text") return null; - try { - return parseBinaryEnvelope(hexToBytes(payload.sample)); - } catch { - return null; - } - } - function envelopeActionNames(envelope = {}) { - return [ - envelope?.actionName, - ...envelope?.restoreActionNames || [] - ].filter(Boolean); - } function normalizeLimit(limit, fallback, max = Infinity) { const value = Number(limit); if (!Number.isFinite(value) || value <= 0) return fallback; return Math.min(max, Math.max(1, Math.floor(value))); } function normalizeMaxEvents(value, fallback = DEFAULT_MAX_EVENTS) { - const number = Number(value); - if (!Number.isFinite(number) || number <= 0) return fallback; - return Math.max(1, Math.min(MAX_CAPTURE_EVENTS, Math.floor(number))); + return normalizeCaptureLimit(value, fallback); } function normalizeSampleBytes(value) { - const number = Number(value); - if (!Number.isFinite(number) || number <= 0) return DEFAULT_BINARY_SAMPLE_BYTES; - return Math.max(16, Math.min(DEFAULT_BINARY_SAMPLE_BYTES, Math.floor(number))); + return normalizeBinarySampleBytes(value); } // src/core/tile.js @@ -2367,6 +2547,8 @@ var MajsoulHelperBundle = (() => { round: null, chang: null, ju: null, + selfSeat: null, + selfSeatRequired: false, honba: 0, riichiSticks: 0, seatWind: null, @@ -2382,6 +2564,7 @@ var MajsoulHelperBundle = (() => { partialStateWarnings: [], events: [] }; + var MISSING_ROUND_START_WARNING = "partial live state: action step reset without decoded round_start"; var GameState = class { constructor() { this.state = structuredClone(INITIAL_STATE); @@ -2395,17 +2578,26 @@ var MajsoulHelperBundle = (() => { applyEvent(event) { if (!event || !event.type) return; if (!isStandardGameEvent(event.type)) return; - if (event.type !== "raw_message") { - this.state.events = [event, ...this.state.events].slice(0, 100); - } const payload = event.payload || {}; - if (payload.binaryEnvelope?.step !== void 0) { - this.state.lastStep = payload.binaryEnvelope.step; + const nextStep = normalizeActionStep(payload.binaryEnvelope?.step); + const previousStep = normalizeActionStep(this.state.lastStep); + const eventHistory = [event, ...this.state.events].slice(0, 100); + if (event.type !== "round_start" && nextStep !== null && previousStep !== null && nextStep < previousStep) { + this.reset({ + lastStep: nextStep, + partialStateWarnings: [MISSING_ROUND_START_WARNING], + events: eventHistory + }); + } else { + this.state.events = eventHistory; + if (nextStep !== null) this.state.lastStep = nextStep; } switch (event.type) { case "round_start": { const nextStateDiagnostics = { invalidTiles: [] }; const hand = sanitizeTiles(payload.tiles ?? [], nextStateDiagnostics, "round_start.tiles"); + const explicitSelfSeat = normalizeSeat(payload.selfSeat); + const selfSeat = explicitSelfSeat ?? inferDealerSelfSeat(hand, payload.ju); this.reset({ round: payload.round ?? null, chang: normalizeRoundIndex(payload.chang), @@ -2413,9 +2605,11 @@ var MajsoulHelperBundle = (() => { honba: payload.honba ?? 0, riichiSticks: payload.riichiSticks ?? 0, roundWind: payload.roundWind ?? roundWindFromChang(payload.chang) ?? null, - seatWind: payload.seatWind ?? seatWindFromJu(payload.ju) ?? null, + selfSeat, + selfSeatRequired: selfSeat === null && hand.length > 0 && isActionNewRoundPayload(payload), + seatWind: payload.seatWind ?? seatWindFromSeats(selfSeat, payload.ju) ?? null, scores: payload.scores ?? [25e3, 25e3, 25e3, 25e3], - scoresKnown: Array.isArray(payload.scores) && payload.scores.length > 0, + scoresKnown: Array.isArray(payload.scores) && payload.scores.length >= 3, hand, handKnown: hand.length > 0, melds: sanitizeSeatMelds(payload.melds, nextStateDiagnostics, "round_start.melds"), @@ -2433,21 +2627,24 @@ var MajsoulHelperBundle = (() => { case "deal_hand": this.state.hand = sanitizeTiles(payload.tiles || [], this.state, "deal_hand.tiles"); this.state.handKnown = this.state.hand.length > 0; + if (normalizeSeat(payload.selfSeat) !== void 0) { + this.state.selfSeat = normalizeSeat(payload.selfSeat); + this.state.selfSeatRequired = false; + if (!this.state.seatWind) this.state.seatWind = seatWindFromSeats(this.state.selfSeat, this.state.ju); + } this.state.drawnTile = null; break; case "draw_tile": { + const seat = normalizeSeat(payload.seat); + this.state.leftTileCount = payload.leftTileCount ?? this.state.leftTileCount; + mergeDoraIndicatorList(this.state, payload.doraIndicators); + if (seat === void 0) break; + this.state.currentTurn = seat; if (!payload.tile) break; const tile = sanitizeTile(payload.tile, this.state, "draw_tile.tile"); if (!tile) break; - if (normalizeSeat(payload.seat) === void 0) { - mergeDoraIndicatorList(this.state, payload.doraIndicators); - this.state.leftTileCount = payload.leftTileCount ?? this.state.leftTileCount; - break; - } - if (normalizeSeat(payload.seat) === 0) this.state.drawnTile = tile; - this.state.currentTurn = normalizeSeat(payload.seat); - this.state.leftTileCount = payload.leftTileCount ?? this.state.leftTileCount; - mergeDoraIndicatorList(this.state, payload.doraIndicators); + inferSelfSeatFromPrivateDraw(this.state, payload, seat); + if (seat === this.state.selfSeat) this.state.drawnTile = tile; break; } case "discard_tile": { @@ -2464,7 +2661,7 @@ var MajsoulHelperBundle = (() => { this.state.riichi[normalizeSeat(payload.seat)] = true; } mergeDoraIndicatorList(this.state, payload.doraIndicators); - if (normalizeSeat(payload.seat) === 0) { + if (normalizeSeat(payload.seat) === this.state.selfSeat) { if (this.state.drawnTile && tileToIndex(this.state.drawnTile) === tileToIndex(tile)) { this.state.drawnTile = null; } else { @@ -2506,14 +2703,14 @@ var MajsoulHelperBundle = (() => { } if (isChiPengGangAction(actionName)) { const claimedTile = removeClaimedDiscard(this.state, meld, callerSeat); - if (callerSeat === 0) { + if (callerSeat === this.state.selfSeat) { removeOwnMeldTiles(this.state, meld, claimedTile); } } - if (isAnGangAddGangAction(actionName) && callerSeat === 0) { + if (isAnGangAddGangAction(actionName) && callerSeat === this.state.selfSeat) { removeOwnKnownTiles(this.state, meld); } - if (isBaBei && callerSeat === 0) { + if (isBaBei && callerSeat === this.state.selfSeat) { removeOwnKnownTile(this.state, "4z"); } break; @@ -2566,22 +2763,43 @@ var MajsoulHelperBundle = (() => { const number = Number(value); return Number.isInteger(number) && number >= 0 ? number : null; } + function normalizeActionStep(value) { + if (value === void 0 || value === null) return null; + const number = Number(value); + return Number.isInteger(number) && number >= 0 ? number : null; + } function roundWindFromChang(chang) { const winds = ["E", "S", "W", "N"]; const index = normalizeRoundIndex(chang); return index === null ? null : winds[index] ?? null; } - function seatWindFromJu(ju) { + function seatWindFromSeats(selfSeat, ju) { const winds = ["E", "S", "W", "N"]; + const playerSeat = normalizeSeat(selfSeat); const dealerSeat = normalizeRoundIndex(ju); - if (dealerSeat === null || dealerSeat > 3) return null; - return winds[(4 - dealerSeat) % 4]; + if (playerSeat === void 0 || dealerSeat === null || dealerSeat > 3) return null; + return winds[(playerSeat - dealerSeat + 4) % 4]; } function normalizeSeat(seat) { if (seat === void 0 || seat === null) return void 0; const value = Number(seat); return Number.isInteger(value) && value >= 0 && value <= 3 ? value : void 0; } + function inferSelfSeatFromPrivateDraw(state, payload, seat) { + if (state.selfSeat !== null || !isPrivateDrawPayload(payload)) return; + state.selfSeat = seat; + state.selfSeatRequired = false; + if (!state.seatWind) state.seatWind = seatWindFromSeats(seat, state.ju); + } + function inferDealerSelfSeat(hand, ju) { + const dealerSeat = normalizeSeat(ju); + return hand.length === 14 && dealerSeat !== void 0 ? dealerSeat : null; + } + function isPrivateDrawPayload(payload = {}) { + if (payload.isSelf === true) return true; + const actionName = payload.binaryEnvelope?.actionName; + return Boolean(payload.tile) && !String(actionName || "").startsWith("Record"); + } function normalizeMeld(meld, state) { if (!meld) return []; return sanitizeTiles(Array.isArray(meld) ? meld : [meld], state, "call_meld.meld"); @@ -2741,6 +2959,9 @@ var MajsoulHelperBundle = (() => { if (state.drawnTile && !state.hand.length) { warnings.push("drawnTile exists without base hand"); } + if (state.selfSeatRequired) { + warnings.push("self seat is unknown; own draw/discard tracking is not reliable yet"); + } for (const warning of state.partialStateWarnings || []) { warnings.push(warning); } @@ -2929,48 +3150,348 @@ var MajsoulHelperBundle = (() => { ukeireTypes: ukeireTiles.length }; } - - // src/core/analyzer.js - function removeOneTile(tiles, discard) { - const discardIndex = tileToIndex(discard); - const result = []; - let removed = false; - for (const tile of tiles) { - if (!removed && tileToIndex(tile) === discardIndex) { - removed = true; + + // src/core/analyzer.js + function removeOneTile(tiles, discard) { + const discardIndex = tileToIndex(discard); + const result = []; + let removed = false; + for (const tile of tiles) { + if (!removed && tileToIndex(tile) === discardIndex) { + removed = true; + continue; + } + result.push(tile); + } + return result; + } + function analyzeHand({ hand = [], drawnTile = null, visibleTiles = [], openMelds = 0 } = {}) { + const combined = sortTiles(drawnTile ? [...hand, drawnTile] : hand); + assertKnownTileLimit([...combined, ...visibleTiles || []]); + const shanten = calculateShanten(combined, { openMelds }); + const canDiscard = combined.length % 3 === 2; + if (!canDiscard) { + return { hand: combined, openMelds, shanten, canDiscard, candidates: [] }; + } + const counts = tilesToCounts(combined); + const candidates = []; + for (let index = 0; index < 34; index += 1) { + if (counts[index] === 0) continue; + const discard = indexToTile(index); + const afterDiscard = removeOneTile(combined, discard); + const discardVisible = [...visibleTiles, discard]; + const ukeire = calculateUkeire(afterDiscard, discardVisible, { openMelds }); + candidates.push({ + discard, + shantenAfterDiscard: ukeire.shanten, + ukeireTiles: ukeire.ukeireTiles, + ukeireBreakdown: ukeire.ukeireBreakdown, + ukeireCount: ukeire.ukeireCount, + ukeireTypes: ukeire.ukeireTypes + }); + } + candidates.sort((a, b) => a.shantenAfterDiscard - b.shantenAfterDiscard || b.ukeireCount - a.ukeireCount || b.ukeireTypes - a.ukeireTypes || tileToIndex(a.discard) - tileToIndex(b.discard)); + return { hand: combined, openMelds, shanten, canDiscard, candidates }; + } + + // src/core/version.js + var HELPER_VERSION = "0.3.0"; + + // src/core/eventDiagnostics.js + function isValidSeat(seat) { + const value = Number(seat); + return Number.isInteger(value) && value >= 0 && value <= 3; + } + function isValidTile(tile) { + try { + normalizeTile(tile); + return true; + } catch { + return false; + } + } + function orderEventsChronologically(events = []) { + const entries = [...events]; + if (entries.every((event) => Number.isFinite(Number(event?.eventId)))) { + return entries.sort((left, right) => Number(left.eventId) - Number(right.eventId)); + } + if (entries.every((event) => Number.isFinite(Number(event?.ts)))) { + return entries.sort((left, right) => Number(left.ts) - Number(right.ts)); + } + return entries; + } + function inferExpectedCurrentTurn(events = []) { + let expectedCurrentTurn = null; + for (const event of orderEventsChronologically(events)) { + if (event.type === "round_start" && isValidSeat(event.payload?.currentTurn)) { + expectedCurrentTurn = Number(event.payload.currentTurn); + } else if (event.type === "draw_tile" && isValidSeat(event.payload?.seat)) { + expectedCurrentTurn = Number(event.payload.seat); + } else if (event.type === "discard_tile" && isValidSeat(event.payload?.seat)) { + expectedCurrentTurn = null; + } else if (event.type === "call_meld" && isValidSeat(event.payload?.seat)) { + expectedCurrentTurn = Number(event.payload.seat); + } else if (event.type === "round_end") { + expectedCurrentTurn = null; + } + } + return expectedCurrentTurn; + } + function buildGameStateDiagnostics(events = [], gameState = {}) { + const orderedEvents = orderEventsChronologically(events); + const chiPengGangEvents = events.filter((event) => isChiPengGangAction2(event.payload?.binaryEnvelope?.actionName)); + const anGangAddGangEvents = events.filter((event) => isAnGangAddGangAction2(event.payload?.binaryEnvelope?.actionName)); + const callMeldEvents = events.filter((event) => event.type === "call_meld"); + const callMeldEventsWithSeat = callMeldEvents.filter((event) => isValidSeat(event.payload?.seat)); + const anGangAddGangEventsWithSeat = anGangAddGangEvents.filter((event) => isValidSeat(event.payload?.seat)); + const closedKanEvents = anGangAddGangEvents.filter((event) => Number(event.payload?.type) === 3); + const addedKanEvents = anGangAddGangEvents.filter((event) => Number(event.payload?.type) === 2); + const unknownKanTypeEvents = anGangAddGangEvents.filter((event) => ![2, 3].includes(Number(event.payload?.type))); + const selfSeat = isValidSeat(gameState.selfSeat) ? Number(gameState.selfSeat) : null; + const ownAnGangAddGangEvents = selfSeat === null ? [] : anGangAddGangEvents.filter((event) => Number(event.payload?.seat) === selfSeat); + const kanMeldMismatches = buildKanMeldMismatches(gameState, anGangAddGangEvents); + const ownKanTilesStillInHand = buildOwnKanTilesStillInHand(gameState, ownAnGangAddGangEvents); + const riichiEvents = events.filter((event) => event.type === "riichi"); + const riichiEventsWithSeat = riichiEvents.filter((event) => isValidSeat(event.payload?.seat)); + const roundEndEvents = events.filter((event) => event.type === "round_end"); + const roundEndEventsWithScores = roundEndEvents.filter((event) => event.payload?.scores?.length); + const ownDrawTileEvents = selfSeat === null ? [] : events.filter((event) => event.type === "draw_tile" && Number(event.payload?.seat) === selfSeat); + const ownDrawTileEventsWithValidTile = ownDrawTileEvents.filter((event) => isValidTile(event.payload?.tile)); + const meldCount = (gameState.melds || []).reduce((sum, melds) => sum + melds.length, 0); + const knownTileCounts = countKnownTiles(gameState); + const eventCounts = countEventTypes(events); + const expectedCurrentTurn = inferExpectedCurrentTurn(orderedEvents); + const currentTurnMatchesExpected = expectedCurrentTurn === null ? null : Number(gameState.currentTurn) === expectedCurrentTurn; + const expectedDiscards = [[], [], [], []]; + let claimableChiPengGangEvents = 0; + for (const event of orderedEvents) { + if (event.type === "discard_tile" && isValidSeat(event.payload?.seat) && isValidTile(event.payload?.tile)) { + expectedDiscards[Number(event.payload.seat)].push(normalizeTile(event.payload.tile)); + } + if (isChiPengGangAction2(event.payload?.binaryEnvelope?.actionName)) { + const removed = removeLatestClaimedDiscard( + expectedDiscards, + normalizeMeld2(event.payload?.meld), + Number(event.payload?.seat) + ); + if (removed) claimableChiPengGangEvents += 1; + } + } + const claimedDiscardTransferred = chiPengGangEvents.length ? discardsEqual(expectedDiscards, gameState.discards || []) : null; + return { + eventCounts, + stateUpdated: { + hand: Boolean(gameState.handKnown && gameState.hand?.length), + drawnTile: Boolean(gameState.drawnTile) || ownDrawTileEventsWithValidTile.length > 0, + discards: Boolean(gameState.discards?.some((tiles) => tiles.length)), + melds: meldCount > 0, + doraIndicators: Boolean(gameState.doraIndicators?.length), + roundMetadata: gameState.chang !== null || gameState.ju !== null || gameState.round !== null, + riichi: Boolean(gameState.riichi?.some(Boolean)), + roundEndReason: gameState.roundEndReason !== null, + currentTurn: currentTurnMatchesExpected, + scores: Boolean(gameState.scoresKnown || gameState.scores?.some((score) => score !== 25e3)), + visibleTiles: Boolean(gameState.visibleTiles?.length), + warningsClear: !gameState.warnings?.length + }, + selfSeat, + callMeldEvents: callMeldEvents.length, + callMeldEventsWithSeat: callMeldEventsWithSeat.length, + riichiEvents: riichiEvents.length, + riichiEventsWithSeat: riichiEventsWithSeat.length, + roundEndEvents: roundEndEvents.length, + roundEndEventsWithScores: roundEndEventsWithScores.length, + ownDrawTileEvents: ownDrawTileEvents.length, + ownDrawTileEventsWithValidTile: ownDrawTileEventsWithValidTile.length, + drawnTileRetained: Boolean(gameState.drawnTile), + chiPengGangEvents: chiPengGangEvents.length, + claimableChiPengGangEvents, + anGangAddGangEvents: anGangAddGangEvents.length, + anGangAddGangEventsWithSeat: anGangAddGangEventsWithSeat.length, + closedKanEvents: closedKanEvents.length, + addedKanEvents: addedKanEvents.length, + unknownKanTypeEvents: unknownKanTypeEvents.length, + ownAnGangAddGangEvents: ownAnGangAddGangEvents.length, + kanTypeKnown: anGangAddGangEvents.length ? unknownKanTypeEvents.length === 0 : null, + kanMeldTileCountsOk: anGangAddGangEvents.length ? kanMeldMismatches.length === 0 : null, + closedKanVisibleTileCountsOk: closedKanEvents.length ? kanMeldMismatches.every((entry) => Number(entry.type) !== 3) : null, + addedKanVisibleTileCountsOk: addedKanEvents.length ? kanMeldMismatches.every((entry) => Number(entry.type) !== 2) : null, + ownKanTilesRemoved: ownAnGangAddGangEvents.length ? ownKanTilesStillInHand.length === 0 : null, + kanMeldMismatches, + ownKanTilesStillInHand, + meldCount, + invalidTiles: gameState.invalidTiles || [], + overKnownTileLimit: knownTileCounts.filter((entry) => entry.count > 4), + expectedCurrentTurn, + currentTurnMatchesExpected, + claimedDiscardTransferred + }; + } + function buildMvpGate({ + events = [], + gameState = {}, + transport = {}, + liveStateComparison = null, + stateDiagnostics = null + } = {}) { + const eventTypes = new Set(events.map((event) => event.type)); + const diagnostics = stateDiagnostics || buildGameStateDiagnostics(events, gameState); + const checks = { + rawMessagesCaptured: Number(transport.rawMessages || 0) > 0, + inboundRawMessagesCaptured: Number(transport.inboundRawMessages || 0) > 0, + binaryEnvelopeDecoded: Number(transport.binaryEnvelopes || 0) > 0, + actionPrototypeDecoded: Number(transport.actionMessages || 0) > 0, + drawTileParsed: eventTypes.has("draw_tile"), + drawTileSeatParsed: hasEventWithValidSeat(events, "draw_tile"), + discardTileParsed: eventTypes.has("discard_tile"), + discardTileSeatParsed: hasEventWithValidSeat(events, "discard_tile"), + gameStateHandUpdated: diagnostics.stateUpdated.hand, + gameStateRoundMetadataUpdated: diagnostics.stateUpdated.roundMetadata, + gameStateDrawnTileUpdated: diagnostics.stateUpdated.drawnTile, + gameStateDiscardsUpdated: diagnostics.stateUpdated.discards, + gameStateDoraIndicatorsUpdated: diagnostics.stateUpdated.doraIndicators, + gameStateScoresUpdated: diagnostics.stateUpdated.scores, + gameStateVisibleTilesUpdated: diagnostics.stateUpdated.visibleTiles, + gameStateWarningsClear: diagnostics.stateUpdated.warningsClear + }; + if (eventTypes.has("call_meld")) { + checks.callMeldSeatParsed = diagnostics.callMeldEventsWithSeat === diagnostics.callMeldEvents; + checks.gameStateMeldsUpdated = diagnostics.stateUpdated.melds; + } + if (diagnostics.claimableChiPengGangEvents > 0) { + checks.claimedDiscardTransferred = diagnostics.claimedDiscardTransferred === true; + } + if (diagnostics.anGangAddGangEvents > 0) { + checks.anGangAddGangSeatParsed = diagnostics.anGangAddGangEventsWithSeat === diagnostics.anGangAddGangEvents; + checks.kanTypeKnown = diagnostics.kanTypeKnown === true; + checks.kanMeldTileCountsOk = diagnostics.kanMeldTileCountsOk === true; + } + if (diagnostics.ownAnGangAddGangEvents > 0) { + checks.ownKanTilesRemoved = diagnostics.ownKanTilesRemoved === true; + } + if (diagnostics.expectedCurrentTurn !== null) { + checks.gameStateCurrentTurnUpdated = diagnostics.currentTurnMatchesExpected === true; + } + if (eventTypes.has("riichi")) { + checks.riichiSeatParsed = diagnostics.riichiEventsWithSeat === diagnostics.riichiEvents; + checks.gameStateRiichiUpdated = diagnostics.stateUpdated.riichi; + } + if (eventTypes.has("round_end")) { + checks.roundEndReasonUpdated = diagnostics.stateUpdated.roundEndReason; + const scores = latestRoundEndScores(events); + if (scores) checks.roundEndScoresUpdated = JSON.stringify(gameState.scores) === JSON.stringify(scores); + } + if (liveStateComparison?.available) { + checks.liveStateSnapshotMatches = liveStateComparison.mismatches.length === 0; + } + const entries = Object.entries(checks); + const missing = entries.filter(([, passed]) => !passed).map(([name]) => name); + return { + ready: missing.length === 0, + readyForRealPageMvp: missing.length === 0, + checks, + passed: entries.length - missing.length, + total: entries.length, + missing, + diagnostics + }; + } + function hasEventWithValidSeat(events, type) { + return events.some((event) => event.type === type && isValidSeat(event.payload?.seat)); + } + function isChiPengGangAction2(actionName) { + return actionName === "ActionChiPengGang" || actionName === "RecordChiPengGang"; + } + function isAnGangAddGangAction2(actionName) { + return actionName === "ActionAnGangAddGang" || actionName === "RecordAnGangAddGang"; + } + function normalizeMeld2(meld) { + const entries = meld ? Array.isArray(meld) ? meld : [meld] : []; + return entries.filter(isValidTile).map((tile) => normalizeTile(tile)); + } + function latestRoundEndScores(events) { + const entries = orderEventsChronologically(events); + for (let index = entries.length - 1; index >= 0; index -= 1) { + const scores = entries[index].type === "round_end" ? entries[index].payload?.scores : null; + if (scores?.length) return scores; + } + return null; + } + function buildKanMeldMismatches(gameState, events) { + const mismatches = []; + for (const event of events) { + const seat = Number(event.payload?.seat); + const tile = eventKanTile(event); + if (!isValidSeat(seat) || !tile) { + mismatches.push({ + seat: isValidSeat(seat) ? seat : null, + type: event.payload?.type ?? null, + tile, + expectedCopies: 4, + actualCopies: 0, + reason: "missing valid seat or tile" + }); continue; } - result.push(tile); + const actualCopies = maxMeldCopies(gameState, seat, tile); + if (actualCopies < 4) { + mismatches.push({ + seat, + type: event.payload?.type ?? null, + tile, + expectedCopies: 4, + actualCopies, + reason: "no four-tile kan meld in final state" + }); + } } - return result; + return mismatches; } - function analyzeHand({ hand = [], drawnTile = null, visibleTiles = [], openMelds = 0 } = {}) { - const combined = sortTiles(drawnTile ? [...hand, drawnTile] : hand); - assertKnownTileLimit([...combined, ...visibleTiles || []]); - const shanten = calculateShanten(combined, { openMelds }); - const canDiscard = combined.length % 3 === 2; - if (!canDiscard) { - return { hand: combined, openMelds, shanten, canDiscard, candidates: [] }; + function buildOwnKanTilesStillInHand(gameState, events) { + const counts = {}; + for (const tile of [...gameState.hand || [], gameState.drawnTile].filter(isValidTile).map(normalizeTile)) { + counts[tile] = (counts[tile] || 0) + 1; } - const counts = tilesToCounts(combined); - const candidates = []; - for (let index = 0; index < 34; index += 1) { - if (counts[index] === 0) continue; - const discard = indexToTile(index); - const afterDiscard = removeOneTile(combined, discard); - const discardVisible = [...visibleTiles, discard]; - const ukeire = calculateUkeire(afterDiscard, discardVisible, { openMelds }); - candidates.push({ - discard, - shantenAfterDiscard: ukeire.shanten, - ukeireTiles: ukeire.ukeireTiles, - ukeireBreakdown: ukeire.ukeireBreakdown, - ukeireCount: ukeire.ukeireCount, - ukeireTypes: ukeire.ukeireTypes - }); + return events.map(eventKanTile).filter(Boolean).filter((tile, index, tiles) => tiles.indexOf(tile) === index).filter((tile) => counts[tile] > 0).map((tile) => ({ tile, count: counts[tile] })); + } + function eventKanTile(event) { + return normalizeMeld2(event.payload?.meld)[0] || null; + } + function maxMeldCopies(gameState, seat, tile) { + return Math.max(0, ...(gameState.melds?.[seat] || []).map((meld) => normalizeMeld2(meld).filter((meldTile) => meldTile === tile).length)); + } + function removeLatestClaimedDiscard(discards, meld, callerSeat) { + if (!isValidSeat(callerSeat) || !meld.length) return false; + const claimedTiles = new Set(meld); + for (let seat = 0; seat < discards.length; seat += 1) { + if (seat === callerSeat) continue; + const river = discards[seat]; + const lastTile = river[river.length - 1]; + if (lastTile && claimedTiles.has(lastTile)) { + river.pop(); + return true; + } } - candidates.sort((a, b) => a.shantenAfterDiscard - b.shantenAfterDiscard || b.ukeireCount - a.ukeireCount || b.ukeireTypes - a.ukeireTypes || tileToIndex(a.discard) - tileToIndex(b.discard)); - return { hand: combined, openMelds, shanten, canDiscard, candidates }; + return false; + } + function discardsEqual(left, right) { + return left.length === right.length && left.every((river, seat) => { + const normalizedRight = (right[seat] || []).filter(isValidTile).map(normalizeTile); + return river.length === normalizedRight.length && river.every((tile, index) => tile === normalizedRight[index]); + }); + } + function countEventTypes(events) { + const counts = {}; + for (const event of events) counts[event.type] = (counts[event.type] || 0) + 1; + return counts; + } + function countKnownTiles(gameState) { + const knownTiles = [ + ...gameState.hand || [], + gameState.drawnTile, + ...gameState.visibleTiles || [] + ].filter(isValidTile); + return tilesToCounts(knownTiles).map((count, index) => ({ tile: indexToTile(index), count })); } // src/core/realPageReadiness.js @@ -3183,11 +3704,8 @@ var MajsoulHelperBundle = (() => { `; // src/ui/overlay.js - var STORAGE_KEY = "majsoul-helper-config"; var OVERLAY_CAPTURE_NOTE = "Majsoul Helper capture export. Contains message summaries/samples plus liveGameState, liveDebugSummary, liveMvpGate, liveSafetySettings, and liveRealPagePreflight snapshots copied from the overlay; no messages were modified by the helper."; - var DEFAULT_BINARY_SAMPLE_BYTES2 = 65536; - var DEFAULT_CAPTURE_LIMIT = 1e4; - var MAX_CAPTURE_LIMIT = 1e4; + var CAPTURE_DOWNLOAD_REVOKE_DELAY_MS = 6e4; var OVERLAY_EVENT_SHIELD_TYPES = [ "pointerdown", "pointerup", @@ -3275,30 +3793,6 @@ var MajsoulHelperBundle = (() => { sanitizedUrl: `${origin}${pathname}` }; } - function readConfig() { - try { - return JSON.parse(window.localStorage.getItem(STORAGE_KEY) || "{}"); - } catch { - return {}; - } - } - function writeConfig(patch) { - try { - const next = { ...readConfig(), ...patch }; - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); - } catch { - } - } - function normalizeCaptureLimit(value, fallback = DEFAULT_CAPTURE_LIMIT) { - const number = Number(value); - if (!Number.isFinite(number) || number <= 0) return fallback; - return Math.max(1, Math.min(MAX_CAPTURE_LIMIT, Math.floor(number))); - } - function normalizeBinarySampleBytes(value, fallback = DEFAULT_BINARY_SAMPLE_BYTES2) { - const number = Number(value); - if (!Number.isFinite(number) || number <= 0) return fallback; - return Math.max(16, Math.min(DEFAULT_BINARY_SAMPLE_BYTES2, Math.floor(number))); - } function renderSeatState(state) { return [0, 1, 2, 3].map((seat) => { const discards = state.discards?.[seat] || []; @@ -3306,10 +3800,11 @@ var MajsoulHelperBundle = (() => { const score = state.scores?.[seat] ?? "-"; const riichi = state.riichi?.[seat] ? " riichi" : ""; const turn = state.currentTurn === seat ? " turn" : ""; + const self = state.selfSeat === seat ? " self" : ""; const meldText = melds.length ? melds.map((meld) => (meld || []).join(" ")).join(" / ") : "-"; return `
-
Seat ${seat}${turn}${riichi} / ${escapeHtml(score)}
+
Seat ${seat}${self}${turn}${riichi} / ${escapeHtml(score)}
River
${discards.length ? renderTiles(discards) : `-`}
Melds: ${escapeHtml(meldText)}
@@ -3556,229 +4051,19 @@ var MajsoulHelperBundle = (() => { } return "Standard game events parsed. Compare gameState with the visible table."; } - function isValidSeat(seat) { - const value = Number(seat); - return Number.isInteger(value) && value >= 0 && value <= 3; - } - function isValidTile(tile) { - try { - normalizeTile(tile); - return true; - } catch { - return false; - } - } - function hasEventWithValidSeat(events, type) { - return events.some((event) => event.type === type && isValidSeat(event.payload?.seat)); - } - function hasOwnDrawTileWithValidTile(events) { - return events.some((event) => event.type === "draw_tile" && Number(event.payload?.seat) === 0 && isValidTile(event.payload?.tile)); - } - function stableJson(value) { - return JSON.stringify(value); - } - function isChiPengGangAction2(actionName) { - return actionName === "ActionChiPengGang" || actionName === "RecordChiPengGang"; - } - function isAnGangAddGangAction2(actionName) { - return actionName === "ActionAnGangAddGang" || actionName === "RecordAnGangAddGang"; - } - function normalizeMeld2(meld) { - if (!meld) return []; - return Array.isArray(meld) ? meld : [meld]; - } - function chronologicalEvents(events) { - const entries = [...events || []]; - if (entries.every((event) => Number.isFinite(Number(event.ts)))) { - return entries.sort((left, right) => Number(left.ts) - Number(right.ts)); - } - return entries.reverse(); - } - function removeLatestClaimedDiscard(discards, meld, callerSeat) { - if (!isValidSeat(callerSeat) || !meld.length) return false; - for (let seat = 0; seat < discards.length; seat += 1) { - if (seat === callerSeat) continue; - const river = discards[seat]; - const lastTile = river[river.length - 1]; - if (lastTile && meld.includes(lastTile)) { - river.pop(); - return true; - } - } - return false; - } - function discardsEqual(left, right) { - return left.length === right.length && left.every((river, index) => river.length === (right[index] || []).length && river.every((tile, tileIndex) => tile === right[index][tileIndex])); - } - function buildClaimedDiscardDiagnostics(events, state) { - const expectedDiscards = [[], [], [], []]; - const chiPengGangEvents = (events || []).filter((event) => isChiPengGangAction2(event.payload?.binaryEnvelope?.actionName)); - let claimableChiPengGangEvents = 0; - for (const event of chronologicalEvents(events)) { - if (event.type === "discard_tile" && isValidSeat(event.payload?.seat) && event.payload?.tile) { - expectedDiscards[Number(event.payload.seat)].push(event.payload.tile); - } - if (isChiPengGangAction2(event.payload?.binaryEnvelope?.actionName)) { - const removed = removeLatestClaimedDiscard( - expectedDiscards, - normalizeMeld2(event.payload?.meld), - Number(event.payload?.seat) - ); - if (removed) claimableChiPengGangEvents += 1; - } - } - return { - chiPengGangEvents: chiPengGangEvents.length, - claimableChiPengGangEvents, - claimedDiscardTransferred: chiPengGangEvents.length ? discardsEqual(expectedDiscards, state.discards || []) : null - }; - } - function buildKanDiagnostics(events, state) { - const anGangAddGangEvents = (events || []).filter((event) => isAnGangAddGangAction2(event.payload?.binaryEnvelope?.actionName)); - const anGangAddGangEventsWithSeat = anGangAddGangEvents.filter((event) => isValidSeat(event.payload?.seat)); - const unknownKanTypeEvents = anGangAddGangEvents.filter((event) => { - const type = Number(event.payload?.type); - return type !== 2 && type !== 3; - }); - const ownAnGangAddGangEvents = anGangAddGangEvents.filter((event) => Number(event.payload?.seat) === 0); - const kanMeldMismatches = buildKanMeldMismatches(state, anGangAddGangEvents); - const ownKanTilesStillInHand = buildOwnKanTilesStillInHand(state, ownAnGangAddGangEvents); - return { - anGangAddGangEvents: anGangAddGangEvents.length, - anGangAddGangEventsWithSeat: anGangAddGangEventsWithSeat.length, - unknownKanTypeEvents: unknownKanTypeEvents.length, - ownAnGangAddGangEvents: ownAnGangAddGangEvents.length, - kanTypeKnown: anGangAddGangEvents.length ? unknownKanTypeEvents.length === 0 : null, - kanMeldTileCountsOk: anGangAddGangEvents.length ? kanMeldMismatches.length === 0 : null, - ownKanTilesRemoved: ownAnGangAddGangEvents.length ? ownKanTilesStillInHand.length === 0 : null - }; - } - function buildKanMeldMismatches(state, events) { - const mismatches = []; - for (const event of events) { - const seat = Number(event.payload?.seat); - const tile = eventKanTile(event); - const actualCopies = isValidSeat(seat) && tile ? maxMeldCopies(state, seat, tile) : 0; - if (actualCopies < 4) { - mismatches.push({ seat, tile, actualCopies }); - } - } - return mismatches; - } - function buildOwnKanTilesStillInHand(state, events) { - const ownKnownTiles = [ - ...state.hand || [], - state.drawnTile - ].filter(Boolean).filter(isValidTile).map((tile) => normalizeTile(tile)); - const counts = {}; - for (const tile of ownKnownTiles) { - counts[tile] = (counts[tile] || 0) + 1; - } - return events.map((event) => eventKanTile(event)).filter(Boolean).filter((tile, index, tiles) => tiles.indexOf(tile) === index).filter((tile) => counts[tile] > 0).map((tile) => ({ tile, count: counts[tile] })); - } - function eventKanTile(event) { - const normalizedTiles = normalizeMeld2(event.payload?.meld).filter(isValidTile).map((tile) => normalizeTile(tile)); - return normalizedTiles[0] || null; - } - function maxMeldCopies(state, seat, tile) { - let maxCopies = 0; - for (const meld of state.melds?.[seat] || []) { - const copies = normalizeMeld2(meld).filter(isValidTile).map((meldTile) => normalizeTile(meldTile)).filter((meldTile) => meldTile === tile).length; - maxCopies = Math.max(maxCopies, copies); - } - return maxCopies; - } - function latestRoundEndScores(events) { - const entries = chronologicalEvents(events); - for (let index = entries.length - 1; index >= 0; index -= 1) { - const event = entries[index]; - if (event.type === "round_end" && event.payload?.scores?.length) { - return event.payload.scores; - } - } - return null; - } - function inferExpectedCurrentTurn(events) { - let expectedCurrentTurn = null; - for (const event of chronologicalEvents(events)) { - if (event.type === "round_start" && isValidSeat(event.payload?.currentTurn)) { - expectedCurrentTurn = Number(event.payload.currentTurn); - } - if (event.type === "draw_tile" && isValidSeat(event.payload?.seat)) { - expectedCurrentTurn = Number(event.payload.seat); - } - if (event.type === "discard_tile" && isValidSeat(event.payload?.seat)) { - expectedCurrentTurn = null; - } - if (event.type === "call_meld" && isValidSeat(event.payload?.seat)) { - expectedCurrentTurn = Number(event.payload.seat); - } - if (event.type === "round_end") { - expectedCurrentTurn = null; - } - } - return expectedCurrentTurn; - } function buildLiveMvpGate(events, state, summary) { - const eventTypes = events.map((event) => event.type); const rawEvents = events.filter((event) => event.type === "raw_message"); - const claimedDiscardDiagnostics = buildClaimedDiscardDiagnostics(events, state); - const kanDiagnostics = buildKanDiagnostics(events, state); - const expectedCurrentTurn = inferExpectedCurrentTurn(events); - const checks = { - rawMessagesCaptured: summary.raw > 0, - inboundRawMessagesCaptured: summary.inbound > 0, - binaryEnvelopeDecoded: summary.envelopes > 0, - actionPrototypeDecoded: rawEvents.some((event) => envelopeActionNames2(event.payload?.envelope).length > 0), - drawTileParsed: events.some((event) => event.type === "draw_tile"), - drawTileSeatParsed: hasEventWithValidSeat(events, "draw_tile"), - discardTileParsed: events.some((event) => event.type === "discard_tile"), - discardTileSeatParsed: hasEventWithValidSeat(events, "discard_tile"), - gameStateHandUpdated: Boolean(state.handKnown && state.hand?.length), - gameStateRoundMetadataUpdated: state.chang !== null || state.ju !== null || state.round !== null, - gameStateDrawnTileUpdated: Boolean(state.drawnTile) || hasOwnDrawTileWithValidTile(events), - gameStateDiscardsUpdated: Boolean(state.discards?.some((tiles) => tiles.length)), - gameStateDoraIndicatorsUpdated: Boolean(state.doraIndicators?.length), - gameStateScoresUpdated: Boolean(state.scoresKnown || state.scores?.some((score) => score !== 25e3)), - gameStateVisibleTilesUpdated: Boolean(state.visibleTiles?.length), - gameStateWarningsClear: !state.warnings?.length - }; - if (eventTypes.includes("call_meld")) { - checks.callMeldSeatParsed = hasEventWithValidSeat(events, "call_meld"); - checks.gameStateMeldsUpdated = Boolean(state.melds?.some((melds) => melds.length)); - } - if (claimedDiscardDiagnostics.claimableChiPengGangEvents > 0) { - checks.claimedDiscardTransferred = claimedDiscardDiagnostics.claimedDiscardTransferred === true; - } - if (kanDiagnostics.anGangAddGangEvents > 0) { - checks.anGangAddGangSeatParsed = kanDiagnostics.anGangAddGangEventsWithSeat === kanDiagnostics.anGangAddGangEvents; - checks.kanTypeKnown = kanDiagnostics.kanTypeKnown === true; - checks.kanMeldTileCountsOk = kanDiagnostics.kanMeldTileCountsOk === true; - } - if (kanDiagnostics.ownAnGangAddGangEvents > 0) { - checks.ownKanTilesRemoved = kanDiagnostics.ownKanTilesRemoved === true; - } - if (expectedCurrentTurn !== null) { - checks.gameStateCurrentTurnUpdated = Number(state.currentTurn) === expectedCurrentTurn; - } - if (eventTypes.includes("riichi")) { - checks.riichiSeatParsed = hasEventWithValidSeat(events, "riichi"); - checks.gameStateRiichiUpdated = Boolean(state.riichi?.some(Boolean)); - } - if (eventTypes.includes("round_end")) { - checks.roundEndReasonUpdated = state.roundEndReason !== null; - const roundEndScores = latestRoundEndScores(events); - if (roundEndScores) { - checks.roundEndScoresUpdated = stableJson(state.scores) === stableJson(roundEndScores); + const { diagnostics: _diagnostics, ...gate } = buildMvpGate({ + events, + gameState: state, + transport: { + rawMessages: summary.raw, + inboundRawMessages: summary.inbound, + binaryEnvelopes: summary.envelopes, + actionMessages: rawEvents.filter((event) => envelopeActionNames2(event.payload?.envelope).length > 0).length } - } - const entries = Object.entries(checks); - return { - checks, - passed: entries.filter(([, value]) => value).length, - total: entries.length, - missing: entries.filter(([, value]) => !value).map(([key]) => key) - }; + }); + return gate; } function renderLiveMvpGate(gate) { const ready = gate.passed === gate.total; @@ -3819,21 +4104,17 @@ var MajsoulHelperBundle = (() => { this.realtimeAdvice = false; this.captureLimitDraft = null; this.binarySampleBytesDraft = null; - const config = readConfig(); - const storedCaptureLimit = normalizeCaptureLimit(config.captureLimit ?? DEFAULT_CAPTURE_LIMIT, DEFAULT_CAPTURE_LIMIT); - const adapterCaptureLimit = normalizeCaptureLimit(adapter.maxEvents ?? DEFAULT_CAPTURE_LIMIT, DEFAULT_CAPTURE_LIMIT); - this.captureLimit = Math.max(DEFAULT_CAPTURE_LIMIT, storedCaptureLimit, adapterCaptureLimit); + const config = loadHelperConfig(window.localStorage); + this.captureLimit = normalizeCaptureLimit(config.captureLimit, adapter.maxEvents ?? DEFAULT_MAX_EVENTS); if (typeof adapter.setMaxEvents === "function") { this.captureLimit = adapter.setMaxEvents(this.captureLimit); } - const storedBinarySampleBytes = normalizeBinarySampleBytes(config.binarySampleBytes ?? DEFAULT_BINARY_SAMPLE_BYTES2, DEFAULT_BINARY_SAMPLE_BYTES2); - const adapterBinarySampleBytes = normalizeBinarySampleBytes(adapter.binarySampleBytes ?? DEFAULT_BINARY_SAMPLE_BYTES2, DEFAULT_BINARY_SAMPLE_BYTES2); - this.binarySampleBytes = Math.max(DEFAULT_BINARY_SAMPLE_BYTES2, storedBinarySampleBytes, adapterBinarySampleBytes); + this.binarySampleBytes = normalizeBinarySampleBytes(config.binarySampleBytes, adapter.binarySampleBytes ?? DEFAULT_BINARY_SAMPLE_BYTES); if (typeof adapter.setBinarySampleBytes === "function") { this.binarySampleBytes = adapter.setBinarySampleBytes(this.binarySampleBytes); } if (config.captureLimit !== this.captureLimit || config.binarySampleBytes !== this.binarySampleBytes) { - writeConfig({ + updateHelperConfig({ captureLimit: this.captureLimit, binarySampleBytes: this.binarySampleBytes }); @@ -3841,21 +4122,39 @@ var MajsoulHelperBundle = (() => { this.copyError = ""; this.copyFallbackText = ""; this.downloadUrl = ""; + this.downloadRevokeTimer = null; this.selfTestResult = null; this.root = null; + this.style = null; + this.adapterListeners = []; this.overlayEventShieldBound = false; } mount() { - if (this.root) return; - const style = document.createElement("style"); - style.textContent = overlayStyles; - document.documentElement.appendChild(style); + if (this.root) return this; + document.getElementById("majsoul-helper-overlay-style")?.remove(); + this.style = document.createElement("style"); + this.style.id = "majsoul-helper-overlay-style"; + this.style.textContent = overlayStyles; + document.documentElement.appendChild(this.style); this.root = document.createElement("div"); this.root.id = "majsoul-helper-overlay"; document.documentElement.appendChild(this.root); this.bindOverlayEventShield(); this.bindAdapter(); this.render(); + return this; + } + destroy() { + for (const { type, listener } of this.adapterListeners) { + this.adapter.removeEventListener(type, listener); + } + this.adapterListeners = []; + this.releaseCaptureDownloadUrl(); + this.root?.remove(); + this.style?.remove(); + this.root = null; + this.style = null; + this.overlayEventShieldBound = false; } bindOverlayEventShield() { if (this.overlayEventShieldBound || !this.root) return; @@ -3868,18 +4167,19 @@ var MajsoulHelperBundle = (() => { this.overlayEventShieldBound = true; } bindAdapter() { - this.adapter.addEventListener("majsoul-helper:event", (event) => { + if (this.adapterListeners.length) return; + this.listenToAdapter("majsoul-helper:event", (event) => { this.gameState.applyEvent(event.detail); this.render(); }); - this.adapter.addEventListener("majsoul-helper:clear", () => { + this.listenToAdapter("majsoul-helper:clear", () => { this.gameState.clearEvents(); this.render(); }); - this.adapter.addEventListener("majsoul-helper:install", () => { + this.listenToAdapter("majsoul-helper:install", () => { this.render(); }); - this.adapter.addEventListener("majsoul-helper:config", () => { + this.listenToAdapter("majsoul-helper:config", () => { if (this.captureLimitDraft === null && Number.isFinite(this.adapter.maxEvents)) { this.captureLimit = this.adapter.maxEvents; } @@ -3888,14 +4188,18 @@ var MajsoulHelperBundle = (() => { } this.render(); }); - this.adapter.addEventListener("majsoul-helper:socket", () => { + this.listenToAdapter("majsoul-helper:socket", () => { this.render(); }); - this.adapter.addEventListener("majsoul-helper:self-test", (event) => { + this.listenToAdapter("majsoul-helper:self-test", (event) => { this.selfTestResult = event.detail; this.render(); }); } + listenToAdapter(type, listener) { + this.adapter.addEventListener(type, listener); + this.adapterListeners.push({ type, listener }); + } render() { if (!this.root) return; const focusSnapshot = this.captureFocusSnapshot(); @@ -3905,7 +4209,7 @@ var MajsoulHelperBundle = (() => { const handForAnalysis = usingManualInput ? this.manualTiles : state.hand; const drawnTileForAnalysis = usingManualInput ? null : state.drawnTile; const visibleTilesForAnalysis = usingManualInput ? [] : state.visibleTiles || []; - const openMeldsForAnalysis = usingManualInput ? 0 : state.melds?.[0]?.length || 0; + const openMeldsForAnalysis = usingManualInput || state.selfSeat === null ? 0 : state.melds?.[state.selfSeat]?.length || 0; const shouldAnalyze = hasValidManualTiles || !usingManualInput && this.realtimeAdvice; const analysisResult = shouldAnalyze && handForAnalysis.length ? safeAnalyzeHand({ hand: handForAnalysis, drawnTile: drawnTileForAnalysis, visibleTiles: visibleTilesForAnalysis, openMelds: openMeldsForAnalysis }) : { analysis: null, error: "" }; const analysis = analysisResult.analysis; @@ -3931,7 +4235,7 @@ var MajsoulHelperBundle = (() => { liveSafetySettings }); const captureLimitValue = this.captureLimitDraft ?? this.captureLimit; - const binarySampleValue = this.binarySampleBytesDraft ?? this.binarySampleBytes ?? installDiagnostics.binarySampleBytes ?? DEFAULT_BINARY_SAMPLE_BYTES2; + const binarySampleValue = this.binarySampleBytesDraft ?? this.binarySampleBytes ?? installDiagnostics.binarySampleBytes ?? DEFAULT_BINARY_SAMPLE_BYTES; this.root.innerHTML = `
Majsoul Helper${helperVersion ? ` v${escapeHtml(helperVersion)}` : ""}
@@ -3982,7 +4286,7 @@ var MajsoulHelperBundle = (() => {
- Download capture +
@@ -4066,8 +4370,8 @@ var MajsoulHelperBundle = (() => { this.root.querySelector('[data-action="copy-capture"]').onclick = async () => { await this.copyText(safeJson(this.buildOverlayCapture())); }; - this.root.querySelector('[data-action="download-capture"]').onclick = (event) => { - if (!this.prepareCaptureDownload(event.currentTarget)) event.preventDefault(); + this.root.querySelector('[data-action="download-capture"]').onclick = () => { + this.downloadCapture(); }; this.root.querySelector('[data-action="clear-debug"]').onclick = () => { this.adapter.clearEvents(); @@ -4160,18 +4464,18 @@ var MajsoulHelperBundle = (() => { if (typeof this.adapter.setMaxEvents === "function") { this.captureLimit = this.adapter.setMaxEvents(nextValue); } - writeConfig({ captureLimit: this.captureLimit }); + updateHelperConfig({ captureLimit: this.captureLimit }); this.render(); } commitBinarySampleBytes(value) { - const nextValue = normalizeBinarySampleBytes(value, this.binarySampleBytes ?? DEFAULT_BINARY_SAMPLE_BYTES2); + const nextValue = normalizeBinarySampleBytes(value, this.binarySampleBytes ?? DEFAULT_BINARY_SAMPLE_BYTES); this.binarySampleBytesDraft = null; if (typeof this.adapter.setBinarySampleBytes === "function") { this.binarySampleBytes = this.adapter.setBinarySampleBytes(nextValue); } else { this.binarySampleBytes = nextValue; } - writeConfig({ binarySampleBytes: this.binarySampleBytes }); + updateHelperConfig({ binarySampleBytes: this.binarySampleBytes }); this.render(); } captureFocusSnapshot() { @@ -4266,7 +4570,7 @@ var MajsoulHelperBundle = (() => { liveCaptureHealth: captureHealth(this.adapter, liveDebugSummary, installDiagnostics, liveGameState) }; } - prepareCaptureDownload(link) { + downloadCapture() { const text = safeJson(this.buildOverlayCapture()); if (typeof Blob === "undefined" || !globalThis.URL?.createObjectURL) { this.copyError = "Capture download is unavailable in this browser context. Select and copy the text below."; @@ -4274,17 +4578,32 @@ var MajsoulHelperBundle = (() => { this.render(); return false; } - if (this.downloadUrl && globalThis.URL?.revokeObjectURL) { - globalThis.URL.revokeObjectURL(this.downloadUrl); - } + this.releaseCaptureDownloadUrl(); const blob = new Blob([text], { type: "application/json" }); this.downloadUrl = globalThis.URL.createObjectURL(blob); - link.href = this.downloadUrl; - link.download = `majsoul-helper-capture-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.json`; + const downloadLink = document.createElement("a"); + downloadLink.href = this.downloadUrl; + downloadLink.download = `majsoul-helper-capture-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.json`; this.copyError = ""; this.copyFallbackText = ""; + downloadLink.click(); + const downloadUrl = this.downloadUrl; + this.downloadRevokeTimer = globalThis.setTimeout(() => { + this.releaseCaptureDownloadUrl(downloadUrl); + }, CAPTURE_DOWNLOAD_REVOKE_DELAY_MS); return true; } + releaseCaptureDownloadUrl(expectedUrl = this.downloadUrl) { + if (!expectedUrl || expectedUrl !== this.downloadUrl) return; + if (this.downloadRevokeTimer !== null) { + globalThis.clearTimeout(this.downloadRevokeTimer); + this.downloadRevokeTimer = null; + } + if (globalThis.URL?.revokeObjectURL) { + globalThis.URL.revokeObjectURL(expectedUrl); + } + this.downloadUrl = ""; + } enableDrag() { const header = this.root.querySelector(".mh-header"); let start = null; @@ -4314,24 +4633,14 @@ var MajsoulHelperBundle = (() => { }; // src/main.js - var STORAGE_KEY2 = "majsoul-helper-config"; - var HELPER_VERSION = "0.2.13"; - function upgradedStoredNumber(value, fallback) { - const number = Number(value); - if (!Number.isFinite(number) || number <= 0) return fallback; - return Math.max(fallback, Math.floor(number)); - } - function readConfig2() { - try { - return JSON.parse(window.localStorage.getItem(STORAGE_KEY2) || "{}"); - } catch { - return {}; - } - } function boot() { const existingHelper = window.__majsoulHelper; if (existingHelper?.version === HELPER_VERSION) return existingHelper; if (existingHelper?.version) { + try { + existingHelper.overlay?.destroy?.(); + } catch { + } try { existingHelper.adapter?.uninstall?.(); } catch { @@ -4342,11 +4651,11 @@ var MajsoulHelperBundle = (() => { } catch { } } - const config = readConfig2(); + const config = loadHelperConfig(window.localStorage); const adapter = new MajsoulAdapter({ helperVersion: HELPER_VERSION, - binarySampleBytes: upgradedStoredNumber(config.binarySampleBytes, DEFAULT_BINARY_SAMPLE_BYTES), - maxEvents: upgradedStoredNumber(config.captureLimit, DEFAULT_MAX_EVENTS) + binarySampleBytes: config.binarySampleBytes, + maxEvents: config.captureLimit }); const gameState = new GameState(); const helper = { diff --git a/package-lock.json b/package-lock.json index 40d36d8..efeeb6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,19 @@ { "name": "majsoul-helper", - "version": "0.2.13", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "majsoul-helper", - "version": "0.2.13", + "version": "0.3.0", "devDependencies": { + "esbuild": "^0.28.1", "jsdom": "^29.1.1", - "vitest": "^1.6.1" + "vitest": "^4.1.10" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" } }, "node_modules/@asamuzakjp/css-color": { @@ -218,10 +222,21 @@ "node": ">=20.19.0" } }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -232,13 +247,13 @@ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -249,13 +264,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -266,13 +281,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -283,13 +298,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -300,13 +315,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -317,13 +332,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -334,13 +349,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -351,13 +366,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -368,13 +383,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -385,13 +400,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -402,13 +417,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -419,13 +434,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -436,13 +451,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -453,13 +468,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -470,13 +485,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -487,13 +502,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -504,13 +519,30 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -521,13 +553,30 @@ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -538,13 +587,30 @@ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -555,13 +621,13 @@ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -572,13 +638,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -589,13 +655,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -606,7 +672,7 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@exodus/bytes": { @@ -627,44 +693,46 @@ } } }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmmirror.com/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.27.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", - "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", - "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", - "cpu": [ - "arm" - ], + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "android" - ] + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", - "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", "cpu": [ "arm64" ], @@ -673,12 +741,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", - "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", "cpu": [ "arm64" ], @@ -687,12 +758,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", - "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", "cpu": [ "x64" ], @@ -701,26 +775,15 @@ "optional": true, "os": [ "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", - "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", - "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", "cpu": [ "x64" ], @@ -729,26 +792,15 @@ "optional": true, "os": [ "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", - "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", - "cpu": [ - "arm" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", - "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", "cpu": [ "arm" ], @@ -757,26 +809,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", - "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", - "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", "cpu": [ "arm64" ], @@ -785,54 +826,32 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", - "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", - "cpu": [ - "loong64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", - "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", "cpu": [ - "loong64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", - "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", - "cpu": [ - "ppc64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", - "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", "cpu": [ "ppc64" ], @@ -841,110 +860,125 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", - "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", "cpu": [ - "riscv64" + "s390x" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", - "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", "cpu": [ - "riscv64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", - "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", "cpu": [ - "s390x" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", - "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" - ] + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", - "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", "cpu": [ - "x64" + "wasm32" ], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", - "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", - "cpu": [ - "x64" - ], + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "openbsd" - ] + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", - "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", - "cpu": [ - "arm64" - ], + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "openharmony" - ] + "dependencies": { + "tslib": "^2.4.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", - "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", "cpu": [ "arm64" ], @@ -953,185 +987,199 @@ "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", - "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", "cpu": [ - "ia32" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", - "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", - "cpu": [ - "x64" ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "tslib": "^2.4.0" + } }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", - "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", - "cpu": [ - "x64" - ], + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } }, - "node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmmirror.com/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.9", - "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, "node_modules/@vitest/expect": { - "version": "1.6.1", - "resolved": "https://registry.npmmirror.com/@vitest/expect/-/expect-1.6.1.tgz", - "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "1.6.1", - "@vitest/utils": "1.6.1", - "chai": "^4.3.10" + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/runner": { - "version": "1.6.1", - "resolved": "https://registry.npmmirror.com/@vitest/runner/-/runner-1.6.1.tgz", - "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "1.6.1", - "p-limit": "^5.0.0", - "pathe": "^1.1.1" + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" - } + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } }, - "node_modules/@vitest/snapshot": { - "version": "1.6.1", - "resolved": "https://registry.npmmirror.com/@vitest/snapshot/-/snapshot-1.6.1.tgz", - "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", "dev": true, "license": "MIT", "dependencies": { - "magic-string": "^0.30.5", - "pathe": "^1.1.1", - "pretty-format": "^29.7.0" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/spy": { - "version": "1.6.1", - "resolved": "https://registry.npmmirror.com/@vitest/spy/-/spy-1.6.1.tgz", - "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", "dev": true, "license": "MIT", "dependencies": { - "tinyspy": "^2.2.0" + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/utils": { - "version": "1.6.1", - "resolved": "https://registry.npmmirror.com/@vitest/utils/-/utils-1.6.1.tgz", - "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", "dev": true, "license": "MIT", "dependencies": { - "diff-sequences": "^29.6.3", - "estree-walker": "^3.0.3", - "loupe": "^2.3.7", - "pretty-format": "^29.7.0" + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", "dev": true, "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/acorn-walk": { - "version": "8.3.5", - "resolved": "https://registry.npmmirror.com/acorn-walk/-/acorn-walk-8.3.5.tgz", - "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", "dev": true, "license": "MIT", "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://opencollective.com/vitest" } }, "node_modules/assertion-error": { - "version": "1.1.0", - "resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-1.1.0.tgz", - "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", "engines": { - "node": "*" + "node": ">=12" } }, "node_modules/bidi-js": { @@ -1144,70 +1192,23 @@ "require-from-string": "^2.0.2" } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmmirror.com/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/chai": { - "version": "4.5.0", - "resolved": "https://registry.npmmirror.com/chai/-/chai-4.5.0.tgz", - "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^1.1.0", - "check-error": "^1.0.3", - "deep-eql": "^4.1.3", - "get-func-name": "^2.0.2", - "loupe": "^2.3.6", - "pathval": "^1.1.1", - "type-detect": "^4.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/check-error": { - "version": "1.0.3", - "resolved": "https://registry.npmmirror.com/check-error/-/check-error-1.0.3.tgz", - "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "dependencies": { - "get-func-name": "^2.0.2" - }, "engines": { - "node": "*" + "node": ">=18" } }, - "node_modules/confbox": { - "version": "0.1.8", - "resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz", - "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, "license": "MIT" }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmmirror.com/css-tree/-/css-tree-3.2.1.tgz", @@ -1236,24 +1237,6 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmmirror.com/decimal.js/-/decimal.js-10.6.0.tgz", @@ -1261,27 +1244,14 @@ "dev": true, "license": "MIT" }, - "node_modules/deep-eql": { - "version": "4.1.4", - "resolved": "https://registry.npmmirror.com/deep-eql/-/deep-eql-4.1.4.tgz", - "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-detect": "^4.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/diff-sequences": { - "version": "29.6.3", - "resolved": "https://registry.npmmirror.com/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=8" } }, "node_modules/entities": { @@ -1297,48 +1267,59 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/estree-walker": { "version": "3.0.3", - "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", @@ -1346,33 +1327,37 @@ "@types/estree": "^1.0.0" } }, - "node_modules/execa": { - "version": "8.0.1", - "resolved": "https://registry.npmmirror.com/execa/-/execa-8.0.1.tgz", - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" - }, "engines": { - "node": ">=16.17" + "node": ">=12.0.0" }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, "node_modules/fsevents": { "version": "2.3.3", - "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "hasInstallScript": true, @@ -1385,29 +1370,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/get-func-name": { - "version": "2.0.2", - "resolved": "https://registry.npmmirror.com/get-func-name/-/get-func-name-2.0.2.tgz", - "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmmirror.com/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -1421,16 +1383,6 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/human-signals": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/human-signals/-/human-signals-5.0.0.tgz", - "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=16.17.0" - } - }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -1438,33 +1390,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, "node_modules/jsdom": { "version": "29.1.1", "resolved": "https://registry.npmmirror.com/jsdom/-/jsdom-29.1.1.tgz", @@ -1507,185 +1432,325 @@ } } }, - "node_modules/local-pkg": { - "version": "0.5.1", - "resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-0.5.1.tgz", - "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "dev": true, - "license": "MIT", + "license": "MPL-2.0", "dependencies": { - "mlly": "^1.7.3", - "pkg-types": "^1.2.1" + "detect-libc": "^2.0.3" }, "engines": { - "node": ">=14" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/loupe": { - "version": "2.3.7", - "resolved": "https://registry.npmmirror.com/loupe/-/loupe-2.3.7.tgz", - "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "get-func-name": "^2.0.1" + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/lru-cache": { - "version": "11.5.0", - "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.5.0.tgz", - "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "BlueOak-1.0.0", + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "20 || >=22" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/mdn-data": { - "version": "2.27.1", - "resolved": "https://registry.npmmirror.com/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/mlly": { - "version": "1.8.2", - "resolved": "https://registry.npmmirror.com/mlly/-/mlly-1.8.2.tgz", - "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.16.0", - "pathe": "^2.0.3", - "pkg-types": "^1.3.1", - "ufo": "^1.6.3" + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/mlly/node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/npm-run-path": { - "version": "5.3.0", - "resolved": "https://registry.npmmirror.com/npm-run-path/-/npm-run-path-5.3.0.tgz", - "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^4.0.0" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/npm-run-path/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=12" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmmirror.com/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "node_modules/lru-cache": { + "version": "11.5.0", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { - "mimic-fn": "^4.0.0" + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmmirror.com/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/p-limit": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-5.0.0.tgz", - "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], "license": "MIT", - "dependencies": { - "yocto-queue": "^1.0.0" - }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=12.20.0" } }, "node_modules/parse5": { @@ -1701,63 +1766,38 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmmirror.com/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, - "node_modules/pathval": { - "version": "1.1.1", - "resolved": "https://registry.npmmirror.com/pathval/-/pathval-1.1.1.tgz", - "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/picocolors": { "version": "1.1.1", - "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, "license": "ISC" }, - "node_modules/pkg-types": { - "version": "1.3.1", - "resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-1.3.1.tgz", - "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", - "dependencies": { - "confbox": "^0.1.8", - "mlly": "^1.7.4", - "pathe": "^2.0.1" + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pkg-types/node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", "dev": true, "funding": [ { @@ -1783,21 +1823,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", @@ -1808,13 +1833,6 @@ "node": ">=6" } }, - "node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz", @@ -1825,57 +1843,39 @@ "node": ">=0.10.0" } }, - "node_modules/rollup": { - "version": "4.60.4", - "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.60.4.tgz", - "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.4", - "@rollup/rollup-android-arm64": "4.60.4", - "@rollup/rollup-darwin-arm64": "4.60.4", - "@rollup/rollup-darwin-x64": "4.60.4", - "@rollup/rollup-freebsd-arm64": "4.60.4", - "@rollup/rollup-freebsd-x64": "4.60.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", - "@rollup/rollup-linux-arm-musleabihf": "4.60.4", - "@rollup/rollup-linux-arm64-gnu": "4.60.4", - "@rollup/rollup-linux-arm64-musl": "4.60.4", - "@rollup/rollup-linux-loong64-gnu": "4.60.4", - "@rollup/rollup-linux-loong64-musl": "4.60.4", - "@rollup/rollup-linux-ppc64-gnu": "4.60.4", - "@rollup/rollup-linux-ppc64-musl": "4.60.4", - "@rollup/rollup-linux-riscv64-gnu": "4.60.4", - "@rollup/rollup-linux-riscv64-musl": "4.60.4", - "@rollup/rollup-linux-s390x-gnu": "4.60.4", - "@rollup/rollup-linux-x64-gnu": "4.60.4", - "@rollup/rollup-linux-x64-musl": "4.60.4", - "@rollup/rollup-openbsd-x64": "4.60.4", - "@rollup/rollup-openharmony-arm64": "4.60.4", - "@rollup/rollup-win32-arm64-msvc": "4.60.4", - "@rollup/rollup-win32-ia32-msvc": "4.60.4", - "@rollup/rollup-win32-x64-gnu": "4.60.4", - "@rollup/rollup-win32-x64-msvc": "4.60.4", - "fsevents": "~2.3.2" - } - }, - "node_modules/rollup/node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } }, "node_modules/saxes": { "version": "6.0.0", @@ -1890,29 +1890,6 @@ "node": ">=v12.22.7" } }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz", @@ -1920,19 +1897,6 @@ "dev": true, "license": "ISC" }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1951,38 +1915,12 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmmirror.com/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", "dev": true, "license": "MIT" }, - "node_modules/strip-final-newline": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strip-literal": { - "version": "2.1.1", - "resolved": "https://registry.npmmirror.com/strip-literal/-/strip-literal-2.1.1.tgz", - "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmmirror.com/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -1997,20 +1935,37 @@ "dev": true, "license": "MIT" }, - "node_modules/tinypool": { - "version": "0.8.4", - "resolved": "https://registry.npmmirror.com/tinypool/-/tinypool-0.8.4.tgz", - "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", "dev": true, "license": "MIT", "engines": { - "node": ">=14.0.0" + "node": ">=18" } }, - "node_modules/tinyspy": { - "version": "2.2.1", - "resolved": "https://registry.npmmirror.com/tinyspy/-/tinyspy-2.2.1.tgz", - "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -2063,27 +2018,18 @@ "node": ">=20" } }, - "node_modules/type-detect": { - "version": "4.1.0", - "resolved": "https://registry.npmmirror.com/type-detect/-/type-detect-4.1.0.tgz", - "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/ufo": { - "version": "1.6.4", - "resolved": "https://registry.npmmirror.com/ufo/-/ufo-1.6.4.tgz", - "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "MIT" + "license": "0BSD", + "optional": true }, "node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmmirror.com/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": { @@ -2091,21 +2037,24 @@ } }, "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -2114,23 +2063,33 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, - "less": { + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { "optional": true }, - "lightningcss": { + "jiti": { + "optional": true + }, + "less": { "optional": true }, "sass": { @@ -2147,85 +2106,89 @@ }, "terser": { "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true } } }, - "node_modules/vite-node": { - "version": "1.6.1", - "resolved": "https://registry.npmmirror.com/vite-node/-/vite-node-1.6.1.tgz", - "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.3.4", - "pathe": "^1.1.1", - "picocolors": "^1.0.0", - "vite": "^5.0.0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/vitest": { - "version": "1.6.1", - "resolved": "https://registry.npmmirror.com/vitest/-/vitest-1.6.1.tgz", - "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "1.6.1", - "@vitest/runner": "1.6.1", - "@vitest/snapshot": "1.6.1", - "@vitest/spy": "1.6.1", - "@vitest/utils": "1.6.1", - "acorn-walk": "^8.3.2", - "chai": "^4.3.10", - "debug": "^4.3.4", - "execa": "^8.0.1", - "local-pkg": "^0.5.0", - "magic-string": "^0.30.5", - "pathe": "^1.1.1", - "picocolors": "^1.0.0", - "std-env": "^3.5.0", - "strip-literal": "^2.0.0", - "tinybench": "^2.5.1", - "tinypool": "^0.8.3", - "vite": "^5.0.0", - "vite-node": "1.6.1", - "why-is-node-running": "^2.2.2" + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "1.6.1", - "@vitest/ui": "1.6.1", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { "optional": true }, + "@opentelemetry/api": { + "optional": true + }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { "optional": true }, "@vitest/ui": { @@ -2236,6 +2199,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, @@ -2287,22 +2253,6 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -2336,19 +2286,6 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true, "license": "MIT" - }, - "node_modules/yocto-queue": { - "version": "1.2.2", - "resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-1.2.2.tgz", - "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } } } } diff --git a/package.json b/package.json index 6fa6fc4..3c11fa3 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,11 @@ { "name": "majsoul-helper", - "version": "0.2.13", + "version": "0.3.0", "private": true, "type": "module", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, "scripts": { "audit": "node scripts/goal-audit.mjs", "build": "node scripts/build-userscript.mjs", @@ -12,12 +15,13 @@ "real-page-gate": "node scripts/real-page-gate.mjs", "replay": "node scripts/replay-capture.mjs", "smoke": "node scripts/smoke-check.mjs", - "test": "vitest run", + "test": "node scripts/run-tests.mjs", "validate-captures": "node scripts/validate-captures.mjs", "verify": "node scripts/verify.mjs" }, "devDependencies": { + "esbuild": "^0.28.1", "jsdom": "^29.1.1", - "vitest": "^1.6.1" + "vitest": "^4.1.10" } } diff --git a/scripts/build-userscript.mjs b/scripts/build-userscript.mjs index e981078..6c4851b 100644 --- a/scripts/build-userscript.mjs +++ b/scripts/build-userscript.mjs @@ -1,12 +1,16 @@ import { build } from "esbuild"; -import { writeFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; +import { HELPER_VERSION } from "../src/core/version.js"; -const version = "0.2.13"; +const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")); +if (packageJson.version !== HELPER_VERSION) { + throw new Error(`Version mismatch: package.json=${packageJson.version}, source=${HELPER_VERSION}`); +} const banner = `// ==UserScript== // @name Majsoul Helper MVP // @namespace https://local.majsoul-helper/ -// @version ${version} +// @version ${HELPER_VERSION} // @description Visible-state/debug helper for Mahjong Soul. No auto discard, no click automation, no message mutation. // @match *://*.mahjongsoul.com/* // @match *://mahjongsoul.game.yo-star.com/* diff --git a/scripts/goal-audit.mjs b/scripts/goal-audit.mjs index 5b5a2ba..9502219 100644 --- a/scripts/goal-audit.mjs +++ b/scripts/goal-audit.mjs @@ -70,6 +70,7 @@ function buildAuditItems(validation) { const overlay = readIfExists("src/ui/overlay.js"); const gameState = readIfExists("src/core/gameState.js"); const adapter = readIfExists("src/adapter/majsoulAdapter.js"); + const captureReplay = readIfExists("src/adapter/captureReplay.js"); const adapterTest = readIfExists("tests/majsoulAdapter.test.js"); const parser = readIfExists("src/adapter/messageParser.js"); const safetyTest = readIfExists("tests/safetyBoundaries.test.js"); @@ -80,14 +81,19 @@ function buildAuditItems(validation) { const realPageGate = readIfExists("scripts/real-page-gate.mjs"); const srcFiles = [ + "src/adapter/captureReplay.js", "src/adapter/majsoulAdapter.js", "src/adapter/messageParser.js", + "src/adapter/protobuf.js", + "src/core/config.js", + "src/core/eventDiagnostics.js", "src/core/gameState.js", "src/core/realPageReadiness.js", "src/core/tile.js", "src/core/shanten.js", "src/core/ukeire.js", "src/core/analyzer.js", + "src/core/version.js", "src/ui/overlay.js", "src/ui/styles.js" ]; @@ -190,11 +196,12 @@ function buildAuditItems(validation) { "patchedAddEventListener", "onmessage", "raw_message", - "helperDiagnostics", + "helperDiagnostics" + ]) && includesAll(captureReplay, [ "summarizeCaptureEvents", "replayCaptureWithDiagnostics" ]) && includesAll(parser, ["parseBinaryEnvelope", "ActionPrototype"]) && existsSync(join(repoRoot, "tests/majsoulAdapter.test.js")), - evidence: ["src/adapter/majsoulAdapter.js", "src/adapter/messageParser.js", "tests/majsoulAdapter.test.js"], + evidence: ["src/adapter/majsoulAdapter.js", "src/adapter/captureReplay.js", "src/adapter/messageParser.js", "tests/majsoulAdapter.test.js"], missing: ["WebSocket hooks, raw capture, parser, diagnostics, or adapter tests"] }), item({ diff --git a/scripts/import-capture.mjs b/scripts/import-capture.mjs index 1b0dc37..db05d91 100644 --- a/scripts/import-capture.mjs +++ b/scripts/import-capture.mjs @@ -200,15 +200,9 @@ function buildImportWarnings(capture) { function isTruncatedRawEvent(event) { if (event?.type !== "raw_message") return false; - const payload = event.payload || {}; - const envelope = payload.envelope || {}; - return Boolean( - payload.truncated - || envelope.payloadTruncated - || envelope.actionPayloadTruncated - || envelope.gameRestorePayloadTruncated - || envelope.snapshotPayloadTruncated - ); + // Envelope flags describe bounded diagnostic previews embedded in an otherwise + // complete raw sample. Only payload.truncated means replay lost message bytes. + return Boolean(event.payload?.truncated); } function formatPath(path) { diff --git a/scripts/mvp-check.mjs b/scripts/mvp-check.mjs index 8e3259e..fdf3f06 100644 --- a/scripts/mvp-check.mjs +++ b/scripts/mvp-check.mjs @@ -119,13 +119,18 @@ const checks = [ "src/core/gameState.js", "src/ui/overlay.js" ].map(read).join("\n"); + // Exporting a capture uses a detached anchor's native download activation. + // It cannot target the game canvas or dispatch gameplay input, so exclude + // that single, named operation while continuing to reject every other + // programmatic click in runtime source. + const gameplaySource = source.replace(/downloadLink\.click\s*\(\s*\)\s*;?/g, ""); const forbidden = [ [/\.click\s*\(/, "page click automation"], [/dispatchEvent\s*\(\s*new\s+(MouseEvent|KeyboardEvent|PointerEvent)/, "synthetic input automation"], [/actionDiscardTile|inputOperation|FastTest\.inputOperation/, "outbound game action helper"], [/anti[-_ ]?cheat|stealth|evade|bypass/i, "anti-detection wording"] ]; - return forbidden.filter(([pattern]) => pattern.test(source)).map(([, label]) => `found ${label}`); + return forbidden.filter(([pattern]) => pattern.test(gameplaySource)).map(([, label]) => `found ${label}`); } } ]; diff --git a/scripts/replay-capture.mjs b/scripts/replay-capture.mjs index 202ca30..ac46f8a 100644 --- a/scripts/replay-capture.mjs +++ b/scripts/replay-capture.mjs @@ -1,9 +1,9 @@ import { readFile, writeFile } from "node:fs/promises"; -import { replayCaptureWithDiagnostics, summarizeCaptureEvents } from "../src/adapter/majsoulAdapter.js"; +import { replayCaptureWithDiagnostics, summarizeCaptureEvents } from "../src/adapter/captureReplay.js"; import { parseBinaryEnvelope } from "../src/adapter/messageParser.js"; +import { buildGameStateDiagnostics, buildMvpGate } from "../src/core/eventDiagnostics.js"; import { GameState } from "../src/core/gameState.js"; import { REAL_PAGE_PREFLIGHT_VERSION, REQUIRED_REAL_PAGE_PREFLIGHT_CHECKS } from "../src/core/realPageReadiness.js"; -import { indexToTile, normalizeTile, tilesToCounts } from "../src/core/tile.js"; const UNMAPPED_UNITY_ACTIONS = new Set([ "ActionNewRound", @@ -53,7 +53,7 @@ const visibleState = gameState.getVisibleState(); const captureSummary = capture.summary || summarizeCaptureEvents(capture.events || []); const replaySummary = summarizeCaptureEvents(events); const diagnostics = buildReplayDiagnostics(capture.events || [], events); -const stateDiagnostics = buildStateDiagnostics(events, visibleState); +const stateDiagnostics = buildGameStateDiagnostics(events, visibleState); const actionDiagnostics = buildActionDiagnostics(capture.events || [], events); const liveStateComparison = compareLiveGameState(capture.liveGameState || null, visibleState); const captureIntegrity = buildCaptureIntegrity(capture); @@ -335,146 +335,12 @@ function buildActionSample(event, envelope) { }; } -function buildStateDiagnostics(events, gameState) { - const chiPengGangEvents = events.filter((event) => isChiPengGangAction(event.payload?.binaryEnvelope?.actionName)); - const anGangAddGangEvents = events.filter((event) => isAnGangAddGangAction(event.payload?.binaryEnvelope?.actionName)); - const callMeldEvents = events.filter((event) => event.type === "call_meld"); - const callMeldEventsWithSeat = callMeldEvents.filter((event) => isValidSeat(event.payload?.seat)); - const anGangAddGangEventsWithSeat = anGangAddGangEvents.filter((event) => isValidSeat(event.payload?.seat)); - const closedKanEvents = anGangAddGangEvents.filter((event) => Number(event.payload?.type) === 3); - const addedKanEvents = anGangAddGangEvents.filter((event) => Number(event.payload?.type) === 2); - const unknownKanTypeEvents = anGangAddGangEvents.filter((event) => { - const type = Number(event.payload?.type); - return type !== 2 && type !== 3; - }); - const ownAnGangAddGangEvents = anGangAddGangEvents.filter((event) => Number(event.payload?.seat) === 0); - const kanMeldMismatches = buildKanMeldMismatches(gameState, anGangAddGangEvents); - const ownKanTilesStillInHand = buildOwnKanTilesStillInHand(gameState, ownAnGangAddGangEvents); - const riichiEvents = events.filter((event) => event.type === "riichi"); - const riichiEventsWithSeat = riichiEvents.filter((event) => isValidSeat(event.payload?.seat)); - const roundEndEvents = events.filter((event) => event.type === "round_end"); - const roundEndEventsWithScores = roundEndEvents.filter((event) => event.payload?.scores?.length); - const ownDrawTileEvents = events.filter((event) => event.type === "draw_tile" && Number(event.payload?.seat) === 0); - const ownDrawTileEventsWithValidTile = ownDrawTileEvents.filter((event) => isValidTile(event.payload?.tile)); - const meldCount = (gameState.melds || []).reduce((sum, melds) => sum + melds.length, 0); - const knownTileCounts = countKnownTiles(gameState); - const eventCounts = countEventTypes(events); - const expectedCurrentTurn = inferExpectedCurrentTurn(events); - const currentTurnMatchesExpected = expectedCurrentTurn === null - ? null - : Number(gameState.currentTurn) === expectedCurrentTurn; - const expectedDiscards = [[], [], [], []]; - let claimableChiPengGangEvents = 0; - for (const event of chronologicalEvents(events)) { - if (event.type === "discard_tile" && isValidSeat(event.payload?.seat) && event.payload?.tile) { - expectedDiscards[Number(event.payload.seat)].push(event.payload.tile); - } - if (isChiPengGangAction(event.payload?.binaryEnvelope?.actionName)) { - const meld = normalizeMeld(event.payload?.meld); - const callerSeat = Number(event.payload?.seat); - const removed = removeLatestClaimedDiscard(expectedDiscards, meld, callerSeat); - if (removed) claimableChiPengGangEvents += 1; - } - } - const claimedDiscardTransferred = chiPengGangEvents.length - ? discardsEqual(expectedDiscards, gameState.discards || []) - : null; - - return { - eventCounts, - stateUpdated: { - hand: Boolean(gameState.handKnown && gameState.hand?.length), - drawnTile: Boolean(gameState.drawnTile) || ownDrawTileEventsWithValidTile.length > 0, - discards: Boolean(gameState.discards?.some((tiles) => tiles.length)), - melds: meldCount > 0, - doraIndicators: Boolean(gameState.doraIndicators?.length), - roundMetadata: gameState.chang !== null || gameState.ju !== null || gameState.round !== null, - riichi: Boolean(gameState.riichi?.some(Boolean)), - roundEndReason: gameState.roundEndReason !== null, - currentTurn: currentTurnMatchesExpected, - scores: Boolean(gameState.scoresKnown || gameState.scores?.some((score) => score !== 25000)), - visibleTiles: Boolean(gameState.visibleTiles?.length), - warningsClear: !(gameState.warnings?.length) - }, - callMeldEvents: callMeldEvents.length, - callMeldEventsWithSeat: callMeldEventsWithSeat.length, - riichiEvents: riichiEvents.length, - riichiEventsWithSeat: riichiEventsWithSeat.length, - roundEndEvents: roundEndEvents.length, - roundEndEventsWithScores: roundEndEventsWithScores.length, - ownDrawTileEvents: ownDrawTileEvents.length, - ownDrawTileEventsWithValidTile: ownDrawTileEventsWithValidTile.length, - drawnTileRetained: Boolean(gameState.drawnTile), - chiPengGangEvents: chiPengGangEvents.length, - claimableChiPengGangEvents, - anGangAddGangEvents: anGangAddGangEvents.length, - anGangAddGangEventsWithSeat: anGangAddGangEventsWithSeat.length, - closedKanEvents: closedKanEvents.length, - addedKanEvents: addedKanEvents.length, - unknownKanTypeEvents: unknownKanTypeEvents.length, - ownAnGangAddGangEvents: ownAnGangAddGangEvents.length, - kanTypeKnown: anGangAddGangEvents.length ? unknownKanTypeEvents.length === 0 : null, - kanMeldTileCountsOk: anGangAddGangEvents.length ? kanMeldMismatches.length === 0 : null, - closedKanVisibleTileCountsOk: closedKanEvents.length - ? kanMeldMismatches.every((entry) => Number(entry.type) !== 3) - : null, - addedKanVisibleTileCountsOk: addedKanEvents.length - ? kanMeldMismatches.every((entry) => Number(entry.type) !== 2) - : null, - ownKanTilesRemoved: ownAnGangAddGangEvents.length ? ownKanTilesStillInHand.length === 0 : null, - kanMeldMismatches, - ownKanTilesStillInHand, - meldCount, - invalidTiles: gameState.invalidTiles || [], - overKnownTileLimit: knownTileCounts.filter((entry) => entry.count > 4), - expectedCurrentTurn, - currentTurnMatchesExpected, - claimedDiscardTransferred: chiPengGangEvents.length ? claimedDiscardTransferred : null - }; -} - -function isChiPengGangAction(actionName) { - return actionName === "ActionChiPengGang" || actionName === "RecordChiPengGang"; -} - -function isAnGangAddGangAction(actionName) { - return actionName === "ActionAnGangAddGang" || actionName === "RecordAnGangAddGang"; -} - -function chronologicalEvents(events) { - const entries = [...(events || [])]; - if (entries.every((event) => Number.isFinite(Number(event.ts)))) { - return entries.sort((left, right) => Number(left.ts) - Number(right.ts)); - } - return entries; -} - -function inferExpectedCurrentTurn(events) { - let expectedCurrentTurn = null; - for (const event of chronologicalEvents(events)) { - if (event.type === "round_start" && isValidSeat(event.payload?.currentTurn)) { - expectedCurrentTurn = Number(event.payload.currentTurn); - } - if (event.type === "draw_tile" && isValidSeat(event.payload?.seat)) { - expectedCurrentTurn = Number(event.payload.seat); - } - if (event.type === "discard_tile" && isValidSeat(event.payload?.seat)) { - expectedCurrentTurn = null; - } - if (event.type === "call_meld" && isValidSeat(event.payload?.seat)) { - expectedCurrentTurn = Number(event.payload.seat); - } - if (event.type === "round_end") { - expectedCurrentTurn = null; - } - } - return expectedCurrentTurn; -} function buildStateCoverage(acceptance, stateDiagnostics) { const checks = acceptance.checks || {}; const requiredKeys = [ "rawMessagesCaptured", + "inboundRawMessagesCaptured", "binaryEnvelopeDecoded", "actionPrototypeDecoded", "drawTileParsed", @@ -638,26 +504,6 @@ function stableJson(value) { return JSON.stringify(value); } -function countEventTypes(events) { - const counts = {}; - for (const event of events) { - counts[event.type] = (counts[event.type] || 0) + 1; - } - return counts; -} - -function countKnownTiles(gameState) { - const knownTiles = [ - ...(gameState.hand || []), - gameState.drawnTile, - ...((gameState.visibleTiles || [])) - ].filter(Boolean); - const counts = tilesToCounts(knownTiles); - return counts - .map((count, index) => ({ tile: indexToTile(index), count })) - .filter((entry) => entry.count > 0); -} - function countRawEnvelopeField(events, field) { const counts = {}; for (const event of events) { @@ -880,194 +726,25 @@ function buildStateUpdateRecommendations(stateDiagnostics) { } function buildAcceptance({ diagnostics, events, gameState, liveStateComparison = null, stateDiagnostics = null }) { - const eventTypes = events.map((event) => event.type); - const checks = { - rawMessagesCaptured: diagnostics.rawMessages > 0, - binaryEnvelopeDecoded: diagnostics.rawMessagesWithEnvelope > 0, - actionPrototypeDecoded: diagnostics.rawActionTotal > 0, - drawTileParsed: eventTypes.includes("draw_tile"), - drawTileSeatParsed: hasEventWithValidSeat(events, "draw_tile"), - discardTileParsed: eventTypes.includes("discard_tile"), - discardTileSeatParsed: hasEventWithValidSeat(events, "discard_tile"), - gameStateHandUpdated: Boolean(gameState.handKnown && gameState.hand?.length), - gameStateRoundMetadataUpdated: gameState.chang !== null || gameState.ju !== null || gameState.round !== null, - gameStateDrawnTileUpdated: Boolean(gameState.drawnTile) || hasOwnDrawTileWithValidTile(events), - gameStateDiscardsUpdated: Boolean(gameState.discards?.some((tiles) => tiles.length)), - gameStateDoraIndicatorsUpdated: Boolean(gameState.doraIndicators?.length), - gameStateScoresUpdated: Boolean(gameState.scoresKnown || gameState.scores?.some((score) => score !== 25000)), - gameStateVisibleTilesUpdated: Boolean(gameState.visibleTiles?.length), - gameStateWarningsClear: !(gameState.warnings?.length) - }; - if (eventTypes.includes("call_meld")) { - checks.callMeldSeatParsed = hasEventWithValidSeat(events, "call_meld"); - checks.gameStateMeldsUpdated = Boolean(gameState.melds?.some((melds) => melds.length)); - } - if (Number(stateDiagnostics?.claimableChiPengGangEvents || 0) > 0) { - checks.claimedDiscardTransferred = stateDiagnostics.claimedDiscardTransferred === true; - } - if (Number(stateDiagnostics?.anGangAddGangEvents || 0) > 0) { - checks.anGangAddGangSeatParsed = Number(stateDiagnostics.anGangAddGangEventsWithSeat || 0) === Number(stateDiagnostics.anGangAddGangEvents || 0); - checks.kanTypeKnown = stateDiagnostics.kanTypeKnown === true; - checks.kanMeldTileCountsOk = stateDiagnostics.kanMeldTileCountsOk === true; - } - if (Number(stateDiagnostics?.ownAnGangAddGangEvents || 0) > 0) { - checks.ownKanTilesRemoved = stateDiagnostics.ownKanTilesRemoved === true; - } - if (stateDiagnostics?.expectedCurrentTurn !== null && stateDiagnostics?.expectedCurrentTurn !== undefined) { - checks.gameStateCurrentTurnUpdated = Number(gameState.currentTurn) === Number(stateDiagnostics.expectedCurrentTurn); - } - if (eventTypes.includes("riichi")) { - checks.riichiSeatParsed = hasEventWithValidSeat(events, "riichi"); - checks.gameStateRiichiUpdated = Boolean(gameState.riichi?.some(Boolean)); - } - if (eventTypes.includes("round_end")) { - checks.roundEndReasonUpdated = gameState.roundEndReason !== null; - const roundEndScores = latestRoundEndScores(events); - if (roundEndScores) { - checks.roundEndScoresUpdated = stableJson(gameState.scores) === stableJson(roundEndScores); + const gate = buildMvpGate({ + events, + gameState, + liveStateComparison, + stateDiagnostics, + transport: { + rawMessages: diagnostics.rawMessages, + inboundRawMessages: diagnostics.inboundRawMessages, + binaryEnvelopes: diagnostics.rawMessagesWithEnvelope, + actionMessages: diagnostics.rawActionTotal } - } - if (liveStateComparison?.available) { - checks.liveStateSnapshotMatches = liveStateComparison.mismatches.length === 0; - } + }); return { - readyForRealPageMvp: Object.values(checks).every(Boolean), - checks, - missing: Object.entries(checks) - .filter(([, passed]) => !passed) - .map(([name]) => name) + readyForRealPageMvp: gate.readyForRealPageMvp, + checks: gate.checks, + missing: gate.missing }; } -function hasEventWithValidSeat(events, type) { - return events.some((event) => event.type === type && isValidSeat(event.payload?.seat)); -} - -function hasOwnDrawTileWithValidTile(events) { - return events.some((event) => event.type === "draw_tile" && Number(event.payload?.seat) === 0 && isValidTile(event.payload?.tile)); -} - -function latestRoundEndScores(events) { - for (let index = events.length - 1; index >= 0; index -= 1) { - const event = events[index]; - if (event.type === "round_end" && event.payload?.scores?.length) { - return event.payload.scores; - } - } - return null; -} - -function isValidSeat(seat) { - const value = Number(seat); - return Number.isInteger(value) && value >= 0 && value <= 3; -} - -function isValidTile(tile) { - try { - normalizeTile(tile); - return true; - } catch { - return false; - } -} - -function normalizeMeld(meld) { - if (!meld) return []; - return Array.isArray(meld) ? meld : [meld]; -} - -function buildKanMeldMismatches(gameState, events) { - const mismatches = []; - for (const event of events) { - const seat = Number(event.payload?.seat); - const tile = eventKanTile(event); - if (!isValidSeat(seat) || !tile) { - mismatches.push({ - seat: isValidSeat(seat) ? seat : null, - type: event.payload?.type ?? null, - tile, - expectedCopies: 4, - actualCopies: 0, - reason: "missing valid seat or tile" - }); - continue; - } - const actualCopies = maxMeldCopies(gameState, seat, tile); - if (actualCopies < 4) { - mismatches.push({ - seat, - type: event.payload?.type ?? null, - tile, - expectedCopies: 4, - actualCopies, - reason: "no four-tile kan meld in final state" - }); - } - } - return mismatches; -} - -function buildOwnKanTilesStillInHand(gameState, events) { - const ownKnownTiles = [ - ...(gameState.hand || []), - gameState.drawnTile - ].filter(Boolean).map((tile) => normalizeTile(tile)); - const counts = {}; - for (const tile of ownKnownTiles) { - counts[tile] = (counts[tile] || 0) + 1; - } - const leftovers = []; - for (const event of events) { - const tile = eventKanTile(event); - if (tile && counts[tile] > 0 && !leftovers.some((entry) => entry.tile === tile)) { - leftovers.push({ tile, count: counts[tile] }); - } - } - return leftovers; -} - -function eventKanTile(event) { - const normalizedTiles = normalizeMeld(event.payload?.meld) - .filter((tile) => isValidTile(tile)) - .map((tile) => normalizeTile(tile)); - return normalizedTiles[0] || null; -} - -function maxMeldCopies(gameState, seat, tile) { - const melds = gameState.melds?.[seat] || []; - let maxCopies = 0; - for (const meld of melds) { - const copies = normalizeMeld(meld) - .filter((meldTile) => isValidTile(meldTile)) - .map((meldTile) => normalizeTile(meldTile)) - .filter((meldTile) => meldTile === tile) - .length; - maxCopies = Math.max(maxCopies, copies); - } - return maxCopies; -} - -function removeLatestClaimedDiscard(discards, meld, callerSeat) { - if (!isValidSeat(callerSeat) || !meld.length) return false; - for (let seat = 0; seat < discards.length; seat += 1) { - if (seat === callerSeat) continue; - const river = discards[seat]; - const lastTile = river[river.length - 1]; - if (lastTile && meld.includes(lastTile)) { - river.pop(); - return true; - } - } - return false; -} - -function discardsEqual(left, right) { - return left.length === right.length && left.every((river, index) => ( - river.length === (right[index] || []).length - && river.every((tile, tileIndex) => tile === right[index][tileIndex]) - )); -} - function toFixture(output, events) { return { fixtureVersion: 1, diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs new file mode 100644 index 0000000..9dc1592 --- /dev/null +++ b/scripts/run-tests.mjs @@ -0,0 +1,25 @@ +import { spawnSync } from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const vitestCli = join(scriptDir, "..", "node_modules", "vitest", "vitest.mjs"); +const nodeOptions = new Set(String(process.env.NODE_OPTIONS || "").split(/\s+/).filter(Boolean)); + +// Node 25 exposes an incomplete global localStorage unless a persistence file +// is configured. That global shadows jsdom's Storage implementation in test +// workers, so disable it only on Node versions that expose the property. +if (Object.prototype.hasOwnProperty.call(globalThis, "localStorage")) { + nodeOptions.add("--no-experimental-webstorage"); +} + +const result = spawnSync(process.execPath, [vitestCli, "run", ...process.argv.slice(2)], { + stdio: "inherit", + env: { + ...process.env, + NODE_OPTIONS: [...nodeOptions].join(" ") + } +}); + +if (result.error) throw result.error; +process.exitCode = result.status ?? 1; diff --git a/scripts/validate-captures.mjs b/scripts/validate-captures.mjs index 8b6e40a..73fb78f 100644 --- a/scripts/validate-captures.mjs +++ b/scripts/validate-captures.mjs @@ -2,7 +2,11 @@ import { spawnSync } from "node:child_process"; import { readdir } from "node:fs/promises"; import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { isMahjongSoulPage, summarizeLiveRealPagePreflight, summarizeLiveSafetySettings } from "../src/core/realPageReadiness.js"; +import { + summarizeLiveRealPagePreflight, + summarizeLiveSafetySettings, + summarizeRealPageReadiness +} from "../src/core/realPageReadiness.js"; const scriptDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(scriptDir, ".."); @@ -215,22 +219,6 @@ function summarizeReplay(file, report) { }; } -function summarizeRealPageReadiness({ readyForRealPageMvp, page, liveOverlayAvailable, liveStateSnapshotMatches, livePreflight, liveSafety }) { - const missing = []; - const mahjongSoulPage = isMahjongSoulPage(page); - if (!readyForRealPageMvp) missing.push("acceptance.readyForRealPageMvp is not true"); - if (!mahjongSoulPage) missing.push("captureMetadata.page is not a Mahjong Soul web page"); - if (!liveOverlayAvailable) missing.push("overlay live debug/gate snapshot is missing"); - if (livePreflight?.ready !== true) missing.push(...(livePreflight?.missing?.length ? livePreflight.missing : ["liveRealPagePreflight checks are not ready"])); - if (liveSafety?.ready !== true) missing.push(...(liveSafety?.missing?.length ? liveSafety.missing : ["liveSafetySettings safety checks are not ready"])); - if (liveStateSnapshotMatches !== true) missing.push("liveStateSnapshotMatches is not true"); - return { - ready: missing.length === 0, - missing, - mahjongSoulPage - }; -} - function summarizeLiveGate(gate) { if (!gate) { return { diff --git a/src/adapter/captureReplay.js b/src/adapter/captureReplay.js new file mode 100644 index 0000000..093bd22 --- /dev/null +++ b/src/adapter/captureReplay.js @@ -0,0 +1,255 @@ +import { parseBinaryEnvelope, parseBinaryMessage, parseReadableMessage } from "./messageParser.js"; +import { isStandardGameEvent } from "../core/events.js"; + +function safeParseError(error) { + return error instanceof Error ? error.message : String(error); +} + +function parseStandardMessagesSafely(data) { + const events = []; + const errors = []; + try { + events.push(...parseReadableMessage(data)); + } catch (error) { + errors.push(`readable: ${safeParseError(error)}`); + } + try { + events.push(...parseBinaryMessage(data)); + } catch (error) { + errors.push(`binary: ${safeParseError(error)}`); + } + return { events, parseError: errors.join("; ") }; +} + +function hexToBytes(hex) { + if (!hex) return new Uint8Array(); + return new Uint8Array(hex.split(/\s+/).filter(Boolean).map((part) => Number.parseInt(part, 16))); +} + +export function parseCapturedSampleEvent(event) { + if (!event || event.type !== "raw_message") return []; + const payload = event.payload || {}; + let data = null; + + if (payload.kind === "text") { + data = payload.sample ?? payload.preview ?? ""; + } else if (payload.sample) { + data = hexToBytes(payload.sample); + } + + if (data === null) return []; + return parseStandardMessagesSafely(data).events.map((parsed) => ({ + ...parsed, + source: event.source, + ts: event.ts, + payload: { + ...parsed.payload, + rawSummary: payload + } + })); +} + +export function replayCaptureWithDiagnostics(capture) { + const events = []; + const inputEvents = capture?.events || []; + const orderedEvents = orderedCaptureEvents(inputEvents); + const replayDedupe = { + inputEvents: inputEvents.length, + ordering: orderedEvents.orderedBy, + rawMessages: 0, + rawMessagesWithParsedEvents: 0, + rawParsedEvents: 0, + liveParsedEvents: 0, + skippedLiveParsedEvents: 0, + retainedLiveParsedEvents: 0, + fallbackLiveParsedEvents: 0, + passthroughParsedEvents: 0, + diagnosticEvents: 0, + replayedEvents: 0 + }; + const replayedParsedKeys = new Set(); + const replayedRawParsedKeys = new Set(); + const replayedRawSummaryKeys = new Set(); + for (const event of orderedEvents.events) { + if (event.type === "raw_message") { + replayDedupe.rawMessages += 1; + const parsedEvents = parseCapturedSampleEvent(event); + if (parsedEvents.length) { + replayDedupe.rawMessagesWithParsedEvents += 1; + const rawSummaryKey = rawSummarySetKey(event.payload); + if (rawSummaryKey) replayedRawSummaryKeys.add(rawSummaryKey); + } + replayDedupe.rawParsedEvents += parsedEvents.length; + for (const parsedEvent of parsedEvents) { + replayedParsedKeys.add(parsedEventReplayKey(parsedEvent)); + replayedRawParsedKeys.add(parsedEventRawReplayKey(parsedEvent)); + events.push(parsedEvent); + } + } else if (isStandardGameEvent(event.type)) { + replayDedupe.liveParsedEvents += 1; + if ( + event.payload?.rawSummary + && ( + replayedParsedKeys.has(parsedEventReplayKey(event)) + || replayedRawParsedKeys.has(parsedEventRawReplayKey(event)) + || replayedRawSummaryKeys.has(rawSummarySetKey(event.payload.rawSummary)) + ) + ) { + replayDedupe.skippedLiveParsedEvents += 1; + continue; + } + replayDedupe.retainedLiveParsedEvents += 1; + if (event.payload?.rawSummary) { + replayDedupe.fallbackLiveParsedEvents += 1; + } else { + replayDedupe.passthroughParsedEvents += 1; + } + events.push(event); + } else { + replayDedupe.diagnosticEvents += 1; + } + } + replayDedupe.replayedEvents = events.length; + return { events, replayDedupe }; +} + +function orderedCaptureEvents(events = []) { + if (events.length && events.every((event) => Number.isFinite(Number(event.eventId)))) { + return { + orderedBy: "eventId", + events: [...events].sort((left, right) => Number(left.eventId) - Number(right.eventId)) + }; + } + return { + orderedBy: "newestFirstFallback", + events: [...events].reverse() + }; +} + +export function replayCapture(capture) { + const { events } = replayCaptureWithDiagnostics(capture); + return events; +} + +function parsedEventRawReplayKey(event) { + const payload = event?.payload || {}; + const rawSummary = rawSummaryReplayKey(payload.rawSummary); + if (!rawSummary) return ""; + return JSON.stringify({ + type: event?.type || "", + source: event?.source || "", + ts: event?.ts ?? null, + methodName: payload.binaryEnvelope?.methodName || "", + actionName: payload.binaryEnvelope?.actionName || "", + rawSummary + }); +} + +function parsedEventReplayKey(event) { + const payload = event?.payload || {}; + return JSON.stringify({ + type: event?.type || "", + source: event?.source || "", + ts: event?.ts ?? null, + methodName: payload.binaryEnvelope?.methodName || "", + actionName: payload.binaryEnvelope?.actionName || "", + seat: payload.seat ?? null, + tile: payload.tile ?? null, + reason: payload.reason ?? null, + meldType: payload.type ?? null, + meld: payload.meld || null, + scores: payload.scores || null, + round: payload.round ?? null, + chang: payload.chang ?? null, + ju: payload.ju ?? null, + rawSummary: rawSummaryReplayKey(payload.rawSummary) + }); +} + +function rawSummaryReplayKey(rawSummary) { + if (!rawSummary) return null; + return { + kind: rawSummary.kind || "", + length: rawSummary.length ?? null, + sample: rawSummary.sample || "", + preview: rawSummary.preview || "" + }; +} + +function rawSummarySetKey(rawSummary) { + const replayKey = rawSummaryReplayKey(rawSummary); + return replayKey ? JSON.stringify(replayKey) : ""; +} + +export function summarizeCaptureEvents(events = []) { + const summary = { + totalEvents: events.length, + rawMessages: 0, + parsedEvents: 0, + diagnosticEvents: 0, + bySource: {}, + byKind: {}, + byMethodName: {}, + byActionName: {}, + byParsedType: {}, + byDiagnosticType: {}, + byUnparsedActionName: {} + }; + const rawActions = {}; + const parsedActions = {}; + + for (const event of events) { + summary.bySource[event.source || "unknown"] = (summary.bySource[event.source || "unknown"] || 0) + 1; + if (event.type === "raw_message") { + summary.rawMessages += 1; + const payload = event.payload || {}; + const kind = payload.kind || "unknown"; + summary.byKind[kind] = (summary.byKind[kind] || 0) + 1; + const envelope = payloadEnvelope(payload); + const methodName = envelope?.methodName; + const actionNames = envelopeActionNames(envelope); + if (methodName) summary.byMethodName[methodName] = (summary.byMethodName[methodName] || 0) + 1; + for (const actionName of actionNames) { + summary.byActionName[actionName] = (summary.byActionName[actionName] || 0) + 1; + rawActions[actionName] = (rawActions[actionName] || 0) + 1; + } + } else if (isStandardGameEvent(event.type)) { + summary.parsedEvents += 1; + summary.byParsedType[event.type] = (summary.byParsedType[event.type] || 0) + 1; + const methodName = event.payload?.binaryEnvelope?.methodName; + const actionNames = [event.payload?.binaryEnvelope?.actionName].filter(Boolean); + if (methodName) summary.byMethodName[methodName] = (summary.byMethodName[methodName] || 0) + 1; + for (const actionName of actionNames) { + summary.byActionName[actionName] = (summary.byActionName[actionName] || 0) + 1; + parsedActions[actionName] = (parsedActions[actionName] || 0) + 1; + } + } else { + summary.diagnosticEvents += 1; + summary.byDiagnosticType[event.type || "unknown"] = (summary.byDiagnosticType[event.type || "unknown"] || 0) + 1; + } + } + + for (const [actionName, count] of Object.entries(rawActions)) { + const missing = count - (parsedActions[actionName] || 0); + if (missing > 0) summary.byUnparsedActionName[actionName] = missing; + } + + return summary; +} + +function payloadEnvelope(payload = {}) { + if (payload.envelope) return payload.envelope; + if (!payload.sample || payload.kind === "text") return null; + try { + return parseBinaryEnvelope(hexToBytes(payload.sample)); + } catch { + return null; + } +} + +function envelopeActionNames(envelope = {}) { + return [ + envelope?.actionName, + ...(envelope?.restoreActionNames || []) + ].filter(Boolean); +} diff --git a/src/adapter/majsoulAdapter.js b/src/adapter/majsoulAdapter.js index f861300..2d56197 100644 --- a/src/adapter/majsoulAdapter.js +++ b/src/adapter/majsoulAdapter.js @@ -1,9 +1,14 @@ import { parseBinaryEnvelope, parseBinaryMessage, parseDecodedMessage, parseReadableMessage } from "./messageParser.js"; +import { summarizeCaptureEvents } from "./captureReplay.js"; +import { + DEFAULT_BINARY_SAMPLE_BYTES, + DEFAULT_MAX_EVENTS, + MAX_CAPTURE_EVENTS, + normalizeBinarySampleBytes, + normalizeCaptureLimit +} from "../core/config.js"; import { isStandardGameEvent } from "../core/events.js"; -const DEFAULT_BINARY_SAMPLE_BYTES = 65536; -const DEFAULT_MAX_EVENTS = 10000; -const MAX_CAPTURE_EVENTS = 10000; const RUNTIME_SHAPE_KEY_LIMIT = 40; const RUNTIME_SHAPE_ACCESSOR_LIMIT = 20; const SELF_TEST_DISCARD_SAMPLE = @@ -1277,28 +1282,6 @@ function hexToBytes(hex) { return new Uint8Array(hex.split(/\s+/).filter(Boolean).map((part) => Number.parseInt(part, 16))); } -export function parseCapturedSampleEvent(event) { - if (!event || event.type !== "raw_message") return []; - const payload = event.payload || {}; - let data = null; - - if (payload.kind === "text") { - data = payload.sample ?? payload.preview ?? ""; - } else if (payload.sample) { - data = hexToBytes(payload.sample); - } - - if (data === null) return []; - return parseStandardMessagesSafely(data).events.map((parsed) => ({ - ...parsed, - source: event.source, - ts: event.ts, - payload: { - ...parsed.payload, - rawSummary: payload - } - })); -} function buildEventBufferDiagnostics(events = [], nextEventId = 1, maxEvents = DEFAULT_MAX_EVENTS) { const ids = events @@ -1316,210 +1299,6 @@ function buildEventBufferDiagnostics(events = [], nextEventId = 1, maxEvents = D }; } -export function replayCaptureWithDiagnostics(capture) { - const events = []; - const inputEvents = capture?.events || []; - const orderedEvents = orderedCaptureEvents(inputEvents); - const replayDedupe = { - inputEvents: inputEvents.length, - ordering: orderedEvents.orderedBy, - rawMessages: 0, - rawMessagesWithParsedEvents: 0, - rawParsedEvents: 0, - liveParsedEvents: 0, - skippedLiveParsedEvents: 0, - retainedLiveParsedEvents: 0, - fallbackLiveParsedEvents: 0, - passthroughParsedEvents: 0, - diagnosticEvents: 0, - replayedEvents: 0 - }; - const replayedParsedKeys = new Set(); - const replayedRawParsedKeys = new Set(); - const replayedRawSummaryKeys = new Set(); - for (const event of orderedEvents.events) { - if (event.type === "raw_message") { - replayDedupe.rawMessages += 1; - const parsedEvents = parseCapturedSampleEvent(event); - if (parsedEvents.length) { - replayDedupe.rawMessagesWithParsedEvents += 1; - const rawSummaryKey = rawSummarySetKey(event.payload); - if (rawSummaryKey) replayedRawSummaryKeys.add(rawSummaryKey); - } - replayDedupe.rawParsedEvents += parsedEvents.length; - for (const parsedEvent of parsedEvents) { - replayedParsedKeys.add(parsedEventReplayKey(parsedEvent)); - replayedRawParsedKeys.add(parsedEventRawReplayKey(parsedEvent)); - events.push(parsedEvent); - } - } else if (isStandardGameEvent(event.type)) { - replayDedupe.liveParsedEvents += 1; - if ( - event.payload?.rawSummary - && ( - replayedParsedKeys.has(parsedEventReplayKey(event)) - || replayedRawParsedKeys.has(parsedEventRawReplayKey(event)) - || replayedRawSummaryKeys.has(rawSummarySetKey(event.payload.rawSummary)) - ) - ) { - replayDedupe.skippedLiveParsedEvents += 1; - continue; - } - replayDedupe.retainedLiveParsedEvents += 1; - if (event.payload?.rawSummary) { - replayDedupe.fallbackLiveParsedEvents += 1; - } else { - replayDedupe.passthroughParsedEvents += 1; - } - events.push(event); - } else { - replayDedupe.diagnosticEvents += 1; - } - } - replayDedupe.replayedEvents = events.length; - return { events, replayDedupe }; -} - -function orderedCaptureEvents(events = []) { - if (events.length && events.every((event) => Number.isFinite(Number(event.eventId)))) { - return { - orderedBy: "eventId", - events: [...events].sort((left, right) => Number(left.eventId) - Number(right.eventId)) - }; - } - return { - orderedBy: "newestFirstFallback", - events: [...events].reverse() - }; -} - -export function replayCapture(capture) { - const { events } = replayCaptureWithDiagnostics(capture); - return events; -} - -function parsedEventRawReplayKey(event) { - const payload = event?.payload || {}; - const rawSummary = rawSummaryReplayKey(payload.rawSummary); - if (!rawSummary) return ""; - return JSON.stringify({ - type: event?.type || "", - source: event?.source || "", - ts: event?.ts ?? null, - methodName: payload.binaryEnvelope?.methodName || "", - actionName: payload.binaryEnvelope?.actionName || "", - rawSummary - }); -} - -function parsedEventReplayKey(event) { - const payload = event?.payload || {}; - return JSON.stringify({ - type: event?.type || "", - source: event?.source || "", - ts: event?.ts ?? null, - methodName: payload.binaryEnvelope?.methodName || "", - actionName: payload.binaryEnvelope?.actionName || "", - seat: payload.seat ?? null, - tile: payload.tile ?? null, - reason: payload.reason ?? null, - meldType: payload.type ?? null, - meld: payload.meld || null, - scores: payload.scores || null, - round: payload.round ?? null, - chang: payload.chang ?? null, - ju: payload.ju ?? null, - rawSummary: rawSummaryReplayKey(payload.rawSummary) - }); -} - -function rawSummaryReplayKey(rawSummary) { - if (!rawSummary) return null; - return { - kind: rawSummary.kind || "", - length: rawSummary.length ?? null, - sample: rawSummary.sample || "", - preview: rawSummary.preview || "" - }; -} - -function rawSummarySetKey(rawSummary) { - const replayKey = rawSummaryReplayKey(rawSummary); - return replayKey ? JSON.stringify(replayKey) : ""; -} - -export function summarizeCaptureEvents(events = []) { - const summary = { - totalEvents: events.length, - rawMessages: 0, - parsedEvents: 0, - diagnosticEvents: 0, - bySource: {}, - byKind: {}, - byMethodName: {}, - byActionName: {}, - byParsedType: {}, - byDiagnosticType: {}, - byUnparsedActionName: {} - }; - const rawActions = {}; - const parsedActions = {}; - - for (const event of events) { - summary.bySource[event.source || "unknown"] = (summary.bySource[event.source || "unknown"] || 0) + 1; - if (event.type === "raw_message") { - summary.rawMessages += 1; - const payload = event.payload || {}; - const kind = payload.kind || "unknown"; - summary.byKind[kind] = (summary.byKind[kind] || 0) + 1; - const envelope = payloadEnvelope(payload); - const methodName = envelope?.methodName; - const actionNames = envelopeActionNames(envelope); - if (methodName) summary.byMethodName[methodName] = (summary.byMethodName[methodName] || 0) + 1; - for (const actionName of actionNames) { - summary.byActionName[actionName] = (summary.byActionName[actionName] || 0) + 1; - rawActions[actionName] = (rawActions[actionName] || 0) + 1; - } - } else if (isStandardGameEvent(event.type)) { - summary.parsedEvents += 1; - summary.byParsedType[event.type] = (summary.byParsedType[event.type] || 0) + 1; - const methodName = event.payload?.binaryEnvelope?.methodName; - const actionNames = [event.payload?.binaryEnvelope?.actionName].filter(Boolean); - if (methodName) summary.byMethodName[methodName] = (summary.byMethodName[methodName] || 0) + 1; - for (const actionName of actionNames) { - summary.byActionName[actionName] = (summary.byActionName[actionName] || 0) + 1; - parsedActions[actionName] = (parsedActions[actionName] || 0) + 1; - } - } else { - summary.diagnosticEvents += 1; - summary.byDiagnosticType[event.type || "unknown"] = (summary.byDiagnosticType[event.type || "unknown"] || 0) + 1; - } - } - - for (const [actionName, count] of Object.entries(rawActions)) { - const missing = count - (parsedActions[actionName] || 0); - if (missing > 0) summary.byUnparsedActionName[actionName] = missing; - } - - return summary; -} - -function payloadEnvelope(payload = {}) { - if (payload.envelope) return payload.envelope; - if (!payload.sample || payload.kind === "text") return null; - try { - return parseBinaryEnvelope(hexToBytes(payload.sample)); - } catch { - return null; - } -} - -function envelopeActionNames(envelope = {}) { - return [ - envelope?.actionName, - ...(envelope?.restoreActionNames || []) - ].filter(Boolean); -} function normalizeLimit(limit, fallback, max = Infinity) { const value = Number(limit); @@ -1528,15 +1307,11 @@ function normalizeLimit(limit, fallback, max = Infinity) { } function normalizeMaxEvents(value, fallback = DEFAULT_MAX_EVENTS) { - const number = Number(value); - if (!Number.isFinite(number) || number <= 0) return fallback; - return Math.max(1, Math.min(MAX_CAPTURE_EVENTS, Math.floor(number))); + return normalizeCaptureLimit(value, fallback); } function normalizeSampleBytes(value) { - const number = Number(value); - if (!Number.isFinite(number) || number <= 0) return DEFAULT_BINARY_SAMPLE_BYTES; - return Math.max(16, Math.min(DEFAULT_BINARY_SAMPLE_BYTES, Math.floor(number))); + return normalizeBinarySampleBytes(value); } export { DEFAULT_BINARY_SAMPLE_BYTES, DEFAULT_MAX_EVENTS, MAX_CAPTURE_EVENTS, summarizeMessage }; diff --git a/src/adapter/messageParser.js b/src/adapter/messageParser.js index ff5a0ee..aaf83d7 100644 --- a/src/adapter/messageParser.js +++ b/src/adapter/messageParser.js @@ -1,3 +1,15 @@ +import { + bytesToHex, + decodeVarint, + numericField, + numericFields, + parseProtobufEnvelope, + stringField, + stringFields, + summarizePayloadFields, + toUint8Array +} from "./protobuf.js"; + const BINARY_ENVELOPE_SAMPLE_BYTES = 512; function readPath(value, path) { @@ -84,7 +96,7 @@ function readableRoundTiles(payload = {}, seatHands) { const seat = normalizeSeatIndex(firstDefined(payload.selfSeat, payload.playerSeat, payload.actor, payload.who, payload.seat)); if (seat !== undefined && seatHands?.[seat]?.length) return seatHands[seat]; - return seatHands?.[0] || []; + return []; } function readableRoundEndReason(payload = {}, sourceName = "") { @@ -237,6 +249,7 @@ function normalizePayload(type, payload, sourceName = "") { if (type === "round_start") { const seatHands = readableSeatHands(payload); + const selfSeat = normalizeSeatIndex(firstDefined(payload.selfSeat, payload.playerSeat, payload.actor, payload.who, payload.seat)); const chang = firstDefined(payload.chang, payload.round_index, payload.roundIndex); const ju = firstDefined(payload.ju, payload.ju_index, payload.juIndex, payload.dealer); const round = firstDefined( @@ -254,6 +267,7 @@ function normalizePayload(type, payload, sourceName = "") { riichiSticks: firstDefined(payload.riichiSticks, payload.lizhibang, payload.liqibang, payload.sticks, 0), roundWind: firstDefined(payload.roundWind, payload.changfeng, payload.round_wind), seatWind: firstDefined(payload.seatWind, payload.zifeng, payload.seat_wind), + ...(selfSeat !== undefined ? { selfSeat } : {}), scores: firstDefined(payload.scores, payload.points, payload.score), tiles: readableRoundTiles(payload, seatHands), doraIndicators: readableDoraIndicators(payload), @@ -263,8 +277,10 @@ function normalizePayload(type, payload, sourceName = "") { } if (type === "deal_hand") { + const selfSeat = normalizeSeatIndex(firstDefined(payload.selfSeat, payload.playerSeat, payload.actor, payload.who, payload.seat)); return { - tiles: firstDefined(payload.tiles, payload.hand, payload.handTiles, payload.qipai, payload.tehai, []) + tiles: firstDefined(payload.tiles, payload.hand, payload.handTiles, payload.qipai, payload.tehai, []), + ...(selfSeat !== undefined ? { selfSeat } : {}) }; } @@ -353,253 +369,215 @@ function tryParseJsonText(text) { } } -function toUint8Array(data) { - if (data instanceof ArrayBuffer) return new Uint8Array(data); - if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); - return null; -} - -function bytesToHex(bytes, max = bytes.length) { - return Array.from(bytes.slice(0, max), (byte) => byte.toString(16).padStart(2, "0")).join(" "); -} - -function isPrintableShortText(text) { - return typeof text === "string" && /^[\x20-\x7E]{1,80}$/.test(text); -} - -function groupValuesByField(entries, mapValue) { - const grouped = new Map(); - for (const entry of entries) { - const value = mapValue(entry); - if (value === undefined || value === null || value === "") continue; - if (!grouped.has(entry.field)) grouped.set(entry.field, []); - const values = grouped.get(entry.field); - if (values.length < 12) values.push(value); - } - return Array.from(grouped.entries()) - .sort((a, b) => a[0] - b[0]) - .map(([field, values]) => ({ field, values })); -} - -function summarizePayloadFields(bytes) { - if (!bytes?.length) { - return { - varints: [], - strings: [], - tileStrings: [] - }; - } - const fields = parseProtobufEnvelope(bytes).fields; - return { - varints: groupValuesByField(fields.varints, (entry) => entry.value), - strings: groupValuesByField(fields.lengthDelimited, (entry) => ( - isPrintableShortText(entry.text) ? entry.text : undefined - )), - tileStrings: groupValuesByField(fields.lengthDelimited, (entry) => ( - tileLike(entry.text) ? entry.text : undefined - )) - }; -} - -function decodeVarint(bytes, offset) { - let value = 0; - let multiplier = 1; - let cursor = offset; - while (cursor < bytes.length && cursor - offset < 10) { - const byte = bytes[cursor]; - value += (byte & 0x7f) * multiplier; - if (!Number.isSafeInteger(value)) return null; - cursor += 1; - if ((byte & 0x80) === 0) return { value, offset: cursor }; - multiplier *= 128; - } - return null; -} +const MAJSOUL_WEBGL_ACTION_XOR_KEYS = [0x84, 0x5e, 0x4e, 0x42, 0x39, 0xa2, 0x1f, 0x60, 0x1c]; +const MAJSOUL_WEBGL_ACTION_CODEC = "majsoul-webgl-xor-v1"; -function decodeUtf8(bytes) { - try { - return new TextDecoder().decode(bytes); - } catch { - return ""; - } +function isIntegerInRange(value, min, max) { + return Number.isInteger(value) && value >= min && value <= max; } -function parseProtobufEnvelope(bytes) { +function isCompleteProtobufPayload(bytes) { + if (!bytes?.length) return false; let offset = 0; - const lengthDelimited = []; - const varints = []; + let fieldCount = 0; + while (offset < bytes.length) { const tag = decodeVarint(bytes, offset); - if (!tag) break; + if (!tag || tag.offset <= offset) return false; offset = tag.offset; - const field = tag.value >> 3; + + const field = Math.floor(tag.value / 8); const wireType = tag.value & 0x07; + if (!isIntegerInRange(field, 1, 0x1fffffff)) return false; if (wireType === 0) { const value = decodeVarint(bytes, offset); - if (!value) break; - varints.push({ field, value: value.value }); + if (!value || value.offset <= offset) return false; offset = value.offset; - continue; - } - - if (wireType === 2) { + } else if (wireType === 1) { + if (offset + 8 > bytes.length) return false; + offset += 8; + } else if (wireType === 2) { const length = decodeVarint(bytes, offset); - if (!length) break; + if (!length || length.offset <= offset || !Number.isSafeInteger(length.value)) return false; offset = length.offset; - if (!Number.isSafeInteger(length.value) || length.value < 0) break; const end = offset + length.value; - if (!Number.isSafeInteger(end) || end < offset) break; - if (end > bytes.length) break; - const valueBytes = bytes.slice(offset, end); - lengthDelimited.push({ field, bytes: valueBytes, text: decodeUtf8(valueBytes) }); + if (!Number.isSafeInteger(end) || end < offset || end > bytes.length) return false; offset = end; - continue; - } - - if (wireType === 5) { - if (offset + 4 > bytes.length) break; + } else if (wireType === 5) { + if (offset + 4 > bytes.length) return false; offset += 4; - continue; - } - - if (wireType === 1) { - if (offset + 8 > bytes.length) break; - offset += 8; - continue; + } else { + return false; } - break; + fieldCount += 1; } - const methodEntry = lengthDelimited.find((entry) => /^\.?lq\./.test(entry.text)) - || lengthDelimited.find((entry) => /Action|Req|Res|Notify|Lobby/.test(entry.text)); - const dataEntry = lengthDelimited.find((entry) => entry !== methodEntry && entry.bytes.length > 0); - return { - methodName: methodEntry?.text, - payloadBytes: dataEntry?.bytes || new Uint8Array(), - fields: { varints, lengthDelimited } - }; + return offset === bytes.length && fieldCount > 0; } -function parseActionPrototype(payloadBytes) { - if (!payloadBytes?.length) return null; - const decoded = parseProtobufEnvelope(payloadBytes); - const actionEntry = decoded.fields.lengthDelimited.find((entry) => /^(Action|Record)[A-Za-z0-9_]+$/.test(entry.text)); - if (!actionEntry) return null; - const dataEntry = decoded.fields.lengthDelimited.find((entry) => entry !== actionEntry && entry.bytes.length > 0); - return { - step: decoded.fields.varints[0]?.value, - actionName: actionEntry.text, - actionPayloadBytes: dataEntry?.bytes || new Uint8Array(), - actionPayloadSample: bytesToHex(dataEntry?.bytes || new Uint8Array(), BINARY_ENVELOPE_SAMPLE_BYTES), - actionPayloadTruncated: (dataEntry?.bytes.length || 0) > BINARY_ENVELOPE_SAMPLE_BYTES, - actionPayloadFields: summarizePayloadFields(dataEntry?.bytes || new Uint8Array()) - }; +function decodeMajsoulWebglActionPayload(bytes) { + const decoded = Uint8Array.from(bytes || []); + const base = 23 ^ decoded.length; + for (let index = 0; index < decoded.length; index += 1) { + const key = MAJSOUL_WEBGL_ACTION_XOR_KEYS[index % MAJSOUL_WEBGL_ACTION_XOR_KEYS.length]; + decoded[index] ^= (base + 5 * index + key) & 0xff; + } + return decoded; } -function actionNameFromMethod(methodName) { - const lastSegment = String(methodName || "").split(".").pop(); - return /^(Action|Record)[A-Za-z0-9_]+$/.test(lastSegment) ? lastSegment : null; +function optionalFieldsInRange(fields, ids, min, max) { + return ids.every((id) => { + const value = numericField(fields, id); + return value === undefined || isIntegerInRange(value, min, max); + }); } -function tileLike(text) { - return /^[0-9][mps]$|^[1-7]z$/.test(text); -} +function hasPlausibleActionPayload(actionName, fields) { + const name = String(actionName || "").split(".").pop(); + const seat = numericField(fields, 1); + const allTiles = fields.lengthDelimited.map((entry) => entry.text).filter(tileLike); -function varintField(fields, id) { - return fields.varints.find((entry) => entry.field === id)?.value; -} + if (name === "ActionNewRound" || name === "RecordNewRound") { + const record = name === "RecordNewRound"; + const handTiles = tileStringFields(fields, record ? 7 : 4); + const scores = numericFields(fields, record ? 5 : 6); + const doraIndicators = [ + stringField(fields, record ? 4 : 5), + ...tileStringFields(fields, record ? 16 : 14) + ].filter(tileLike); + const leftTileCount = numericField(fields, record ? 15 : 13); + return isIntegerInRange(numericField(fields, 1), 0, 3) + && isIntegerInRange(numericField(fields, 2), 0, 3) + && isIntegerInRange(numericField(fields, 3), 0, 999) + && [3, 4].includes(scores.length) + && scores.every((score) => isIntegerInRange(score, 0, 10_000_000)) + && [0, 13].includes(handTiles.length) + && doraIndicators.length > 0 + && isIntegerInRange(leftTileCount, 0, 136); + } -function varintFields(fields, id) { - return fields.varints.filter((entry) => entry.field === id).map((entry) => entry.value); -} + if (name === "ActionDiscardTile" || name === "RecordDiscardTile") { + return isIntegerInRange(seat, 0, 3) + && tileLike(stringField(fields, 2)) + && optionalFieldsInRange(fields, [3, 5, 9], 0, 1); + } -function decodeVarintSequence(bytes = new Uint8Array()) { - const values = []; - let offset = 0; - while (offset < bytes.length) { - const decoded = decodeVarint(bytes, offset); - if (!decoded || decoded.offset <= offset) break; - values.push(decoded.value); - offset = decoded.offset; + if (name === "ActionDealTile" || name === "RecordDealTile") { + const tile = stringField(fields, 2); + return isIntegerInRange(seat, 0, 3) + && (tile === undefined || tileLike(tile)) + && isIntegerInRange(numericField(fields, 3), 0, 136); } - return offset === bytes.length ? values : []; -} -function packedVarintFields(fields, id) { - return fields.lengthDelimited - .filter((entry) => entry.field === id) - .flatMap((entry) => decodeVarintSequence(entry.bytes)); -} + if (name === "ActionChiPengGang" || name === "RecordChiPengGang") { + const meld = tileStringFields(fields, 3); + return isIntegerInRange(seat, 0, 3) + && isIntegerInRange(numericField(fields, 2), 0, 3) + && [3, 4].includes(meld.length); + } -function numericField(fields, id) { - return numericFields(fields, id)[0]; -} + if (name === "ActionAnGangAddGang" || name === "RecordAnGangAddGang") { + return isIntegerInRange(seat, 0, 3) + && isIntegerInRange(numericField(fields, 2), 0, 3) + && tileStringFields(fields, 3).length > 0; + } -function numericFields(fields, id) { - return [ - ...varintFields(fields, id), - ...packedVarintFields(fields, id) - ]; -} + if (name === "ActionBaBei" || name === "RecordBaBei") { + return isIntegerInRange(seat, 0, 3); + } -function stringField(fields, id) { - return fields.lengthDelimited.find((entry) => entry.field === id && entry.text)?.text; -} + if (name === "ActionLiqi" || name === "RecordLiqi") { + return isIntegerInRange(seat, 0, 3) + && isIntegerInRange(numericField(fields, 2), 1, 2); + } -function stringFields(fields, id) { - return fields.lengthDelimited.filter((entry) => entry.field === id && entry.text).map((entry) => entry.text); -} + if (name === "ActionLiqiSuccess" || name === "RecordLiqiSuccess") { + return isIntegerInRange(seat, 0, 3) + && isIntegerInRange(numericField(fields, 2), 0, 10_000_000) + && isIntegerInRange(numericField(fields, 3), 0, 100); + } -function tileStringFields(fields, id) { - return stringFields(fields, id).filter(tileLike); -} + if (name === "ActionHule" || name === "RecordHule") { + return nestedPayloadEntries(fields, 1).length > 0 + || numericFields(fields, 5).length >= 3 + || tileStringFields(fields, 7).length > 0; + } + + if (name === "ActionLiuJu" || name === "RecordLiuJu") { + return isIntegerInRange(numericField(fields, 1), 0, 10) + || nestedPayloadEntries(fields, 2).length > 0 + || tileStringFields(fields, 4).length > 0; + } -function decodeUnityEncodedDiscardPayload(bytes) { - if (!bytes || bytes.length !== 14) return null; - const seat = bytes[1] ^ 0x7e; - const tile = String.fromCharCode(bytes[4] ^ 0x66, bytes[5] ^ 0xd4); - const tsumogiri = bytes[9] ^ 0xca; + if (name === "ActionNoTile" || name === "RecordNoTile") { + return optionalFieldsInRange(fields, [1, 4], 0, 1) + && nestedPayloadEntries(fields, 3).length > 0; + } - if (!Number.isInteger(seat) || seat < 0 || seat > 3) return null; - if (!tileLike(tile)) return null; - if (![0, 1].includes(tsumogiri)) return null; + if (toEventType(name) === "dora") return allTiles.length > 0; + return false; +} + +function selectActionPayloadBytes(actionName, bytes, { allowWebglXor = false } = {}) { + const rawBytes = bytes || new Uint8Array(); + const rawFields = parseProtobufEnvelope(rawBytes).fields; + if (allowWebglXor) { + const decodedBytes = decodeMajsoulWebglActionPayload(rawBytes); + if (isCompleteProtobufPayload(decodedBytes)) { + const decodedFields = parseProtobufEnvelope(decodedBytes).fields; + if (hasPlausibleActionPayload(actionName, decodedFields)) { + return { + bytes: decodedBytes, + fields: decodedFields, + payloadCodec: MAJSOUL_WEBGL_ACTION_CODEC + }; + } + } + } + if (isCompleteProtobufPayload(rawBytes) && hasPlausibleActionPayload(actionName, rawFields)) { + return { bytes: rawBytes, fields: rawFields, payloadCodec: undefined }; + } + + return { bytes: rawBytes, fields: rawFields, payloadCodec: undefined }; +} + + +function parseActionPrototype(payloadBytes, { allowWebglXor = false } = {}) { + if (!payloadBytes?.length) return null; + const decoded = parseProtobufEnvelope(payloadBytes); + const actionEntry = decoded.fields.lengthDelimited.find((entry) => ( + entry.field === 2 && /^(Action|Record)[A-Za-z0-9_]+$/.test(entry.text) + )); + if (!actionEntry) return null; + const dataEntry = decoded.fields.lengthDelimited.find((entry) => entry.field === 3); + const actionPayloadBytes = dataEntry?.bytes || new Uint8Array(); + const selectedPayload = selectActionPayloadBytes(actionEntry.text, actionPayloadBytes, { allowWebglXor }); return { - seat, - tile, - tsumogiri: Boolean(tsumogiri), - isRiichi: false, - doraIndicators: [], - payloadCodec: "unity-xor-discard-short" + step: numericField(decoded.fields, 1), + actionName: actionEntry.text, + actionPayloadBytes, + actionPayloadSample: bytesToHex(actionPayloadBytes, BINARY_ENVELOPE_SAMPLE_BYTES), + actionPayloadTruncated: actionPayloadBytes.length > BINARY_ENVELOPE_SAMPLE_BYTES, + actionPayloadFields: summarizePayloadFields(selectedPayload.bytes, { isTileString: tileLike }), + actionPayloadCodec: selectedPayload.payloadCodec }; } -function decodeUnityEncodedDealPayload(bytes) { - if (!bytes) return null; - if (bytes.length === 6) { - const seat = bytes[1] ^ 0x76; - if (!Number.isInteger(seat) || seat < 0 || seat > 3) return null; - return { - seat, - doraIndicators: [], - payloadCodec: "unity-xor-deal-seat-short" - }; - } +function actionNameFromMethod(methodName) { + const lastSegment = String(methodName || "").split(".").pop(); + return /^(Action|Record)[A-Za-z0-9_]+$/.test(lastSegment) ? lastSegment : null; +} - if (bytes.length === 24) { - const tile = String.fromCharCode(bytes[4] ^ 0x5c, bytes[5] ^ 0xca); - if (!tileLike(tile)) return null; - return { - seat: 0, - tile, - doraIndicators: [], - payloadCodec: "unity-xor-deal-self-draw" - }; - } +function tileLike(text) { + return /^[0-9][mps]$|^[1-7]z$/.test(text); +} - return null; + +function tileStringFields(fields, id) { + return stringFields(fields, id).filter(tileLike); } function nestedPayloadFields(fields, id) { @@ -738,18 +716,22 @@ function actionPayloadEnvelope(baseEnvelope, actionPrototype, actionPayloadBytes actionPayloadLength: actionPayloadBytes.length, actionPayloadSample: bytesToHex(actionPayloadBytes, BINARY_ENVELOPE_SAMPLE_BYTES), actionPayloadTruncated: actionPayloadBytes.length > BINARY_ENVELOPE_SAMPLE_BYTES, - actionPayloadFields: summarizePayloadFields(actionPayloadBytes) + actionPayloadFields: summarizePayloadFields(actionPayloadBytes, { isTileString: tileLike }) }; } function parseRestoredActionEvents(baseEnvelope, restoreFields) { const events = []; for (const entry of nestedPayloadEntries(restoreFields, 2)) { - const actionPrototype = parseActionPrototype(entry.bytes); + const actionPrototype = parseActionPrototype(entry.bytes, { allowWebglXor: false }); if (!actionPrototype?.actionName) continue; const type = toEventType(actionPrototype.actionName); if (!type) continue; - const actionPayload = decodeSimpleActionPayload(actionPrototype.actionName, actionPrototype.actionPayloadBytes); + const actionPayload = decodeSimpleActionPayload( + actionPrototype.actionName, + actionPrototype.actionPayloadBytes, + { allowWebglXor: false } + ); const payload = { ...actionPayload, binaryEnvelope: actionPayloadEnvelope(baseEnvelope, actionPrototype, actionPrototype.actionPayloadBytes) @@ -810,7 +792,7 @@ function extractGameRestoreActionNames(payloadBytes) { if (!payloadBytes?.length) return []; const fields = parseProtobufEnvelope(payloadBytes).fields; return nestedPayloadEntries(fields, 2) - .map((entry) => parseActionPrototype(entry.bytes)?.actionName) + .map((entry) => parseActionPrototype(entry.bytes, { allowWebglXor: false })?.actionName) .filter(Boolean); } @@ -823,10 +805,13 @@ function extractRestoreActionNames(methodName, payloadBytes) { return undefined; } -function decodeSimpleActionPayload(actionName, bytes) { +function decodeSimpleActionPayload(actionName, bytes, { allowWebglXor = false } = {}) { if (!actionName || !bytes?.length) return {}; - const decoded = parseProtobufEnvelope(bytes); - const fields = decoded.fields; + const selectedPayload = selectActionPayloadBytes(actionName, bytes, { allowWebglXor }); + const fields = selectedPayload.fields; + const codec = selectedPayload.payloadCodec + ? { payloadCodec: selectedPayload.payloadCodec } + : {}; const allTiles = fields.lengthDelimited.map((entry) => entry.text).filter(tileLike); if (actionName === "ActionDiscardTile" || actionName === "RecordDiscardTile") { @@ -835,10 +820,10 @@ function decodeSimpleActionPayload(actionName, bytes) { tile: stringField(fields, 2), tsumogiri: Boolean(numericField(fields, 5)), isRiichi: Boolean(firstDefined(numericField(fields, 3), numericField(fields, 9))), - doraIndicators: tileStringFields(fields, 8) + doraIndicators: tileStringFields(fields, 8), + ...codec }; - if (decoded.seat !== undefined || decoded.tile || decoded.doraIndicators.length) return decoded; - return decodeUnityEncodedDiscardPayload(bytes) || decoded; + return decoded; } if (actionName === "ActionDealTile" || actionName === "RecordDealTile") { @@ -846,7 +831,8 @@ function decodeSimpleActionPayload(actionName, bytes) { seat: numericField(fields, 1), tile: stringField(fields, 2), leftTileCount: numericField(fields, 3), - doraIndicators: tileStringFields(fields, 6) + doraIndicators: tileStringFields(fields, 6), + ...codec }; if (decoded.seat !== undefined || decoded.tile || decoded.leftTileCount !== undefined || decoded.doraIndicators.length) { const riichi = decodeLiQiSuccess(fields, 5); @@ -855,14 +841,15 @@ function decodeSimpleActionPayload(actionName, bytes) { ...(riichi ? { riichi } : {}) }; } - return decodeUnityEncodedDealPayload(bytes) || decoded; + return decoded; } if (actionName === "ActionChiPengGang" || actionName === "RecordChiPengGang") { const decoded = { seat: numericField(fields, 1), type: numericField(fields, 2), - meld: tileStringFields(fields, 3) + meld: tileStringFields(fields, 3), + ...codec }; if (decoded.seat !== undefined || decoded.type !== undefined || decoded.meld.length) { const riichi = decodeLiQiSuccess(fields, 5); @@ -879,7 +866,8 @@ function decodeSimpleActionPayload(actionName, bytes) { seat: numericField(fields, 1), type: firstDefined(numericField(fields, 2), stringField(fields, 2)), doraIndicators: tileStringFields(fields, 6), - meld: tileStringFields(fields, 3) + meld: tileStringFields(fields, 3), + ...codec }; } @@ -889,14 +877,16 @@ function decodeSimpleActionPayload(actionName, bytes) { type: "babei", meld: ["4z"], tsumogiri: Boolean(actionName === "RecordBaBei" ? numericField(fields, 8) : numericField(fields, 9)), - doraIndicators: tileStringFields(fields, 6) + doraIndicators: tileStringFields(fields, 6), + ...codec }; } if (actionName === "ActionLiqi" || actionName === "RecordLiqi") { return { seat: numericField(fields, 1), - step: numericField(fields, 2) + step: numericField(fields, 2), + ...codec }; } @@ -904,7 +894,8 @@ function decodeSimpleActionPayload(actionName, bytes) { return { seat: numericField(fields, 1), score: numericField(fields, 2), - riichiSticks: numericField(fields, 3) + riichiSticks: numericField(fields, 3), + ...codec }; } @@ -915,7 +906,8 @@ function decodeSimpleActionPayload(actionName, bytes) { reason: "hule", scores: scores.length ? scores : gameEndScores, tiles: allTiles, - doraIndicators: tileStringFields(fields, 7) + doraIndicators: tileStringFields(fields, 7), + ...codec }; } @@ -929,7 +921,8 @@ function decodeSimpleActionPayload(actionName, bytes) { tiles: tileStringFields(fields, 4).length ? tileStringFields(fields, 4) : allTiles, allPlayerTiles: tileStringFields(fields, 6), ...(scores.length ? { scores } : {}), - ...(riichi ? { riichi } : {}) + ...(riichi ? { riichi } : {}), + ...codec }; } @@ -942,14 +935,16 @@ function decodeSimpleActionPayload(actionName, bytes) { gameEnd: Boolean(numericField(fields, 4)), scores: scoreInfo.scores, doraIndicators: scoreInfo.doraIndicators, - tiles: allTiles + tiles: allTiles, + ...codec }; } if (toEventType(actionName) === "dora") { return { tile: allTiles[0], - doraIndicators: allTiles + doraIndicators: allTiles, + ...codec }; } @@ -970,7 +965,8 @@ function decodeSimpleActionPayload(actionName, bytes) { doraIndicators, scores: numericFields(fields, 6), tiles: handTiles.length ? handTiles : allTiles.filter((tile) => !doraIndicators.includes(tile)), - leftTileCount: numericField(fields, 13) + leftTileCount: numericField(fields, 13), + ...codec }; } @@ -990,7 +986,8 @@ function decodeSimpleActionPayload(actionName, bytes) { doraIndicators, scores: numericFields(fields, 5), tiles: tileStringFields(fields, 7), - leftTileCount: numericField(fields, 15) + leftTileCount: numericField(fields, 15), + ...codec }; } @@ -1031,13 +1028,15 @@ function parseBinaryFrame(data) { const requestId = frameType === 1 ? null : bytes[1] | (bytes[2] << 8); const envelope = parseProtobufEnvelope(bytes.slice(messageOffset)); - const actionPrototype = envelope.methodName === ".lq.ActionPrototype" ? parseActionPrototype(envelope.payloadBytes) : null; + const actionPrototype = envelope.methodName === ".lq.ActionPrototype" + ? parseActionPrototype(envelope.payloadBytes, { allowWebglXor: true }) + : null; const directActionName = actionPrototype ? null : actionNameFromMethod(envelope.methodName); const actionName = actionPrototype?.actionName || directActionName; const actionPayloadBytes = actionPrototype?.actionPayloadBytes || (directActionName ? envelope.payloadBytes : new Uint8Array()); const actionPayloadFields = actionPrototype?.actionPayloadFields - || (directActionName ? summarizePayloadFields(actionPayloadBytes) : undefined); + || (directActionName ? summarizePayloadFields(actionPayloadBytes, { isTileString: tileLike }) : undefined); const restoreActionNames = extractRestoreActionNames(envelope.methodName, envelope.payloadBytes); const publicEnvelope = { frameType, @@ -1053,7 +1052,10 @@ function parseBinaryFrame(data) { actionPayloadLength: actionName ? actionPayloadBytes.length : undefined, actionPayloadSample: actionName ? bytesToHex(actionPayloadBytes, BINARY_ENVELOPE_SAMPLE_BYTES) : undefined, actionPayloadTruncated: actionName ? actionPayloadBytes.length > BINARY_ENVELOPE_SAMPLE_BYTES : undefined, - actionPayloadFields + actionPayloadFields, + ...(actionPrototype?.actionPayloadCodec + ? { actionPayloadCodec: actionPrototype.actionPayloadCodec } + : {}) }; return { envelope: publicEnvelope, @@ -1118,7 +1120,9 @@ export function parseBinaryMessage(data) { const type = toEventType(actionName || envelope.methodName); if (!type) return []; const actionPayload = actionName - ? decodeSimpleActionPayload(actionName, frame.actionPayloadBytes) + ? decodeSimpleActionPayload(actionName, frame.actionPayloadBytes, { + allowWebglXor: envelope.methodName === ".lq.ActionPrototype" + }) : {}; return expandStandardEvents(type, { ...actionPayload, diff --git a/src/adapter/protobuf.js b/src/adapter/protobuf.js new file mode 100644 index 0000000..f6d3d9b --- /dev/null +++ b/src/adapter/protobuf.js @@ -0,0 +1,173 @@ +export function toUint8Array(data) { + if (data instanceof ArrayBuffer) return new Uint8Array(data); + if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + return null; +} + +export function bytesToHex(bytes, max = bytes.length) { + return Array.from(bytes.slice(0, max), (byte) => byte.toString(16).padStart(2, "0")).join(" "); +} + +export function isPrintableShortText(text) { + return typeof text === "string" && /^[\x20-\x7E]{1,80}$/.test(text); +} + +export function groupValuesByField(entries, mapValue) { + const grouped = new Map(); + for (const entry of entries) { + const value = mapValue(entry); + if (value === undefined || value === null || value === "") continue; + if (!grouped.has(entry.field)) grouped.set(entry.field, []); + const values = grouped.get(entry.field); + if (values.length < 12) values.push(value); + } + return Array.from(grouped.entries()) + .sort((a, b) => a[0] - b[0]) + .map(([field, values]) => ({ field, values })); +} + +export function summarizePayloadFields(bytes, { isTileString = () => false } = {}) { + if (!bytes?.length) { + return { + varints: [], + strings: [], + tileStrings: [] + }; + } + const fields = parseProtobufEnvelope(bytes).fields; + return { + varints: groupValuesByField(fields.varints, (entry) => entry.value), + strings: groupValuesByField(fields.lengthDelimited, (entry) => ( + isPrintableShortText(entry.text) ? entry.text : undefined + )), + tileStrings: groupValuesByField(fields.lengthDelimited, (entry) => ( + isTileString(entry.text) ? entry.text : undefined + )) + }; +} + +export function decodeVarint(bytes, offset) { + let value = 0; + let multiplier = 1; + let cursor = offset; + while (cursor < bytes.length && cursor - offset < 10) { + const byte = bytes[cursor]; + value += (byte & 0x7f) * multiplier; + if (!Number.isSafeInteger(value)) return null; + cursor += 1; + if ((byte & 0x80) === 0) return { value, offset: cursor }; + multiplier *= 128; + } + return null; +} + +export function decodeUtf8(bytes) { + try { + return new TextDecoder().decode(bytes); + } catch { + return ""; + } +} + +export function parseProtobufEnvelope(bytes) { + let offset = 0; + const lengthDelimited = []; + const varints = []; + while (offset < bytes.length) { + const tag = decodeVarint(bytes, offset); + if (!tag) break; + offset = tag.offset; + const field = tag.value >> 3; + const wireType = tag.value & 0x07; + + if (wireType === 0) { + const value = decodeVarint(bytes, offset); + if (!value) break; + varints.push({ field, value: value.value }); + offset = value.offset; + continue; + } + + if (wireType === 2) { + const length = decodeVarint(bytes, offset); + if (!length) break; + offset = length.offset; + if (!Number.isSafeInteger(length.value) || length.value < 0) break; + const end = offset + length.value; + if (!Number.isSafeInteger(end) || end < offset) break; + if (end > bytes.length) break; + const valueBytes = bytes.slice(offset, end); + lengthDelimited.push({ field, bytes: valueBytes, text: decodeUtf8(valueBytes) }); + offset = end; + continue; + } + + if (wireType === 5) { + if (offset + 4 > bytes.length) break; + offset += 4; + continue; + } + + if (wireType === 1) { + if (offset + 8 > bytes.length) break; + offset += 8; + continue; + } + + break; + } + + const methodEntry = lengthDelimited.find((entry) => /^\.?lq\./.test(entry.text)) + || lengthDelimited.find((entry) => /Action|Req|Res|Notify|Lobby/.test(entry.text)); + const dataEntry = lengthDelimited.find((entry) => entry !== methodEntry && entry.bytes.length > 0); + return { + methodName: methodEntry?.text, + payloadBytes: dataEntry?.bytes || new Uint8Array(), + fields: { varints, lengthDelimited } + }; +} + +export function varintField(fields, id) { + return fields.varints.find((entry) => entry.field === id)?.value; +} + +export function varintFields(fields, id) { + return fields.varints.filter((entry) => entry.field === id).map((entry) => entry.value); +} + +export function decodeVarintSequence(bytes = new Uint8Array()) { + const values = []; + let offset = 0; + while (offset < bytes.length) { + const decoded = decodeVarint(bytes, offset); + if (!decoded || decoded.offset <= offset) break; + values.push(decoded.value); + offset = decoded.offset; + } + return offset === bytes.length ? values : []; +} + +export function packedVarintFields(fields, id) { + return fields.lengthDelimited + .filter((entry) => entry.field === id) + .flatMap((entry) => decodeVarintSequence(entry.bytes)); +} + +export function numericField(fields, id) { + return numericFields(fields, id)[0]; +} + +export function numericFields(fields, id) { + return [ + ...varintFields(fields, id), + ...packedVarintFields(fields, id) + ]; +} + +export function stringField(fields, id) { + return fields.lengthDelimited.find((entry) => entry.field === id && entry.text)?.text; +} + +export function stringFields(fields, id) { + return fields.lengthDelimited.filter((entry) => entry.field === id && entry.text).map((entry) => entry.text); +} diff --git a/src/core/config.js b/src/core/config.js new file mode 100644 index 0000000..79ab745 --- /dev/null +++ b/src/core/config.js @@ -0,0 +1,74 @@ +export const HELPER_CONFIG_KEY = "majsoul-helper-config"; +export const HELPER_CONFIG_VERSION = 1; +export const DEFAULT_BINARY_SAMPLE_BYTES = 65536; +export const MIN_BINARY_SAMPLE_BYTES = 16; +export const DEFAULT_MAX_EVENTS = 10000; +export const MAX_CAPTURE_EVENTS = 10000; + +export function normalizeCaptureLimit(value, fallback = DEFAULT_MAX_EVENTS) { + return normalizeInteger(value, { fallback, min: 1, max: MAX_CAPTURE_EVENTS }); +} + +export function normalizeBinarySampleBytes(value, fallback = DEFAULT_BINARY_SAMPLE_BYTES) { + return normalizeInteger(value, { + fallback, + min: MIN_BINARY_SAMPLE_BYTES, + max: DEFAULT_BINARY_SAMPLE_BYTES + }); +} + +export function loadHelperConfig(storage = globalThis.localStorage) { + const stored = readStoredConfig(storage); + const config = migrateConfig(stored); + if (JSON.stringify(stored) !== JSON.stringify(config)) writeStoredConfig(storage, config); + return config; +} + +export function updateHelperConfig(patch, storage = globalThis.localStorage) { + const current = loadHelperConfig(storage); + const next = normalizeCurrentConfig({ ...current, ...patch, configVersion: HELPER_CONFIG_VERSION }); + writeStoredConfig(storage, next); + return next; +} + +function migrateConfig(stored) { + if (stored.configVersion === HELPER_CONFIG_VERSION) return normalizeCurrentConfig(stored); + return normalizeCurrentConfig({ + ...stored, + configVersion: HELPER_CONFIG_VERSION, + captureLimit: Math.max(DEFAULT_MAX_EVENTS, normalizeCaptureLimit(stored.captureLimit)), + binarySampleBytes: Math.max(DEFAULT_BINARY_SAMPLE_BYTES, normalizeBinarySampleBytes(stored.binarySampleBytes)) + }); +} + +function normalizeCurrentConfig(config) { + return { + ...config, + configVersion: HELPER_CONFIG_VERSION, + captureLimit: normalizeCaptureLimit(config.captureLimit), + binarySampleBytes: normalizeBinarySampleBytes(config.binarySampleBytes) + }; +} + +function readStoredConfig(storage) { + try { + const parsed = JSON.parse(storage?.getItem?.(HELPER_CONFIG_KEY) || "{}"); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +function writeStoredConfig(storage, config) { + try { + storage?.setItem?.(HELPER_CONFIG_KEY, JSON.stringify(config)); + } catch { + // Restricted page contexts may disable storage; runtime settings still apply. + } +} + +function normalizeInteger(value, { fallback, min, max }) { + const number = Number(value); + if (!Number.isFinite(number) || number <= 0) return fallback; + return Math.max(min, Math.min(max, Math.floor(number))); +} diff --git a/src/core/eventDiagnostics.js b/src/core/eventDiagnostics.js new file mode 100644 index 0000000..8279be8 --- /dev/null +++ b/src/core/eventDiagnostics.js @@ -0,0 +1,339 @@ +import { indexToTile, normalizeTile, tilesToCounts } from "./tile.js"; + +export function isValidSeat(seat) { + const value = Number(seat); + return Number.isInteger(value) && value >= 0 && value <= 3; +} + +export function isValidTile(tile) { + try { + normalizeTile(tile); + return true; + } catch { + return false; + } +} + +export function orderEventsChronologically(events = []) { + const entries = [...events]; + if (entries.every((event) => Number.isFinite(Number(event?.eventId)))) { + return entries.sort((left, right) => Number(left.eventId) - Number(right.eventId)); + } + if (entries.every((event) => Number.isFinite(Number(event?.ts)))) { + return entries.sort((left, right) => Number(left.ts) - Number(right.ts)); + } + return entries; +} + +export function inferExpectedCurrentTurn(events = []) { + let expectedCurrentTurn = null; + for (const event of orderEventsChronologically(events)) { + if (event.type === "round_start" && isValidSeat(event.payload?.currentTurn)) { + expectedCurrentTurn = Number(event.payload.currentTurn); + } else if (event.type === "draw_tile" && isValidSeat(event.payload?.seat)) { + expectedCurrentTurn = Number(event.payload.seat); + } else if (event.type === "discard_tile" && isValidSeat(event.payload?.seat)) { + expectedCurrentTurn = null; + } else if (event.type === "call_meld" && isValidSeat(event.payload?.seat)) { + expectedCurrentTurn = Number(event.payload.seat); + } else if (event.type === "round_end") { + expectedCurrentTurn = null; + } + } + return expectedCurrentTurn; +} + +export function buildGameStateDiagnostics(events = [], gameState = {}) { + const orderedEvents = orderEventsChronologically(events); + const chiPengGangEvents = events.filter((event) => isChiPengGangAction(event.payload?.binaryEnvelope?.actionName)); + const anGangAddGangEvents = events.filter((event) => isAnGangAddGangAction(event.payload?.binaryEnvelope?.actionName)); + const callMeldEvents = events.filter((event) => event.type === "call_meld"); + const callMeldEventsWithSeat = callMeldEvents.filter((event) => isValidSeat(event.payload?.seat)); + const anGangAddGangEventsWithSeat = anGangAddGangEvents.filter((event) => isValidSeat(event.payload?.seat)); + const closedKanEvents = anGangAddGangEvents.filter((event) => Number(event.payload?.type) === 3); + const addedKanEvents = anGangAddGangEvents.filter((event) => Number(event.payload?.type) === 2); + const unknownKanTypeEvents = anGangAddGangEvents.filter((event) => ![2, 3].includes(Number(event.payload?.type))); + const selfSeat = isValidSeat(gameState.selfSeat) ? Number(gameState.selfSeat) : null; + const ownAnGangAddGangEvents = selfSeat === null + ? [] + : anGangAddGangEvents.filter((event) => Number(event.payload?.seat) === selfSeat); + const kanMeldMismatches = buildKanMeldMismatches(gameState, anGangAddGangEvents); + const ownKanTilesStillInHand = buildOwnKanTilesStillInHand(gameState, ownAnGangAddGangEvents); + const riichiEvents = events.filter((event) => event.type === "riichi"); + const riichiEventsWithSeat = riichiEvents.filter((event) => isValidSeat(event.payload?.seat)); + const roundEndEvents = events.filter((event) => event.type === "round_end"); + const roundEndEventsWithScores = roundEndEvents.filter((event) => event.payload?.scores?.length); + const ownDrawTileEvents = selfSeat === null + ? [] + : events.filter((event) => event.type === "draw_tile" && Number(event.payload?.seat) === selfSeat); + const ownDrawTileEventsWithValidTile = ownDrawTileEvents.filter((event) => isValidTile(event.payload?.tile)); + const meldCount = (gameState.melds || []).reduce((sum, melds) => sum + melds.length, 0); + const knownTileCounts = countKnownTiles(gameState); + const eventCounts = countEventTypes(events); + const expectedCurrentTurn = inferExpectedCurrentTurn(orderedEvents); + const currentTurnMatchesExpected = expectedCurrentTurn === null + ? null + : Number(gameState.currentTurn) === expectedCurrentTurn; + const expectedDiscards = [[], [], [], []]; + let claimableChiPengGangEvents = 0; + + for (const event of orderedEvents) { + if (event.type === "discard_tile" && isValidSeat(event.payload?.seat) && isValidTile(event.payload?.tile)) { + expectedDiscards[Number(event.payload.seat)].push(normalizeTile(event.payload.tile)); + } + if (isChiPengGangAction(event.payload?.binaryEnvelope?.actionName)) { + const removed = removeLatestClaimedDiscard( + expectedDiscards, + normalizeMeld(event.payload?.meld), + Number(event.payload?.seat) + ); + if (removed) claimableChiPengGangEvents += 1; + } + } + + const claimedDiscardTransferred = chiPengGangEvents.length + ? discardsEqual(expectedDiscards, gameState.discards || []) + : null; + + return { + eventCounts, + stateUpdated: { + hand: Boolean(gameState.handKnown && gameState.hand?.length), + drawnTile: Boolean(gameState.drawnTile) || ownDrawTileEventsWithValidTile.length > 0, + discards: Boolean(gameState.discards?.some((tiles) => tiles.length)), + melds: meldCount > 0, + doraIndicators: Boolean(gameState.doraIndicators?.length), + roundMetadata: gameState.chang !== null || gameState.ju !== null || gameState.round !== null, + riichi: Boolean(gameState.riichi?.some(Boolean)), + roundEndReason: gameState.roundEndReason !== null, + currentTurn: currentTurnMatchesExpected, + scores: Boolean(gameState.scoresKnown || gameState.scores?.some((score) => score !== 25000)), + visibleTiles: Boolean(gameState.visibleTiles?.length), + warningsClear: !(gameState.warnings?.length) + }, + selfSeat, + callMeldEvents: callMeldEvents.length, + callMeldEventsWithSeat: callMeldEventsWithSeat.length, + riichiEvents: riichiEvents.length, + riichiEventsWithSeat: riichiEventsWithSeat.length, + roundEndEvents: roundEndEvents.length, + roundEndEventsWithScores: roundEndEventsWithScores.length, + ownDrawTileEvents: ownDrawTileEvents.length, + ownDrawTileEventsWithValidTile: ownDrawTileEventsWithValidTile.length, + drawnTileRetained: Boolean(gameState.drawnTile), + chiPengGangEvents: chiPengGangEvents.length, + claimableChiPengGangEvents, + anGangAddGangEvents: anGangAddGangEvents.length, + anGangAddGangEventsWithSeat: anGangAddGangEventsWithSeat.length, + closedKanEvents: closedKanEvents.length, + addedKanEvents: addedKanEvents.length, + unknownKanTypeEvents: unknownKanTypeEvents.length, + ownAnGangAddGangEvents: ownAnGangAddGangEvents.length, + kanTypeKnown: anGangAddGangEvents.length ? unknownKanTypeEvents.length === 0 : null, + kanMeldTileCountsOk: anGangAddGangEvents.length ? kanMeldMismatches.length === 0 : null, + closedKanVisibleTileCountsOk: closedKanEvents.length + ? kanMeldMismatches.every((entry) => Number(entry.type) !== 3) + : null, + addedKanVisibleTileCountsOk: addedKanEvents.length + ? kanMeldMismatches.every((entry) => Number(entry.type) !== 2) + : null, + ownKanTilesRemoved: ownAnGangAddGangEvents.length ? ownKanTilesStillInHand.length === 0 : null, + kanMeldMismatches, + ownKanTilesStillInHand, + meldCount, + invalidTiles: gameState.invalidTiles || [], + overKnownTileLimit: knownTileCounts.filter((entry) => entry.count > 4), + expectedCurrentTurn, + currentTurnMatchesExpected, + claimedDiscardTransferred + }; +} + +export function buildMvpGate({ + events = [], + gameState = {}, + transport = {}, + liveStateComparison = null, + stateDiagnostics = null +} = {}) { + const eventTypes = new Set(events.map((event) => event.type)); + const diagnostics = stateDiagnostics || buildGameStateDiagnostics(events, gameState); + const checks = { + rawMessagesCaptured: Number(transport.rawMessages || 0) > 0, + inboundRawMessagesCaptured: Number(transport.inboundRawMessages || 0) > 0, + binaryEnvelopeDecoded: Number(transport.binaryEnvelopes || 0) > 0, + actionPrototypeDecoded: Number(transport.actionMessages || 0) > 0, + drawTileParsed: eventTypes.has("draw_tile"), + drawTileSeatParsed: hasEventWithValidSeat(events, "draw_tile"), + discardTileParsed: eventTypes.has("discard_tile"), + discardTileSeatParsed: hasEventWithValidSeat(events, "discard_tile"), + gameStateHandUpdated: diagnostics.stateUpdated.hand, + gameStateRoundMetadataUpdated: diagnostics.stateUpdated.roundMetadata, + gameStateDrawnTileUpdated: diagnostics.stateUpdated.drawnTile, + gameStateDiscardsUpdated: diagnostics.stateUpdated.discards, + gameStateDoraIndicatorsUpdated: diagnostics.stateUpdated.doraIndicators, + gameStateScoresUpdated: diagnostics.stateUpdated.scores, + gameStateVisibleTilesUpdated: diagnostics.stateUpdated.visibleTiles, + gameStateWarningsClear: diagnostics.stateUpdated.warningsClear + }; + + if (eventTypes.has("call_meld")) { + checks.callMeldSeatParsed = diagnostics.callMeldEventsWithSeat === diagnostics.callMeldEvents; + checks.gameStateMeldsUpdated = diagnostics.stateUpdated.melds; + } + if (diagnostics.claimableChiPengGangEvents > 0) { + checks.claimedDiscardTransferred = diagnostics.claimedDiscardTransferred === true; + } + if (diagnostics.anGangAddGangEvents > 0) { + checks.anGangAddGangSeatParsed = diagnostics.anGangAddGangEventsWithSeat === diagnostics.anGangAddGangEvents; + checks.kanTypeKnown = diagnostics.kanTypeKnown === true; + checks.kanMeldTileCountsOk = diagnostics.kanMeldTileCountsOk === true; + } + if (diagnostics.ownAnGangAddGangEvents > 0) { + checks.ownKanTilesRemoved = diagnostics.ownKanTilesRemoved === true; + } + if (diagnostics.expectedCurrentTurn !== null) { + checks.gameStateCurrentTurnUpdated = diagnostics.currentTurnMatchesExpected === true; + } + if (eventTypes.has("riichi")) { + checks.riichiSeatParsed = diagnostics.riichiEventsWithSeat === diagnostics.riichiEvents; + checks.gameStateRiichiUpdated = diagnostics.stateUpdated.riichi; + } + if (eventTypes.has("round_end")) { + checks.roundEndReasonUpdated = diagnostics.stateUpdated.roundEndReason; + const scores = latestRoundEndScores(events); + if (scores) checks.roundEndScoresUpdated = JSON.stringify(gameState.scores) === JSON.stringify(scores); + } + if (liveStateComparison?.available) { + checks.liveStateSnapshotMatches = liveStateComparison.mismatches.length === 0; + } + + const entries = Object.entries(checks); + const missing = entries.filter(([, passed]) => !passed).map(([name]) => name); + return { + ready: missing.length === 0, + readyForRealPageMvp: missing.length === 0, + checks, + passed: entries.length - missing.length, + total: entries.length, + missing, + diagnostics + }; +} + +function hasEventWithValidSeat(events, type) { + return events.some((event) => event.type === type && isValidSeat(event.payload?.seat)); +} + +function isChiPengGangAction(actionName) { + return actionName === "ActionChiPengGang" || actionName === "RecordChiPengGang"; +} + +function isAnGangAddGangAction(actionName) { + return actionName === "ActionAnGangAddGang" || actionName === "RecordAnGangAddGang"; +} + +function normalizeMeld(meld) { + const entries = meld ? (Array.isArray(meld) ? meld : [meld]) : []; + return entries.filter(isValidTile).map((tile) => normalizeTile(tile)); +} + +function latestRoundEndScores(events) { + const entries = orderEventsChronologically(events); + for (let index = entries.length - 1; index >= 0; index -= 1) { + const scores = entries[index].type === "round_end" ? entries[index].payload?.scores : null; + if (scores?.length) return scores; + } + return null; +} + +function buildKanMeldMismatches(gameState, events) { + const mismatches = []; + for (const event of events) { + const seat = Number(event.payload?.seat); + const tile = eventKanTile(event); + if (!isValidSeat(seat) || !tile) { + mismatches.push({ + seat: isValidSeat(seat) ? seat : null, + type: event.payload?.type ?? null, + tile, + expectedCopies: 4, + actualCopies: 0, + reason: "missing valid seat or tile" + }); + continue; + } + const actualCopies = maxMeldCopies(gameState, seat, tile); + if (actualCopies < 4) { + mismatches.push({ + seat, + type: event.payload?.type ?? null, + tile, + expectedCopies: 4, + actualCopies, + reason: "no four-tile kan meld in final state" + }); + } + } + return mismatches; +} + +function buildOwnKanTilesStillInHand(gameState, events) { + const counts = {}; + for (const tile of [...(gameState.hand || []), gameState.drawnTile].filter(isValidTile).map(normalizeTile)) { + counts[tile] = (counts[tile] || 0) + 1; + } + return events + .map(eventKanTile) + .filter(Boolean) + .filter((tile, index, tiles) => tiles.indexOf(tile) === index) + .filter((tile) => counts[tile] > 0) + .map((tile) => ({ tile, count: counts[tile] })); +} + +function eventKanTile(event) { + return normalizeMeld(event.payload?.meld)[0] || null; +} + +function maxMeldCopies(gameState, seat, tile) { + return Math.max(0, ...(gameState.melds?.[seat] || []).map((meld) => ( + normalizeMeld(meld).filter((meldTile) => meldTile === tile).length + ))); +} + +function removeLatestClaimedDiscard(discards, meld, callerSeat) { + if (!isValidSeat(callerSeat) || !meld.length) return false; + const claimedTiles = new Set(meld); + for (let seat = 0; seat < discards.length; seat += 1) { + if (seat === callerSeat) continue; + const river = discards[seat]; + const lastTile = river[river.length - 1]; + if (lastTile && claimedTiles.has(lastTile)) { + river.pop(); + return true; + } + } + return false; +} + +function discardsEqual(left, right) { + return left.length === right.length && left.every((river, seat) => { + const normalizedRight = (right[seat] || []).filter(isValidTile).map(normalizeTile); + return river.length === normalizedRight.length && river.every((tile, index) => tile === normalizedRight[index]); + }); +} + +function countEventTypes(events) { + const counts = {}; + for (const event of events) counts[event.type] = (counts[event.type] || 0) + 1; + return counts; +} + +function countKnownTiles(gameState) { + const knownTiles = [ + ...(gameState.hand || []), + gameState.drawnTile, + ...(gameState.visibleTiles || []) + ].filter(isValidTile); + return tilesToCounts(knownTiles).map((count, index) => ({ tile: indexToTile(index), count })); +} diff --git a/src/core/gameState.js b/src/core/gameState.js index 2a59299..9e1fc9e 100644 --- a/src/core/gameState.js +++ b/src/core/gameState.js @@ -11,6 +11,8 @@ const INITIAL_STATE = { round: null, chang: null, ju: null, + selfSeat: null, + selfSeatRequired: false, honba: 0, riichiSticks: 0, seatWind: null, @@ -27,6 +29,8 @@ const INITIAL_STATE = { events: [] }; +const MISSING_ROUND_START_WARNING = "partial live state: action step reset without decoded round_start"; + export class GameState { constructor() { this.state = structuredClone(INITIAL_STATE); @@ -43,18 +47,32 @@ export class GameState { applyEvent(event) { if (!event || !event.type) return; if (!isStandardGameEvent(event.type)) return; - if (event.type !== "raw_message") { - this.state.events = [event, ...this.state.events].slice(0, 100); - } const payload = event.payload || {}; - if (payload.binaryEnvelope?.step !== undefined) { - this.state.lastStep = payload.binaryEnvelope.step; + const nextStep = normalizeActionStep(payload.binaryEnvelope?.step); + const previousStep = normalizeActionStep(this.state.lastStep); + const eventHistory = [event, ...this.state.events].slice(0, 100); + if ( + event.type !== "round_start" + && nextStep !== null + && previousStep !== null + && nextStep < previousStep + ) { + this.reset({ + lastStep: nextStep, + partialStateWarnings: [MISSING_ROUND_START_WARNING], + events: eventHistory + }); + } else { + this.state.events = eventHistory; + if (nextStep !== null) this.state.lastStep = nextStep; } switch (event.type) { case "round_start": { const nextStateDiagnostics = { invalidTiles: [] }; const hand = sanitizeTiles(payload.tiles ?? [], nextStateDiagnostics, "round_start.tiles"); + const explicitSelfSeat = normalizeSeat(payload.selfSeat); + const selfSeat = explicitSelfSeat ?? inferDealerSelfSeat(hand, payload.ju); this.reset({ round: payload.round ?? null, chang: normalizeRoundIndex(payload.chang), @@ -62,9 +80,11 @@ export class GameState { honba: payload.honba ?? 0, riichiSticks: payload.riichiSticks ?? 0, roundWind: payload.roundWind ?? roundWindFromChang(payload.chang) ?? null, - seatWind: payload.seatWind ?? seatWindFromJu(payload.ju) ?? null, + selfSeat, + selfSeatRequired: selfSeat === null && hand.length > 0 && isActionNewRoundPayload(payload), + seatWind: payload.seatWind ?? seatWindFromSeats(selfSeat, payload.ju) ?? null, scores: payload.scores ?? [25000, 25000, 25000, 25000], - scoresKnown: Array.isArray(payload.scores) && payload.scores.length > 0, + scoresKnown: Array.isArray(payload.scores) && payload.scores.length >= 3, hand, handKnown: hand.length > 0, melds: sanitizeSeatMelds(payload.melds, nextStateDiagnostics, "round_start.melds"), @@ -82,21 +102,24 @@ export class GameState { case "deal_hand": this.state.hand = sanitizeTiles(payload.tiles || [], this.state, "deal_hand.tiles"); this.state.handKnown = this.state.hand.length > 0; + if (normalizeSeat(payload.selfSeat) !== undefined) { + this.state.selfSeat = normalizeSeat(payload.selfSeat); + this.state.selfSeatRequired = false; + if (!this.state.seatWind) this.state.seatWind = seatWindFromSeats(this.state.selfSeat, this.state.ju); + } this.state.drawnTile = null; break; case "draw_tile": { + const seat = normalizeSeat(payload.seat); + this.state.leftTileCount = payload.leftTileCount ?? this.state.leftTileCount; + mergeDoraIndicatorList(this.state, payload.doraIndicators); + if (seat === undefined) break; + this.state.currentTurn = seat; if (!payload.tile) break; const tile = sanitizeTile(payload.tile, this.state, "draw_tile.tile"); if (!tile) break; - if (normalizeSeat(payload.seat) === undefined) { - mergeDoraIndicatorList(this.state, payload.doraIndicators); - this.state.leftTileCount = payload.leftTileCount ?? this.state.leftTileCount; - break; - } - if (normalizeSeat(payload.seat) === 0) this.state.drawnTile = tile; - this.state.currentTurn = normalizeSeat(payload.seat); - this.state.leftTileCount = payload.leftTileCount ?? this.state.leftTileCount; - mergeDoraIndicatorList(this.state, payload.doraIndicators); + inferSelfSeatFromPrivateDraw(this.state, payload, seat); + if (seat === this.state.selfSeat) this.state.drawnTile = tile; break; } case "discard_tile": { @@ -113,7 +136,7 @@ export class GameState { this.state.riichi[normalizeSeat(payload.seat)] = true; } mergeDoraIndicatorList(this.state, payload.doraIndicators); - if (normalizeSeat(payload.seat) === 0) { + if (normalizeSeat(payload.seat) === this.state.selfSeat) { if (this.state.drawnTile && tileToIndex(this.state.drawnTile) === tileToIndex(tile)) { this.state.drawnTile = null; } else { @@ -158,14 +181,14 @@ export class GameState { } if (isChiPengGangAction(actionName)) { const claimedTile = removeClaimedDiscard(this.state, meld, callerSeat); - if (callerSeat === 0) { + if (callerSeat === this.state.selfSeat) { removeOwnMeldTiles(this.state, meld, claimedTile); } } - if (isAnGangAddGangAction(actionName) && callerSeat === 0) { + if (isAnGangAddGangAction(actionName) && callerSeat === this.state.selfSeat) { removeOwnKnownTiles(this.state, meld); } - if (isBaBei && callerSeat === 0) { + if (isBaBei && callerSeat === this.state.selfSeat) { removeOwnKnownTile(this.state, "4z"); } break; @@ -222,17 +245,24 @@ function normalizeRoundIndex(value) { return Number.isInteger(number) && number >= 0 ? number : null; } +function normalizeActionStep(value) { + if (value === undefined || value === null) return null; + const number = Number(value); + return Number.isInteger(number) && number >= 0 ? number : null; +} + function roundWindFromChang(chang) { const winds = ["E", "S", "W", "N"]; const index = normalizeRoundIndex(chang); return index === null ? null : winds[index] ?? null; } -function seatWindFromJu(ju) { +function seatWindFromSeats(selfSeat, ju) { const winds = ["E", "S", "W", "N"]; + const playerSeat = normalizeSeat(selfSeat); const dealerSeat = normalizeRoundIndex(ju); - if (dealerSeat === null || dealerSeat > 3) return null; - return winds[(4 - dealerSeat) % 4]; + if (playerSeat === undefined || dealerSeat === null || dealerSeat > 3) return null; + return winds[(playerSeat - dealerSeat + 4) % 4]; } function normalizeSeat(seat) { @@ -241,6 +271,24 @@ function normalizeSeat(seat) { return Number.isInteger(value) && value >= 0 && value <= 3 ? value : undefined; } +function inferSelfSeatFromPrivateDraw(state, payload, seat) { + if (state.selfSeat !== null || !isPrivateDrawPayload(payload)) return; + state.selfSeat = seat; + state.selfSeatRequired = false; + if (!state.seatWind) state.seatWind = seatWindFromSeats(seat, state.ju); +} + +function inferDealerSelfSeat(hand, ju) { + const dealerSeat = normalizeSeat(ju); + return hand.length === 14 && dealerSeat !== undefined ? dealerSeat : null; +} + +function isPrivateDrawPayload(payload = {}) { + if (payload.isSelf === true) return true; + const actionName = payload.binaryEnvelope?.actionName; + return Boolean(payload.tile) && !String(actionName || "").startsWith("Record"); +} + function normalizeMeld(meld, state) { if (!meld) return []; return sanitizeTiles(Array.isArray(meld) ? meld : [meld], state, "call_meld.meld"); @@ -427,6 +475,9 @@ function buildWarnings(state) { if (state.drawnTile && !state.hand.length) { warnings.push("drawnTile exists without base hand"); } + if (state.selfSeatRequired) { + warnings.push("self seat is unknown; own draw/discard tracking is not reliable yet"); + } for (const warning of state.partialStateWarnings || []) { warnings.push(warning); } diff --git a/src/core/realPageReadiness.js b/src/core/realPageReadiness.js index a521d5e..5b8690e 100644 --- a/src/core/realPageReadiness.js +++ b/src/core/realPageReadiness.js @@ -137,3 +137,34 @@ export function summarizeLiveSafetySettings(settings) { missing }; } + +export function summarizeRealPageReadiness({ + readyForRealPageMvp, + page, + liveOverlayAvailable, + liveStateSnapshotMatches, + livePreflight, + liveSafety +}) { + const missing = []; + const mahjongSoulPage = isMahjongSoulPage(page); + if (!readyForRealPageMvp) missing.push("acceptance.readyForRealPageMvp is not true"); + if (!mahjongSoulPage) missing.push("captureMetadata.page is not a Mahjong Soul web page"); + if (!liveOverlayAvailable) missing.push("overlay live debug/gate snapshot is missing"); + if (livePreflight?.ready !== true) { + missing.push(...(livePreflight?.missing?.length + ? livePreflight.missing + : ["liveRealPagePreflight checks are not ready"])); + } + if (liveSafety?.ready !== true) { + missing.push(...(liveSafety?.missing?.length + ? liveSafety.missing + : ["liveSafetySettings safety checks are not ready"])); + } + if (liveStateSnapshotMatches !== true) missing.push("liveStateSnapshotMatches is not true"); + return { + ready: missing.length === 0, + missing: [...new Set(missing)], + mahjongSoulPage + }; +} diff --git a/src/core/version.js b/src/core/version.js new file mode 100644 index 0000000..e42810b --- /dev/null +++ b/src/core/version.js @@ -0,0 +1 @@ +export const HELPER_VERSION = "0.3.0"; diff --git a/src/main.js b/src/main.js index e5a1b7c..715cfbf 100644 --- a/src/main.js +++ b/src/main.js @@ -1,34 +1,24 @@ -import { DEFAULT_BINARY_SAMPLE_BYTES, DEFAULT_MAX_EVENTS, MajsoulAdapter } from "./adapter/majsoulAdapter.js"; +import { MajsoulAdapter } from "./adapter/majsoulAdapter.js"; +import { loadHelperConfig } from "./core/config.js"; import { GameState } from "./core/gameState.js"; import { analyzeHand } from "./core/analyzer.js"; import { parseTiles } from "./core/tile.js"; +import { HELPER_VERSION } from "./core/version.js"; import { Overlay } from "./ui/overlay.js"; -const STORAGE_KEY = "majsoul-helper-config"; -const HELPER_VERSION = "0.2.13"; - -function upgradedStoredNumber(value, fallback) { - const number = Number(value); - if (!Number.isFinite(number) || number <= 0) return fallback; - return Math.max(fallback, Math.floor(number)); -} - -function readConfig() { - try { - return JSON.parse(window.localStorage.getItem(STORAGE_KEY) || "{}"); - } catch { - return {}; - } -} - function boot() { const existingHelper = window.__majsoulHelper; if (existingHelper?.version === HELPER_VERSION) return existingHelper; if (existingHelper?.version) { + try { + existingHelper.overlay?.destroy?.(); + } catch { + // Best-effort UI cleanup; the new overlay will replace it below. + } try { existingHelper.adapter?.uninstall?.(); } catch { - // Best-effort upgrade cleanup; the new adapter will install below. + // Best-effort hook cleanup; the new adapter will install below. } try { existingHelper.overlay?.root?.remove?.(); @@ -37,11 +27,11 @@ function boot() { // DOM cleanup should not block replacing an older helper singleton. } } - const config = readConfig(); + const config = loadHelperConfig(window.localStorage); const adapter = new MajsoulAdapter({ helperVersion: HELPER_VERSION, - binarySampleBytes: upgradedStoredNumber(config.binarySampleBytes, DEFAULT_BINARY_SAMPLE_BYTES), - maxEvents: upgradedStoredNumber(config.captureLimit, DEFAULT_MAX_EVENTS) + binarySampleBytes: config.binarySampleBytes, + maxEvents: config.captureLimit }); const gameState = new GameState(); const helper = { diff --git a/src/ui/overlay.js b/src/ui/overlay.js index c8644f0..a1a29e1 100644 --- a/src/ui/overlay.js +++ b/src/ui/overlay.js @@ -1,14 +1,20 @@ import { analyzeHand } from "../core/analyzer.js"; +import { + DEFAULT_BINARY_SAMPLE_BYTES, + DEFAULT_MAX_EVENTS as DEFAULT_CAPTURE_LIMIT, + loadHelperConfig, + normalizeBinarySampleBytes, + normalizeCaptureLimit, + updateHelperConfig +} from "../core/config.js"; +import { buildMvpGate } from "../core/eventDiagnostics.js"; import { isStandardGameEvent } from "../core/events.js"; import { buildLiveRealPagePreflight, CAPTURE_VERIFICATION } from "../core/realPageReadiness.js"; -import { doraFromIndicator, normalizeTile, parseTiles, sortTiles } from "../core/tile.js"; +import { doraFromIndicator, parseTiles, sortTiles } from "../core/tile.js"; import { overlayStyles } from "./styles.js"; -const STORAGE_KEY = "majsoul-helper-config"; const OVERLAY_CAPTURE_NOTE = "Majsoul Helper capture export. Contains message summaries/samples plus liveGameState, liveDebugSummary, liveMvpGate, liveSafetySettings, and liveRealPagePreflight snapshots copied from the overlay; no messages were modified by the helper."; -const DEFAULT_BINARY_SAMPLE_BYTES = 65536; -const DEFAULT_CAPTURE_LIMIT = 10000; -const MAX_CAPTURE_LIMIT = 10000; +const CAPTURE_DOWNLOAD_REVOKE_DELAY_MS = 60_000; const OVERLAY_EVENT_SHIELD_TYPES = [ "pointerdown", "pointerup", @@ -109,35 +115,6 @@ function getCurrentPageDiagnostics() { }; } -function readConfig() { - try { - return JSON.parse(window.localStorage.getItem(STORAGE_KEY) || "{}"); - } catch { - return {}; - } -} - -function writeConfig(patch) { - try { - const next = { ...readConfig(), ...patch }; - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); - } catch { - // Storage can be unavailable in restricted page contexts; runtime config still applies. - } -} - -function normalizeCaptureLimit(value, fallback = DEFAULT_CAPTURE_LIMIT) { - const number = Number(value); - if (!Number.isFinite(number) || number <= 0) return fallback; - return Math.max(1, Math.min(MAX_CAPTURE_LIMIT, Math.floor(number))); -} - -function normalizeBinarySampleBytes(value, fallback = DEFAULT_BINARY_SAMPLE_BYTES) { - const number = Number(value); - if (!Number.isFinite(number) || number <= 0) return fallback; - return Math.max(16, Math.min(DEFAULT_BINARY_SAMPLE_BYTES, Math.floor(number))); -} - function renderSeatState(state) { return [0, 1, 2, 3].map((seat) => { const discards = state.discards?.[seat] || []; @@ -145,12 +122,13 @@ function renderSeatState(state) { const score = state.scores?.[seat] ?? "-"; const riichi = state.riichi?.[seat] ? " riichi" : ""; const turn = state.currentTurn === seat ? " turn" : ""; + const self = state.selfSeat === seat ? " self" : ""; const meldText = melds.length ? melds.map((meld) => (meld || []).join(" ")).join(" / ") : "-"; return `
-
Seat ${seat}${turn}${riichi} / ${escapeHtml(score)}
+
Seat ${seat}${self}${turn}${riichi} / ${escapeHtml(score)}
River
${discards.length ? renderTiles(discards) : `-`}
Melds: ${escapeHtml(meldText)}
@@ -434,274 +412,19 @@ function captureHealth(adapter, summary, installDiagnostics = {}, state = {}) { return "Standard game events parsed. Compare gameState with the visible table."; } -function isValidSeat(seat) { - const value = Number(seat); - return Number.isInteger(value) && value >= 0 && value <= 3; -} - -function isValidTile(tile) { - try { - normalizeTile(tile); - return true; - } catch { - return false; - } -} - -function hasEventWithValidSeat(events, type) { - return events.some((event) => event.type === type && isValidSeat(event.payload?.seat)); -} - -function hasOwnDrawTileWithValidTile(events) { - return events.some((event) => ( - event.type === "draw_tile" - && Number(event.payload?.seat) === 0 - && isValidTile(event.payload?.tile) - )); -} - -function stableJson(value) { - return JSON.stringify(value); -} - -function isChiPengGangAction(actionName) { - return actionName === "ActionChiPengGang" || actionName === "RecordChiPengGang"; -} - -function isAnGangAddGangAction(actionName) { - return actionName === "ActionAnGangAddGang" || actionName === "RecordAnGangAddGang"; -} - -function normalizeMeld(meld) { - if (!meld) return []; - return Array.isArray(meld) ? meld : [meld]; -} - -function chronologicalEvents(events) { - const entries = [...(events || [])]; - if (entries.every((event) => Number.isFinite(Number(event.ts)))) { - return entries.sort((left, right) => Number(left.ts) - Number(right.ts)); - } - return entries.reverse(); -} - -function removeLatestClaimedDiscard(discards, meld, callerSeat) { - if (!isValidSeat(callerSeat) || !meld.length) return false; - for (let seat = 0; seat < discards.length; seat += 1) { - if (seat === callerSeat) continue; - const river = discards[seat]; - const lastTile = river[river.length - 1]; - if (lastTile && meld.includes(lastTile)) { - river.pop(); - return true; - } - } - return false; -} - -function discardsEqual(left, right) { - return left.length === right.length && left.every((river, index) => ( - river.length === (right[index] || []).length - && river.every((tile, tileIndex) => tile === right[index][tileIndex]) - )); -} - -function buildClaimedDiscardDiagnostics(events, state) { - const expectedDiscards = [[], [], [], []]; - const chiPengGangEvents = (events || []).filter((event) => ( - isChiPengGangAction(event.payload?.binaryEnvelope?.actionName) - )); - let claimableChiPengGangEvents = 0; - - for (const event of chronologicalEvents(events)) { - if (event.type === "discard_tile" && isValidSeat(event.payload?.seat) && event.payload?.tile) { - expectedDiscards[Number(event.payload.seat)].push(event.payload.tile); - } - if (isChiPengGangAction(event.payload?.binaryEnvelope?.actionName)) { - const removed = removeLatestClaimedDiscard( - expectedDiscards, - normalizeMeld(event.payload?.meld), - Number(event.payload?.seat) - ); - if (removed) claimableChiPengGangEvents += 1; - } - } - - return { - chiPengGangEvents: chiPengGangEvents.length, - claimableChiPengGangEvents, - claimedDiscardTransferred: chiPengGangEvents.length - ? discardsEqual(expectedDiscards, state.discards || []) - : null - }; -} - -function buildKanDiagnostics(events, state) { - const anGangAddGangEvents = (events || []).filter((event) => ( - isAnGangAddGangAction(event.payload?.binaryEnvelope?.actionName) - )); - const anGangAddGangEventsWithSeat = anGangAddGangEvents.filter((event) => isValidSeat(event.payload?.seat)); - const unknownKanTypeEvents = anGangAddGangEvents.filter((event) => { - const type = Number(event.payload?.type); - return type !== 2 && type !== 3; - }); - const ownAnGangAddGangEvents = anGangAddGangEvents.filter((event) => Number(event.payload?.seat) === 0); - const kanMeldMismatches = buildKanMeldMismatches(state, anGangAddGangEvents); - const ownKanTilesStillInHand = buildOwnKanTilesStillInHand(state, ownAnGangAddGangEvents); - return { - anGangAddGangEvents: anGangAddGangEvents.length, - anGangAddGangEventsWithSeat: anGangAddGangEventsWithSeat.length, - unknownKanTypeEvents: unknownKanTypeEvents.length, - ownAnGangAddGangEvents: ownAnGangAddGangEvents.length, - kanTypeKnown: anGangAddGangEvents.length ? unknownKanTypeEvents.length === 0 : null, - kanMeldTileCountsOk: anGangAddGangEvents.length ? kanMeldMismatches.length === 0 : null, - ownKanTilesRemoved: ownAnGangAddGangEvents.length ? ownKanTilesStillInHand.length === 0 : null - }; -} - -function buildKanMeldMismatches(state, events) { - const mismatches = []; - for (const event of events) { - const seat = Number(event.payload?.seat); - const tile = eventKanTile(event); - const actualCopies = isValidSeat(seat) && tile ? maxMeldCopies(state, seat, tile) : 0; - if (actualCopies < 4) { - mismatches.push({ seat, tile, actualCopies }); - } - } - return mismatches; -} - -function buildOwnKanTilesStillInHand(state, events) { - const ownKnownTiles = [ - ...(state.hand || []), - state.drawnTile - ].filter(Boolean).filter(isValidTile).map((tile) => normalizeTile(tile)); - const counts = {}; - for (const tile of ownKnownTiles) { - counts[tile] = (counts[tile] || 0) + 1; - } - return events - .map((event) => eventKanTile(event)) - .filter(Boolean) - .filter((tile, index, tiles) => tiles.indexOf(tile) === index) - .filter((tile) => counts[tile] > 0) - .map((tile) => ({ tile, count: counts[tile] })); -} - -function eventKanTile(event) { - const normalizedTiles = normalizeMeld(event.payload?.meld) - .filter(isValidTile) - .map((tile) => normalizeTile(tile)); - return normalizedTiles[0] || null; -} - -function maxMeldCopies(state, seat, tile) { - let maxCopies = 0; - for (const meld of state.melds?.[seat] || []) { - const copies = normalizeMeld(meld) - .filter(isValidTile) - .map((meldTile) => normalizeTile(meldTile)) - .filter((meldTile) => meldTile === tile) - .length; - maxCopies = Math.max(maxCopies, copies); - } - return maxCopies; -} - -function latestRoundEndScores(events) { - const entries = chronologicalEvents(events); - for (let index = entries.length - 1; index >= 0; index -= 1) { - const event = entries[index]; - if (event.type === "round_end" && event.payload?.scores?.length) { - return event.payload.scores; - } - } - return null; -} - -function inferExpectedCurrentTurn(events) { - let expectedCurrentTurn = null; - for (const event of chronologicalEvents(events)) { - if (event.type === "round_start" && isValidSeat(event.payload?.currentTurn)) { - expectedCurrentTurn = Number(event.payload.currentTurn); - } - if (event.type === "draw_tile" && isValidSeat(event.payload?.seat)) { - expectedCurrentTurn = Number(event.payload.seat); - } - if (event.type === "discard_tile" && isValidSeat(event.payload?.seat)) { - expectedCurrentTurn = null; - } - if (event.type === "call_meld" && isValidSeat(event.payload?.seat)) { - expectedCurrentTurn = Number(event.payload.seat); - } - if (event.type === "round_end") { - expectedCurrentTurn = null; - } - } - return expectedCurrentTurn; -} - function buildLiveMvpGate(events, state, summary) { - const eventTypes = events.map((event) => event.type); const rawEvents = events.filter((event) => event.type === "raw_message"); - const claimedDiscardDiagnostics = buildClaimedDiscardDiagnostics(events, state); - const kanDiagnostics = buildKanDiagnostics(events, state); - const expectedCurrentTurn = inferExpectedCurrentTurn(events); - const checks = { - rawMessagesCaptured: summary.raw > 0, - inboundRawMessagesCaptured: summary.inbound > 0, - binaryEnvelopeDecoded: summary.envelopes > 0, - actionPrototypeDecoded: rawEvents.some((event) => envelopeActionNames(event.payload?.envelope).length > 0), - drawTileParsed: events.some((event) => event.type === "draw_tile"), - drawTileSeatParsed: hasEventWithValidSeat(events, "draw_tile"), - discardTileParsed: events.some((event) => event.type === "discard_tile"), - discardTileSeatParsed: hasEventWithValidSeat(events, "discard_tile"), - gameStateHandUpdated: Boolean(state.handKnown && state.hand?.length), - gameStateRoundMetadataUpdated: state.chang !== null || state.ju !== null || state.round !== null, - gameStateDrawnTileUpdated: Boolean(state.drawnTile) || hasOwnDrawTileWithValidTile(events), - gameStateDiscardsUpdated: Boolean(state.discards?.some((tiles) => tiles.length)), - gameStateDoraIndicatorsUpdated: Boolean(state.doraIndicators?.length), - gameStateScoresUpdated: Boolean(state.scoresKnown || state.scores?.some((score) => score !== 25000)), - gameStateVisibleTilesUpdated: Boolean(state.visibleTiles?.length), - gameStateWarningsClear: !(state.warnings?.length) - }; - if (eventTypes.includes("call_meld")) { - checks.callMeldSeatParsed = hasEventWithValidSeat(events, "call_meld"); - checks.gameStateMeldsUpdated = Boolean(state.melds?.some((melds) => melds.length)); - } - if (claimedDiscardDiagnostics.claimableChiPengGangEvents > 0) { - checks.claimedDiscardTransferred = claimedDiscardDiagnostics.claimedDiscardTransferred === true; - } - if (kanDiagnostics.anGangAddGangEvents > 0) { - checks.anGangAddGangSeatParsed = kanDiagnostics.anGangAddGangEventsWithSeat === kanDiagnostics.anGangAddGangEvents; - checks.kanTypeKnown = kanDiagnostics.kanTypeKnown === true; - checks.kanMeldTileCountsOk = kanDiagnostics.kanMeldTileCountsOk === true; - } - if (kanDiagnostics.ownAnGangAddGangEvents > 0) { - checks.ownKanTilesRemoved = kanDiagnostics.ownKanTilesRemoved === true; - } - if (expectedCurrentTurn !== null) { - checks.gameStateCurrentTurnUpdated = Number(state.currentTurn) === expectedCurrentTurn; - } - if (eventTypes.includes("riichi")) { - checks.riichiSeatParsed = hasEventWithValidSeat(events, "riichi"); - checks.gameStateRiichiUpdated = Boolean(state.riichi?.some(Boolean)); - } - if (eventTypes.includes("round_end")) { - checks.roundEndReasonUpdated = state.roundEndReason !== null; - const roundEndScores = latestRoundEndScores(events); - if (roundEndScores) { - checks.roundEndScoresUpdated = stableJson(state.scores) === stableJson(roundEndScores); + const { diagnostics: _diagnostics, ...gate } = buildMvpGate({ + events, + gameState: state, + transport: { + rawMessages: summary.raw, + inboundRawMessages: summary.inbound, + binaryEnvelopes: summary.envelopes, + actionMessages: rawEvents.filter((event) => envelopeActionNames(event.payload?.envelope).length > 0).length } - } - const entries = Object.entries(checks); - return { - checks, - passed: entries.filter(([, value]) => value).length, - total: entries.length, - missing: entries.filter(([, value]) => !value).map(([key]) => key) - }; + }); + return gate; } function renderLiveMvpGate(gate) { @@ -750,21 +473,17 @@ export class Overlay { this.realtimeAdvice = false; this.captureLimitDraft = null; this.binarySampleBytesDraft = null; - const config = readConfig(); - const storedCaptureLimit = normalizeCaptureLimit(config.captureLimit ?? DEFAULT_CAPTURE_LIMIT, DEFAULT_CAPTURE_LIMIT); - const adapterCaptureLimit = normalizeCaptureLimit(adapter.maxEvents ?? DEFAULT_CAPTURE_LIMIT, DEFAULT_CAPTURE_LIMIT); - this.captureLimit = Math.max(DEFAULT_CAPTURE_LIMIT, storedCaptureLimit, adapterCaptureLimit); + const config = loadHelperConfig(window.localStorage); + this.captureLimit = normalizeCaptureLimit(config.captureLimit, adapter.maxEvents ?? DEFAULT_CAPTURE_LIMIT); if (typeof adapter.setMaxEvents === "function") { this.captureLimit = adapter.setMaxEvents(this.captureLimit); } - const storedBinarySampleBytes = normalizeBinarySampleBytes(config.binarySampleBytes ?? DEFAULT_BINARY_SAMPLE_BYTES, DEFAULT_BINARY_SAMPLE_BYTES); - const adapterBinarySampleBytes = normalizeBinarySampleBytes(adapter.binarySampleBytes ?? DEFAULT_BINARY_SAMPLE_BYTES, DEFAULT_BINARY_SAMPLE_BYTES); - this.binarySampleBytes = Math.max(DEFAULT_BINARY_SAMPLE_BYTES, storedBinarySampleBytes, adapterBinarySampleBytes); + this.binarySampleBytes = normalizeBinarySampleBytes(config.binarySampleBytes, adapter.binarySampleBytes ?? DEFAULT_BINARY_SAMPLE_BYTES); if (typeof adapter.setBinarySampleBytes === "function") { this.binarySampleBytes = adapter.setBinarySampleBytes(this.binarySampleBytes); } if (config.captureLimit !== this.captureLimit || config.binarySampleBytes !== this.binarySampleBytes) { - writeConfig({ + updateHelperConfig({ captureLimit: this.captureLimit, binarySampleBytes: this.binarySampleBytes }); @@ -772,16 +491,21 @@ export class Overlay { this.copyError = ""; this.copyFallbackText = ""; this.downloadUrl = ""; + this.downloadRevokeTimer = null; this.selfTestResult = null; this.root = null; + this.style = null; + this.adapterListeners = []; this.overlayEventShieldBound = false; } mount() { - if (this.root) return; - const style = document.createElement("style"); - style.textContent = overlayStyles; - document.documentElement.appendChild(style); + if (this.root) return this; + document.getElementById("majsoul-helper-overlay-style")?.remove(); + this.style = document.createElement("style"); + this.style.id = "majsoul-helper-overlay-style"; + this.style.textContent = overlayStyles; + document.documentElement.appendChild(this.style); this.root = document.createElement("div"); this.root.id = "majsoul-helper-overlay"; @@ -789,6 +513,20 @@ export class Overlay { this.bindOverlayEventShield(); this.bindAdapter(); this.render(); + return this; + } + + destroy() { + for (const { type, listener } of this.adapterListeners) { + this.adapter.removeEventListener(type, listener); + } + this.adapterListeners = []; + this.releaseCaptureDownloadUrl(); + this.root?.remove(); + this.style?.remove(); + this.root = null; + this.style = null; + this.overlayEventShieldBound = false; } bindOverlayEventShield() { @@ -803,18 +541,19 @@ export class Overlay { } bindAdapter() { - this.adapter.addEventListener("majsoul-helper:event", (event) => { + if (this.adapterListeners.length) return; + this.listenToAdapter("majsoul-helper:event", (event) => { this.gameState.applyEvent(event.detail); this.render(); }); - this.adapter.addEventListener("majsoul-helper:clear", () => { + this.listenToAdapter("majsoul-helper:clear", () => { this.gameState.clearEvents(); this.render(); }); - this.adapter.addEventListener("majsoul-helper:install", () => { + this.listenToAdapter("majsoul-helper:install", () => { this.render(); }); - this.adapter.addEventListener("majsoul-helper:config", () => { + this.listenToAdapter("majsoul-helper:config", () => { if (this.captureLimitDraft === null && Number.isFinite(this.adapter.maxEvents)) { this.captureLimit = this.adapter.maxEvents; } @@ -823,15 +562,20 @@ export class Overlay { } this.render(); }); - this.adapter.addEventListener("majsoul-helper:socket", () => { + this.listenToAdapter("majsoul-helper:socket", () => { this.render(); }); - this.adapter.addEventListener("majsoul-helper:self-test", (event) => { + this.listenToAdapter("majsoul-helper:self-test", (event) => { this.selfTestResult = event.detail; this.render(); }); } + listenToAdapter(type, listener) { + this.adapter.addEventListener(type, listener); + this.adapterListeners.push({ type, listener }); + } + render() { if (!this.root) return; const focusSnapshot = this.captureFocusSnapshot(); @@ -841,7 +585,9 @@ export class Overlay { const handForAnalysis = usingManualInput ? this.manualTiles : state.hand; const drawnTileForAnalysis = usingManualInput ? null : state.drawnTile; const visibleTilesForAnalysis = usingManualInput ? [] : (state.visibleTiles || []); - const openMeldsForAnalysis = usingManualInput ? 0 : (state.melds?.[0]?.length || 0); + const openMeldsForAnalysis = usingManualInput || state.selfSeat === null + ? 0 + : (state.melds?.[state.selfSeat]?.length || 0); const shouldAnalyze = hasValidManualTiles || (!usingManualInput && this.realtimeAdvice); const analysisResult = shouldAnalyze && handForAnalysis.length ? safeAnalyzeHand({ hand: handForAnalysis, drawnTile: drawnTileForAnalysis, visibleTiles: visibleTilesForAnalysis, openMelds: openMeldsForAnalysis }) @@ -926,7 +672,7 @@ export class Overlay {
- Download capture +
@@ -1018,8 +764,8 @@ export class Overlay { this.root.querySelector('[data-action="copy-capture"]').onclick = async () => { await this.copyText(safeJson(this.buildOverlayCapture())); }; - this.root.querySelector('[data-action="download-capture"]').onclick = (event) => { - if (!this.prepareCaptureDownload(event.currentTarget)) event.preventDefault(); + this.root.querySelector('[data-action="download-capture"]').onclick = () => { + this.downloadCapture(); }; this.root.querySelector('[data-action="clear-debug"]').onclick = () => { this.adapter.clearEvents(); @@ -1114,7 +860,7 @@ export class Overlay { if (typeof this.adapter.setMaxEvents === "function") { this.captureLimit = this.adapter.setMaxEvents(nextValue); } - writeConfig({ captureLimit: this.captureLimit }); + updateHelperConfig({ captureLimit: this.captureLimit }); this.render(); } @@ -1126,7 +872,7 @@ export class Overlay { } else { this.binarySampleBytes = nextValue; } - writeConfig({ binarySampleBytes: this.binarySampleBytes }); + updateHelperConfig({ binarySampleBytes: this.binarySampleBytes }); this.render(); } @@ -1231,7 +977,7 @@ export class Overlay { }; } - prepareCaptureDownload(link) { + downloadCapture() { const text = safeJson(this.buildOverlayCapture()); if (typeof Blob === "undefined" || !globalThis.URL?.createObjectURL) { this.copyError = "Capture download is unavailable in this browser context. Select and copy the text below."; @@ -1239,18 +985,34 @@ export class Overlay { this.render(); return false; } - if (this.downloadUrl && globalThis.URL?.revokeObjectURL) { - globalThis.URL.revokeObjectURL(this.downloadUrl); - } + this.releaseCaptureDownloadUrl(); const blob = new Blob([text], { type: "application/json" }); this.downloadUrl = globalThis.URL.createObjectURL(blob); - link.href = this.downloadUrl; - link.download = `majsoul-helper-capture-${new Date().toISOString().replace(/[:.]/g, "-")}.json`; + const downloadLink = document.createElement("a"); + downloadLink.href = this.downloadUrl; + downloadLink.download = `majsoul-helper-capture-${new Date().toISOString().replace(/[:.]/g, "-")}.json`; this.copyError = ""; this.copyFallbackText = ""; + downloadLink.click(); + const downloadUrl = this.downloadUrl; + this.downloadRevokeTimer = globalThis.setTimeout(() => { + this.releaseCaptureDownloadUrl(downloadUrl); + }, CAPTURE_DOWNLOAD_REVOKE_DELAY_MS); return true; } + releaseCaptureDownloadUrl(expectedUrl = this.downloadUrl) { + if (!expectedUrl || expectedUrl !== this.downloadUrl) return; + if (this.downloadRevokeTimer !== null) { + globalThis.clearTimeout(this.downloadRevokeTimer); + this.downloadRevokeTimer = null; + } + if (globalThis.URL?.revokeObjectURL) { + globalThis.URL.revokeObjectURL(expectedUrl); + } + this.downloadUrl = ""; + } + enableDrag() { const header = this.root.querySelector(".mh-header"); let start = null; diff --git a/tests/captureDoctor.test.js b/tests/captureDoctor.test.js index fac86d6..9aa51d4 100644 --- a/tests/captureDoctor.test.js +++ b/tests/captureDoctor.test.js @@ -16,7 +16,7 @@ describe("capture doctor script", () => { expect(result.status).toBe(0); expect(result.stdout).toContain("Capture doctor: tests/fixtures/capture-ready.json"); - expect(result.stdout).toContain("Replay acceptance: ready (15/15 checks)"); + expect(result.stdout).toContain("Replay acceptance: ready (16/16 checks)"); expect(result.stdout).toContain("Real-page readiness: not ready"); expect(result.stdout).toContain("Capture export: incomplete"); expect(result.stdout).toContain("Page: missing"); diff --git a/tests/config.test.js b/tests/config.test.js new file mode 100644 index 0000000..b924b38 --- /dev/null +++ b/tests/config.test.js @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_BINARY_SAMPLE_BYTES, + DEFAULT_MAX_EVENTS, + HELPER_CONFIG_VERSION, + loadHelperConfig, + updateHelperConfig +} from "../src/core/config.js"; + +class MemoryStorage { + constructor(value = null) { + this.value = value; + } + + getItem() { + return this.value; + } + + setItem(_key, value) { + this.value = value; + } +} + +describe("helper config", () => { + it("migrates legacy low sampling limits once", () => { + const storage = new MemoryStorage(JSON.stringify({ captureLimit: 200, binarySampleBytes: 1024 })); + + expect(loadHelperConfig(storage)).toMatchObject({ + configVersion: HELPER_CONFIG_VERSION, + captureLimit: DEFAULT_MAX_EVENTS, + binarySampleBytes: DEFAULT_BINARY_SAMPLE_BYTES + }); + }); + + it("preserves intentional lower limits after migration", () => { + const storage = new MemoryStorage(); + loadHelperConfig(storage); + updateHelperConfig({ captureLimit: 500, binarySampleBytes: 4096 }, storage); + + expect(loadHelperConfig(storage)).toMatchObject({ + configVersion: HELPER_CONFIG_VERSION, + captureLimit: 500, + binarySampleBytes: 4096 + }); + }); + + it("recovers from malformed storage", () => { + const storage = new MemoryStorage("not-json"); + expect(loadHelperConfig(storage)).toMatchObject({ + captureLimit: DEFAULT_MAX_EVENTS, + binarySampleBytes: DEFAULT_BINARY_SAMPLE_BYTES + }); + }); +}); diff --git a/tests/docs.test.js b/tests/docs.test.js index c4d3c4f..ddbaefb 100644 --- a/tests/docs.test.js +++ b/tests/docs.test.js @@ -1,5 +1,6 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; +import { HELPER_VERSION } from "../src/core/version.js"; describe("docs", () => { it("documents safe real-page capture steps", () => { @@ -12,7 +13,7 @@ describe("docs", () => { expect(doc).toContain("@inject-into page"); expect(doc).toContain("mahjongsoul.game.yo-star.com"); expect(doc).toContain("Keep realtime advice off"); - expect(doc).toContain("Majsoul Helper v0.2.13"); + expect(doc).toContain(`Majsoul Helper v${HELPER_VERSION}`); expect(doc).toContain("page dispatch hooked"); expect(doc).toContain("Do not use any auto-clicking or auto-discard tool"); expect(doc).toContain("npm run capture-doctor -- captures/capture-real.json"); diff --git a/tests/eventDiagnostics.test.js b/tests/eventDiagnostics.test.js new file mode 100644 index 0000000..23c0955 --- /dev/null +++ b/tests/eventDiagnostics.test.js @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { buildMvpGate, inferExpectedCurrentTurn, orderEventsChronologically } from "../src/core/eventDiagnostics.js"; + +describe("event diagnostics", () => { + it("uses event ids to restore same-timestamp adapter events", () => { + const newestFirst = [ + { eventId: 3, ts: 100, type: "discard_tile", payload: { seat: 2, tile: "1m" } }, + { eventId: 2, ts: 100, type: "draw_tile", payload: { seat: 2, tile: "1m" } }, + { eventId: 1, ts: 100, type: "round_start", payload: { currentTurn: 0 } } + ]; + + expect(orderEventsChronologically(newestFirst).map((event) => event.eventId)).toEqual([1, 2, 3]); + expect(inferExpectedCurrentTurn(newestFirst)).toBeNull(); + }); + + it("requires inbound traffic in both live and offline MVP gates", () => { + const gate = buildMvpGate({ + events: [], + gameState: {}, + transport: { rawMessages: 1, inboundRawMessages: 0, binaryEnvelopes: 1, actionMessages: 1 } + }); + + expect(gate.checks.inboundRawMessagesCaptured).toBe(false); + expect(gate.missing).toContain("inboundRawMessagesCaptured"); + }); +}); diff --git a/tests/fixtures/capture-draw-discard.json b/tests/fixtures/capture-draw-discard.json index d5f9a19..836cb3c 100644 --- a/tests/fixtures/capture-draw-discard.json +++ b/tests/fixtures/capture-draw-discard.json @@ -10,7 +10,7 @@ "kind": "Uint8Array", "length": 52, "preview": "Uint8Array(52)", - "sample": "01 0a 13 2e 6c 71 2e 41 63 74 69 6f 6e 50 72 6f 74 6f 74 79 70 65 12 1c 08 0a 12 0e 41 63 74 69 6f 6e 44 65 61 6c 54 69 6c 65 1a 08 08 00 12 02 35 6d 18 37", + "sample": "01 0a 13 2e 6c 71 2e 41 63 74 69 6f 6e 50 72 6f 74 6f 74 79 70 65 12 1c 08 0b 12 0e 41 63 74 69 6f 6e 44 65 61 6c 54 69 6c 65 1a 08 08 00 12 02 35 6d 18 37", "truncated": false } }, @@ -22,7 +22,7 @@ "kind": "Uint8Array", "length": 53, "preview": "Uint8Array(53)", - "sample": "01 0a 13 2e 6c 71 2e 41 63 74 69 6f 6e 50 72 6f 74 6f 74 79 70 65 12 1d 08 0b 12 11 41 63 74 69 6f 6e 44 69 73 63 61 72 64 54 69 6c 65 1a 06 08 01 12 02 39 73", + "sample": "01 0a 13 2e 6c 71 2e 41 63 74 69 6f 6e 50 72 6f 74 6f 74 79 70 65 12 1d 08 0a 12 11 41 63 74 69 6f 6e 44 69 73 63 61 72 64 54 69 6c 65 1a 06 08 01 12 02 39 73", "truncated": false } } diff --git a/tests/gameState.test.js b/tests/gameState.test.js index 9e18b3e..b566d84 100644 --- a/tests/gameState.test.js +++ b/tests/gameState.test.js @@ -44,7 +44,7 @@ describe("GameState", () => { type: "round_start", source: "ws_in", ts: 1, - payload: { round: "1-2", chang: 1, ju: 2, honba: 3, riichiSticks: 1 } + payload: { round: "1-2", chang: 1, ju: 2, selfSeat: 0, honba: 3, riichiSticks: 1 } }); const state = gameState.getVisibleState(); @@ -76,7 +76,7 @@ describe("GameState", () => { type: "round_start", source: "ws_in", ts: 1, - payload: { round: "1-3", chang: 1, ju: 3 } + payload: { round: "1-3", chang: 1, ju: 3, selfSeat: 0 } }); const state = gameState.getVisibleState(); @@ -401,7 +401,7 @@ describe("GameState", () => { type: "deal_hand", source: "manual", ts: 1, - payload: { tiles: ["5p", "5p", "5p", "5p", "1m", "2m"] } + payload: { tiles: ["5p", "5p", "5p", "5p", "1m", "2m"], selfSeat: 0 } }); gameState.applyEvent({ type: "call_meld", @@ -493,7 +493,7 @@ describe("GameState", () => { type: "deal_hand", source: "manual", ts: 1, - payload: { tiles: ["4z", "1m", "2m", "3m"] } + payload: { tiles: ["4z", "1m", "2m", "3m"], selfSeat: 0 } }); gameState.applyEvent({ type: "call_meld", @@ -520,7 +520,7 @@ describe("GameState", () => { type: "deal_hand", source: "manual", ts: 1, - payload: { tiles: ["4z", "1m", "2m", "3m"] } + payload: { tiles: ["4z", "1m", "2m", "3m"], selfSeat: 0 } }); gameState.applyEvent({ type: "call_meld", @@ -545,7 +545,7 @@ describe("GameState", () => { type: "deal_hand", source: "manual", ts: 1, - payload: { tiles: ["4z", "4z", "1m", "2m"] } + payload: { tiles: ["4z", "4z", "1m", "2m"], selfSeat: 0 } }); gameState.applyEvent({ type: "call_meld", @@ -623,7 +623,7 @@ describe("GameState", () => { type: "deal_hand", source: "manual", ts: 1, - payload: { tiles: ["3p", "5p", "1m", "2m", "3m"] } + payload: { tiles: ["3p", "5p", "1m", "2m", "3m"], selfSeat: 0 } }); gameState.applyEvent({ type: "discard_tile", @@ -655,7 +655,7 @@ describe("GameState", () => { type: "deal_hand", source: "manual", ts: 1, - payload: { tiles: ["6s", "8s", "1m", "2m"] } + payload: { tiles: ["6s", "8s", "1m", "2m"], selfSeat: 0 } }); gameState.applyEvent({ type: "discard_tile", @@ -789,6 +789,92 @@ describe("GameState", () => { expect(state.visibleTiles).toEqual(["4p", "4p", "7z", "1m"]); }); + it("resets stale round state when action steps regress without a decoded round_start", () => { + const gameState = new GameState(); + gameState.applyEvent({ + type: "round_start", + source: "ws_in", + ts: 1, + payload: { + round: "E1", + chang: 0, + ju: 3, + selfSeat: 3, + honba: 0, + riichiSticks: 0, + scores: [25000, 25000, 25000, 25000], + tiles: parseTiles("123m456m789p123s1z"), + doraIndicators: ["6s"], + binaryEnvelope: { + methodName: ".lq.ActionPrototype", + actionName: "ActionNewRound", + step: 1 + } + } + }); + gameState.applyEvent({ + type: "discard_tile", + source: "ws_in", + ts: 2, + payload: { seat: 0, tile: "7z", binaryEnvelope: { step: 52 } } + }); + gameState.applyEvent({ + type: "draw_tile", + source: "ws_in", + ts: 3, + payload: { seat: 3, tile: "4s", binaryEnvelope: { step: 53 } } + }); + + gameState.applyEvent({ + type: "discard_tile", + source: "ws_in", + ts: 4, + payload: { seat: 1, tile: "1s", binaryEnvelope: { step: 1 } } + }); + + const resetState = gameState.getVisibleState(); + expect(resetState.round).toBeNull(); + expect(resetState.selfSeat).toBeNull(); + expect(resetState.hand).toEqual([]); + expect(resetState.handKnown).toBe(false); + expect(resetState.drawnTile).toBeNull(); + expect(resetState.discards).toEqual([[], ["1s"], [], []]); + expect(resetState.doraIndicators).toEqual([]); + expect(resetState.lastStep).toBe(1); + expect(resetState.partialStateWarnings).toEqual([ + "partial live state: action step reset without decoded round_start" + ]); + + gameState.applyEvent({ + type: "round_start", + source: "ws_in", + ts: 5, + payload: { + round: "E2", + chang: 0, + ju: 0, + selfSeat: 3, + honba: 0, + riichiSticks: 0, + scores: [25000, 25000, 25000, 25000], + tiles: parseTiles("123m456p789s123z1m"), + doraIndicators: ["2p"], + binaryEnvelope: { + methodName: ".lq.ActionPrototype", + actionName: "ActionNewRound", + step: 1 + } + } + }); + + const freshRoundState = gameState.getVisibleState(); + expect(freshRoundState.round).toBe("E2"); + expect(freshRoundState.hand).toEqual(parseTiles("123m456p789s123z1m")); + expect(freshRoundState.discards).toEqual([[], [], [], []]); + expect(freshRoundState.partialStateWarnings).toEqual([]); + expect(freshRoundState.warnings).toEqual([]); + }); + it("can clear debug event history without clearing visible state", () => { const gameState = new GameState(); gameState.applyEvent({ type: "dora", source: "manual", ts: 1, payload: { tile: "1z" } }); @@ -849,6 +935,7 @@ describe("GameState", () => { payload: { chang: 0, ju: 1, + selfSeat: 0, tiles: ["1m", "2m", "3m"], doraIndicators: ["4p"], scores: [25000, 25000, 25000, 25000], @@ -910,4 +997,137 @@ describe("GameState", () => { expect(state.scores).toEqual([25000, 25000, 25000, 25000]); expect(state.scoresKnown).toBe(true); }); + + it("tracks a non-zero self seat for draws, discards, and meld consumption", () => { + const gameState = new GameState(); + gameState.applyEvent({ + type: "round_start", + payload: { + selfSeat: 2, + ju: 1, + tiles: ["3p", "5p", "1m", "2m", "3m"], + discards: [[], ["4p"], [], []] + } + }); + gameState.applyEvent({ type: "draw_tile", payload: { seat: 2, tile: "9s" } }); + gameState.applyEvent({ type: "discard_tile", payload: { seat: 2, tile: "9s" } }); + gameState.applyEvent({ + type: "call_meld", + payload: { + seat: 2, + meld: ["3p", "4p", "5p"], + binaryEnvelope: { actionName: "ActionChiPengGang" } + } + }); + + const state = gameState.getVisibleState(); + expect(state.selfSeat).toBe(2); + expect(state.seatWind).toBe("S"); + expect(state.hand).toEqual(["1m", "2m", "3m"]); + expect(state.discards[1]).toEqual([]); + expect(state.discards[2]).toEqual(["9s"]); + }); + + it("updates public turn metadata for a hidden opponent draw", () => { + const gameState = new GameState(); + gameState.applyEvent({ type: "round_start", payload: { selfSeat: 2, tiles: ["1m", "2m", "3m"] } }); + gameState.applyEvent({ + type: "draw_tile", + payload: { seat: 1, leftTileCount: 48, doraIndicators: ["4p"] } + }); + + const state = gameState.getVisibleState(); + expect(state.currentTurn).toBe(1); + expect(state.leftTileCount).toBe(48); + expect(state.doraIndicators).toEqual(["4p"]); + expect(state.drawnTile).toBeNull(); + }); + + it("infers the dealer as self from a fourteen-tile opening hand", () => { + const gameState = new GameState(); + gameState.applyEvent({ + type: "round_start", + payload: { + ju: 3, + tiles: parseTiles("123m456m789p123s11z") + } + }); + + const state = gameState.getVisibleState(); + expect(state.selfSeat).toBe(3); + expect(state.seatWind).toBe("E"); + }); + + it("clears unknown-self warnings after a private live draw identifies the player", () => { + const gameState = new GameState(); + gameState.applyEvent({ + type: "round_start", + payload: { + ju: 1, + tiles: parseTiles("123m456m789p123s1z"), + binaryEnvelope: { methodName: ".lq.ActionPrototype", actionName: "ActionNewRound" } + } + }); + expect(gameState.getVisibleState().warnings).toContain( + "self seat is unknown; own draw/discard tracking is not reliable yet" + ); + + gameState.applyEvent({ + type: "draw_tile", + payload: { + seat: 2, + tile: "1z", + binaryEnvelope: { actionName: "ActionDealTile" } + } + }); + + const state = gameState.getVisibleState(); + expect(state.selfSeat).toBe(2); + expect(state.seatWind).toBe("S"); + expect(state.warnings).not.toContain("self seat is unknown; own draw/discard tracking is not reliable yet"); + }); + + it("does not reset state when one action step expands into discard and riichi events", () => { + const gameState = new GameState(); + gameState.applyEvent({ + type: "round_start", + payload: { + selfSeat: 2, + tiles: parseTiles("123m456m789p123s1z"), + binaryEnvelope: { actionName: "ActionNewRound", step: 1 } + } + }); + gameState.applyEvent({ + type: "draw_tile", + payload: { + seat: 2, + tile: "9s", + binaryEnvelope: { actionName: "ActionDealTile", step: 2 } + } + }); + gameState.applyEvent({ + type: "discard_tile", + payload: { + seat: 2, + tile: "9s", + isRiichi: true, + binaryEnvelope: { actionName: "ActionDiscardTile", step: 3 } + } + }); + gameState.applyEvent({ + type: "riichi", + payload: { + seat: 2, + binaryEnvelope: { actionName: "ActionDiscardTile", step: 3 } + } + }); + + const state = gameState.getVisibleState(); + expect(state.hand).toEqual(parseTiles("123m456m789p123s1z")); + expect(state.drawnTile).toBeNull(); + expect(state.discards[2]).toEqual(["9s"]); + expect(state.riichi[2]).toBe(true); + expect(state.lastStep).toBe(3); + expect(state.warnings).not.toContain("partial live state: action step reset without decoded round_start"); + }); }); diff --git a/tests/importCapture.test.js b/tests/importCapture.test.js index 31345fb..6062295 100644 --- a/tests/importCapture.test.js +++ b/tests/importCapture.test.js @@ -96,11 +96,20 @@ describe("import-capture script", () => { const dir = mkdtempSync(join(tmpdir(), "majsoul-import-")); const source = join(dir, "majsoul-helper-capture.json"); const output = join(dir, "captures", "capture-real.json"); - writeFileSync(source, JSON.stringify(buildOverlayShapedCapture())); + const capture = buildOverlayShapedCapture(); + capture.events[0].payload.truncated = false; + Object.assign(capture.events[0].payload.envelope, { + payloadTruncated: true, + actionPayloadTruncated: true, + gameRestorePayloadTruncated: true, + snapshotPayloadTruncated: true + }); + writeFileSync(source, JSON.stringify(capture)); const result = runImport([source, "--out", output]); expect(result.status).toBe(0); + expect(result.stdout).not.toContain("capture includes truncated raw WebSocket samples"); expect(result.stdout).not.toContain("Import notice:"); }); diff --git a/tests/majsoulAdapter.test.js b/tests/majsoulAdapter.test.js index 24e7919..c91e3c8 100644 --- a/tests/majsoulAdapter.test.js +++ b/tests/majsoulAdapter.test.js @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { DEFAULT_BINARY_SAMPLE_BYTES, DEFAULT_MAX_EVENTS, MajsoulAdapter, summarizeCaptureEvents } from "../src/adapter/majsoulAdapter.js"; +import { summarizeCaptureEvents } from "../src/adapter/captureReplay.js"; +import { DEFAULT_BINARY_SAMPLE_BYTES, DEFAULT_MAX_EVENTS, MajsoulAdapter } from "../src/adapter/majsoulAdapter.js"; const originalWebSocket = globalThis.WebSocket; const originalCustomEvent = globalThis.CustomEvent; diff --git a/tests/messageParser.test.js b/tests/messageParser.test.js index 5eb4a2a..5fa5963 100644 --- a/tests/messageParser.test.js +++ b/tests/messageParser.test.js @@ -323,6 +323,7 @@ describe("messageParser", () => { riichiSticks: 2, roundWind: undefined, seatWind: undefined, + selfSeat: 2, scores: [28000, 23200, 25000, 23800], tiles: ["3m", "3m", "1p", "2p"], doraIndicators: ["7z"], @@ -336,7 +337,6 @@ describe("messageParser", () => { binaryEnvelope: { methodName: "RecordNewRound", actionName: "RecordNewRound", - step: undefined, decodedSource: "client" } } @@ -344,6 +344,26 @@ describe("messageParser", () => { ]); }); + it("does not pretend seat zero is self when all-seat hands lack selfSeat", () => { + expect(parseDecodedMessage({ + name: "RecordNewRound", + data: { + chang: 0, + ju: 1, + tiles0: ["1m", "2m", "3m"], + tiles1: ["4p", "5p", "6p"] + } + })[0].payload).toMatchObject({ + tiles: [], + seatHands: [ + ["1m", "2m", "3m"], + ["4p", "5p", "6p"], + [], + [] + ] + }); + }); + it("keeps decoded call target seats from Mahjong Soul RecordChiPengGang objects", () => { expect(parseDecodedMessage({ name: "RecordChiPengGang", @@ -415,6 +435,8 @@ describe("messageParser", () => { }) } }); + expect(parseBinaryMessage(frame)[0].payload).not.toHaveProperty("payloadCodec"); + expect(parseBinaryEnvelope(frame)).not.toHaveProperty("actionPayloadCodec"); }); it("extracts ActionPrototype action names and simple discard payload fields", () => { @@ -476,6 +498,40 @@ describe("messageParser", () => { }) } }); + expect(parseBinaryMessage(frame)[0].payload).not.toHaveProperty("payloadCodec"); + expect(parseBinaryEnvelope(frame)).not.toHaveProperty("actionPayloadCodec"); + }); + + it("uses ActionPrototype field numbers when unknown fields precede the action data", () => { + const discardPayload = [ + ...protobufVarint(1, 1), + ...protobufString(2, "2p") + ]; + const actionPrototypePayload = [ + ...protobufString(4, "ActionDealTile"), + ...protobufString(5, "not action data"), + ...protobufVarint(6, 99), + ...protobufVarint(1, 7), + ...protobufString(2, "ActionDiscardTile"), + ...protobufBytes(3, discardPayload) + ]; + const frame = new Uint8Array([ + 1, + ...protobufString(1, ".lq.ActionPrototype"), + ...protobufBytes(2, actionPrototypePayload) + ]); + + expect(parseBinaryEnvelope(frame)).toMatchObject({ + actionName: "ActionDiscardTile", + step: 7 + }); + expect(parseBinaryMessage(frame)[0]).toMatchObject({ + type: "discard_tile", + payload: { + seat: 1, + tile: "2p" + } + }); }); it("extracts Record action names carried by ActionPrototype frames", () => { @@ -529,21 +585,22 @@ describe("messageParser", () => { expect(parseBinaryEnvelope(frame)).toMatchObject({ actionName: "ActionDiscardTile", step: 6, + actionPayloadCodec: "majsoul-webgl-xor-v1", actionPayloadFields: { - varints: [], - strings: [], - tileStrings: [] + tileStrings: [ + { field: 2, values: ["3z"] } + ] } }); expect(parseBinaryMessage(frame)[0]).toMatchObject({ type: "discard_tile", payload: { - seat: 0, + seat: 2, tile: "3z", tsumogiri: false, isRiichi: false, doraIndicators: [], - payloadCodec: "unity-xor-discard-short", + payloadCodec: "majsoul-webgl-xor-v1", binaryEnvelope: expect.objectContaining({ methodName: ".lq.ActionPrototype", actionName: "ActionDiscardTile", @@ -569,9 +626,10 @@ describe("messageParser", () => { expect(parseBinaryMessage(hiddenFrame)[0]).toMatchObject({ type: "draw_tile", payload: { - seat: 3, + seat: 1, + leftTileCount: 68, doraIndicators: [], - payloadCodec: "unity-xor-deal-seat-short" + payloadCodec: "majsoul-webgl-xor-v1" } }); @@ -590,12 +648,94 @@ describe("messageParser", () => { expect(parseBinaryMessage(selfFrame)[0]).toMatchObject({ type: "draw_tile", payload: { - seat: 0, + seat: 2, tile: "3z", + leftTileCount: 58, doraIndicators: [], - payloadCodec: "unity-xor-deal-self-draw" + payloadCodec: "majsoul-webgl-xor-v1" + } + }); + }); + + it("decodes a captured 232-byte Unity WebGL ActionNewRound payload", () => { + const encodedNewRoundPayload = hexBytes(` + 8b 62 47 50 54 ba 1e 80 72 c3 ad 86 48 03 c5 6b 9e 03 ff be 84 d9 84 16 + a0 b1 bf 08 dd ae f5 d1 73 b0 2b c8 00 6c 29 06 37 1e d2 34 c5 09 61 3a + 02 57 b9 1f 55 54 b3 72 54 24 68 c4 e2 53 50 16 5e 93 23 44 f4 df 7e 7f + ab ca e7 b8 dc 67 d6 e8 9d 6b 8d ee ed e1 35 d3 1f d9 3f 26 11 10 74 7e + f6 47 9f 73 11 71 59 0b 91 1c 49 57 a6 4a 10 5f 59 b5 3b a6 39 f8 c8 94 + f8 f4 66 b3 f2 bd c1 eb a8 a2 f4 07 84 9b 8b 11 67 cb c7 d6 6e ea 1d d4 + 62 51 15 14 2e ee 68 31 25 b6 69 62 7f 7f d1 01 4e 24 95 b8 b0 d0 77 a4 + 57 cd 08 bb 8c 9c c6 95 72 a1 b8 aa 31 80 ed ec e6 0b f9 3e f2 06 70 30 + 35 98 58 8c 03 cd 50 78 50 19 48 fe 22 58 18 b8 58 03 6d 66 f2 75 b5 7b + de ae e9 b0 e0 97 12 8f 4b 8d f2 d9 d3 88 2c 91 + `); + const actionPrototypePayload = [ + ...protobufVarint(1, 1), + ...protobufString(2, "ActionNewRound"), + ...protobufBytes(3, encodedNewRoundPayload) + ]; + const frame = new Uint8Array([ + 1, + ...protobufString(1, ".lq.ActionPrototype"), + ...protobufBytes(2, actionPrototypePayload) + ]); + + expect(encodedNewRoundPayload).toHaveLength(232); + expect(parseBinaryEnvelope(frame)).toMatchObject({ + actionName: "ActionNewRound", + actionPayloadLength: 232, + actionPayloadCodec: "majsoul-webgl-xor-v1", + actionPayloadFields: { + tileStrings: [ + { + field: 4, + values: ["1s", "5z", "1s", "5s", "6m", "4p", "2s", "7z", "7p", "2m", "3z", "6p"] + }, + { field: 14, values: ["6s"] } + ] + } + }); + expect(parseBinaryMessage(frame)[0]).toMatchObject({ + type: "round_start", + payload: { + round: "0-0", + chang: 0, + ju: 0, + honba: 0, + riichiSticks: 0, + scores: [25000, 25000, 25000, 25000], + tiles: ["1s", "5z", "1s", "5s", "6m", "4p", "2s", "7z", "7p", "2m", "3z", "6p", "1z"], + doraIndicators: ["6s"], + leftTileCount: 69, + payloadCodec: "majsoul-webgl-xor-v1" + } + }); + }); + + it("does not accept XOR-decoded noise without a valid action schema", () => { + const noise = hexBytes("95 7e 63 68 55 ae 4e 9c"); + const actionPrototypePayload = [ + ...protobufVarint(1, 1), + ...protobufString(2, "ActionNewRound"), + ...protobufBytes(3, noise) + ]; + const frame = new Uint8Array([ + 1, + ...protobufString(1, ".lq.ActionPrototype"), + ...protobufBytes(2, actionPrototypePayload) + ]); + + expect(parseBinaryEnvelope(frame)).not.toHaveProperty("actionPayloadCodec", "majsoul-webgl-xor-v1"); + expect(parseBinaryMessage(frame)[0]).toMatchObject({ + type: "round_start", + payload: { + round: undefined, + tiles: [], + doraIndicators: [] } }); + expect(parseBinaryMessage(frame)[0].payload).not.toHaveProperty("payloadCodec"); }); it("does not emit riichi from unmapped long Unity draw payload bytes", () => { @@ -989,6 +1129,8 @@ describe("messageParser", () => { } } ]); + expect(parseBinaryMessage(frame)[1].payload).not.toHaveProperty("payloadCodec"); + expect(parseBinaryMessage(frame)[1].payload.binaryEnvelope).not.toHaveProperty("actionPayloadCodec"); }); it("extracts a visible round snapshot from ResSyncGame game_restore responses", () => { diff --git a/tests/overlay.test.js b/tests/overlay.test.js index f42959d..671960c 100644 --- a/tests/overlay.test.js +++ b/tests/overlay.test.js @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { Overlay } from "../src/ui/overlay.js"; import { GameState } from "../src/core/gameState.js"; import { REAL_PAGE_PREFLIGHT_VERSION, REQUIRED_REAL_PAGE_PREFLIGHT_CHECKS } from "../src/core/realPageReadiness.js"; +import { HELPER_VERSION } from "../src/core/version.js"; class FakeAdapter extends EventTarget { constructor() { @@ -12,7 +13,7 @@ class FakeAdapter extends EventTarget { this.events = []; this.installDiagnostics = { installed: true, - helperVersion: "0.2.13", + helperVersion: HELPER_VERSION, installAttempts: 1, installedAt: "2026-05-25T00:00:00.000Z", installFailureReason: "", @@ -144,12 +145,28 @@ describe("Overlay", () => { overlay.mount(); expect(document.querySelector("#majsoul-helper-overlay").textContent).toContain("Training/review use only"); - expect(document.querySelector(".mh-title").textContent).toContain("v0.2.13"); + expect(document.querySelector(".mh-title").textContent).toContain(`v${HELPER_VERSION}`); expect(document.querySelector('[data-action="realtime-advice"]').checked).toBe(false); expect(document.querySelector('[data-role="realtime-risk"]')).toBeNull(); expect(document.querySelector("#majsoul-helper-overlay").textContent).toContain("Enter a hand or enable realtime advice"); }); + it("destroys DOM and adapter listeners cleanly", () => { + const adapter = new FakeAdapter(); + const overlay = new Overlay({ adapter, gameState: new GameState() }); + overlay.mount(); + const render = vi.spyOn(overlay, "render"); + + overlay.destroy(); + adapter.dispatchEvent(new CustomEvent("majsoul-helper:event", { + detail: { type: "draw_tile", payload: { seat: 1 } } + })); + + expect(document.querySelector("#majsoul-helper-overlay")).toBeNull(); + expect(document.querySelector("#majsoul-helper-overlay-style")).toBeNull(); + expect(render).not.toHaveBeenCalled(); + }); + it("marks realtime advice as an active risk feature when enabled", () => { const overlay = new Overlay({ adapter: new FakeAdapter(), gameState: new GameState() }); overlay.mount(); @@ -295,7 +312,7 @@ describe("Overlay", () => { expect(text).toContain("9s"); expect(text).toContain("Seat 2 / 24000"); expect(text).toContain("Melds: 3p 4p 5p"); - expect(text).toContain("Seat 3 turn / 25000"); + expect(text).toContain("Seat 3 self turn / 25000"); }); it("does not show a stale turn marker after discard", () => { @@ -307,8 +324,8 @@ describe("Overlay", () => { const text = document.querySelector("#majsoul-helper-overlay").textContent; expect(text).toContain("Turn: -"); - expect(text).toContain("Seat 3 / 25000"); - expect(text).not.toContain("Seat 3 turn / 25000"); + expect(text).toContain("Seat 3 self / 25000"); + expect(text).not.toContain("Seat 3 self turn / 25000"); }); it("analyzes manual input without enabling realtime advice", () => { @@ -596,33 +613,64 @@ describe("Overlay", () => { }); }); - it("prepares a downloadable capture JSON without using clipboard", async () => { - const adapter = new FakeAdapter(); - adapter.exportCapture = vi.fn(({ limit } = {}) => ({ formatVersion: 1, limit, events: [] })); - const overlay = new Overlay({ adapter, gameState: new GameState() }); - overlay.mount(); - - const input = document.querySelector('[data-role="capture-limit"]'); - input.value = "9"; - input.dispatchEvent(new Event("change", { bubbles: true })); - const link = document.querySelector('[data-action="download-capture"]'); - link.addEventListener("click", (event) => event.preventDefault()); - link.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); - - expect(adapter.exportCapture).toHaveBeenCalledWith({ limit: 9 }); - expect(URL.createObjectURL).toHaveBeenCalledTimes(1); - expect(navigator.clipboard.writeText).not.toHaveBeenCalled(); - expect(link.href).toBe("blob:majsoul-helper-capture"); - expect(link.download).toMatch(/^majsoul-helper-capture-.+\.json$/); - const blob = URL.createObjectURL.mock.calls[0][0]; - expect(blob.type).toBe("application/json"); - const captureText = await blob.text(); - expect(captureText).toContain('"liveGameState"'); - expect(captureText).toContain('"liveDebugSummary"'); - expect(captureText).toContain('"liveMvpGate"'); - expect(captureText).toContain('"liveSafetySettings"'); - expect(captureText).toContain('"liveRealPagePreflight"'); - expect(captureText).toContain('"verification"'); + it("downloads capture JSON explicitly even when the game prevents the button click default", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-13T12:34:56.789Z")); + const temporaryLinks = []; + const linkClick = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function click() { + temporaryLinks.push({ + href: this.href, + download: this.download, + isConnected: this.isConnected + }); + }); + const preventGameDefault = vi.fn((event) => event.preventDefault()); + document.addEventListener("click", preventGameDefault, { capture: true }); + + try { + const adapter = new FakeAdapter(); + adapter.exportCapture = vi.fn(({ limit } = {}) => ({ formatVersion: 1, limit, events: [] })); + const overlay = new Overlay({ adapter, gameState: new GameState() }); + overlay.mount(); + + const input = document.querySelector('[data-role="capture-limit"]'); + input.value = "9"; + input.dispatchEvent(new Event("change", { bubbles: true })); + const button = document.querySelector('[data-action="download-capture"]'); + expect(button.tagName).toBe("BUTTON"); + button.click(); + + expect(preventGameDefault).toHaveBeenCalled(); + expect(adapter.exportCapture).toHaveBeenCalledWith({ limit: 9 }); + expect(URL.createObjectURL).toHaveBeenCalledTimes(1); + expect(navigator.clipboard.writeText).not.toHaveBeenCalled(); + expect(linkClick).toHaveBeenCalledTimes(1); + expect(temporaryLinks).toEqual([{ + href: "blob:majsoul-helper-capture", + download: "majsoul-helper-capture-2026-07-13T12-34-56-789Z.json", + isConnected: false + }]); + const blob = URL.createObjectURL.mock.calls[0][0]; + expect(blob.type).toBe("application/json"); + const captureText = await blob.text(); + expect(captureText).toContain('"liveGameState"'); + expect(captureText).toContain('"liveDebugSummary"'); + expect(captureText).toContain('"liveMvpGate"'); + expect(captureText).toContain('"liveSafetySettings"'); + expect(captureText).toContain('"liveRealPagePreflight"'); + expect(captureText).toContain('"verification"'); + expect(URL.revokeObjectURL).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(59_999); + expect(URL.revokeObjectURL).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(URL.revokeObjectURL).toHaveBeenCalledOnce(); + expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:majsoul-helper-capture"); + } finally { + document.removeEventListener("click", preventGameDefault, { capture: true }); + linkClick.mockRestore(); + vi.useRealTimers(); + } }); it("uses capture limit when copying capture JSON", () => { @@ -762,11 +810,26 @@ describe("Overlay", () => { captureLimit: 10000, binarySampleBytes: 65536 }); - expect(document.querySelector('[data-role="install-diagnostics"]').textContent).toContain("v0.2.13"); + expect(document.querySelector('[data-role="install-diagnostics"]').textContent).toContain(`v${HELPER_VERSION}`); expect(document.querySelector('[data-role="install-diagnostics"]').textContent).toContain("sample 65536 bytes"); expect(document.querySelector('[data-role="install-diagnostics"]').textContent).toContain("page dispatch hooked"); }); + it("preserves intentional lower limits after config migration", () => { + window.localStorage.setItem("majsoul-helper-config", JSON.stringify({ + configVersion: 1, + captureLimit: 2500, + binarySampleBytes: 4096 + })); + const adapter = new FakeAdapter(); + new Overlay({ adapter, gameState: new GameState() }).mount(); + + expect(adapter.maxEvents).toBe(2500); + expect(adapter.getInstallDiagnostics().binarySampleBytes).toBe(4096); + expect(document.querySelector('[data-role="capture-limit"]').value).toBe("2500"); + expect(document.querySelector('[data-role="binary-sample-bytes"]').value).toBe("4096"); + }); + it("shows capture pause state in debug diagnostics", () => { const adapter = new FakeAdapter(); const overlay = new Overlay({ adapter, gameState: new GameState() }); diff --git a/tests/packageScripts.test.js b/tests/packageScripts.test.js index f8d72b1..6edaeb9 100644 --- a/tests/packageScripts.test.js +++ b/tests/packageScripts.test.js @@ -5,6 +5,7 @@ describe("package scripts", () => { it("defines audit, build, capture-doctor, import-capture, mvp-check, real-page-gate, smoke, test, replay, validate-captures, and verify commands", () => { const pkg = JSON.parse(readFileSync("package.json", "utf8")); + expect(pkg.engines.node).toBe("^20.19.0 || ^22.13.0 || >=24.0.0"); expect(pkg.scripts).toMatchObject({ audit: "node scripts/goal-audit.mjs", build: "node scripts/build-userscript.mjs", @@ -14,7 +15,7 @@ describe("package scripts", () => { "real-page-gate": "node scripts/real-page-gate.mjs", replay: "node scripts/replay-capture.mjs", smoke: "node scripts/smoke-check.mjs", - test: "vitest run", + test: "node scripts/run-tests.mjs", "validate-captures": "node scripts/validate-captures.mjs", verify: "node scripts/verify.mjs" }); diff --git a/tests/replayCapture.test.js b/tests/replayCapture.test.js index 281563c..7b174d2 100644 --- a/tests/replayCapture.test.js +++ b/tests/replayCapture.test.js @@ -3,7 +3,7 @@ import { execFileSync, spawnSync } from "node:child_process"; import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { replayCapture, replayCaptureWithDiagnostics } from "../src/adapter/majsoulAdapter.js"; +import { replayCapture, replayCaptureWithDiagnostics } from "../src/adapter/captureReplay.js"; import { GameState } from "../src/core/gameState.js"; import { REAL_PAGE_PREFLIGHT_VERSION, REQUIRED_REAL_PAGE_PREFLIGHT_CHECKS } from "../src/core/realPageReadiness.js"; @@ -182,9 +182,9 @@ describe("replayCapture", () => { expect(events[0]).toMatchObject({ type: "discard_tile", payload: { - seat: 0, + seat: 2, tile: "3z", - payloadCodec: "unity-xor-discard-short" + payloadCodec: "majsoul-webgl-xor-v1" } }); expect(replayDedupe).toMatchObject({ @@ -1042,6 +1042,7 @@ describe("replayCapture", () => { readyForRealPageMvp: false, checks: { rawMessagesCaptured: true, + inboundRawMessagesCaptured: true, binaryEnvelopeDecoded: true, actionPrototypeDecoded: true, drawTileParsed: false, @@ -1449,6 +1450,7 @@ describe("replayCapture", () => { readyForRealPageMvp: false, checks: { rawMessagesCaptured: true, + inboundRawMessagesCaptured: true, binaryEnvelopeDecoded: true, actionPrototypeDecoded: true, drawTileParsed: true, @@ -1533,6 +1535,7 @@ describe("replayCapture", () => { readyForRealPageMvp: true, checks: { rawMessagesCaptured: true, + inboundRawMessagesCaptured: true, binaryEnvelopeDecoded: true, actionPrototypeDecoded: true, drawTileParsed: true, @@ -2393,7 +2396,7 @@ describe("replayCapture", () => { type: "deal_hand", source: "fixture", ts: 1, - payload: { tiles: ["3p", "5p", "1m", "2m", "3m"] } + payload: { tiles: ["3p", "5p", "1m", "2m", "3m"], selfSeat: 0 } } ] })); @@ -2441,7 +2444,7 @@ describe("replayCapture", () => { type: "deal_hand", source: "fixture", ts: 1, - payload: { tiles: ["6s", "8s", "1m", "2m"] } + payload: { tiles: ["6s", "8s", "1m", "2m"], selfSeat: 0 } } ] })); @@ -2484,7 +2487,7 @@ describe("replayCapture", () => { type: "deal_hand", source: "fixture", ts: 1, - payload: { tiles: ["5p", "5p", "5p", "5p", "1m"] } + payload: { tiles: ["5p", "5p", "5p", "5p", "1m"], selfSeat: 0 } } ] })); @@ -2619,6 +2622,7 @@ describe("replayCapture", () => { readyForRealPageMvp: false, missing: [ "rawMessagesCaptured", + "inboundRawMessagesCaptured", "binaryEnvelopeDecoded", "actionPrototypeDecoded", "drawTileParsed", diff --git a/tests/safetyBoundaries.test.js b/tests/safetyBoundaries.test.js index f11ce1a..bffef63 100644 --- a/tests/safetyBoundaries.test.js +++ b/tests/safetyBoundaries.test.js @@ -17,9 +17,12 @@ function readProjectSource() { describe("safety boundaries", () => { it("does not automate page clicks or keyboard input", () => { const source = readProjectSource(); - expect(source).not.toMatch(/\.click\s*\(/); - expect(source).not.toMatch(/dispatchEvent\s*\(\s*new\s+(MouseEvent|KeyboardEvent|PointerEvent)/); - expect(source).not.toMatch(/pyautogui|selenium|webdriver/i); + const captureDownloadActivations = source.match(/downloadLink\.click\s*\(\s*\)/g) || []; + expect(captureDownloadActivations).toHaveLength(1); + const gameplaySource = source.replace(/downloadLink\.click\s*\(\s*\)/g, ""); + expect(gameplaySource).not.toMatch(/\.click\s*\(/); + expect(gameplaySource).not.toMatch(/dispatchEvent\s*\(\s*new\s+(MouseEvent|KeyboardEvent|PointerEvent)/); + expect(gameplaySource).not.toMatch(/pyautogui|selenium|webdriver/i); }); it("does not hide the overlay or anti-detection behavior", () => { diff --git a/tests/userscriptRuntime.test.js b/tests/userscriptRuntime.test.js index 60b6b30..0ca4778 100644 --- a/tests/userscriptRuntime.test.js +++ b/tests/userscriptRuntime.test.js @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { readFileSync } from "node:fs"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { HELPER_VERSION } from "../src/core/version.js"; class FakeWebSocket extends EventTarget { constructor(url = "wss://example.test/socket") { @@ -37,8 +38,8 @@ describe("built userscript runtime", () => { window.eval(userscript); expect(window.__majsoulHelper).toBeTruthy(); - expect(window.__majsoulHelper.version).toBe("0.2.13"); - expect(document.querySelector(".mh-title").textContent).toContain("v0.2.13"); + expect(window.__majsoulHelper.version).toBe(HELPER_VERSION); + expect(document.querySelector(".mh-title").textContent).toContain(`v${HELPER_VERSION}`); expect(window.__majsoulHelper.adapter.getInstallDiagnostics().maxEvents).toBe(10000); expect(window.__majsoulHelper.adapter.getInstallDiagnostics().binarySampleBytes).toBe(65536); expect(document.querySelector("#majsoul-helper-overlay")).toBeTruthy(); @@ -89,10 +90,10 @@ describe("built userscript runtime", () => { window.eval(userscript); expect(oldAdapter.uninstall).toHaveBeenCalledTimes(1); - expect(window.__majsoulHelper.version).toBe("0.2.13"); + expect(window.__majsoulHelper.version).toBe(HELPER_VERSION); expect(window.__majsoulHelper.adapter).not.toBe(oldAdapter); expect(document.querySelectorAll("#majsoul-helper-overlay")).toHaveLength(1); - expect(document.querySelector(".mh-title").textContent).toContain("v0.2.13"); + expect(document.querySelector(".mh-title").textContent).toContain(`v${HELPER_VERSION}`); }); it("sets the helper singleton before the DOM is ready", () => {