feat(edison): MR1 (2010) format — forked core, pre-errata pool, resource pipeline (#316)

* fix(chat): mark replay hint as system message

* fix(ygopro): use full 256-bit seed entropy for server deck shuffle

The pre-duel shuffle folded the 8-word seed into a 32-bit xorshift32
state, limiting the shuffle space to 2^32 permutations and discarding
224 bits of generated entropy. Seed the shuffle with xoshiro256**
through per-lane SplitMix64 instead, preserving the full seed.

Fisher-Yates and rejection sampling are unchanged. No replay or
recovery impact: shuffled decks are stored in DuelRecord and never
re-derived from the seed.

* fix(edopro): preserve 64-bit duel seeds when launching CoreIntegrator

Seeds were passed to the core child process through Number(), whose
53-bit mantissa rounds ~99.6% of random uint64 values. The core played
with rounded seeds while the replay stored the exact ones, so replays
re-simulated with a different RNG stream and desynced.

Serialize the launch payload with the seeds spliced in as raw JSON
integer literals. The splice runs only over the config serialization,
so player-provided strings cannot forge the placeholder.

* test(edison): add MR1 behavior suite against headless ocgcore WASM

In-process harness (no worker threads) boots the bundled WASM core with
deterministic seeds and drives scripted duels. Verifies MR1 era rules on
the pinned binary (koishipro-core ^1.5.2):

- turn-1 draw for the starting player
- ignition priority (Exiled Force vs Trap Hole, duel_rule 2 differential)
- single face-up Field Spell destroyed with REASON_RULE (rule 5 differential)
- damage step: flip effects in substep 6, activation-window masking,
  Honest resolution-time ATK math

Documents two confirmed gaps as it.failing executable documentation:
- core implements modern 0 ATK vs 0 ATK battle (neither destroyed);
  2010 rule requires mutual destruction — ungated by duel_rule
- Honest script cannot activate during damage calculation (2010 ruling
  allowed substep 4; modern PSCT behavior)

* docs(edison): add format compliance roadmap

Living checklist for Edison (TCG March 2010) correctness across repos:
MR1 rules verified in core source and now by the behavior suite, banlist
audit results, April 2010 pool cutoff, pre-errata coverage (11 present,
25 missing), server wiring drift, client MR1 board layout gap, and
matchmaking scope. Records the two suite-confirmed gaps (0-ATK battle
core rule, Honest damage-calculation window).

* feat(edison): verify core compliance + 3 duel_rule<=1 fork gates

Re-verify all 13 official Edison rule-differences against the ocgcore WASM.
Fix the 3 real core gaps in the fork, each gated by duel_rule<=1 so modern
duels are untouched:
- #3 Union 1-per-monster (card::get_union_count folds modern+old)
- #13 0-ATK mutual destruction (field::calculate_battle_damage)
- #10 LP-cost-to-0 refusal (field::check_lp_cost)

Add the MR1 behavior + pre-errata behavior test suites (headless ocgcore),
green on BOTH the stock and forked WASM. Fork patch + reproducible build in
src/test-support/ocgcore/wasm/.

* feat(build): private manifest override + GitHub token for private sources

Generic add-on to fetch PRIVATE git sources (the pre-errata scripts moat today,
any private repo tomorrow) without leaking them into the public repo:

- resources.manifest.json stays PUBLIC (no private source/assembly).
- resources.manifest.private.json (gitignored) declares the private source(s) +
  their assembly; resources-lib.sh merges it over the base into an effective
  manifest at build and runtime. Explicit MANIFEST_PATH (tests) skips the merge.
- resources.manifest.private.example.json documents the format.
- scripts/setup-git-credentials.sh: env-based git credential helper for
  github.com HTTPS using a read-only GH_PRIVATE_TOKEN. Never persisted to a
  layer or disk; no-op when unset.
- Dockerfile: BuildKit secret (id=gh_private_token) for the resource builder;
  COPY resources.manifest*.json into builder + final image.
- entrypoint.sh: set up credentials before the runtime updater loop.

Host setup (not in repo): create resources.manifest.private.json; add
GH_PRIVATE_TOKEN to .env; pass --secret to docker build.

* feat(edison): switch server pool to pre-errata.es.cdb, drop classic from pool

Eliminate classic from the server card pool (base + whitelist + pre-errata
overlay model):
- manifest: assemble pre-errata.es.cdb (bilingual pool, 28 cards) instead of the
  old edison-pre-errata.cdb; remove `classic` from runtime.standard. classic.cdb
  stays assembled (the ocgcore differential tests use its codes as baselines).
- harness: load pre-errata.es.cdb.
Suite green on both cores; ResourcePoolResolver + manifest bats unaffected.

* feat(edison): deliver fork ocgcore as a resources add-on + boot verifier

The Edison fork WASM is now delivered through the resources manifest (source
"edison-core" → assembly ygopro/core/ocgcore-worker) instead of a bespoke
Docker step, so it is fetched, seeded, and refreshed by the same pipeline as
the cdbs/lflists and can be bumped by editing the manifest.

verifyEdisonCore() runs at boot: it hashes the resolved core and logs loudly
(or aborts when EDISON_CORE_REQUIRED=true) if it is missing or its sha256 does
not match the expected fork build — ending the silent fallback to the stock
core that koishipro-core.js does when the binary is absent. YGOProResourceLoader
now resolves the core path through the shared edisonCorePath() so the loader and
the verifier can never disagree.

* test: relocate ocgcore integration tests to fork and pre-errata repos

These 28 tests plus the HeadlessDuel harness exercise the core engine, not server code.

They coupled npm test to assembled resources and private pre-errata scripts.

Moved 9 engine/MR1 tests to evolution-ygopro-core and 19 pre-errata tests to the private repo.

Production CardStorage stays; the harness is vendored into each destination.

* chore: ignore local session dir and fetched core artifact

The .claude/ session dir and the root ocgcore-worker WASM (fetched via the

manifest add-on) are local artifacts and must not be versioned.

* docs: move edison roadmap out of the public repo

The roadmap is edison pre-errata research; it referenced relocated tests,

the removed harness, and the private erratas doc. Preserved in the private repo.

* chore(edison): remove damage-step window debug tracer

The DUEL_DEBUG_DS_WINDOWS trace was pre-errata research instrumentation.

Its purpose left with the relocated damage-step suite; dropped from the server.

* refactor(core): rename edisonCore to generic ocgcoreFork

The forked ocgcore is the single core the server loads for ALL rooms; its

duel_rule gates make it behave like stock in modern formats and apply pre-

errata rulings across legacy eras (Edison, GOAT, HAT). Naming it 'edisonCore'

wrongly implied it was Edison-only. Renamed module + symbols; env var

EDISON_CORE_REQUIRED -> OCGCORE_FORK_REQUIRED (new in this PR, no deployments affected).

* refactor(core): collapse ocgcore fork verifier into a logged load decision

Removed the separate boot verifier, the sha256 pin, and the OCGCORE_FORK_REQUIRED

env var. The fork-vs-stock choice is now a single explicit decision logged at

card-load time (resolveForkCorePath): fork present -> use it; absent -> loud warn

+ stock fallback. Download integrity belongs to the manifest delivery layer, and

a corrupt WASM fails loudly at instantiation, so the per-boot sha check was redundant.

* chore(docker): drop build-time token machinery; private sources are runtime-only

The forked-core seed is assembled public-only at build; the private manifest override

is .dockerignored (never in the build context) and mounted at runtime, with the token

from the container env. Removes the dead --secret mount, GH_PRIVATE_TOKEN export, and

build-time setup-git-credentials call; updates the manifest example + stale comments.

* refactor(core): resolve fork-core path once, fix stale runtime comments

resolveForkCorePath ran on every card-storage (re)load, re-logging the fork/stock

decision each 10-min refresh. Cache it in the loader (logged once); the worker still

re-reads the binary per load. Also drop the stale build-time BuildKit-secret mention

in setup-git-credentials.sh (runtime-only now).
This commit is contained in:
Diango Gavidia 2026-08-05 09:34:06 -04:00 committed by GitHub
parent 7462eee8bf
commit 83e8cd5a73
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 424 additions and 28 deletions

View file

@ -78,3 +78,9 @@ README.md
CHANGELOG.md
.github/CONTRIBUTING.md
.github/CODE_OF_CONDUCT.md
# Private manifest override — NOT baked; provided at runtime via a volume mount.
# Keeping it out of the build context makes the seed public-only (no token at build).
resources.manifest.private.json
# generated by the private-override merge (resources-lib.sh)
resources.manifest.effective.json

7
.gitignore vendored
View file

@ -70,3 +70,10 @@ resources/
# Local bats-core installation (not a repo dependency)
tools/
resources.manifest.private.json
resources.manifest.effective.json
resources.manifest.private.json.*
# Local session + fetched core artifact (not versioned)
.claude/
/ocgcore-worker

View file

@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1
# Stage 1: Clone repositories and assemble resources
FROM public.ecr.aws/docker/library/node:24.11.0-bullseye-slim AS resources-builder
@ -11,7 +12,12 @@ WORKDIR /build
# single source of truth, shared with local dev (README) and the runtime refresh
# loop (entrypoint). This produces /build/resources/releases/<id> and a current symlink.
COPY scripts/ ./scripts/
COPY resources.manifest.json ./
# resources.manifest.json = public base (+ the shipped example). The private override is
# NOT part of the build — it is provided at runtime, so the seed is public-only.
COPY resources.manifest*.json ./
# Assemble the PUBLIC resource seed so the server boots immediately. Private sources are
# fetched at runtime by the entrypoint's updater (mounted private override + a token from
# the container env); no token ever touches the build.
RUN bash scripts/clone_repositories.sh && bash scripts/setup_resources.sh
@ -81,8 +87,11 @@ COPY --from=core-builder /app/CoreIntegrator ./core/CoreIntegrator
# then refreshes resources/current in place and the in-memory reload picks it up.
COPY --from=resources-builder /build/resources ./resources
# Provisioning scripts (scripts/) + manifest — single source of truth, reused at runtime.
# Provisioning scripts (scripts/) + the PUBLIC manifest — reused by the runtime updater loop.
# The private override is not baked in: mount it at runtime (-v .../resources.manifest.private.json
# :/app/resources.manifest.private.json) and pass a read-only token via the container env
# (--env-file); the entrypoint sets up git-credentials before the loop clones private sources.
COPY scripts/ ./scripts/
COPY resources.manifest.json ./
COPY resources.manifest*.json ./
CMD ["dumb-init", "bash", "scripts/entrypoint.sh"]

View file

@ -59,6 +59,13 @@
"type": "git",
"url": "https://github.com/evolutionygo/cards-art-server",
"branch": "main"
},
{
"comment": "ocgcore fork WASM — runtime add-on; the loader logs whether the fork or the stock core is active",
"id": "edison-core",
"type": "http",
"url": "https://github.com/diangogav/evolution-ygopro-core/releases/download/v1.0.0-edison/libocgcore-edison-fork.wasm",
"filename": "ocgcore-worker"
}
],
"assembly": [
@ -112,6 +119,12 @@
"from": "evolution-assets",
"file": "lflist/edison.lflist.conf"
},
{
"comment": "ocgcore fork WASM — placed at ygopro/core/ocgcore-worker; read once at boot by YGOProResourceLoader",
"target": "ygopro/core/ocgcore-worker",
"from": "edison-core",
"file": "ocgcore-worker"
},
{
"target": "ygopro/formats/md/lflist.conf",
"from": "evolution-lflists",
@ -165,10 +178,10 @@
"file": "cdb/classic.cdb"
},
{
"comment": "Scripts for classic pre-errata alt-coded cards",
"target": "ygopro/classic/script",
"comment": "Edison pre-errata pool (bilingual .es/.en; server loads .es — engine is language-neutral)",
"target": "ygopro/formats/edison/pre-errata.es.cdb",
"from": "evolution-assets",
"dir": "card-scripts/classic"
"file": "cdb/pre-errata.es.cdb"
},
{
"comment": "YGOPro Moecube prereleases (extended pool only)",
@ -186,7 +199,6 @@
"ygopro": {
"standard": [
"base",
"classic",
"formats/jtp",
"formats/jtp-adv-2007-03",
"formats/genesys",

View file

@ -0,0 +1,25 @@
{
"comment": "PRIVATE manifest override — TEMPLATE. Copy to resources.manifest.private.json (gitignored) and set the real repo url(s). resources-lib.sh merges this over the public base manifest (append sources + assembly), so PRIVATE git sources never appear in the public base. Add ANY private source here — not just pre-errata scripts. Not baked into the image: mount this file into the running container (-v <host>/resources.manifest.private.json:/app/resources.manifest.private.json:ro) and pass a read-only GH_PRIVATE_TOKEN via the container env (--env-file), or use an SSH url + key. The entrypoint's updater clones the private sources on start.",
"sources": [
{
"id": "my-private-source",
"type": "git",
"url": "https://github.com/YOUR_ORG/YOUR_PRIVATE_REPO.git",
"branch": "main"
}
],
"assembly": [
{
"comment": "Example: Edison pre-errata card scripts served from the private repo.",
"target": "ygopro/formats/edison/script",
"from": "my-private-source",
"dir": "card-scripts/edison"
},
{
"comment": "Example: classic pre-errata card scripts served from the private repo.",
"target": "ygopro/classic/script",
"from": "my-private-source",
"dir": "card-scripts/classic"
}
]
}

View file

@ -8,6 +8,11 @@
set -u
# Configure git to authenticate private manifest sources (read-only token from
# GH_PRIVATE_TOKEN in the container env / --env-file). No-op when unset. Must run
# before the updater loop, which re-clones sources.
bash scripts/setup-git-credentials.sh
bash scripts/resources-updater.sh &
exec node ./src/index.js

View file

@ -32,6 +32,27 @@ _check_jq() {
}
_check_jq
# ---------------------------------------------------------------------------
# Private override merge — keeps PRIVATE git sources (private repos, e.g. the
# pre-errata scripts moat) OUT of the public base manifest. When the caller uses
# the default base manifest AND a private override file exists, merge base +
# override (append sources + assembly) into an effective manifest and use it.
# Prod/CI provide resources.manifest.private.json (gitignored) + a read-only
# GH_PRIVATE_TOKEN (or SSH) for the private repos. Generic: add any private
# source here. See resources.manifest.private.example.json.
# An explicitly-set MANIFEST_PATH (e.g. tests) skips the merge.
# ---------------------------------------------------------------------------
MANIFEST_OVERRIDE="${MANIFEST_OVERRIDE:-resources.manifest.private.json}"
if [ "$MANIFEST_PATH" = "resources.manifest.json" ] && [ -f "$MANIFEST_OVERRIDE" ]; then
MANIFEST_EFFECTIVE="${MANIFEST_EFFECTIVE:-resources.manifest.effective.json}"
jq -s '.[0] as $b | .[1] as $o | $b
| .sources = ($b.sources + ($o.sources // []))
| .assembly = ($b.assembly + ($o.assembly // []))' \
"$MANIFEST_PATH" "$MANIFEST_OVERRIDE" > "$MANIFEST_EFFECTIVE" \
|| fail "failed to merge private manifest override into $MANIFEST_EFFECTIVE"
MANIFEST_PATH="$MANIFEST_EFFECTIVE"
fi
# ---------------------------------------------------------------------------
# manifest_get — wrapper around jq queries on the manifest
# Usage: manifest_get <jq-filter> [manifest-path]

View file

@ -0,0 +1,19 @@
#!/usr/bin/env bash
# setup-git-credentials.sh — authenticate github.com HTTPS clones of PRIVATE
# repos declared in the private manifest override, using a read-only token.
#
# Generic add-on: any private GitHub source (pre-errata scripts today, other
# private repos in the future) is fetched with GH_PRIVATE_TOKEN. Reads the token
# from the container env (--env-file) at runtime — the entrypoint runs this before
# the resource updater loop. No-op when the token is absent (only public sources
# are cloned). The token is NEVER written to disk — an env-based credential helper
# reads it at clone time; public HTTPS clones don't challenge auth, so the token
# is only ever sent to private repos.
set -u
if [ -n "${GH_PRIVATE_TOKEN:-}" ]; then
git config --global credential."https://github.com".helper \
'!f() { echo "username=x-access-token"; echo "password=${GH_PRIVATE_TOKEN}"; }; f'
echo "[git-credentials] github.com HTTPS token helper configured (private sources)."
else
echo "[git-credentials] GH_PRIVATE_TOKEN not set — skipping (public sources only)."
fi

View file

@ -18,6 +18,7 @@ import { Commands } from "../../../../../shared/messages/Commands";
import { JSONMessageProcessor } from "../../../../messages/JSONMessageProcessor";
import { ClientMessage } from "../../../../../shared/messages/MessageProcessor";
import { ErrorMessages } from "../../../../messages/server-to-client/error-messages/ErrorMessages";
import { serializeCoreLaunchPayload } from "./serialize-core-launch-payload";
import { ErrorClientMessage } from "../../../../messages/server-to-client/ErrorClientMessage";
import { StartDuelClientMessage } from "../../../../messages/server-to-client/game-messages/StartDuelClientMessage";
import { TimeLimitClientMessage } from "../../../../messages/server-to-client/game-messages/TimeLimitClientMessage";
@ -229,10 +230,10 @@ export class DuelingState extends RoomState {
const core = spawn(
`./core/CoreIntegrator`,
[
JSON.stringify({
serializeCoreLaunchPayload({
config: {
startLp: this.room.startLp.toString(),
seeds: seeds.map((seed) => Number(seed)),
seeds,
flags: Number(this.room.duelFlag),
lp: this.room.startLp,
startingDrawCount: this.room.startHand,

View file

@ -0,0 +1,61 @@
import { serializeCoreLaunchPayload } from "./serialize-core-launch-payload";
const SEEDS: bigint[] = [12345678901234567890n, 18446744073709551615n, 9007199254740993n, 7n];
const createPayload = (players: unknown[] = []) => ({
config: {
startLp: "8000",
seeds: SEEDS,
flags: 190464,
lp: 8000,
startingDrawCount: 5,
drawCountPerTurn: 1,
firstToPlay: 0,
timeLimit: 240,
},
players,
});
describe("serializeCoreLaunchPayload", () => {
it("serializes 64-bit seeds as exact JSON integers", () => {
const json = serializeCoreLaunchPayload(createPayload());
expect(json).toContain(
'"seeds":[12345678901234567890,18446744073709551615,9007199254740993,7]',
);
});
it("produces valid JSON with the rest of the config intact", () => {
const json = serializeCoreLaunchPayload(createPayload());
const parsed = JSON.parse(json) as { config: Record<string, unknown> };
expect(parsed.config.startLp).toBe("8000");
expect(parsed.config.flags).toBe(190464);
expect(parsed.config.timeLimit).toBe(240);
});
it("serializes players untouched", () => {
const players = [{ team: 0, mainDeck: [10000, 10001], sideDeck: [], extraDeck: [], turn: 0 }];
const json = serializeCoreLaunchPayload(createPayload(players));
const parsed = JSON.parse(json) as { players: unknown[] };
expect(parsed.players).toEqual(players);
});
it("is not corrupted by player fields containing the exact seeds placeholder", () => {
const maliciousName = '__SEEDS_PLACEHOLDER__"]},"x":[';
const players = [
{ team: 0, name: maliciousName, mainDeck: [], sideDeck: [], extraDeck: [], turn: 0 },
];
const json = serializeCoreLaunchPayload(createPayload(players));
const parsed = JSON.parse(json) as { players: Array<{ name: string }> };
expect(parsed.players[0]!.name).toBe(maliciousName);
expect(json).toContain('"seeds":[12345678901234567890');
});
});

View file

@ -0,0 +1,29 @@
const SEEDS_PLACEHOLDER = "__SEEDS_PLACEHOLDER__";
export interface CoreLaunchPayload {
config: {
startLp: string;
seeds: bigint[];
flags: number;
lp: number;
startingDrawCount: number;
drawCountPerTurn: number;
firstToPlay: number;
timeLimit: number;
};
players: unknown[];
}
// The core parses `seeds` as uint64_t. Serializing them through Number
// rounds everything above 2^53, so they are spliced into the JSON as raw
// integer literals. The splice runs only over the config serialization,
// which contains no player-provided data, so the placeholder cannot be
// forged from the outside.
export const serializeCoreLaunchPayload = (payload: CoreLaunchPayload): string => {
const { seeds, ...config } = payload.config;
const configJson = JSON.stringify({ ...config, seeds: SEEDS_PLACEHOLDER }).replace(
`"${SEEDS_PLACEHOLDER}"`,
`[${seeds.join(",")}]`,
);
return `{"config":${configJson},"players":${JSON.stringify(payload.players)}}`;
};

View file

@ -23,6 +23,7 @@ import { findReconnectingPlayer } from "@shared/room/domain/findReconnectingPlay
import { getMessageIdentifier } from "../../../utils/response-time-utils";
import {
ChatColor,
OcgcoreScriptConstants,
YGOProCtosUpdateDeck,
YGOProMsgDamage,
@ -608,7 +609,7 @@ export class YGOProDuelingState extends RoomState {
private broadcastReplay(index: number, total: number, replayBuffer: Buffer): void {
const hintMsg = new YGOProStocChat().fromPartial({
player_type: 0,
player_type: ChatColor.LIGHTBLUE,
msg: `#{replay_hint_part1}${index}#{replay_hint_part2}`,
});
const hintBuffer = Buffer.from(hintMsg.toFullPayload());

View file

@ -10,6 +10,7 @@
*/
import { mock } from "jest-mock-extended";
import { ChatColor, YGOProStocChat } from "ygopro-msg-encode";
import { Logger } from "@shared/logger/domain/Logger";
import { DuelRecordMother } from "@test-support/mothers/room/DuelRecordMother";
@ -25,7 +26,10 @@ const HOST_INFO = {
draw_count: 1,
};
type SendAllEvrp = { sendAllEvrp(): Promise<void> };
type TestableDuelingState = {
sendAllEvrp(): Promise<void>;
broadcastReplay(index: number, total: number, replayBuffer: Buffer): void;
};
function makeClient() {
return { sendMessageToClient: jest.fn() };
@ -43,10 +47,10 @@ function makeRoom(overrides: Record<string, unknown> = {}) {
// Bind room + logger onto a prototype instance and expose the private method,
// skipping the OCGCore-heavy constructor entirely.
function buildState(room: object, logger: Logger): SendAllEvrp {
function buildState(room: object, logger: Logger): TestableDuelingState {
const state = Object.create(YGOProDuelingState.prototype);
Object.assign(state, { room, logger });
return state as SendAllEvrp;
return state as TestableDuelingState;
}
describe("YGOProDuelingState.sendAllEvrp()", () => {
@ -119,3 +123,16 @@ describe("YGOProDuelingState.sendAllEvrp()", () => {
});
});
});
describe("YGOProDuelingState.broadcastReplay()", () => {
it("marks the replay hint as a system chat message", () => {
const client = makeClient();
const state = buildState(makeRoom({ clients: [client] }), mock<Logger>());
state.broadcastReplay(1, 1, Buffer.from([0x17]));
const hintFrame = client.sendMessageToClient.mock.calls[0]?.[0] as Buffer;
const hint = new YGOProStocChat().fromPayload(hintFrame.subarray(3));
expect(hint.player_type).toBe(ChatColor.LIGHTBLUE);
});
});

View file

@ -0,0 +1,76 @@
import YGOProDeck from "ygopro-deck-encode";
import { shuffleDecksBySeed } from "./shuffle-decks-by-seed";
const SEED = [1, 2, 3, 4, 5, 6, 7, 8];
// Both seeds fold to the same 32-bit state (0x31e48131) under a
// 32-bit reduction of the 256-bit seed. A shuffle that consumes the
// full seed entropy must produce different orders for them.
const COLLIDING_SEED_A = [1, 2, 3, 4, 5, 6, 7, 8];
const COLLIDING_SEED_B = [101, 102, 103, 104, 105, 106, 107, 3091460252];
const createDeck = () =>
new YGOProDeck({
main: Array.from({ length: 40 }, (_, i) => 10000 + i),
extra: Array.from({ length: 15 }, (_, i) => 20000 + i),
side: Array.from({ length: 15 }, (_, i) => 30000 + i),
name: "test-deck",
});
describe("shuffleDecksBySeed", () => {
it("produces the same order for the same seed", () => {
const [first] = shuffleDecksBySeed([createDeck()], [...SEED]);
const [second] = shuffleDecksBySeed([createDeck()], [...SEED]);
expect(first!.main).toEqual(second!.main);
});
it("does not mutate the input decks", () => {
const deck = createDeck();
const originalMain = [...deck.main];
shuffleDecksBySeed([deck], [...SEED]);
expect(deck.main).toEqual(originalMain);
});
it("returns a permutation of the original main deck", () => {
const deck = createDeck();
const [shuffled] = shuffleDecksBySeed([deck], [...SEED]);
expect([...shuffled!.main].sort()).toEqual([...deck.main].sort());
});
it("keeps extra and side deck order untouched", () => {
const deck = createDeck();
const [shuffled] = shuffleDecksBySeed([deck], [...SEED]);
expect(shuffled!.extra).toEqual(deck.extra);
expect(shuffled!.side).toEqual(deck.side);
});
it("produces different orders for different seeds", () => {
const [first] = shuffleDecksBySeed([createDeck()], [1, 2, 3, 4, 5, 6, 7, 8]);
const [second] = shuffleDecksBySeed([createDeck()], [8, 7, 6, 5, 4, 3, 2, 1]);
expect(first!.main).not.toEqual(second!.main);
});
it("consumes the full seed entropy, not a 32-bit reduction", () => {
const [first] = shuffleDecksBySeed([createDeck()], COLLIDING_SEED_A);
const [second] = shuffleDecksBySeed([createDeck()], COLLIDING_SEED_B);
expect(first!.main).not.toEqual(second!.main);
});
it("shuffles each deck with a single advancing rng stream", () => {
const [first, second] = shuffleDecksBySeed([createDeck(), createDeck()], [...SEED]);
const [alone] = shuffleDecksBySeed([createDeck()], [...SEED]);
expect(first!.main).toEqual(alone!.main);
expect(second!.main).not.toEqual(first!.main);
});
});

View file

@ -1,21 +1,47 @@
import YGOProDeck from "ygopro-deck-encode";
const UINT32_RANGE = 0x1_0000_0000;
const MASK64 = (1n << 64n) - 1n;
const splitMix64 = (value: bigint): bigint => {
let z = (value + 0x9e37_79b9_7f4a_7c15n) & MASK64;
z = ((z ^ (z >> 30n)) * 0xbf58_476d_1ce4_e5b9n) & MASK64;
z = ((z ^ (z >> 27n)) * 0x94d0_49bb_1331_11ebn) & MASK64;
return z ^ (z >> 31n);
};
const rotl64 = (x: bigint, k: bigint): bigint => ((x << k) & MASK64) | (x >> (64n - k));
// xoshiro256** seeded through SplitMix64 so the full 256-bit seed
// sequence drives the shuffle instead of a 32-bit reduction.
const createSeededRng = (seed: number[]) => {
let state = 0x9e37_79b9;
for (const value of seed) {
state = (Math.imul(state ^ (value >>> 0), 1664525) + 1013904223) >>> 0;
}
if (state === 0) {
state = 1;
// Seed layout: 8 uint32 words folded pairwise into 4 uint64 lanes;
// missing words default to zero. SplitMix64 is bijective per lane, so
// no seed entropy is lost.
const lane = (index: number) => {
const low = BigInt((seed[index * 2] ?? 0) >>> 0);
const high = BigInt((seed[index * 2 + 1] ?? 0) >>> 0);
return splitMix64((high << 32n) | low);
};
let s0 = lane(0);
let s1 = lane(1);
let s2 = lane(2);
let s3 = lane(3);
// An all-zero state is a fixed point of xoshiro256** (constant output);
// fall back to the SplitMix64 golden-ratio constant so it always advances.
if (s0 === 0n && s1 === 0n && s2 === 0n && s3 === 0n) {
s0 = 0x9e37_79b9_7f4a_7c15n;
}
return () => {
state ^= state << 13;
state >>>= 0;
state ^= state >>> 17;
state ^= state << 5;
return state >>> 0;
const result = (rotl64((s1 * 5n) & MASK64, 7n) * 9n) & MASK64;
const t = (s1 << 17n) & MASK64;
s2 ^= s0;
s3 ^= s1;
s1 ^= s2;
s0 ^= s3;
s2 ^= t;
s3 = rotl64(s3, 45n);
return Number(result >> 32n);
};
};

View file

@ -10,6 +10,7 @@ import { Logger } from "src/shared/logger/domain/Logger";
import LoggerFactory from "src/shared/logger/infrastructure/LoggerFactory";
import { config } from "src/config";
import { resolvePools } from "./ResourcePoolResolver";
import { resolveForkCorePath } from "./ocgcoreFork";
const CARD_STORAGE_RELOAD_INTERVAL_MS = 10 * 60 * 1000;
@ -17,6 +18,8 @@ let _sharedInstance: YGOProResourceLoader | null = null;
export class YGOProResourceLoader {
private readonly logger: Logger;
private forkCorePath: string | undefined;
private forkCoreResolved = false;
constructor() {
this.logger = LoggerFactory.getLogger();
@ -244,11 +247,18 @@ export class YGOProResourceLoader {
});
}
// Resolve the fork-vs-stock core path once (logged once); the card-load worker
// still re-reads the binary on each (re)load to pick up a refreshed fork.
private resolvedForkCorePath(): string | undefined {
if (!this.forkCoreResolved) {
this.forkCorePath = resolveForkCorePath(this.logger);
this.forkCoreResolved = true;
}
return this.forkCorePath;
}
private async loadCardStorageFromPaths(paths: string[], label: string) {
const ocgcoreWasmPathConfig = "./ocgcore-worker";
const ocgcoreWasmPath = ocgcoreWasmPathConfig
? path.resolve(process.cwd(), ocgcoreWasmPathConfig)
: undefined;
const ocgcoreWasmPath = this.resolvedForkCorePath();
const { cardStorage, dbCount, failedFiles, sha512 } = await runInWorker(
CardLoadWorker,
(worker) => worker.load(),

View file

@ -0,0 +1,38 @@
const existsSyncMock = jest.fn();
jest.mock("fs", () => ({
existsSync: (...args: unknown[]) => existsSyncMock(...args),
}));
import { ocgcoreForkPath, resolveForkCorePath } from "./ocgcoreFork";
function makeLogger() {
return {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
child: jest.fn(),
};
}
describe("resolveForkCorePath", () => {
afterEach(() => existsSyncMock.mockReset());
it("returns the fork path and logs it as active when the binary is present", () => {
existsSyncMock.mockReturnValue(true);
const logger = makeLogger();
expect(resolveForkCorePath(logger as never)).toBe(ocgcoreForkPath());
expect(logger.info).toHaveBeenCalledTimes(1);
expect(logger.warn).not.toHaveBeenCalled();
});
it("returns undefined and warns loudly when the fork binary is missing", () => {
existsSyncMock.mockReturnValue(false);
const logger = makeLogger();
expect(resolveForkCorePath(logger as never)).toBeUndefined();
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.info).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,33 @@
import { existsSync } from "fs";
import path from "path";
import { Logger } from "@shared/logger/domain/Logger";
import { config } from "src/config";
// Path within the assembled resource tree where the forked ocgcore WASM lands
// (mirrors the manifest assembly rule). The fork is the evolution build from
// github.com/diangogav/evolution-ygopro-core; it implements pre-errata /
// legacy-era rulings (Edison, GOAT, HAT, ...) behind duel_rule gates, so it
// behaves like the stock core in modern formats. Delivered as a runtime add-on
// through the resources manifest rather than baked into the image.
export const OCGCORE_RESOURCE_PATH = "ygopro/core/ocgcore-worker";
export function ocgcoreForkPath(): string {
return path.resolve(config.resources.dir, OCGCORE_RESOURCE_PATH);
}
// Explicit fork-vs-stock decision, logged when resolved (the loader resolves it
// once and caches). Returns the fork path when the binary is present, or undefined
// to let koishipro-core.js use its bundled STOCK WASM. Legacy-format rooms need the
// fork for correct rulings — running stock there is legal but wrong — so the warn is loud.
export function resolveForkCorePath(logger: Logger): string | undefined {
const file = ocgcoreForkPath();
if (existsSync(file)) {
logger.info(`🧩 ocgcore fork active (${file}).`);
return file;
}
logger.warn(
`⚠️ ocgcore fork not found at ${file} — legacy-format rooms will run the STOCK core (koishipro-core.js fallback).`,
);
return undefined;
}