mirror of
https://github.com/diangogav/EDOpro-server-ts
synced 2026-08-24 00:23:05 -04:00
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:
parent
7462eee8bf
commit
83e8cd5a73
18 changed files with 424 additions and 28 deletions
|
|
@ -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
7
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
15
Dockerfile
15
Dockerfile
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
25
resources.manifest.private.example.json
Normal file
25
resources.manifest.private.example.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
19
scripts/setup-git-credentials.sh
Executable file
19
scripts/setup-git-credentials.sh
Executable 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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)}}`;
|
||||
};
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
76
src/ygopro/utils/shuffle-decks-by-seed.test.ts
Normal file
76
src/ygopro/utils/shuffle-decks-by-seed.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
38
src/ygopro/ygopro/ocgcoreFork.test.ts
Normal file
38
src/ygopro/ygopro/ocgcoreFork.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
33
src/ygopro/ygopro/ocgcoreFork.ts
Normal file
33
src/ygopro/ygopro/ocgcoreFork.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue