Compare commits

..

103 commits

Author SHA1 Message Date
Diango Gavidia
447d93df48
feat(matchmaking): add edison to the ranked queue (#322)
Some checks failed
PR Pipeline / build (push) Failing after 5s
Release Please / release (push) Failing after 2s
PR Pipeline / bash-resources-tests (push) Failing after 4s
Edison joins tcg and jtp as an accepted matchmaking format. Everything it
needs already existed on its own — the rule set, the ban list, the bots —
so this only wires it into the queue.

The join string rides CTOS_JOIN_GAME's fixed utf16[20] pass field as
"<token>,mm<5>#<7>", which caps a format's room token at 3 characters.
"edison" (6) does not fit. Single duels reuse the existing "ed" alias;
ranked human pairs play best-of-3, so "edm" is added to formatRuleMappings.
It spreads the shared edisonRuleSet() payload, so the MATCH variant can
never drift from "edison"/"ed" — one rule set, three tokens.

Bot fallback picks from a new edison roster wired to the three Edison bots
already shipped in the botlist. They are hidden there, kept out of the
random pool, but stay reachable by explicit name — which is exactly how the
roster requests them. Those rooms stay best-of-1: windbot cannot side deck.

The existing it.each(MATCHMAKING_FORMATS) wire-budget guards pick edison up
automatically and prove both tokens fit the pass field.
2026-08-20 06:23:50 -04:00
Diango Gavidia
6e7baf2cc7
fix(ban-list): announce whitelist lists with the hash ygopro clients compute (#321)
A room hosted with the Edison list showed "Forbidden List: Unknown Banlist" in
MDPro3. The client matches the hash the host announces against the hashes it
computed from its own lists, and ours matched nothing.

Two divergences, both on the ygopro path. The lflist library we take the hash
from parses limit 0-2 entries only, so it ignores the 3539 three-copy rows a
whitelist has to enumerate — the server already works around this for the
entries themselves via parseUnrestrictedEntries, but the hash still came from
the library. And clients that implement `$whitelist` fold 0x0f0f0f0f into the
hash when they parse the directive.

For the Edison list the server announced 0xd802337f, computed over 132 entries;
the client expects 0x67989402, computed over all 3671 plus the xor.

Scoped to whitelist lists on the ygopro path, which is safe in both directions.
A normal list never enumerates 3-copy entries, so library and client already
agree and nothing changes there. A whitelist list's old hash matched no client
at all — classic YGOPro and YGOMobile do not implement the directive and discard
the 3-copy rows outright — so the value being replaced was useful to nobody.
EDOPro keeps the library hash because it parses the directive without touching
its hash; its loader is untouched.

Genesys is excluded: it folds per-card point costs into its own hash, which we
do not model, so recomputing there would trade one mismatch for another.

Verified by running the real edison.lflist.conf through YGOProBanList: the
recomputed hash is 0x67989402, the value MDPro3 derives from the same file.
2026-08-19 18:38:35 -04:00
Diango Gavidia
46e353c588
feat(room): join-command overhaul — pairing joins, room identity, edison bots (#319)
* chore(dependencies): update various package versions in package-lock.json

* feat(windbot): playable edison bots and format-scoped random pools

Edison bots join the botlist under short names that fit the utf16[20]
pass field. Botlist entries gain a format tag; random bot selection
resolves a pool from the join tokens (mirroring the room's rule-tier
precedence) so a format room can no longer roll a bot with an illegal
deck. Bot names are validated against the pass-field budget at boot,
and the bot-request failure path delivers its JOINERROR before the
canonical room teardown destroys the sockets.

* feat(ban-list): deterministic alias resolution with load-time format aliases

Format banlists carry an explicit alias captured from their
formats/<dir> path at load time. Alias resolution tries the alias
field, then the exact normalized name, and only then the substring
scan — now tie-broken by shortest normalized name instead of load
order, with a memoized warning on ambiguity. Adds getFirstOCGIndex
so OCG-list tokens stop hardcoding index 0.

* fix(room): rule-mapping clamps, state coherence, and boot-order hardening

The lp clamp compared a NaN (parseInt over the whole token) so lp0
started duels at 0 LP; clamps now act on the extracted number. The
edison rule set is shared between its two tokens. The room's DuelState
label and its state object now always transition together: waiting()
sets both and resets isStart/ready flags, and setDuelFinished disposes
the OCGCore before delegating — the ocgcore-error rewind no longer
leaks the core, lies to the room list, or leaves a stale TRY_START
armed. Missing banlist aliases warn once instead of silently
mislabeling rooms. Windbot bootstrap moved before socket init so a bad
botlist aborts pre-listen, and the never-initialized registry fallback
composes the full base chain.

* feat(room): exact-string pairing joins and (name,password) room identity

Empty-password commands made purely of recognized tokens become
pairing joins: they route to a waiting same-command room with a free
seat, an empty password, and a compatible league — or create a fresh
room. They never land in a dueling or full room. A disconnected
pairing player who re-sends the bare command is first matched as a
legitimate reconnector (same predicate as findReconnectingPlayer)
before the pairing scan runs.

Non-pairing joins now identify a room by the exact (name, password)
pair: a mismatched pair creates its own room instead of rejecting, so
a passwordless pairing room no longer blocks passworded rooms that
share its name. Spectating a room mid-duel with the correct password
is unchanged. docs/join-commands.md is the reference for the whole
command system.
2026-08-10 18:12:12 -04:00
github-actions[bot]
c028fcd495
chore(main): release 2.14.0 (#240)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-05 20:47:30 -04:00
Diango Gavidia
21f81774c9
fix(ranking): key rank by banlist name to unify formats across paths (#318)
Ranking is keyed by the normalized banlist name, but the name was
re-derived by round-tripping a hash through findByHash(...).name. On the
YGOPro path this used the cross-referenced edoBanListHash, which could
silently resolve to null and drop the per-format rank (only "Global" was
written). The same format played on both paths (e.g. Edison) shares one
normalized name but has different lflist hashes, so this was fragile.

Carry banListName directly on GameOverDomainEvent, resolved at the source
from each room's own banlist repository (EDOPro via BanListMemoryRepository,
YGOPro via MercuryBanListMemoryRepository). Both stats consumers read the
name from the event; banListHash is kept solely as audit metadata in the
match/duel resume records (it is finer-grained and distinguishes errata
from pre-errata pools).
2026-08-05 16:51:42 -04:00
Diango Gavidia
07b5f4d06a
chore(edison): post-merge cleanup of #316 (redact framing, fix comment) (#317)
* chore: drop strategic 'moat' framing from resources-lib comment

Public repo — keep the comment factual (private sources kept out of the base).

* chore: correct fork-core manifest comment (read per load, not once at boot)
2026-08-05 10:09:04 -04:00
Diango Gavidia
83e8cd5a73
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).
2026-08-05 09:34:06 -04:00
Diango
7462eee8bf feat(rule-mappings): add support for JTP Advanced March 2007 format 2026-07-29 00:08:11 -04:00
Diango Gavidia
7a801b141d
fix(emotes): reject spectator emotes server-side (#315)
handleEmote allowed spectators through, resolving them to
NetPlayerType.OBSERVER and broadcasting the frame. On the client that
maps to the opponent's HUD side, so a spectator could make an emote
appear as if a duelist sent it.

Reject spectators early (before any parsing/allocation or the
rate-limiter). Only seated duelists may send; spectators still receive
and see emotes. The OBSERVER branch in the playerType resolution is now
dead and removed.

Add tests: a duelist's valid emote broadcasts once; a spectator's emote
is dropped before the rate-limiter is reached.
2026-07-25 21:39:53 -04:00
Diango Gavidia
8a2a43050d
feat(emotes): relay a dedicated emote opcode to the room (#313)
New custom CTOS/STOC 0xfc opcode (same family as PING/PONG 0xff/0xfe and
RECONNECT 0xfd). The client sends CTOS 0xfc with a catalog id; the base
RoomState — inherited by every ygopro/Mercury state, exactly like CHAT —
validates it, rate-limits per client, and broadcasts STOC 0xfc carrying
the sender's seat so each viewer maps it to the right HUD side.

- ygopro/emote/emote-protocol.ts: opcode, id allowlist, STOC frame builder.
- Commands.EMOTE = 0xfc; RoomState.handleEmote (Mercury rooms only).
- YgoClient.tryEmote: per-client cooldown, on the client so it survives
  room state transitions. Unknown ids are dropped — no arbitrary strings.
2026-07-25 16:30:57 -04:00
Diango Gavidia
200aec0be2
feat(matchmaking): ranked human pairs play best-of-3 matches (#314)
Ranked matchmaking rooms were single-duel: the FORMAT_ROOM_TOKEN tokens
(tt, jtp) set no mode, so hostInfo defaulted to GameMode.SINGLE.

- RuleMappings: the four match shortcuts (oomr/omr/tomr/tmr) set
  mode MATCH but left best_of at the SINGLE default of 1, which makes
  Match.needWins = 1 — functionally a single duel. Pin best_of: 3.
- RuleMappings: new "jm" format mapping — jtp rules + MATCH + best_of
  3, kept to 2 chars for the utf16[20] join-string budget.
- MatchmakingRoomFactory: FORMAT_ROOM_TOKEN_MATCH (tcg → tmr,
  jtp → jm) selected by a new matchMode input; "tmr" sits at the
  19-char wire ceiling like "jtp", covered by the budget guard.
- bootstrapMatchmaking: human pairs get matchMode true; bot fallback
  stays best-of-1 because windbot never submits a side deck and the
  side-deck timeout would kick it mid-match.

Ranked reporting is unaffected: GameOverDomainEvent already fires once
per match (only when isMatchFinished), so ELO/stats never multi-count.
2026-07-25 16:10:10 -04:00
Diango Gavidia
d52dfd7408
feat(windbot): shorten TCG botlist names to fit the join wire budget (#312)
The vs-AI client embeds the bot name in its own CTOS_JOIN_GAME `pass`
field (fixed utf16[20]); "Salamangreat Bot" (16) overflowed the field
with any room token, so findByName never resolved the truncated name.
Drop the redundant " Bot" suffix from the TCG botlist entries so
`tt,ai#<name>` fits. Deck files (bot.deck) and the windbot launch are
unaffected — only the lookup/display name changes; the matchmaking
roster is updated to the same names for findByName coherence.
2026-07-24 21:25:39 -04:00
Diango Gavidia
4f706da2e0
feat(matchmaking): open the ranked TCG room pool via the tt token (#311)
Ranked TCG rooms were created with the `to` token (rule 1), whose scope
check bounces cards without a TCG release (recent OCG printings, ot=0x9)
with CARD_OCG_ONLY at join. The TCG format is defined by its banlist,
not by release scope — same product call the client now applies to
casual and vs-AI rooms with the `toot` token.

`tt` is a new short alias of the existing `toot` mapping (rule 5 +
first TCG lflist), added because the matchmaking join string must fit
the utf16[20] wire field: tokens are capped at 3 chars and `toot` has
four. No new behavior — same handler, second name.
2026-07-24 19:37:06 -04:00
Diango Gavidia
7850081571
fix(matchmaking): abort incomplete room reservations (#310) 2026-07-24 09:04:52 -04:00
Diango Gavidia
3afaec3f92
feat(matchmaking): multi-format queue with per-format bot roster (tcg, jtp) (#309)
* feat(matchmaking): add multi-format enum and Zod schema guard

Replace SUPPORTED_FORMAT literal with MATCHMAKING_FORMATS const array and
MatchmakingFormat derived union. Update EnqueueMatchmakingController to use
z.enum(MATCHMAKING_FORMATS) so JTP is accepted and genesys/unknown formats
return HTTP 400. Adds QueueEntry.test.ts covering the array contract.

* feat(matchmaking): per-format room token and wire-budget guard

Add FORMAT_ROOM_TOKEN record (tcg→"to", jtp→"jtp") to MatchmakingRoomFactory.
createMatchmakingRoom now accepts an optional format field and uses the mapped
token for the room command. Port signatures in MatchmakingQueueDeps gain format:
createRankedRoom(format), createBotRoom(format), spawnBot(roomId, format).
Adds matchmakingRoomToken.test.ts as the wire-budget guard asserting every
format token produces a join string ≤ 19 UTF-16 chars.

* feat(matchmaking): per-format bot roster and bootstrap wiring

Add MatchmakingBotRoster.ts with MATCHMAKING_BOT_ROSTER (Record<MatchmakingFormat,
BotIdentity[]>) and pickBotFromRoster(format, random). TCG has 7 identity pairs
verified against botlist.example.json; JTP has [(Joey,JTP),(Yugi,Yugi)].

Remove MatchmakingTcgBotDecks.ts (superseded by the roster). Update
bootstrapMatchmaking.ts to thread format through createRankedRoom/createBotRoom/
spawnBot and call requestBot with explicit pair.name + pair.deck as deckOverride,
ensuring identity coherence and that deckcode is cleared.

Adds MatchmakingBotRoster.test.ts and deckOverrideBoundary.test.ts covering
roster structure, pickBotFromRoster determinism, and the deckOverride→deckcode
boundary via RequestWindBotJoin.

* refactor(matchmaking): drop deprecated SUPPORTED_FORMAT alias

* test(matchmaking): harden wire-budget guard and co-locate tests

- Exercise the real createMatchmakingRoom generator per format (incl. jtp
  19-char boundary) instead of duplicating NAME_ENTROPY_CHARS/PASSWORD_CHARS
- Move domain tests out of __tests__/ into sibling co-located files; merge the
  token test into MatchmakingRoomFactory.test.ts
- Add cross-format isolation test proving tcg and jtp entries never pair
- Assert jtp enqueue propagates format to the queued entry
- Update stale factory docstring to per-format FORMAT_ROOM_TOKEN and jtp
  zero-slack 19-char worst case
2026-07-23 09:48:38 -04:00
Diango Gavidia
e2054d15bb
feat(matchmaking): auto-pairing queue with ranked + windbot fallback (#308)
* feat(matchmaking): add auto-pairing queue with ranked/bot fallback

Implements the v1 matchmaking contract (tcg/ranked) with three Express
endpoints under /api/matchmaking/*, an in-memory queue singleton, and an
additive room-creation factory that builds ranked/bot rooms without a
client PlayerInfoMessage.

- MatchmakingQueue: Map-based singleton, unref'd sweep interval, human
  pairing, 15s bot fallback, 8s poll TTL
- createMatchmakingRoom: reuses YGOProRoom.create via synthetic PlayerInfo
- Enqueue/Status/Cancel controllers with Zod validation and ticket auth
- bootstrapMatchmaking wires ports to windbot + room factory

* fix(matchmaking): reap orphaned rooms, throttle status/cancel, contain room-creation throws

- Add MatchmakingRoomReaper: finalize matchmaking-created rooms still empty
  past MATCHMAKING_ROOM_JOIN_GRACE_MS (45s) via the existing FinalizeYGOProRoom
  path, driven by an unref'd sweep timer. Fixes orphaned ranked/bot rooms that
  leak when no real client ever joins.
- Add RateLimitMiddleware to GET /api/matchmaking/status and DELETE
  /api/matchmaking/queue; keep the existing unknown-ticket short-circuit that
  returns 404 without advancing the O(n) tick.
- Wrap per-entry room creation in pairHumans/botFallback in try/catch so a
  synchronous throw from a room-creation port no longer aborts the sweep or
  500s an unrelated poller; failures are reported via onRoomCreationError.

* fix(matchmaking): reap matched queue entries after grace window

A matched entry was never removed from usersInQueue: poll() returned the
matched result but never deleted it, and expireStale() only swept searching
entries. The user stayed pinned forever, so re-enqueue threw
DuplicateQueueEntryError (HTTP 409) and Quick Match stopped working after the
first match until restart.

Stamp matchedAt when an entry becomes matched and add MATCHED_GRACE_MS
(30s). expireStale() now also drops matched entries past the grace window,
freeing both entries and usersInQueue. Re-polls within the window still get
the matched result (idempotency). Only queue bookkeeping is reaped; the room
lifecycle stays owned by MatchmakingRoomReaper.

* fix(matchmaking): serve TCG-legal bot deck in fallback

Matchmaking v1 rooms are TCG (rule 1 + TCG banlist), but the bot fallback
requested a random server-botlist bot whose deck (JTP/Yugi) fails the TCG
deck-check, ejecting the human before the duel starts.

- Add MATCHMAKING_TCG_BOT_DECKS pool + pickRandomTcgBotDeck helper.
- Thread deckOverride through WindbotModule.requestBot so RequestWindBotJoin
  returns a bot whose deck reflects the override (buildUrl reads bot.deck for
  the windbot deck= param, so the override must land on the bot, not only the
  token payload).
- bootstrapMatchmaking spawnBot now picks a TCG deck and passes it as the
  deckOverride; queue's spawnBot(roomId) signature unchanged.
- botlist.example.json keeps Joey/Yugi and adds TCG bot entries.

* fix(matchmaking): fit join string within CTOS_JOIN_GAME pass field

The matchmaking join string (to,mm-<12hex>#<16hex>, ~35 chars) exceeded
the fixed utf16[20] CTOS_JOIN_GAME pass field and was truncated on encode,
destroying the password segment so the human's join failed the password
check (bot joins via a different internal path, so only the human broke).

Shorten the join string to <= 19 chars: to,mm<5-base36>#<7-base36> (18).
The name keeps the shortest TCG-only token (to -> rule 1 + TCG banlist);
the mm prefix stays collision-proof against rule validators and is
checked against YGOProRoomList.findByName to guarantee both paired
players resolve to the same room. Password stays non-empty (private room).

* fix(matchmaking): clear deckcode on override, poll-based matched expiry, clamp deck index

W1: RequestWindBotJoin clears deckcode when a deckOverride is set, so windbot cannot
honor the source bot's deckcode over the override and play a non-TCG deck.

W3: MatchmakingQueue matched-entry grace is measured from lastPollAt instead of
matchedAt, so an actively-polling slow-join client is never dropped mid-flow.

S2: pickRandomTcgBotDeck clamps the index to len-1 so random()===1.0 no longer
returns undefined.

* fix(matchmaking): tear down bot room when the human leaves (mark AI room on bot join)
2026-07-21 20:14:17 -04:00
Diango Gavidia
2fa9923eb3
feat(version): add GET /api/resources/version endpoint (#307)
* feat(version): expose sha512 and card-db fingerprint getters

* feat(version): add GET /api/resources/version endpoint
2026-07-14 19:28:53 -04:00
Diango Gavidia
816f30513e
feat(bootstrap): hot-reload ban lists without a server restart (#306)
* test(ban-list): add replaceAll contract tests for BanListMemoryRepository (REQ-301, REQ-305)

* feat(ban-list): add replaceAll double-buffer swap to edopro and ygopro repos (REQ-301, REQ-305)

* test(bootstrap): add re-callable banlist loader extraction tests (REQ-302, REQ-303)

* feat(bootstrap): extract re-callable banlist loaders (REQ-302, REQ-303)

* feat(bootstrap): add banlist reloader with change detection and atomic swap (REQ-304, REQ-305)

* test(room): assert banlist reload does not mutate in-flight rooms (REQ-306)

* feat(bootstrap): start banlist reloader on server boot (REQ-304)

* chore(ban-list): drop internal requirement tags from comments and test names
2026-07-14 18:12:12 -04:00
Diango Gavidia
aa7c96bd46
feat(resources): declarative manifest-driven cdb/banlist sources + ygopro pool derivation (#305)
* feat(resources): add declarative resources.manifest.json

Introduces the normative manifest (11 sources, 22 assembly rules)
replacing all hardcoded URLs and mappings in the bash pipeline.
Covers RSM-001 through RSM-012 source + assembly schema.

* feat(resources): add resources-lib.sh shared bash library

Implements all shared functions sourced by the two resource scripts:
- fail() logger, jq preflight (fail-fast on missing jq)
- validate_manifest (RSM-005 a-e: JSON parse, type check, dangling from,
  file+dir combo, target collision without overwrite)
- http_integrity_check (RSM-006: non-empty + SQLite magic for .cdb)
- apply_rule (RSM-002: whole-source/dir/file/only-glob via find -name,
  cwd-invariant, spaces+parens safe)
- apply_rule_with_fallback (RSM-003: ordered from[] fallback chain)
- sync_repo (RSM-007: moved from clone_repositories.sh, id-keyed dirs)

Manifest path resolved via BASH_SOURCE[0] (D1 cwd invariant, D8 id-keying).

* test(resources): add bats fixture tree and resources-lib test suite

Fixture tree (test/fixtures/):
- repositories/source-a, source-b: git-less fixture dirs for assembly tests
- repositories/source-with-dir: dir-only copy shape fixture
- repositories/http-source: crafted .cdb files (valid SQLite magic, empty,
  corrupt) and a non-cdb file for integrity check tests
- manifests/: 6 JSON fixtures covering each RSM-005 failure scenario plus a
  valid minimal manifest

Bats suite (test/resources-lib.bats, 24 tests):
- RSM-005 a-e: each bad-manifest fixture causes validate_manifest exit 1
- RSM-006: empty file, non-SQLite .cdb, valid .cdb, non-cdb integrity checks
- RSM-002: all rule shapes including only-glob with spaces+parens in filenames
- RSM-003: first-source-wins, fallback-to-second, chain exhausted
- RSM-004: collision detection + intentional overwrite pass
- RSM-012: unused evolution-assets files absent; manifest counts (11/22)

All 24 tests pass: tools/bats-core/bin/bats test/resources-lib.bats

biome.json: exclude test/fixtures/** (fixture files are intentionally invalid
JSON in some cases and are not production code).

* test(resources): make bats suite self-contained (generate fixtures in setup)

Previously, test/fixtures/repositories/ was generated on disk but gitignored
by *repositories/ and *.cdb patterns in .gitignore, so fixture source files
and crafted .cdb binaries were never committed.

Fix: move all fixture source generation into setup() — each test creates its
own tree under mktemp -d. Binary fixture data (SQLite magic bytes, empty file,
corrupt bytes) is produced inline via printf. The test/fixtures/manifests/ JSON
files remain in git as before.

All 24 tests pass with the self-contained approach.

* fix(resources): harden integrity check and manifest validation per review

- http_integrity_check: binary cmp of first 16 bytes (no command substitution) so the
  trailing SQLite magic NUL is compared; a 15-byte file is rejected
- validate_manifest: RSM-001 checks for non-empty/unique id, non-empty url, http
  filename, and array-from requiring file
- resources.manifest.json: order ocg lflist rule after the ygopro/formats whole-dir
  rule (D2 prefix-nested invariant)
- tests: dir+only shape, apply_rule failure paths, executor overwrite, specific
  offending-id/target assertions

* fix(resources): harden validate_manifest and http_integrity_check

- Add early structural guard: .sources and .assembly must be arrays;
  abort naming the violation if either is a non-array type.
- Capture jq exit status in the unknown-type check (b); a jq crash
  (e.g. non-string type field) now fails hard instead of fail-open.
- Use (.type|tostring) so integer or boolean type values produce a
  readable error instead of crashing jq.
- Case-insensitive .cdb match in http_integrity_check via ${filepath,,}
  so uppercase .CDB extensions also trigger the SQLite header check.
- Remove vestigial 6th "" argument from all apply_rule calls in the
  bats suite (function signature is 5 args: src_dir dir file only target).
- Add 6 new bats tests covering all four hardening scenarios (41 total).

Fixes JD backlog items 1-4 from the PR1a adversarial review.

* feat(resources): rewrite clone_repositories.sh as manifest interpreter

Replaces all hardcoded git/wget calls with a loop over sources[] from
resources.manifest.json. Sources are keyed by id (repositories/<id>).

- Source resources-lib.sh (validate_manifest, sync_repo,
  http_integrity_check, fail) so no logic is duplicated.
- Run validate_manifest fail-fast before any network operation (RSM-005).
- git sources: call sync_repo <id> <url> <branch> (D8 id-keyed dirs).
  Missing branch field passes "" so sync_repo uses remote default (RSM-007).
- http sources: wget -qO repositories/<filename> <url>, then
  http_integrity_check for non-empty + SQLite magic on .cdb files (RSM-006).
- Remove cd repositories line; all paths are repo-root-relative (D1).
- No hardcoded URLs, branches, or filenames remain (RSM-010).

Bats coverage: 6 new tests (47 total) covering validation abort,
git/http stub flows, empty-download fail, RSM-010 lint, D1 cwd lint.

* feat(resources): rewrite setup_resources.sh as assembly interpreter

Replaces all hardcoded cp/MAP blocks with a loop over assembly[] from
resources.manifest.json. Atomic swap and GC skeleton survive byte-compatible.

- Source resources-lib.sh (apply_rule, apply_rule_with_fallback, fail).
- Iterate assembly[] rules: dispatch to apply_rule for single-source
  rules (whole-source / dir / file / only-glob) and to
  apply_rule_with_fallback for from-array (RSM-003 fallback chain).
- Resolve source dir per source type: git -> repositories/<id>;
  http -> repositories/ (flat file, keyed by filename from clone stage).
- .git directories are stripped from staging before the atomic publish
  (JD backlog item 6; matches design D1/PUBLISH stage).
- Atomic symlink swap and GC are preserved verbatim from the original
  script; RESOURCES_KEEP_RELEASES env var behaviour is unchanged.
- REPOS_ROOT / RELEASES_ROOT env var overrides for test isolation.
- No hardcoded source paths, directory names, or copy mappings remain
  (RSM-010).

Bats coverage: 5 new tests (52 total) — assembly, .git strip,
abort-before-swap, GC keep limit, RSM-010 lint.

* test(resources): verify content parity between old and new layout

Main scripts (from main branch) cloned sources and assembled old layout.
New scripts (this branch) cloned sources and assembled new layout.
Old layout path-translated in temp dir:
  ygopro/alternatives -> ygopro/formats
  ygopro/ocg -> ygopro/formats/ocg
  ygopro/prereleases-cdb -> ygopro/extensions/prereleases
  ygopro/cards-art -> ygopro/extensions/cards-art

Result: diff -rq exit code 0 — zero content differences.
The 4 unused evolution-assets files are absent from the new layout.

* feat(docker): install jq and copy resources-lib + manifest into build stages

The resource provisioning scripts (clone_repositories.sh, setup_resources.sh)
source resources-lib.sh and read resources.manifest.json via jq. Both build
stages that run or ship these scripts must therefore have jq installed and the
library + manifest present, or the Docker build and the runtime updater sidecar
break. Add jq to the apt-get lists of the resources-builder and final stages,
and add resources-lib.sh + resources.manifest.json to both COPY lines.

* fix(resources): harden symlink target, REPOS_ROOT honoring, and http-rule validation

- setup_resources.sh: derive the current symlink target from basename $RELEASES
  instead of hardcoding 'releases/', so a non-default RELEASES_ROOT no longer
  produces a dangling symlink.
- setup_resources.sh: define _resolve_src_dir() once before the assembly loop
  (not redefined per iteration) and pass the source id via jq --arg instead of
  interpolating it into the filter string.
- clone_repositories.sh + resources-lib.sh (sync_repo): honor REPOS_ROOT
  (default ./repositories) so both fetch and assembly stages agree on the
  checkout root; defaults are unchanged.
- resources-lib.sh (validate_manifest): reject any assembly rule that references
  an http source without a file key (http sources resolve to a flat file under
  repositories/, so a dir/only/whole-source rule would copy the whole tree);
  the error names the offending rule target.

* test(resources): make git-clone and .git-strip assertions load-bearing

- clone git-source test: assert the recorded CALL_LOG contains the expected
  'git clone --depth 1 --branch main <url>' invocation instead of only checking
  exit 0, and isolate the checkout via REPOS_ROOT so it never touches the real
  repositories/ tree (which also guarantees the clean-clone path).
- .git-strip test: the fixture only used file: rules, so .git never reached
  staging and the assertion passed vacuously even with the strip line removed;
  additionally find on the current symlink without -L never descended into the
  release. Add a whole-source rule over the git fixture so .git lands in staging
  and use 'find -L' so the check is load-bearing (fails when the strip line is
  removed, passes when present).
- add http-source-no-file.json fixture + test for the new validate_manifest
  rule rejecting http sources referenced without a file key; give valid-minimal
  the http rule a file key so it stays valid under the new check.
- isolate the http clone tests via REPOS_ROOT to stop real-tree pollution.

* chore(config): align YGOPRO folder env to new resource layout

Update YGOPRO_FOLDERS and YGOPRO_EXTRA_FOLDERS in .env.example and
docker-compose.prod.yaml to the enumerated leaf-dir shape under the new
formats/ and extensions/ layout. Leaf dirs are enumerated because the
loader's readdir is non-recursive (goat/rush/speed/gx/mdc omitted).

* fix(resources): harden manifest validation and .git strip

- validate_manifest: reject assembly rules with empty/missing target
  (previously published a literal null/ dir), naming the rule index.
- validate_manifest: for rules resolving to an http source, require the
  rule file to equal the source filename, naming target + source id.
- setup_resources.sh: remove '|| true' (and 2>/dev/null) from the .git
  strip so a failed strip aborts before the atomic publish.

* test(resources): cover target/filename validation and fix stub bashism

- Add fixtures + tests for missing-target and http file/filename mismatch.
- Fix git stub shebang to bash (${@: -1} is a bashism under /bin/sh).
- Add resources-lib.sh to the RSM-010 hardcoded-URL lint (lib is clean).

* fix(resources): guard against non-object assembly elements in validate_manifest

* ci(resources): add bash-resources-tests job to pipeline

* docs(resources): update README env blocks to new layout and add migration + jq notes

* feat(resources): add runtime.ygopro pool section to manifest (RFD-001)

* test(resources): bats regression for validate_manifest tolerates runtime section (RFD-002)

* test(resolver): add failing unit tests for ResourcePoolResolver (RFD-003/004/005)

* feat(resolver): implement ResourcePoolResolver (RFD-003/004/005)

* feat(config): add manifestPath (MANIFEST_PATH env, default ./resources.manifest.json)

* feat(loader): wire YGOProResourceLoader to use ResourcePoolResolver for pool derivation

* feat(search): wire YGOProCardSearchRepository to use resolved extended pool

* test(resolver): add whole-chain integration test with in-memory fixtures (RFD-007)

* fix(config): remove enumerated YGOPRO_FOLDERS/EXTRA values; derivation is now the default (RFD-006)

* docs(resources): update README env section for manifest-driven pool derivation (RFD-008)

- Replace YGOPRO_FOLDERS/EXTRA_FOLDERS as required env vars with RESOURCES_DIR + MANIFEST_PATH
- Document standard/extended pool derivation from resources.manifest.json
- Add pre-deploy smoke check procedure with pool inspection snippet
- Mark YGOPRO_FOLDERS/EXTRA_FOLDERS as optional override (deprecated)

* feat(resources): adopt production source taxonomy and add classic pre-errata pool

Rename manifest source ids to edopro-*/ygopro-* scheme, add custom-cards
and classic (pre-errata alt-coded variants for edison/hat) with its own
scripts, and expose classic as a distinct cdb via classic.cdb filename.
Point edopro banlist bootstrap at the new evolution-lflists/lflists dirs.

* feat(resources): warn on missing pool dirs and duplicate cdb basenames

* fix(resources): make pool diagnostics one-shot and fix doc/test drift

* refactor(resources)!: remove deprecated YGOPRO_FOLDERS env override; manifest is sole pool source

YGOPRO_FOLDERS and YGOPRO_EXTRA_FOLDERS are removed entirely. The manifest
runtime.ygopro.standard/.extended section is now the only source of pool
membership. Removed env field from ResourcePoolResolverOptions, env-override
branches from resolvePools, and 5 associated tests. All callers updated.
MANIFEST_PATH, RESOURCES_DIR, and YGOPRO_EXTRA_SCRIPTS remain untouched.

* refactor(scripts): move resource pipeline scripts into scripts/ dir

Moves clone_repositories.sh, setup_resources.sh, resources-lib.sh,
resources-updater.sh, entrypoint.sh, build_core_integrator.sh, and
install_dependencies.sh from repo root into scripts/. resources.manifest.json
stays at root as user-facing config.

Landmines handled:
- .dockerignore: scripts/ excluded but !scripts/*.sh re-included so the
  build context delivers the .sh files to Docker COPY
- resources-lib.sh: MANIFEST_PATH default changed from BASH_SOURCE-relative
  to CWD-relative (resources.manifest.json) since the manifest stays at root
- entrypoint.sh: bash resources-updater.sh -> bash scripts/resources-updater.sh
- resources-updater.sh: bare clone/setup calls -> scripts/ prefixed

Dockerfile: two COPYs per stage (scripts/ + manifest), RUN and CMD updated.
test/resources-lib.bats + test/manifest-runtime-tolerance.bats: LIB and all
bash "$REPO_ROOT/..." paths updated to scripts/ prefix. README: all command
examples updated. .github/ docs move included (was staged, not yet committed).

* test(resources): drop brittle manifest-snapshot assertions

The RSM-012 count/reference tests hardcoded the migration-era manifest
(11 sources, 22 rules, no classic.cdb). The production manifest is a
living config: it now has 10 sources, 21 rules, and legitimately
references classic.cdb. These snapshot tests assert content that is
meant to evolve; schema/behavior tests provide the real coverage.

* chore(deps): bump koishipro-core.js to ^1.5.2 and ygopro-msg-encode to ^1.3.0

The resource folder-discovery work was developed and tested against
koishipro-core.js 1.5.2 (non-recursive readdir pool scanning). Pinning
the lockfile to the tested versions keeps CI consistent with local.
2026-07-13 23:36:57 -04:00
Diango
0337addef3 fix(deps): resolve two moderate npm audit vulnerabilities
- typeorm 0.3.28 -> 0.3.30: SQL injection in UpdateQueryBuilder/
  SoftDeleteQueryBuilder orderBy for MySQL/MariaDB (GHSA-9ggv-8w38-r7pm)
- js-yaml 3.14.2 -> 3.15.0 (transitive via @istanbuljs/load-nyc-config):
  quadratic-complexity DoS in merge key handling (GHSA-h67p-54hq-rp68)
2026-07-05 17:45:04 -04:00
Diango
2385260d7a feat(genesys): show point list with card costs in inspect view
Genesys entries load as 3-copy cards in the 'all' bucket, which the inspect
view never counted or displayed. Surface that bucket: count it in the ban list
listing and render a dedicated 'Point List' section showing each card with its
point cost, sorted by cost descending.
2026-07-03 10:45:06 -04:00
Diango
a7308204b4 fix(setup): re-clone repository when directory exists without .git
sync_repo only handled a valid git checkout or a missing directory. A directory
populated out-of-band (no .git) fell through to git clone, which aborts on a
non-empty destination and, under set -e, killed the whole sync.
2026-07-03 09:43:26 -04:00
Diango
a720913390 fix(genesys): copy edopro genesys list as lowercase to overwrite in place
Using the lowercase genesys.lflist.conf filename overwrites the list shipped
by the termitaklk repo instead of adding a second file on a case-sensitive
filesystem, which caused the Genesys ban list to load twice.
2026-07-03 09:43:25 -04:00
Diango Gavidia
98d5ff0e17
fix(ygopro): resolve genesys ban list by alias and reject join when missing (#304)
Rooms created with the genesys command hardcoded lflist 0, which resolves
to the first loaded ban list (an OCG list at runtime) instead of Genesys.
That made isGenesys() false in YGOProDeckValidator, so the point and copy
validation added in #303 never ran on the ygopro path.

- Resolve the lflist index via findIndexByAlias("genesys"), like goat.
- Throw when the Genesys ban list is not loaded instead of silently
  validating against the wrong list.
- Send JOINERROR and close the socket when a join strategy throws;
  previously any strategy error became an unhandled promise rejection.
- Tests now seed the ban list repository and assert the room resolves
  the Genesys hash; the old assertions (lflist === 0) were actually
  asserting "ban list not found".
2026-07-02 17:29:09 -04:00
Diango Gavidia
b3b51e5bc8
feat(genesys): move point list to lflist.conf, validate on both paths (#303)
* feat(genesys): carry point costs on the ban list

Add an optional third column to lflist.conf parsing so a ban list can
carry per-card Genesys point costs. BanList gains a `points` map, and the
edopro/ygopro loaders parse the column via a shared `parseBanListEntry`
helper. Points do not affect the ban list hash, so client/server banlist
matching is unchanged, and two-column lists keep working unchanged.

* feat(genesys): validate decks from the ban list on both paths

Read Genesys point costs from the ban list's `points` map instead of a
bundled genesys.json, so the point list has a single source of truth (the
lflist.conf) shared by the edopro and ygopro/mercury paths.

- GenesysRulesValidationHandler takes the injected points map.
- Fix the ygopro path, where Genesys was a silent no-op: YGOProRoom now
  passes maxDeckPoints to DeckRules and YGOProDeckValidator gains a
  Genesys branch.
- Add MaxCopiesValidationHandler to enforce the 3-copies-max rule for
  every card (alias-aware), not only the pointed ones, on both paths.
- Remove the obsolete genesys.json and its in-repo generators.

* chore(genesys): source the ban list from evolution-assets

setup_resources.sh now stages the Genesys lflist from evolution-assets
(where the generator and its scheduled CI now live) for both the edopro
and ygopro paths, instead of the third-party termitaklk list.
2026-07-01 12:00:18 -04:00
Diango Gavidia
224f140706
fix(card): refresh EDOPro card DB in place so the C++ core sees hot-reloads (#302)
The C++ core (CardSqliteRepository) opens the fixed path evolution_cards.db
fresh per duel. The previous reloader built a timestamped file, swapped only
the TS datasource, and deleted evolution_cards.db on dispose, crashing the core
mid-duel with "no such table: datas" (mitigated by disabling it in #301).

Rework the reload to keep evolution_cards.db as the single canonical artifact:
build the merged DB into a temp, then atomically rename it onto
evolution_cards.db. A running duel keeps its already-open inode (untouched); a
new duel opens the replaced file; the file is never a sidecar the core cannot
see and is never deleted. The previous datasource is only closed after a grace,
never rm'd.

Extract the CardDbReloader ports into EdoProCardDbPorts so the rename/dispose
logic is unit-testable without better-sqlite3, and re-enable the reloader in
bootstrapPersistence.
2026-06-30 18:40:16 -04:00
Diango Gavidia
9013963d58
fix(card): disable EDOPro card DB hot-reload crashing the core (#301)
The C++ core opens the fixed path evolution_cards.db
(core/.../CardSqliteRepository.cpp) fresh per duel. The Slice 2 reloader
built a timestamped evolution_cards.<id>.db, swapped the TS datasource, and
on dispose deleted evolution_cards.db. After the first reload the core could
no longer find the file, crashing mid-duel with "no such table: datas" then
"Core exited".

Stop the bleeding by not starting the reloader: the server falls back to the
self-contained evolution_cards.db built at boot, which the core reads
reliably. EDOPro cards refresh via the daily image rebuild until the reload
is reworked to refresh evolution_cards.db in place (atomic rename at the
fixed path) so both the TS datasource and the C++ core stay in sync.
2026-06-30 12:57:35 -04:00
Diango Gavidia
178107edc8
fix(core): resolve EDOPro scripts under RESOURCES_DIR (resources/current) (#300)
The CoreIntegrator hardcoded 'resources/edopro/scripts', but the resources
hot-reload work (#296) moved everything under resources/current/. The core could
no longer open the script directory and crashed mid-duel (filesystem error:
recursive directory iterator cannot open directory [resources/edopro/scripts]).

Read RESOURCES_DIR (default resources/current), matching the TS config.resources.dir
— the core inherits the env from the spawning server, so the two stay in sync.
2026-06-30 09:45:30 -04:00
Diango Gavidia
a2dc475169
feat(card): refresh the inspection index on a TTL (#295) (#299)
The card inspection index was cached for the process lifetime, so the / page
kept showing stale data after a hot reload. Rebuild it once the TTL (10 min)
lapses, keyed off an injectable clock for testing.
2026-06-29 22:32:11 -04:00
Diango Gavidia
69a7b52b3e
chore(card): remove dead mercury pre-release cdb generator (#298)
mercuryDataSource + PreReleasesYGOProSQLiteTypeORM were only used by the manual
generate-mercury-pre-releases-cdb utility — never at boot, in CI, or by the
Dockerfile — and the prerelease cards are served from the ygopro extra folders.
Remove the utility, the class, the mercuryDataSource/options, and the npm script.

Unrelated: the YGOPro 'Mercury' engine (RoomType.MERCURY, MercuryRoomList,
config.servers.mercury, MercuryBanListMemoryRepository) is untouched.
2026-06-29 21:32:55 -04:00
Diango Gavidia
050ae582e9
feat: hot-reload the EDOPro card DB via atomic datasource swap (#297)
* feat(card): hot-reload EDOPro card DB via atomic datasource swap

The EDOPro card DB (merged evolution_cards.db) was loaded once at boot and never
refreshed. Add live reload without restart:

- data-source.ts becomes a holder: buildCardDataSource(file) factory +
  getCardDataSource()/swapCardDataSource(). Consumers read the current datasource
  per query so a swap takes effect immediately.
- CardDbReloader: generic SHA-fingerprint -> build-new -> swap -> destroy-old
  orchestrator, BetterLock-serialized (unit-tested with fakes).
- EdoProSQLiteTypeORM.build(file): build + merge every .cdb into a FRESH datasource
  (never touches the live connection), so a rebuild is safe while lookups run.
- EdoProCardDbHotReload: fingerprints resources/current/edopro/databases, rebuilds
  into a new file, swaps the holder, disposes the previous datasource + file, on a
  10-min timer; primed and started from bootstrapPersistence.
- CardSQLiteTYpeORMRepository reads getCardDataSource() per findByCode.

* fix(card): address code-review findings on EDOPro card DB hot reload

- Defer disposing the swapped-out datasource + deleting its file by a grace
  period, so in-flight findByCode calls on the old datasource can finish before
  its connection and file disappear (previously destroyed immediately on swap).
- build() destroys the half-built datasource and removes its file if the merge
  fails, instead of leaking a connection + stranded file.
- Fingerprint the .cdb directory by size+mtime instead of reading and SHA-hashing
  every file each interval, so the periodic check no longer blocks the shared
  event loop on hundreds of MB.
- Log dispose/delete failures instead of swallowing them.
2026-06-29 21:10:45 -04:00
Diango Gavidia
afa8110777
feat: runtime-updatable resources (releases/current) with in-container refresh (#296)
* feat(resources): runtime-updatable resources via releases/current symlink

Assemble each resource build into resources/releases/<id> and atomically
repoint a resources/current symlink at it (setup_resources.sh), so a refresh
is a single atomic rename and in-flight reads keep their release via POSIX
open handles. Route all resource paths through a configurable base
(config.resources.dir / RESOURCES_DIR, default ./resources/current) and make
the Docker build reuse the scripts instead of duplicating the clone/assemble
logic inline. data-source.ts reads RESOURCES_DIR from env directly to avoid
coupling its module-load options to a mocked config.

* feat(docker): resources-updater sidecar + shared resources volume

Add a resources-updater sidecar (Dockerfile.updater + resources-updater.sh)
that re-runs clone_repositories.sh + setup_resources.sh on a loop into a shared
resources_data volume, keeping the server image slim (no git/network at
runtime). The server mounts the volume read-only and depends on the updater
being healthy (current symlink present) so the volume is seeded before boot.
This activates the existing YGOPro card reload end to end with no server code.

* fix(resources): address code-review findings on the resources volume slice

- clone_repositories.sh: clone ygopro-scripts from Fluorohydride (the source the
  Dockerfile deliberately used) instead of moenext, which the script refactor had
  silently reverted.
- Revert the mercury pre-release paths (data-source.ts, PreReleasesMercurySQLiteTypeORM)
  to their original literals: they target a separate, already-divergent subsystem and
  must not be routed through resources/current in this slice. This also drops the
  config coupling at module load.
- setup_resources.sh: nanosecond-precision release id (avoid same-second collisions)
  and drop a non-symlink current/ before the swap so the atomic rename always lands.
- docker-compose.prod.yaml: give the updater healthcheck a 900s start_period so the
  multi-minute first clone on an empty volume can't deadlock the server's depends_on.

* chore(docker): align prod YGOPRO_FOLDERS to the curated alternatives subset

Enumerate the intended alternative formats (edison, genesys, hat, jtp, md,
tengu, world) instead of the alternatives parent, so prod matches the curated
local selection rather than auto-enabling every format.

* refactor(docker): self-updating server container instead of compose sidecar

Run the resource refresh loop inside the server container (entrypoint.sh starts
resources-updater.sh in the background, server runs in the foreground under
dumb-init), instead of a separate compose sidecar. This fits the prod 'docker
run' deploy (no compose), and the baked resource seed lets the server boot
immediately while the first refresh runs in the background.

- clone_repositories.sh fast-forwards existing shallow clones (delta pull) with
  a fresh-clone fallback; setup_resources.sh strips .git from the assembled
  release instead of repositories/, so refreshes pull deltas instead of
  re-downloading everything each interval.
- Dockerfile final stage installs git/wget, copies the scripts + entrypoint, and
  runs via the entrypoint.
- Drop Dockerfile.updater and the compose resources-updater service + volume.
2026-06-29 13:58:27 -04:00
Diango Gavidia
29809f1453
feat: public card & banlist inspection page (#294)
* feat(card): add card catalog read model with cdb provenance

Search cards by name or passcode across EDOPro and YGOPro by reading the
raw .cdb files, capturing the source .cdb of each card. Includes browse by
source, name resolution and pagination via a shared CdbCardSearchRepository
base. YGOPro indexes YGOPRO_FOLDERS + YGOPRO_EXTRA_FOLDERS.

* feat(banlist): add banlist views and detail with resolved card names

Expose available banlists per engine with per-category counts, and a detail
view that resolves each entry id to its card name (forbidden, limited,
semi-limited and whitelisted).

* feat(http): public arcane inspection page and endpoints

Add public read-only endpoints to inspect the cards and banlists the server
has loaded, so players can self-verify before reporting missing cards:
GET /api/banlists, /api/banlists/:engine/:name, /api/databases,
/api/databases/cards and /api/cards. Serve an arcane app-shell page at / with
sidebar filters and a list-first main view.

* fix(http): rate limit public inspection endpoints

The card and database inspection endpoints run synchronous index scans on the
event loop shared with the game servers, so an unthrottled public flood could
degrade live duels. Add a per-IP Redis rate limit (mirroring JoinHandler),
gated by RATE_LIMIT_ENABLED and fail-open, applied only to the inspection
routes.

* fix(card): log skipped unreadable .cdb files

The card index silently swallowed read/parse errors per .cdb, so a corrupt
database would drop its cards from search with no trace -- the exact 'missing
card' class this feature is meant to diagnose. Log the failed file (mirroring
YGOProResourceLoader) and cover the skip path with a test.
2026-06-28 17:54:14 -04:00
Diango Gavidia
f63602b80b
refactor(db): classify card-db infrastructure by bounded context (#293)
* refactor(db): move card entities to shared to fix dependency direction

CardEntity and CardTextEntity model the universal YGOPro .cdb schema (datas/
texts tables), not edopro-specific data, and are consumed cross-context: by the
edopro card repository and by the shared data-source that also backs the mercury
prereleases database. Living under @edopro forced shared/data-source.ts to
import from a bounded context (shared -> context), an inverted dependency. Move
them to src/shared/db/sqlite/infrastructure so shared depends only on shared and
the edopro repository points to shared (context -> shared).

* refactor(db): move sqlite ORM wrappers to their bounded contexts

EdoProSQLiteTypeORM is edopro-only (used by bootstrapPersistence) and
PreReleasesMercurySQLiteTypeORM is ygopro-only (used by the mercury prereleases
util), yet both lived under src/shared/db. Move them to
edopro/card/infrastructure/sqlite and ygopro/card/infrastructure/sqlite
respectively. Only data-source.ts and the shared card entities remain under
shared, which is what both contexts genuinely share.
2026-06-28 10:59:05 -04:00
Diango Gavidia
5af3b416eb
refactor: clean up index.ts composition root (hexagonal + DDD) (#292)
* refactor(ygopro): extract YGOPro protocol version to single source

Replace the duplicated 0x1362 literal (index.ts and DuelRecord.ts) with a
single canonical constant YGOPRO_PROTOCOL_VERSION in
ygopro/ygopro/protocol-version.ts, removing the 'must stay in sync' comment.

* refactor(ygopro): extract join-strategy composition out of the root

Move the join-routing policy into composeJoinStrategies (room/application) and
the windbot adapter wiring into bootstrapWindbot (windbot/infrastructure). The
composition root now only decides whether windbot is available and delegates,
shrinking the inline block from ~33 lines to a single declarative call.

* refactor(bootstrap): extract resource and persistence startup from the root

Group the ban-list/resource loading and the SQLite/Postgres/Redis connections
into bootstrapResources and bootstrapPersistence under src/bootstrap. start()
now reads as an index. Also drop the dead BanListMemoryRepository import.

* refactor(bootstrap): split resource bootstrap by bounded context

Separate bootstrapResources into bootstrapEdoproResources and
bootstrapYgoproResources, with bootstrapResources orchestrating them in order.
The mandatory edopro-before-ygopro load order (ygopro cross-references edopro
ban lists to resolve _edoBanListHash) is documented at the call site and as a
precondition on the ygopro loader.
2026-06-28 10:18:37 -04:00
Diango Gavidia
c8f82ff311
fix(reconnect): allow ranked by-name reconnect over half-open sockets (#291)
The `!socket.closed` guard in findReconnectingPlayer was universalized to
ranked rooms, which broke reconnection for mobile clients on the raw-TCP
edopro path. When the app is backgrounded the socket goes half-open: no
FIN/RST reaches the server, so `socket.closed` stays false (the TCP path
has no liveness heartbeat) and the player was forced into spectator mode
of their own duel.

Drop the closed-socket requirement for ranked (incl. external/PIN) rooms,
matching by name + non-strong-auth only. Casual rooms keep requiring same
remote address + closed socket. The takeover is safe: the stale socket is
destroyed when the new one is attached (Client.setSocket), and ranked
reconnects still validate account credentials downstream in Reconnect.run.
2026-06-23 18:05:42 -04:00
Diango Gavidia
02de10d33b
feat(ygopro): add ws heartbeat and app-level ping echo (#290)
Detect half-open WebSocket connections (e.g. a mobile client whose
runtime is frozen in background) via a per-connection isAlive flag and a
periodic sweep that terminates peers that missed their pong. terminate()
fires the existing onClose -> DisconnectHandler flow so the player is
removed from the room. Inbound frames also refresh isAlive, so active
duels are never reaped over a delayed pong.

Echo application-level PING (0xff) straight back as PONG (0xfe),
preserving the payload, mirroring the TCP server so clients can measure
in-duel RTT over the WS path too.

Heartbeat interval is configurable via YGOPRO_WEBSOCKET_HEARTBEAT_MS
(default 30000).
2026-06-22 16:21:51 -04:00
Diango Gavidia
3668a51436
ci: bump checkout and setup-node actions to latest majors (#289)
The workflows pinned old action majors (checkout@v3/@v4, setup-node@v3)
whose action runtimes are being deprecated by GitHub. Bump to the
current stable majors:
- actions/checkout: v3/v4 -> v7 (both pipeline.yaml and release-please.yaml)
- actions/setup-node: v3 -> v6

Usage is basic (checkout with fetch-depth/submodules, setup-node with
node-version), unaffected by the major changes. pipeline.yaml runs on
this PR, so its checks validate the new versions directly.
2026-06-19 13:10:21 -04:00
Diango Gavidia
6aad541835
ci: align CI Node version with engines (22 -> 24) (#288)
package.json declares `engines.node >=24.11.0`, but the pipeline ran
build and tests on Node 22 — validating on a version the project does
not claim to support. Bump setup-node to the 24.x line so CI matches
the declared runtime (local dev is on 24.13.0).
2026-06-19 13:03:29 -04:00
Diango Gavidia
a73fc849a2
ci: enforce Biome format check in the pipeline (#287)
The pipeline already ran `npm run lint` (biome lint), but biome lint
only checks lint rules, not formatting — mis-formatted code (e.g. spaces
instead of tabs) could land without CI catching it.

Replace it with `biome ci`, which checks lint + format + assist in one
read-only pass designed for CI. Verified clean against the current repo
(377 files).
2026-06-19 12:54:57 -04:00
Diango Gavidia
e9e225aa0b
refactor: remove empty blocks flagged by Biome (#286)
Re-enable noEmptyBlockStatements (the last rule turned off during the
migration) and remove the 2 empty blocks it surfaced:
- Room.ts: a no-op `this.players.forEach(() => {})` that did nothing
- WindbotTokenStore.ts: an empty constructor (entries is initialized inline)

Build and all 704 tests pass unchanged.
2026-06-19 12:50:23 -04:00
Diango Gavidia
2ce758b755
chore: point .git-blame-ignore-revs at the squash-merged migration commit (#285)
PR #283 was squash-merged, so the original reformat commit hash
(c08c2469) never reached main. Repoint the blame-ignore file at the
squash commit b8bbc32a so `git blame` actually skips the mass reflow.
2026-06-19 12:44:59 -04:00
Diango Gavidia
d7d222e229
refactor: remove dead private class members (#284)
Re-enable Biome's noUnusedPrivateClassMembers rule and remove the 12
unused private members it surfaced:
- JSONMessageProcessor: _command, _previousMessage (and the dead if block)
- RoomCreator: unused socket field
- Room: _turn
- ServerMessagesController: unused injected logger (and its call site)
- GenesysRulesValidationHandler: unused banList
- 6 deck error classes: write-only cardId field

Build and all 704 tests pass unchanged.
2026-06-19 12:37:25 -04:00
Diango Gavidia
b8bbc32ab6
chore: migrate from ESLint + Prettier to Biome (#283)
* chore(tooling): replace ESLint + Prettier with Biome

- Add biome.json migrated from the ESLint flat config (preset: none,
  drop-in rules) with tab indentation, lineWidth 100, and the
  evolution-types submodule excluded.
- Enable unsafeParameterDecoratorsEnabled for DI/worker param decorators.
- Switch lint/lint:fix scripts and lint-staged to Biome; drop ESLint
  and Prettier devDependencies and their config files.
- Update .editorconfig to tabs and refresh CONTRIBUTING/testing docs.

No source reformatting in this commit.

* style: reformat codebase with Biome (spaces to tabs)

Mass-apply `biome format --write` across src/ and config files.
Indentation converted from 2 spaces to tabs, lineWidth 100, plus
Biome's default trailing commas. No logic changes — build and the
full test suite (704 tests) pass unchanged.

This is a formatting-only commit; see .git-blame-ignore-revs.

* chore: ignore the Biome reformat commit in git blame
2026-06-19 12:23:08 -04:00
Diango Gavidia
52279ffe28
chore(deps): bump evolution-types submodule to TypeScript 6 (#282)
Updates the evolution-types submodule from d5d5880 to 6c63181:
TypeScript 6 upgrade, strict mode, and dependency bumps. Only
configuration changed; no exported types were affected.
2026-06-19 10:38:22 -04:00
Diango Gavidia
d527fee620
chore(deps): patch transitive advisories via npm audit fix (#281)
Re-resolve transitive deps to clear security advisories. Only
package-lock.json changed — no direct dependency ranges were touched, so
jest stays on v30 (this is audit fix without --force, not a downgrade).

Resolved:
- undici 7.24.6 -> 7.28.0 (HIGH: TLS cert validation bypass in SOCKS5
  ProxyAgent; plus a moderate shared-cache info disclosure)
- @babel/core 7.29.0 -> 7.29.7 (low: arbitrary file read via
  sourceMappingURL comment)
- js-yaml -> 4.2.0 on the commitlint/eslint resolution chains

Remaining: a single root advisory (js-yaml <=4.1.1, moderate) pinned at
3.14.2 by @istanbuljs/load-nyc-config (jest coverage). Dev-only with no
production surface; clearing it needs a major js-yaml bump that risks
breaking coverage, so it is intentionally left.

Audit: 21 -> 19 findings, 0 high / 0 critical. The remaining 19 are that
one dev-only advisory fanned out across the istanbul/jest tree.
Verified: 704/704 tests pass, build green.
2026-06-19 09:24:48 -04:00
Diango Gavidia
58ccaf2061
chore(deps): upgrade to TypeScript 6 (#280)
Bump typescript 5.9.3 -> 6.0.3. Application code compiles unchanged; the
only tsc output were tsconfig deprecation warnings (baseUrl, node10
moduleResolution), silenced via ignoreDeprecations "6.0" until the TS 7
config migration.

TS 6 stopped auto-loading ambient @types for files outside the tsconfig
include, which broke ts-jest type-checking of the (excluded) test files
(Cannot find name 'describe/expect/it'). Added tsconfig.test.json so
ts-jest type-checks with types [node, jest] without leaking jest types
into the production build.

Bump the test toolchain to versions that declare TS 6 support and clear
peer-dependency resolution:
- ts-jest 29.4.5 -> 29.4.11
- jest-mock-extended 4.0.0 -> 4.0.1
- typescript-eslint (+ plugin/parser) 8.48 -> 8.61.1

Verified: build green, 702/702 tests pass, lint clean.
2026-06-19 08:59:43 -04:00
Diango Gavidia
d708d2be9c
fix(join): route blank join password to a default room instead of AI (#278)
A blank join password matched WindBotJoinStrategy and spawned an AI (WindBot)
room. Blank now falls through to the default chain and creates a normal room;
only an explicit "ai" token (any position, case-insensitive) routes to WindBot.
2026-06-18 22:35:08 -04:00
Diango Gavidia
2477327141
feat(admission): tell PIN users their credentials are invalid on rejection (#279)
When a PIN fails to authenticate, the credential degrades to guest and a ranked
room rejects it. Send a red STOC_CHAT "Invalid username or password." before the
generic JOINERROR so the player learns the cause instead of an opaque disconnect.

Also add debug-level admission diagnostics (resolved credential kind and the
admission decision) behind an optional logger to ease future triage.
2026-06-18 22:34:32 -04:00
Diango Gavidia
b9dc5a27a9
feat(admission): let verified players sit in External rooms (one-way cross-league) (#277)
Relax the ranked segregation into a one-way cross: a verified (ticket)
player may now step DOWN into an External (PIN) room and play, while an
external player still never steps UP into a Verified room.

Verified rooms stay pure — they remain ticket-only, which keeps them
tournament-grade. The asymmetry is intentional and doubles as a trust
signal: the lobby shows External and Verified rooms apart (see #274), so
externals see a club they cannot sit at and are nudged to verify.

Ranked stays a shared table, so a verified player who steps down earns
ranked points against PIN identities by choice.

The whole change is one line in RoomLeague.admitsAsPlayer; both the JOIN
path and the spectator->player escalation already route through it.
2026-06-16 17:29:03 -04:00
Diango Gavidia
a52c6c2ce4
refactor(reconnect): split player-in-room check into name-taken and reconnecting-player (#276)
Replace the overloaded edopro RoomState.playerAlreadyInRoom with two
single-responsibility helpers in src/shared/room/domain:

- isNameTaken: pure duplicate-nick guard used by the WAITING phase
  (edopro WaitingState + ygopro YGOProWaitingState).
- findReconnectingPlayer: the mid-duel reconnection matcher already used
  by ygopro, now also used by the four edopro mid-duel states
  (Dueling, RPS, SideDecking, ChoosingOrder).

Two behavior changes in edopro:
- WAITING now rejects a duplicate nick unconditionally (previously, in
  unranked rooms, only when the existing socket was closed and shared the
  same IP).
- Ranked mid-duel reconnection now requires socket.closed (previously it
  matched by nick alone, allowing an in-flight session hijack — C3). This
  brings edopro to parity with the ygopro reconnection hardening.

edopro clients carry no credential (isStrongAuth is always false), so the
strong-auth branch does not apply; the rest is equivalent.
2026-06-15 16:12:35 -04:00
Diango Gavidia
69176dfc51
refactor(admission): remove redundant join-time auth (double-auth) (#275)
DefaultJoinStrategy no longer authenticates before emitting JOIN — admission
(ranked auth + league segregation) is decided once, downstream, by AdmitToRoom
in the WaitingState. This removes the second auth pass that ran on the legacy
join path.

Also removes the now-dead CheckIfUseCanJoin injection from the ygopro chain
(JoinContext, YGOProJoinHandler, WSYGOProServer, YGOProServer). edopro's
Reconnect still uses CheckIfUseCanJoin, so that is left untouched.

Note: the strategy no longer sends the legacy ServerErrorMessage detail before
the JOINERROR (the web client ignored it); rejection now goes through
AdmitToRoom's rejectAdmission.
2026-06-15 15:40:51 -04:00
Diango Gavidia
6cb347c9d9
feat(lobby): expose room league in the lobby DTO and broadcast (#274)
The client needs to tell Verified rooms (ticket) from External rooms (PIN)
apart to place them in separate lobby sections. `ranked: boolean` cannot, so
add `league: "verified" | "external" | "casual"` to both the room-list DTO and
the real-time broadcast.

- RoomLeague.type exposes the league identifier.
- toRoomListDTO includes it; toRealTimePresentation is overridden to add it to
  the broadcast.
2026-06-15 15:13:48 -04:00
Diango Gavidia
dba554971c
feat(reconnect): harden mid-duel reconnect against identity hijack (#273)
Replaces the by-name reconnect match (which keyed only on the display name)
with findReconnectingPlayer, so a JOIN can reclaim a seat mid-duel only when:
- the target is NOT strong-auth -- ticket players reconnect through their
  single-use token, so they are unreachable by name (a verified player can no
  longer be hijacked with a stolen PIN or another ticket),
- the target's socket is actually closed -- no live session is taken over,
- the name matches, and casual rooms also require the same remote address.

The weak by-name path now only ever reaches legacy players that are genuinely
disconnected; verified players are unreachable through it. Closes the C3/C4/C5
hijack vectors from the audit.

- YgoClient.isStrongAuth, derived from the credential remembered on join.
- findReconnectingPlayer: pure, table-tested.
- The 4 mid-duel states (RPS / choosing-order / dueling / side-decking) use it.

The socket.closed guard on the token reconnect path (C7) is intentionally left
out for now (defence-in-depth with a timing risk), to be evaluated separately.
2026-06-15 15:04:14 -04:00
Diango Gavidia
c31cd170d4
feat(admission): close spectator->player escalation (#272)
A spectator could bypass league segregation: join a room as spectator
(allowed) and then promote itself to player via handleToDuel, which sat it
down without re-checking admission.

Now the client remembers the PlayerCredential it joined with, and handleToDuel
asks the room's league whether that credential may take a seat. A wrong-league
spectator stays in the stands -- same guard at both doors (the JOIN and the
stands). The remembered credential is also the foundation for the upcoming
reconnection hardening (Layer 4).

- YgoClient gains `credential`, set when seated or admitted as spectator.
- AdmissionTarget.admitSpectator now receives the credential.
- handleToDuel checks room.league.admitsAsPlayer before spectatorToPlayerUnsafe.
2026-06-15 14:23:13 -04:00
Diango Gavidia
d425ad9780
feat(admission): wire AdmitToRoom into the join flow (#271)
Activates league segregation at the JOIN door. handleJoin now delegates to
AdmitToRoom (resolve -> decide -> apply) instead of the inline ranked check,
so a wrong-league client falls back to spectating and a guest is rejected
from ranked rooms.

- YGOProRoom implements the AdmissionTarget port (seat / spectate / reject),
  capturing the connecting socket + player info; buildPlayer extracted so a
  given seat can be reused.
- waiting() builds AdmitToRoom (CredentialResolver + RoomAdmission).
- RankedUserResolver removed (subsumed by CredentialResolver).

Known gap, closed in the next slice: the spectator->player switch
(handleToDuel) does not pass admission yet, so a wrong-league spectator can
still sit down. The whole redesign reaches prod on a single final deploy,
after the remaining slices land.
2026-06-15 13:17:21 -04:00
Diango Gavidia
b549c59af0
feat(admission): determine room league + AdmitToRoom use case (#270)
Second additive slice of the connection-flow redesign. Still no behavior
change: RoomLeague becomes the room's source of truth, and the use case is
not wired into the join path yet.

- RoomLeague.determine centralizes "which league a room is born into"
  (the casual flag wins; ticket host -> Verified; PIN host -> External),
  replacing the inline `casual ? false : (rankedOverride ?? hasPin)`.
- YGOProRoom now carries `league`; `ranked` is derived from `league.isRanked`,
  identical to the previous computation (covered by integration tests).
- AdmitToRoom is the admission use case (resolve -> decide -> apply) that talks
  to the room through the AdmissionTarget port, so it stays free of sockets and
  wire messages (Dependency Inversion).
2026-06-15 12:32:42 -04:00
Diango Gavidia
e64e2c0814
feat(admission): add pure admission domain + credential resolver (#269)
Foundation for the redesigned room connection flow (Layer 3 — admission).
Purely additive: no existing code is touched and no behavior changes yet.

Domain (src/shared/room/admission/domain, transport-agnostic, pure):
- PlayerCredential: verified | external | guest
- RoomLeague: Verified/External/Casual with admitsAsPlayer (the segregation rule)
- Admission: player | spectator | rejected
- RoomAdmission.decide: pure policy holding the whole business contract
  (ranked requires an account even to watch; league segregation; seat-or-watch)

Application (src/ygopro/room/admission/application):
- CredentialResolver: resolves identity in a single pass (ticket -> verified,
  valid PIN -> external, else guest), which removes the historical double-auth
  on the join path
2026-06-15 11:59:15 -04:00
Diango Gavidia
b2502b71cd
fix(reconnect): revoke reconnection tokens on room teardown to stop TokenIndex leak (#268)
Reconnection tokens were only unregistered on rotation (a successful
reconnect), so the token a player still held when a duel/room ended leaked
into the global in-memory TokenIndex (which has no TTL) for the lifetime of
the process, left pointing at destroyed clients.

Add ReconnectionTokenIssuer.revoke() in the shared layer (de-register from the
index + clear the token on the client, idempotent) and call it for every
client at room teardown:
- ygopro: FinalizeYGOProRoom.run (canonical teardown funnel)
- edopro: RoomList.deleteRoom (single delete funnel; no application-level
  teardown exists in this subtree)
2026-06-15 00:05:13 -04:00
DiangoGav
a90c12fb16 feat(ygopro): support token reconnection across all duel phases
- issue the reconnection token at match start, skipped for windbot rooms

- add EXPRESS_RECONNECT listeners to all four duel-phase states

- extract resyncBoard() reused by deck-resubmit and token reconnect

- wire the generic ExpressReconnectHandler in WSYGOProServer

- tests for issuance, rotation, windbot guard and invalid token
2026-06-13 19:48:30 -04:00
DiangoGav
45c7d7810e refactor(edopro): consume shared reconnect layer and support all duel phases
- issue the reconnection token at match start (WaitingState.tryStart)

- add EXPRESS_RECONNECT to RPS and choosing-order states

- dueling/side-decking rotate/resolve via the shared issuer + ack

- wire the generic ExpressReconnectHandler in SocketConnectionHandler

- remove the old edopro-only handler and token message (moved to shared)

- characterization tests for express reconnect + token rotation
2026-06-13 19:48:11 -04:00
DiangoGav
01bf460fb2 feat(reconnect): add reusable token reconnection layer in shared
Transport-agnostic reconnection-by-token primitives for both subtrees:

- ReconnectionTokenIssuer: issue/rotate/resolve over YgoClient + TokenIndex

- ExpressReconnectHandler: generic conn-level router (room resolver + guard)

- ReconnectionAckMessage: 0xfd success/failure ack frames

- ReconnectionTokenClientMessage: moved to shared (0xfd token frame)

- TokenIndex characterization tests
2026-06-13 19:47:46 -04:00
DiangoGav
c1d7cb6c0d feat(genesys): add new cards and adjust points for existing cards 2026-06-13 13:07:08 -04:00
DiangoGav
c5357a8c39 feat(disconnect): implement hasNoConnectedPlayers check and refactor cleanup logic 2026-06-13 12:59:15 -04:00
DiangoGav
afb8b1b8e8 feat(botlist): add Yugi to the botlist example configuration 2026-06-13 09:17:04 -04:00
DiangoGav
ff347973df fix(ygopro): send the banlist hash in STOC_JOIN_GAME, not the in-memory index
The wire convention (srvpro2 room.ts:297; ygopro clients resolve the name
via DeckManager::GetLFListName) is that info.lflist carries the banlist
HASH. We were serializing the raw HostInfo, whose lflist field holds the
in-memory banlist INDEX (0, 1, 2…) — every standard client resolved the
room's banlist name as unknown.

joinGameMessage now takes the room's banListHash (already computed at
room creation) and overrides the wire field; the index never leaves the
process. 0 = no banlist, matching the blank-list convention.
2026-06-12 16:43:15 -04:00
DiangoGav
cba2f95d24 feat(room): support casual token and expose ranked flag in room list
- 'casual' in the room command forces ranked=false, overriding the
  ticket's rankedOverride so authenticated hosts can create unranked rooms
- toRoomListDTO now includes 'ranked' so clients can badge/filter rooms
2026-06-11 23:18:46 -04:00
DiangoGav
bfb096b4eb test(replay): align EVRP tests with project conventions
The EVRP suite passed but diverged from docs/testing.md and, worse,
YGOProDuelingState.evrp.test.ts exercised a hand-copied simulateSendAllEvrp
instead of the real method — leaving sendAllEvrp uncovered and green even
when production broke.

- Invoke the real YGOProDuelingState.sendAllEvrp() via a prototype instance
  with room/logger injected (OCGCore loads lazily, so import is safe).
- Add DuelRecordMother; remove three duplicate makeRecord factories.
- Use mock<Logger>() instead of a hand-rolled jest.fn() logger.
- Drop R/D scaffolding labels from describe/it names and comments.
- Co-locate the three files next to their source (out of __tests__/).

Full suite green; mutation-checked that disabling the real broadcast
turns YGOProDuelingStateEvrp red.
2026-06-10 23:33:40 -04:00
DiangoGav
a8c26c0cdb fix(banlist): source JTP whitelist from evolution-assets
The termitaklk/lflist JTP list shipped 11 alt-art variant codes without
their base card (e.g. 83764719 instead of Monster Reborn 83764718) plus
7 codes missing from cards.cdb, so base cards were rejected with
DECKERROR type=0x4 (CARD_UNKNOWN) in whitelist validation.

The evolution-assets list (lflist/jtp.lflist.conf) lists base codes, so
both the original and alt-art versions stay legal (variants resolve via
their alias). Only JTP is switched; other formats keep their sources.
2026-06-10 23:06:00 -04:00
Diango Gavidia
95369ac557
feat(replay): add STOC_EVRP_EXPORT 0xF0, EvrpSerializer, and omniscient broadcast (#267)
- add evrp-constants.ts: STOC_EVRP_EXPORT=0xF0, EVRP_VERSION=1, EVRP_CHUNK_BYTES=49152
- DuelRecord.toEvrpFrames(): omniscient stream via toPlayback identity cb +
  includeNonObserver:true + includeResponse:false (design D1)
- EvrpSerializer: match-level gzipped JSON envelope + 48-KiB chunk frames
  in [len:2 LE][0xF0][ver:1][i:2 LE][N:2 LE][payload] wire format (design D5)
- YGOProDuelingState.sendAllEvrp(): try/catch + logger.error, called after
  sendAllReplays() in finalizeWithReplays() (design D3, spec R2)
- 25 unit/integration tests: omniscience gate D1, envelope schema+gzip R1,
  chunk boundaries+wire format D5, failure isolation R2
2026-06-10 19:45:41 -04:00
Diango Gavidia
3e2a083610
fix(join): send ranked-reject JOINERROR with the ygopro serializer (#265)
The web client could not decode the JOINERROR sent when an anonymous join to a
ranked room is rejected ("Data too short: need 8 bytes, got 5"). CheckIfUseCanJoin
sent it via @edopro's ErrorClientMessage, whose STOC_ERROR_MSG body is
[msg:1][code:4] = 5 bytes, without the 3 struct-alignment padding bytes. The
client (YGOProStocErrorMsg) expects [msg:1][pad:3][code:4] = 8 bytes with code at
offset 4. DECKERROR already worked because it goes through the ygopro repository.

CheckIfUseCanJoin no longer sends the JOINERROR (its wire format is
client-specific); it only sends the auth-detail buffer and returns false. Each
caller now sends the JOINERROR in its own format:
- DefaultJoinStrategy (ygopro/web): messageRepository.errorMessage(JOINERROR, 0)
- Reconnect + JoinHandler (edopro/desktop): ErrorClientMessage (preserved)
2026-06-09 20:12:38 -04:00
Diango Gavidia
ff44e400d6
feat(ticket): log consume rejections for ranked-auth observability (#264)
ticket consume() returned a bare null on every failure, so ranked-auth
rejections were undiagnosable in production. Log each rejection with its
reason (invalid UUID, no Redis instance, key not found, Redis error) plus an
info line on success.

These started as temporary TICKET_DIAG instrumentation while debugging the
ranked handshake; promoted to permanent structured logging since the reason
is genuinely useful in production. Behaviour is unchanged (still fail-closed).
2026-06-09 17:50:40 -04:00
Diango Gavidia
41a6e7e16b
fix(deck): encode deck error code so the client shows the specific error (#263)
When the server rejected an invalid deck it sent STOC_ERROR_MSG with the raw
DeckErrorType as the code (e.g. 6). The client decodes the subtype from the
high 4 bits (code >>> 28) and the offending card from the low 28 bits, so a
raw type was read as subtype 0 and shown as a generic "invalid deck" with no
detail and no offending card.

Encode the code via ygopro-lflist-encode's YGOProLFListError.toPayload()
(((type & 0xF) << 28) | (cardId & 0x0FFFFFFF)) >>> 0 — the same encoder
SRVPro2 uses, so the bit layout stays canonical and shared. Wired into the 4
deck-rejection call sites in YGOProWaitingState and YGOProSideDeckingState.

The client needs no change — it already decodes the subtype with >>> 28.
2026-06-09 17:38:09 -04:00
Diango Gavidia
9dc33e811e
fix(chat): include spectator name in Mercury chat messages (#262)
Spectator chat in Mercury rooms was attributed to the seat-0 player. The
STOC_CHAT opcode (0x19) only carries player_type + msg, and every spectator
shares player_type=7, so the client had no name to attribute the message to
and fell back to a duelist's identity.

Prefix the spectator's name into the chat text in handleMercuryChat so the
client can render "Name: message". Duelists are unchanged. This mirrors what
the non-Mercury path already does via SpectatorMessageClientMessage.
2026-06-09 16:54:52 -04:00
Diango Gavidia
77aae5da68
fix(join): close socket on join rejections that send a message (#261)
A rejected join that sends a JOINERROR previously either left the socket
open (ranked path) or terminated it abruptly (windbot/ai paths). An open
socket left the client believing it was still connected, so retries never
re-ran the WS handshake -- where the ticket travels -- and the client got
stuck. An abrupt terminate() could also drop the JOINERROR frame before
the client received it.

Use socket.close() (graceful: flushes the queued error frame, then tears
down) on every join error path that sends a message:
- DefaultJoinStrategy: ranked reject after checkIfUserCanJoin fails
- YGOProWaitingState: resolver returns null
- WindBotJoinStrategy: tag-mode reject + requestBot failure
- AIJoinTokenStrategy: invalid token + room not found

Message-less rejections (wrong password) keep destroy(): no frame to flush
and faster resource release. Rename SocketDestroyOnError.test.ts to
SocketCloseOnError.test.ts.
2026-06-09 13:45:45 -04:00
Diango Gavidia
61d4f8f51c
fix(ranked): require room password on ticket-authenticated joins (#260)
Ticket joins resolved an existing room by name and emitted JOIN without
comparing the room password, so any ticket holder could enter a
password-protected room without its key. The ticket replaces only the
username:password login credential, not the per-room "command#password" key.

Mirror DefaultJoinStrategy: reject the join and destroy the socket when the
existing room password does not match. Rooms with no password keep joining
freely, so spectating ranked is unaffected.
2026-06-09 11:16:52 -04:00
Diango Gavidia
7822d6af19
feat(ws): accept game-ticket via ?ticket= query param on the WS handshake (#259)
Browsers can't set custom headers on the WebSocket handshake, so a web client
can't deliver the ranked ticket via Authorization: Bearer. Read the ticket also
from the ?ticket=<uuid> query param, keeping the header for desktop clients
(header takes precedence). Single-use ticket + 30s TTL make the URL/log exposure
irrelevant. Duel protocol and TCP legacy path untouched.
2026-06-06 23:18:31 -04:00
Diango Gavidia
ec1aa86a56
feat(startup): structured logs, Redis connection observability and fail-closed consume (#258)
* feat(redis): log connection state on startup via event listeners

Register ready/error/reconnecting listeners in Redis.connect() so the
server emits structured logs on connection state changes without blocking
startup. Wire Redis into the bootstrap sequence alongside other databases.

* fix(redis): fail-closed on getdel errors in RedisTicketRepository

Wrap the GETDEL call in try/catch so a Redis connection error returns null
instead of propagating — a Redis failure never grants ranked status.

* feat(logging): structured startup logs with phase emojis

Add phase-ordered startup logs (boot → resources → DBs → modules →
ports → ready) with sobor emoji markers. Centralise port reporting in
index.ts and remove duplicate per-server listen callbacks.
2026-06-06 20:04:48 -04:00
Diango Gavidia
62b7e697b8
build(compose): add Valkey service to the dev docker-compose (#257)
The server's single-use ticket store reads via GETDEL on a Redis-compatible
instance; until now only the prod compose had one. Mirrors prod (valkey 9.0,
appendonly, ping healthcheck); port 6379 exposed so a locally-run server can
connect through REDIS_URI=redis://localhost:6379.
2026-06-06 20:04:45 -04:00
Diango Gavidia
9f3e0385a0
feat(ranked): wire ticket-authenticated ranked join end-to-end (#256)
* feat(room): add rankedOverride param to YGOProRoom.create()

When rankedOverride=true the room is always ranked regardless of whether
the player provided a game password. Ticket-authenticated sockets need
this to create ranked rooms without a game password.

* feat(join): add TicketJoinStrategy for ticket-authenticated sockets

Sockets with resolvedUserId (ticket-auth succeeded at WS handshake)
are routed to this strategy, which creates or finds a ranked room
without game password validation and without checkIfUserCanJoin.
The ban-check happens later via RankedUserResolver in YGOProWaitingState.

* refactor(waiting-state): inject RankedUserResolver into YGOProWaitingState

Replaces the direct userAuth.run() call in handleJoin with
resolver.resolve(playerInfo, socket). The resolver handles both paths:
ticket-first (resolvedUserId present) and game password fallback.
YGOProRoom.waiting() now wires RankedUserResolver inline.

* feat(bootstrap): always register base strategy chain in JoinStrategyRegistry

Previously TicketJoinStrategy would not run when windbot was disabled
because setStrategies was only called inside the windbot block. Now the
base chain [TicketJoinStrategy, DefaultJoinStrategy] is always-on.
When windbot is enabled, AI and wind strategies are prepended to it.
2026-06-06 18:47:37 -04:00
Diango Gavidia
729e105aab
feat(ranked): add RankedUserResolver with ticket-first identity resolution (#255)
* feat(user-profile): add findById to port and Postgres adapter

Extend UserProfileRepository with findById(userId) so callers
can verify a user's existence by id before granting ranked access.
Implement in UserProfilePostgresRepository via findOneBy({ id }).

feat(ranked-join): add RankedUserResolver for ticket-first credential resolution

Introduce RankedUserResolver, a collaborator for the ranked join flow.
When a socket carries a resolved user id (ticket auth), the resolver
confirms existence via findById and re-checks the ban status before
granting access (defense-in-depth). Without a resolved user id it
falls back to UserAuth.run() with the game password.

* test(ranked): assert isBanned is not reached for a non-existent user
2026-06-06 17:59:26 -04:00
Diango Gavidia
0e6f7335e4
feat(ws): validate single-use ticket on the WebSocket handshake (#254)
* feat(ws): inject TicketRepository and validate Bearer token on WS handshake

Gate the message pump with a ready promise so the first binary frame
(PlayerInfo/JoinGame) is never dropped while the Redis ticket lookup is
in flight. Header absent → normal flow; header present + consume null →
socket closed (fail-closed); header present + userId returned →
resolvedUserId set on the socket.

Wire RedisTicketRepository injection in index.ts.

* fix(ws): do not release the message pump on a rejected ticket connection

When a ticket is rejected the socket is closed; releasing the pump would
dispatch a buffered frame to an already-closed socket. Skip the release on
the rejected path so no handler runs for a rejected connection.

* refactor(ws): extract HandshakeTicketAuthenticator with explicit auth result

Move Bearer token extraction and ticket consumption out of WSYGOProServer
into a dedicated HandshakeTicketAuthenticator with three explicit states
(anonymous / authenticated / rejected), satisfying SRP and DIP without
changing any observable behaviour.
2026-06-06 17:27:12 -04:00
Diango Gavidia
a2f3f7243b
feat(ticket): add socket resolvedUserId field and single-use ticket repository (#253)
* feat(socket): add optional resolvedUserId field to ISocket and implementations

Introduces `resolvedUserId?: string` as an optional field on ISocket,
WebSocketClientSocket, and SocketMock. This field is the carrier for
a user identity that has been authenticated via a single-use ticket
at the WS upgrade handshake, enabling ranked eligibility without a
game password.

The field is intentionally optional so that TCP sockets remain
unaffected — resolvedUserId is undefined on all TCP connections,
preserving the existing game password join path.

* feat(ticket): add TicketRepository port and Redis adapter

Adds the TicketRepository interface (domain port) and RedisTicketRepository
(infrastructure adapter) that implements atomic single-use ticket consumption.

The adapter enforces three security invariants:
  1. UUID format pre-validation before any Redis call — prevents key injection
  2. GETDEL for atomic read-and-delete — prevents ticket replay
  3. Fail-closed when Redis.getInstance() returns undefined — ranked status
     is never granted without a reachable store

Key format is ticket:<uuid> per spec. The adapter returns null for all
rejection paths so callers can treat null uniformly as "not authenticated".
2026-06-06 15:50:48 -04:00
DiangoGav
6a9be1ffa0 Merge branch 'main' of github.com:diangogav/EDOpro-server-ts 2026-06-05 22:36:43 -04:00
DiangoGav
b248bbd790 chore: update libocgcore.so binary file 2026-06-05 22:35:23 -04:00
Diango Gavidia
a4d3d1f15b
fix(deps): bump evolution-types submodule to fix postgres startup crash (#252)
Picks up the explicit varchar column type on the user profile entity. Without
it, TypeORM inferred Object from the string | null union and failed metadata
validation on postgres (DataTypeNotSupportedError), crashing the server on
DataSource.initialize().
2026-06-05 07:56:49 -04:00
Diango Gavidia
47c6fc716e
chore(deps): bump evolution-types submodule to latest main (#251)
Sync the evolution-types submodule to its latest main. The update adds a new
nullable column to the user profile entity; the change is additive and
backward-compatible. tsc --noEmit passes. No DB migration is run here.
2026-06-04 23:04:53 -04:00
DiangoGav
301282d409 chore: update package-lock.json with new dependencies and version upgrades 2026-06-01 13:05:01 -04:00
DiangoGav
b5fc9a151e build(docker): ship windbot botlist in image and wire windbot env vars
- Dockerfile: copy config/ into the final image so FileBotlistRepository
  finds the botlist at boot (tsc only emits dist/; without this the server
  crashes with ENOENT when ENABLE_WINDBOT=true)
- docker-compose.prod.yaml: pass windbot env vars to the server service
  (opt-in, off by default; windbot container is run separately)
- docker-compose.yaml: drop the stale windbot stub
2026-05-29 10:25:26 -04:00
Diango Gavidia
8b8b026f33
chore: clean dist/ before build to drop stale artifacts (#250)
Add a clean script (native fs.rmSync, no new dep) and chain it into build so
every build starts from an empty dist/. Without it, test files excluded from
compilation could linger in dist/ from older builds, defeating the tsconfig
exclude. Chained explicitly into build rather than a prebuild hook because
npm 11 does not auto-run prebuild for npm run build.
2026-05-27 16:22:04 -04:00
Diango Gavidia
d3051549da
test: complete legacy test migration to co-location (groups B–F) (#249)
* test: co-locate message tests and dedupe processors

Group B of the legacy tests/ -> co-location migration (docs/testing.md).

Moves to co-location:
- JoinGameMessage, PlayerInfoMessage -> src/edopro/messages/client-to-server/
- JSONMessageProcessor (comprehensive) -> src/edopro/messages/
- MessageProcessor (comprehensive) -> src/shared/messages/

Dedupe the two test files that lived under tests/modules/message-processor/
and tested the same sources as the edopro/messages ones:
- JSONMessageProcessor: dropped (its 3 synthetic-mechanics cases were already
  covered by the comprehensive suite).
- MessageProcessor: its 3 cases used UNIQUE real wire-format fixtures
  (PLAYER_INFO/CREATE_GAME/RESPONSE/TIME_CONFIRM), merged into the comprehensive
  suite with descriptive titles, then dropped. No coverage lost.

Imports switched to same-dir relative. 501 tests green (was 504; -3 redundant).

* test: co-locate ban-list, client and deck tests

* test: co-locate edopro room tests

* test: co-locate mercury (ygopro) client and room tests

* test: co-locate room, duel, match and rps tests

* test: resolve Match.test.ts collision onto the comprehensive suite

The legacy Match suite (~35 cases) and a co-located 2-case suite (from the
draw-score fix 10551e3c) both tested the same Match source; the 2-case suite
was a strict subset. Keep the comprehensive suite co-located in src/, drop the
2-case file, and fold its only extra assertions (isFinished()===false after a
draw and after a win) into the matching cases. Fix a misleading title:
'should increment both scores when draw occurs' already asserted 0-0, renamed
to 'should NOT increment scores when a draw occurs'. No coverage lost.

* chore: drop emptied tests/ from jest roots and tsconfig

All legacy tests are now co-located under src/, so the root tests/ directory
is gone. Remove its references from jest roots, tsconfig.json exclude and
tsconfig.eslint.json include.

* docs: mark legacy test migration complete in testing conventions
2026-05-27 16:05:22 -04:00
Diango Gavidia
9eac87c8b4
test: co-locate utils and stats tests (#248)
Pilot slice of the legacy tests/ -> co-location migration (docs/testing.md).
Move BufferToUTF16 and BasicStatsCalculator tests next to their sources in
src/ and switch imports to @shared/@test-support aliases (relative for
same-dir). Establishes the migration pattern: git mv + alias imports + drop
emptied tests/ dirs.

504 tests green, unchanged count.
2026-05-27 15:15:33 -04:00
Diango Gavidia
2a94ae1c48
chore: add .editorconfig for 2-space indentation (#247)
Editors apply 2-space indent, LF, UTF-8 and trailing-newline automatically.
Chosen over an eslint indent rule because the CI gates on `npm run lint` and
the eslint config intentionally omits stylistic rules — a global indent rule
would fail CI repo-wide or force a big-bang reformat. .editorconfig guides
without gating. Update docs/testing.md to match and mark completed migration
targets.
2026-05-27 15:02:24 -04:00
Diango Gavidia
19ea97d78f
refactor(test): co-locate shared test-support and consolidate YGOProRoom (#246)
Move the shared Object Mothers and Mock classes from tests/modules/shared/
to src/test-support/ (git mv preserves history), the canonical home per
docs/testing.md. Add a @test-support/* path alias (in both tsconfig.json and
tsconfig.eslint.json) and update the 4 legacy importers.

Exclude **/*.test.ts and src/test-support/** from the production tsconfig so
tsc no longer emits test code into dist/ — a pre-existing leak now that test
support lives under src/. Jest is unaffected (ts-jest compiles independently).

Consolidate the 3 inline YGOProRoom factories (YGOProRoomFinalizing,
YGOProRoomWindbotFlags, FinalizeYGOProRoom) onto YGOProRoomMother, extended
to accept overrides. YGOProRoom is no longer built two different ways.

504 tests green, build clean, no test artifacts in dist after rebuild.
2026-05-27 14:46:37 -04:00
Diango Gavidia
c2aac4021a
docs: establish co-located testing conventions (#245)
* docs: establish co-located testing conventions

Add docs/testing.md as the testing standard: co-located tests in src/,
Object Mother for shared domain entities + inline make* factories for
suite-local stubs, shared Mock classes / jest.mock() / mock<T>() for
mocking, English describe/it naming, 2-space indent.

Reconcile AGENTS.md, which previously mandated the opposite (tests/ folder,
always-Mother, Spanish naming) — Prime Directive #1 and SOP-002/004 now
match the co-located reality the suite already drifted to. Link the doc
from CONTRIBUTING.md. Ignore the local .atl/ skill-registry cache.

* docs: remove stale api and node-cpp design docs
2026-05-27 14:23:29 -04:00
Diango Gavidia
8380879611
feat(ygopro/windbot): add provider use cases, botlist repository and domain types (#244)
* feat(ygopro/windbot): add provider use cases, botlist repository and domain types

Add the application + domain layers for the windbot integration, built
on the WindbotTokenStore shipped in PR-1.

Domain:
  - WindbotData: bot value object { name, deck, dialog?, hidden?, deckcode? }
  - WindbotErrors: WindbotNotFoundError, WindbotsExhaustedError
  - WindbotBotlistRepository: port interface (findAll/findByName/pickRandom)

Application (use cases over WindbotTokenStore):
  - RequestWindBotJoin: pick bot by name or random, register token, return it
  - ConsumeWindBotToken: resolve a reverse-connection token to its payload
  - CleanupWindBotTokens: drop all tokens for a room on finalize

Infrastructure:
  - FileBotlistRepository: boot-time JSON load + zod validation, mirrors the
    existing banlist loader pattern; pickRandom excludes hidden bots

No HTTP trigger, no module wiring yet — those land in PR-3b. The provider
use cases are pure orchestration over the domain store and ports.

Slice 3a/8 of the windbot integration chain. Covers REQ-PROVIDER-301..303
and REQ-BOTLIST-801..803 from the windbot-port spec.

* feat(ygopro/windbot): add HTTP provider, config schema and module wiring

Complete the windbot provider stack on top of the use cases from the
previous commit.

Infrastructure:
  - HttpWindBotProvider: fires a GET to the WindBot Docker service so it
    reverse-connects. Request shape matches the WindBot binary contract
    (query-string params, `password=AIJOIN#{token}`, plus name/deck/host/
    port/version and optional dialog/deckcode). 10-attempt retry, no
    backoff, AbortSignal.timeout(500) per attempt, isFinalizing() guard.
  - WindbotConfig: parseWindbotConfig(env) — pure, testable; disabled by
    default, fail-fast when ENABLE_WINDBOT=true but endpoint/botlist missing.

Application:
  - WindbotModule: singleton facade composing the use cases + provider.
    requestBot registers a token then fires the HTTP trigger, cleaning up
    the token if the trigger fails. Exposes isEnabled/consumeToken/cleanupRoom.

Domain:
  - WindbotUnreachableError for the exhausted-retry path.

The provider talks HTTP only (Docker-separate runtime decision) — no
reverse-WS branch. Not wired into bootstrap yet (PR-7).

Slice 3b/8 of the windbot integration chain. Covers REQ-HTTP-401..404
and REQ-CONFIG-701..703 from the windbot-port spec.

* feat(ygopro): refactor join dispatch into a strategy chain with windbot support

Replace the single password-split branch in YGOProJoinHandler with an
ordered JoinStrategyRegistry resolved per join:

  AIJoinTokenStrategy -> WindBotJoinStrategy -> DefaultJoinStrategy

- DefaultJoinStrategy is a behavior-identical extraction of the previous
  find-or-create logic; it always matches (terminal fallback). Existing
  non-AI joins are unchanged.
- WindBotJoinStrategy handles blank / AI / AI#name when windbot is enabled:
  creates the room, sets windbot/noHost/noReconnect flags, rejects tag-mode
  rooms, and fires WindbotModule.requestBot as fire-and-forget (destroys the
  room + notifies the human if the bot trigger fails).
- AIJoinTokenStrategy catches the reverse-connecting bot (AIJOIN#token),
  consumes the token, and marks the bot client internal. It locates the bot
  client by socket identity after queuing behind the join on room.mutex
  (async-mutex is FIFO), so marking is deterministic.

YGOProRoom gains additive windbot flags (windbot?, noHost, noReconnect).

When windbot is disabled the registry contains only DefaultJoinStrategy, so
getInstance() never throws and behavior is identical to before.

The strategies are not wired into bootstrap yet (PR-7). room.finalizing
(PR-5) and the finalize cleanup hook (PR-6) are still pending; a () => false
placeholder is used for the retry-abort callback.

Slice 4/8 of the windbot integration chain. Covers REQ-JOIN-101..104 and
REQ-ROOM-501..502 from the windbot-port spec.

* feat(ygopro/windbot): add room.finalizing and wire the bot retry-abort guard

Add an additive `finalizing: boolean` flag to YGOProRoom, flipped to true
as the first statement of YGOProDuelingState.removeRoom() (before the
REMOVE-ROOM broadcast and room deletion). This lets an in-flight windbot
HTTP retry loop bail out the moment its room starts tearing down.

Replace the `() => false` placeholder in WindBotJoinStrategy with
`() => room.finalizing`, completing the retry-abort wiring left from the
strategy-chain slice.

No server-side RPS synthesis: the WindBot binary is a real reverse-connected
client that sends its own CTOS_HAND_RESULT, exactly as in srvpro2 (whose
onHandResult has no isInternal branching). Synthesizing a hand would
double-submit. The bot's captain status is already granted by the existing
YGOProRoomState.toRPS() promotion, proven by new tests — so the RPS state
machine is left untouched.

Slice 5/8 of the windbot integration chain. Covers REQ-HTTP-402 and
REQ-CLIENT-603 (REQ-CLIENT-604 superseded by the bot-sends-own-hand model).

* feat(ygopro/windbot): clear windbot tokens when a room finalizes

Wire CleanupWindBotTokens into YGOProDuelingState.removeRoom(): after the
room is marked finalizing, drop any windbot tokens still mapped to it. For
a completed duel the bot already consumed its token so this returns 0; it
matters when a bot never connected before teardown.

Add a WindbotModule.isInitialized() static guard so the hook is a no-op for
non-windbot rooms — removeRoom() runs for every room and getInstance() must
never throw when windbot was never initialized. Add resetForTests() seam.

Known gap (deferred to PR-7): DisconnectHandler.handleYGOPro() deletes a
room directly when all players disconnect mid-duel, bypassing this hook; an
unconsumed token would leak in-memory until restart. Acceptable for Phase 1.

Slice 6/8 of the windbot integration chain. Covers REQ-TOKEN-204 and
REQ-PROVIDER-303 from the windbot-port spec.

* feat(ygopro/windbot): wire windbot bootstrap, config validation and docker

Activate the windbot integration chain behind ENABLE_WINDBOT. When the flag
is off (default) nothing in this slice runs and behavior is identical to before.

Bootstrap (src/index.ts, gated by config.windbot.enabled):
  FileBotlistRepository -> WindbotTokenStore -> HttpWindBotProvider
  -> WindbotModule.init(...) -> JoinStrategyRegistry.setStrategies(
       [AIJoinTokenStrategy, WindBotJoinStrategy, DefaultJoinStrategy])

Config (src/config/index.ts): parseWindbotConfig reads ENABLE_WINDBOT,
WINDBOT_ENDPOINT, WINDBOT_MY_IP, WINDBOT_BOTLIST and fails fast at boot when
enabled but endpoint/botlist are missing.

Token cleanup hardening:
  - WindbotModule.cleanupRoomIfEnabled(roomId) centralises the
    initialized+enabled guard (no-op, never throws, when off).
  - DisconnectHandler.handleYGOPro now marks the room finalizing and clears
    its windbot tokens when all players drop mid-duel, closing the leak the
    removeRoom hook alone did not cover.

Hard-error UX: WindBotJoinStrategy and AIJoinTokenStrategy now destroy the
socket after sending JOINERROR, matching the default wrong-password path.

Artifacts: config/botlist.example.json sample, docker-compose windbot
service stub (image URI is a placeholder to confirm before enabling).

Final slice 7/8 of the windbot integration chain. Covers REQ-CONFIG-701..703.

* feat(ygopro/windbot): support multi-token AI room commands

Let a human request an AI duel while declaring room rules/format in the
same password, e.g. `ai#Joey`, `ai,jtp#Joey`, `nc,ns,ai#joey`.

WindBotJoinStrategy.matches now detects "ai" among the comma-separated
config tokens (order-independent, case-insensitive) instead of only the
exact `AI` / `AI#name` forms; blank password still routes to windbot when
enabled. The bot name is taken from the segment after the first `#`.

The rule/format tokens (jtp, edison, goat, nc, ns, mode, ...) are applied
by YGOProRoom.create as before, since the strategy still creates the room
with the full raw password — so the human's deck is validated against the
chosen banlist while the bot brings a format-legal deck.

FileBotlistRepository.findByName is now case-insensitive so `ai#joey`
resolves the bot registered as `Joey`.

Covers REQ-JOIN-101/102 extension for windbot rooms.

* chore(ygopro/windbot): remove SDD scaffolding comments and dead labels

Strip PR-N slice labels and REQ-XXX requirement tags from production and
test comments now that the feature is merged into a single branch — these
referenced slices and specs that do not live in the repo. Also drop a few
obvious narration comments. JSDoc contracts and the why-comments (mutex
FIFO ordering, password-split regression note, token-leak guards) are kept.
REQ-XXX traceability is preserved only in test describe() names.

* feat(ygopro/windbot): destroy AI room when the human leaves in any phase

A windbot room is created with noHost/noReconnect, so the bot cannot host
nor can the human reconnect — once the human leaves, the room is dead. But
DisconnectHandler only tore down on all-sockets-closed or a WAITING-phase
leave, leaving a zombie room with an orphaned bot in RPS/CHOOSING_ORDER/
SIDE_DECKING/DUELING.

Extract the duplicated teardown sequence into FinalizeYGOProRoom (finalizing
flag, windbot token cleanup, close still-open client sockets, deleteRoom,
REMOVE-ROOM broadcast) and delegate to it from YGOProDuelingState.removeRoom
and DisconnectHandler. Add a noHost branch so a player leaving an AI room is
torn down in any phase. Closing open sockets also fixes a latent bot-socket
leak. Non-AI rooms keep the existing WAITING playerLeave behaviour.

* chore(ygopro/windbot): trim botlist example to a single JTP bot
2026-05-27 13:47:44 -04:00
DiangoGav
f86d185b6a fix: update ygopro-scripts repository source in Dockerfile 2026-05-24 10:54:06 -04:00
DiangoGav
052a050d27 fix: update permissions for libocgcore.so to executable 2026-05-24 10:53:54 -04:00
DiangoGav
d4d000ea9b chore: update core 2026-05-24 09:49:39 -04:00
Diango Gavidia
cce58150be
feat(ygopro): add isInternal flag and deck-check bypass for bot clients (#243)
Introduce `isInternal` on `YGOProClient` (default `false`, set via the
monotonic `markInternal()` method — mirrors the existing `captain()`
pattern) so the join flow can mark a client as a bot.

In `YGOProWaitingState.handleUpdateDeck`, after the deck buffer is
parsed by `deckCreator.build()` and before `shouldValidateDeck()` runs,
short-circuit when the player is internal: store the deck via
`setDecksToPlayerUnsafe` and return. The bot has already validated its
deck client-side, so the banlist/rules validator is humans-only.

Foundation slice for the windbot integration chain (PR 2/8). Covers
REQ-CLIENT-601 and REQ-CLIENT-602 from the windbot-port spec.
2026-05-13 09:57:16 -04:00
Diango Gavidia
ddecdbfb9a
feat(ygopro/windbot): add WindbotTokenStore for bot reverse-connection auth (#242)
Introduce a ygopro-local token store that issues one-shot 12-char hex
tokens used by external WindBot processes when they reverse-connect to
the server with password `AIJOIN#{token}`.

The store lives entirely under `src/ygopro/windbot/domain/` so it stays
isolated from the edopro reconnect flow and the shared TokenIndex.

API:
  - register(roomId, botName, deck) -> token  (re-rolls on rare collision)
  - consume(token) -> payload  (one-shot, throws "Windbot token not found")
  - clearByRoom(roomId) -> count  (drops all room entries on finalize)
  - createForTests() -> isolated instance for unit tests

Foundation slice for the windbot integration chain (PR 1/8). Covers
REQ-TOKEN-201..204 from the windbot-port spec.
2026-05-13 09:37:57 -04:00
DiangoGav
24378a793e Merge branch 'main' of github.com:diangogav/EDOpro-server-ts 2026-05-05 15:19:58 -04:00
DiangoGav
b7abb6ffaa feat(genesys): add new cards with unique codes and point values 2026-05-05 15:19:43 -04:00
455 changed files with 45076 additions and 28360 deletions

View file

@ -61,8 +61,9 @@ mercury/pre-releases/tcg/*
# Git Submodules & Repositories
*repositories/
# Scripts
# Scripts — exclude the directory but re-include the .sh files for the build
scripts/
!scripts/*.sh
# Miscellaneous Directories
core/dependencies/
@ -75,5 +76,11 @@ Dockerfile
docker-compose*.yaml
README.md
CHANGELOG.md
CONTRIBUTING.md
CODE_OF_CONDUCT.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

13
.editorconfig Normal file
View file

@ -0,0 +1,13 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = tab
indent_size = 2
# Markdown: trailing spaces are significant (line breaks)
[*.md]
trim_trailing_whitespace = false

View file

@ -5,10 +5,9 @@
WEBSOCKET_PORT=4000
# ── YGOPro Card Resources ──
YGOPRO_FOLDERS=./resources/ygopro/base,./resources/ygopro/ocg,./resources/ygopro/a
lternatives
YGOPRO_EXTRA_DB_FOLDERS=./resources/ygopro/prereleases-cdb,./resources/ygopro/card
s-art
# Resources live under resources/current (a symlink to the active release,
# maintained by setup_resources.sh / the in-container refresh loop).
RESOURCES_DIR=./resources/current
# ── Database ──
POSTGRES_HOST=localhost

View file

@ -1,2 +0,0 @@
# Resources (external git repos)
resources/

8
.git-blame-ignore-revs Normal file
View file

@ -0,0 +1,8 @@
# Bulk reformat with Biome (2-space indentation -> tabs).
# Squash commit of the ESLint+Prettier -> Biome migration (#283): most of
# its diff is the mechanical reflow, safe to ignore for `git blame`.
#
# Enable locally with:
# git config blame.ignoreRevsFile .git-blame-ignore-revs
# GitHub applies this file automatically.
b8bbc32ab6fd89f1ab955b6426cd13cd1095a772

View file

@ -8,14 +8,14 @@ Thank you for your interest in contributing to Evolution Server! Your help is we
2. **Describe your changes** clearly in your Pull Request (PR).
3. **Link related issues** in your PR description if applicable.
4. **Ensure your code passes linting** by running `npm run lint` before submitting.
5. **Write clear, maintainable code** following the TypeScript and ESLint (Codely) standards used in this project.
6. **Add or update tests** if your changes affect logic or features.
5. **Write clear, maintainable code** following the TypeScript and Biome standards used in this project.
6. **Add or update tests** if your changes affect logic or features. Follow the [testing conventions](./docs/testing.md).
7. **Update documentation** if your changes require it.
## Code Style
- Use TypeScript for all new code.
- Follow the ESLint rules defined in `.eslintrc.js` (Codely TypeScript config).
- Follow the Biome rules defined in `biome.json`. Run `npm run lint:fix` (lint + format) before submitting.
- Use clear, descriptive variable and function names.
- Keep functions and files focused and modular.
- Prefer immutability and pure functions when possible.

View file

@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v7
with:
fetch-depth: 0
submodules: true
@ -19,18 +19,36 @@ jobs:
- name: Update submodules
run: git submodule update --init --recursive
- uses: actions/setup-node@v3
- uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Lint & format check
run: npx biome ci
- name: Build
run: npm run build
- name: Test
run: npm test
bash-resources-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
submodules: true
- name: Update submodules
run: git submodule update --init --recursive
- name: Install jq and bats
run: sudo apt-get update && sudo apt-get install -y jq bats
- name: Run bash resource tests
run: bats test/*.bats

View file

@ -19,7 +19,7 @@ jobs:
release-type: node
- name: Checkout 🛬
if: ${{ steps.release.outputs.release_created }}
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Tag major and minor versions 🏷
if: ${{ steps.release.outputs.release_created }}
run: |

15
.gitignore vendored
View file

@ -25,6 +25,9 @@ postgres_data/
# Project configuration
*.conf
# Agent tooling (local skill-registry cache)
.atl/
# VS Code & IDEs
.vscode/
.idea/
@ -63,4 +66,14 @@ dependencies/
build/
mycard
resources/
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

@ -26,7 +26,7 @@ As an AI agent, you must strictly adhere to these rules. **Violation of these ru
- **DDD is Mandatory**: Business logic lives in `domain/`. Never import `infrastructure/` or `application/`. Dependencies point inward.
- **Hexagonal Architecture**: Dependencies point inward. The core doesn't know about the database or sockets.
- **Mother Pattern**: ALWAYS use `*Mother` classes (Object Factories) for test data. Never manually instantiate complex entities in tests.
- **Test data**: Use `*Mother` classes for shared domain entities; local `make*` factories for suite-specific stubs. Tests are **co-located** in `src/` next to their source. See [testing conventions](./docs/testing.md).
- **Chain of Responsibility**: Use this pattern for complex validations (e.g., Deck Rules).
- **Dependency Injection**: Use `diod` or constructor injection to manage dependencies.
@ -62,7 +62,7 @@ When performing these actions, **ALWAYS** follow the corresponding Standard Oper
| Action | Skill / SOP |
| --------------------------------------------------- | ----------------------------------------------- |
| "Implement feature...", "Create use case..." | **[SOP-001] Feature Implementation (DDD)** |
| "Write tests...", "Fix bug...", "Add unit test" | **[SOP-002] Testing Strategy (Mother Pattern)** |
| "Write tests...", "Fix bug...", "Add unit test" | **[SOP-002] Testing Strategy** |
| "Add field to DB", "Update schema", "New entity" | **[SOP-003] Database Migration** |
| "Create new module", "New architecture component" | **[SOP-004] Module Creation** |
| "Fix complex type error", "Optimize TS build" | **typescript-expert** |
@ -91,38 +91,35 @@ When performing these actions, **ALWAYS** follow the corresponding Standard Oper
4. **Interface Layer**:
- Expose via Controller (HTTP) or Event Handler (Socket).
### [SOP-002] Testing Strategy (Mother Pattern)
### [SOP-002] Testing Strategy
**Goal**: Ensure robust testing using consistent data factories.
**Goal**: Consistent, co-located tests. Full conventions in [docs/testing.md](./docs/testing.md).
1. **Locate/Create Mother**:
- Check `tests/modules/[module]/domain/[Entity]Mother.ts`.
- If missing, create it using `@faker-js/faker`.
- _Template_:
1. **Co-locate the test**: create `[Thing].test.ts` next to `src/[module]/[Thing].ts`. Do **not** add to the root `tests/` folder (legacy, being migrated out).
2. **Build test data**:
- Shared domain entities → use/create a `*Mother` (static `create(overrides?)`, faker defaults):
```typescript
export class UserMother {
static create(overrides?: Partial<UserPrimitives>): User {
const primitives = {
return User.fromPrimitives({
id: UuidMother.create(),
name: faker.person.firstName(),
...overrides,
};
return User.fromPrimitives(primitives);
});
}
}
```
2. **Write Test Spec**:
- Create `[UseCase].test.ts` in `tests/modules/[module]/application/`.
- **Rule**: Describe the test case in **Spanish**.
- _Template_:
- Suite-local stubs (fake repo/provider/socket) → inline `make*` factory with an `overrides` param.
3. **Naming**: `describe` the unit, `it` the behavior in **English**, present tense, no "should":
```typescript
describe("CrearSala", () => {
it("debe permitir crear una sala pública correctamente", async () => {
describe("CreateRoom", () => {
it("creates a public room", async () => {
const user = UserMother.create();
// ...
});
});
```
4. **Mocks**: shared Mock classes for infra (Logger/Socket/MessageRepository), `jest.mock()` for module singletons, `mock<T>()` (jest-mock-extended) for one-off interfaces. Reset singletons in `afterEach`.
### [SOP-003] Database Migration
@ -146,8 +143,8 @@ When performing these actions, **ALWAYS** follow the corresponding Standard Oper
1. **Structure**:
```bash
mkdir -p src/new-module/{domain,application,infrastructure}
mkdir -p tests/modules/new-module
```
Tests are co-located next to their source inside these folders — no separate `tests/` tree.
2. **Registration**:
- Register new entities in TypeORM config.
- Register new controllers/handlers in the dependency injection container (`diod`).

View file

@ -1,5 +1,83 @@
# Changelog
## [2.14.0](https://github.com/diangogav/EDOpro-server-ts/compare/v2.13.2...v2.14.0) (2026-08-05)
### Features
* **admission:** add pure admission domain + credential resolver ([#269](https://github.com/diangogav/EDOpro-server-ts/issues/269)) ([e64e2c0](https://github.com/diangogav/EDOpro-server-ts/commit/e64e2c08144cc7a9ee2a0301f8ece89990048f70))
* **admission:** close spectator-&gt;player escalation ([#272](https://github.com/diangogav/EDOpro-server-ts/issues/272)) ([c31cd17](https://github.com/diangogav/EDOpro-server-ts/commit/c31cd170d4a847332fbdb5eb46285a5510f382b0))
* **admission:** determine room league + AdmitToRoom use case ([#270](https://github.com/diangogav/EDOpro-server-ts/issues/270)) ([b549c59](https://github.com/diangogav/EDOpro-server-ts/commit/b549c59af0bc244549501a863f2687e3a1f8a78e))
* **admission:** let verified players sit in External rooms (one-way cross-league) ([#277](https://github.com/diangogav/EDOpro-server-ts/issues/277)) ([b9dc5a2](https://github.com/diangogav/EDOpro-server-ts/commit/b9dc5a27a9c2df568daedd39de5b48ff31fd7bd0))
* **admission:** tell PIN users their credentials are invalid on rejection ([#279](https://github.com/diangogav/EDOpro-server-ts/issues/279)) ([2477327](https://github.com/diangogav/EDOpro-server-ts/commit/24773271412f254d25996acb4de0b10d6d881266))
* **admission:** wire AdmitToRoom into the join flow ([#271](https://github.com/diangogav/EDOpro-server-ts/issues/271)) ([d425ad9](https://github.com/diangogav/EDOpro-server-ts/commit/d425ad9780d3598c5dd177195bb458e060e73fab))
* **bootstrap:** hot-reload ban lists without a server restart ([#306](https://github.com/diangogav/EDOpro-server-ts/issues/306)) ([816f305](https://github.com/diangogav/EDOpro-server-ts/commit/816f30513e2420be825cd138c8da0ae3038d2dd6))
* **botlist:** add Yugi to the botlist example configuration ([afb8b1b](https://github.com/diangogav/EDOpro-server-ts/commit/afb8b1b8e810496b06c763fd3e63742b86496bdd))
* **card:** refresh the inspection index on a TTL ([#295](https://github.com/diangogav/EDOpro-server-ts/issues/295)) ([#299](https://github.com/diangogav/EDOpro-server-ts/issues/299)) ([a2dc475](https://github.com/diangogav/EDOpro-server-ts/commit/a2dc475169c7b452156437f456a14c3d63df8ec6))
* **disconnect:** implement hasNoConnectedPlayers check and refactor cleanup logic ([c5357a8](https://github.com/diangogav/EDOpro-server-ts/commit/c5357a8c3943f4068fdf922c2fe547961184f31d))
* **edison:** MR1 (2010) format — forked core, pre-errata pool, resource pipeline ([#316](https://github.com/diangogav/EDOpro-server-ts/issues/316)) ([83e8cd5](https://github.com/diangogav/EDOpro-server-ts/commit/83e8cd5a73e0129a41a77731daca752c63bf04b0))
* **emotes:** relay a dedicated emote opcode to the room ([#313](https://github.com/diangogav/EDOpro-server-ts/issues/313)) ([8a2a430](https://github.com/diangogav/EDOpro-server-ts/commit/8a2a43050d623536b5ac64269a003ec7e093e0cd))
* **genesys:** add new cards and adjust points for existing cards ([c1d7cb6](https://github.com/diangogav/EDOpro-server-ts/commit/c1d7cb6c0d03e1b05b6585c117ff8430c0ffc21e))
* **genesys:** add new cards with unique codes and point values ([b7abb6f](https://github.com/diangogav/EDOpro-server-ts/commit/b7abb6ffaa2c4d8b5d049325ac8f55fdffd8312c))
* **genesys:** move point list to lflist.conf, validate on both paths ([#303](https://github.com/diangogav/EDOpro-server-ts/issues/303)) ([b3b51e5](https://github.com/diangogav/EDOpro-server-ts/commit/b3b51e5bc8ac99bf1c8ae4bc0fdbcbe748c0c2f3))
* **genesys:** show point list with card costs in inspect view ([2385260](https://github.com/diangogav/EDOpro-server-ts/commit/2385260d7a2ca0251c04ab98c64584f4bb04658e))
* hot-reload the EDOPro card DB via atomic datasource swap ([#297](https://github.com/diangogav/EDOpro-server-ts/issues/297)) ([050ae58](https://github.com/diangogav/EDOpro-server-ts/commit/050ae582e9270aa740fd348a06621514fdaf6449))
* **lobby:** expose room league in the lobby DTO and broadcast ([#274](https://github.com/diangogav/EDOpro-server-ts/issues/274)) ([6cb347c](https://github.com/diangogav/EDOpro-server-ts/commit/6cb347c9d989dddcf4f2fc99f3c3dd0e2b651ab5))
* **matchmaking:** auto-pairing queue with ranked + windbot fallback ([#308](https://github.com/diangogav/EDOpro-server-ts/issues/308)) ([e2054d1](https://github.com/diangogav/EDOpro-server-ts/commit/e2054d15bb9ae0b474751f499574d28f193ed1f8))
* **matchmaking:** multi-format queue with per-format bot roster (tcg, jtp) ([#309](https://github.com/diangogav/EDOpro-server-ts/issues/309)) ([3afaec3](https://github.com/diangogav/EDOpro-server-ts/commit/3afaec3f92a20ac898fe3d54de720881f63e2c81))
* **matchmaking:** open the ranked TCG room pool via the tt token ([#311](https://github.com/diangogav/EDOpro-server-ts/issues/311)) ([4f706da](https://github.com/diangogav/EDOpro-server-ts/commit/4f706da2e006aa88b0bcf7022ef21506c6d224b8))
* **matchmaking:** ranked human pairs play best-of-3 matches ([#314](https://github.com/diangogav/EDOpro-server-ts/issues/314)) ([200aec0](https://github.com/diangogav/EDOpro-server-ts/commit/200aec0be2123b5b0c3d81cca241a1d5910fde8e))
* public card & banlist inspection page ([#294](https://github.com/diangogav/EDOpro-server-ts/issues/294)) ([29809f1](https://github.com/diangogav/EDOpro-server-ts/commit/29809f145301e442d599c646859774168948f88c))
* **ranked-join:** add RankedUserResolver for ticket-first credential resolution ([729e105](https://github.com/diangogav/EDOpro-server-ts/commit/729e105aab8282c1213f575a23bbaebfdae9f222))
* **ranked:** add RankedUserResolver with ticket-first identity resolution ([#255](https://github.com/diangogav/EDOpro-server-ts/issues/255)) ([729e105](https://github.com/diangogav/EDOpro-server-ts/commit/729e105aab8282c1213f575a23bbaebfdae9f222))
* **ranked:** wire ticket-authenticated ranked join end-to-end ([#256](https://github.com/diangogav/EDOpro-server-ts/issues/256)) ([9f3e038](https://github.com/diangogav/EDOpro-server-ts/commit/9f3e0385a0be3b67fab9d05ad8da634ab18c7db8))
* **reconnect:** add reusable token reconnection layer in shared ([01bf460](https://github.com/diangogav/EDOpro-server-ts/commit/01bf460fb27a191983d6927ec792c9e92be28ada))
* **reconnect:** harden mid-duel reconnect against identity hijack ([#273](https://github.com/diangogav/EDOpro-server-ts/issues/273)) ([dba5549](https://github.com/diangogav/EDOpro-server-ts/commit/dba554971c9d75ec471cbc9267e8d1b06596e715))
* **replay:** add STOC_EVRP_EXPORT 0xF0, EvrpSerializer, and omniscient broadcast ([#267](https://github.com/diangogav/EDOpro-server-ts/issues/267)) ([95369ac](https://github.com/diangogav/EDOpro-server-ts/commit/95369ac5577bf114e3fce50969e3ad5481501fd6))
* **resources:** declarative manifest-driven cdb/banlist sources + ygopro pool derivation ([#305](https://github.com/diangogav/EDOpro-server-ts/issues/305)) ([aa7c96b](https://github.com/diangogav/EDOpro-server-ts/commit/aa7c96bd46b76f03a051fdf410feb2b03209644d))
* **room:** support casual token and expose ranked flag in room list ([cba2f95](https://github.com/diangogav/EDOpro-server-ts/commit/cba2f95d2447047abb32c42e9db2738ace9105d8))
* **rule-mappings:** add support for JTP Advanced March 2007 format ([7462eee](https://github.com/diangogav/EDOpro-server-ts/commit/7462eee8bfc9c861df0bca0f7b679eef33e1c7a5))
* runtime-updatable resources (releases/current) with in-container refresh ([#296](https://github.com/diangogav/EDOpro-server-ts/issues/296)) ([afa8110](https://github.com/diangogav/EDOpro-server-ts/commit/afa81107776b7708892cf65d9daf2c146bc1bc68))
* **startup:** structured logs, Redis connection observability and fail-closed consume ([#258](https://github.com/diangogav/EDOpro-server-ts/issues/258)) ([ec1aa86](https://github.com/diangogav/EDOpro-server-ts/commit/ec1aa86a564c649e4518d8ff78db8fddcf5b1145))
* **ticket:** add socket resolvedUserId field and single-use ticket repository ([#253](https://github.com/diangogav/EDOpro-server-ts/issues/253)) ([a2f3f72](https://github.com/diangogav/EDOpro-server-ts/commit/a2f3f7243b7f852cf88dc1a66c353e42a0457877))
* **ticket:** log consume rejections for ranked-auth observability ([#264](https://github.com/diangogav/EDOpro-server-ts/issues/264)) ([ff44e40](https://github.com/diangogav/EDOpro-server-ts/commit/ff44e400d6d12b2b7b7e1a19e92ee91bb8a0f27a))
* **version:** add GET /api/resources/version endpoint ([#307](https://github.com/diangogav/EDOpro-server-ts/issues/307)) ([2fa9923](https://github.com/diangogav/EDOpro-server-ts/commit/2fa9923eb3abd2e7eff6ed8938c7a58dcfc6253e))
* **windbot:** shorten TCG botlist names to fit the join wire budget ([#312](https://github.com/diangogav/EDOpro-server-ts/issues/312)) ([d52dfd7](https://github.com/diangogav/EDOpro-server-ts/commit/d52dfd7408f02e61426edfc08f5f2ad4c7a82a4f))
* **ws:** accept game-ticket via ?ticket= query param on the WS handshake ([#259](https://github.com/diangogav/EDOpro-server-ts/issues/259)) ([7822d6a](https://github.com/diangogav/EDOpro-server-ts/commit/7822d6af195977ec14871463d1247e0212c63fdc))
* **ws:** validate single-use ticket on the WebSocket handshake ([#254](https://github.com/diangogav/EDOpro-server-ts/issues/254)) ([0e6f733](https://github.com/diangogav/EDOpro-server-ts/commit/0e6f7335e4d811e193b1408330a6772f3335936b))
* **ygopro/windbot:** add provider use cases, botlist repository and domain types ([#244](https://github.com/diangogav/EDOpro-server-ts/issues/244)) ([8380879](https://github.com/diangogav/EDOpro-server-ts/commit/83808796118e5f0588b0603e5d8a9297349937ea))
* **ygopro/windbot:** add WindbotTokenStore for bot reverse-connection auth ([#242](https://github.com/diangogav/EDOpro-server-ts/issues/242)) ([ddecdbf](https://github.com/diangogav/EDOpro-server-ts/commit/ddecdbfb9ad202b27369111b6344b8363c58afad))
* **ygopro:** add isInternal flag and deck-check bypass for bot clients ([#243](https://github.com/diangogav/EDOpro-server-ts/issues/243)) ([cce5815](https://github.com/diangogav/EDOpro-server-ts/commit/cce58150be54ec741066d78ab662ad03bed407a9))
* **ygopro:** add ws heartbeat and app-level ping echo ([#290](https://github.com/diangogav/EDOpro-server-ts/issues/290)) ([02de10d](https://github.com/diangogav/EDOpro-server-ts/commit/02de10d33bc85f3282c07afdfeae0005319bec26))
* **ygopro:** support token reconnection across all duel phases ([a90c12f](https://github.com/diangogav/EDOpro-server-ts/commit/a90c12fb16149dab8b876b039dfe58b7e5d2a356))
### Bug Fixes
* **banlist:** source JTP whitelist from evolution-assets ([a8c26c0](https://github.com/diangogav/EDOpro-server-ts/commit/a8c26c0cdb8f7d896f4d455af5e424995d9ac233))
* **card:** disable EDOPro card DB hot-reload crashing the core ([#301](https://github.com/diangogav/EDOpro-server-ts/issues/301)) ([9013963](https://github.com/diangogav/EDOpro-server-ts/commit/9013963d58c2672e2063a8561b17c3293b46aadf))
* **card:** refresh EDOPro card DB in place so the C++ core sees hot-reloads ([#302](https://github.com/diangogav/EDOpro-server-ts/issues/302)) ([224f140](https://github.com/diangogav/EDOpro-server-ts/commit/224f14070683880d3cd477ae2889d2ecf0c01868))
* **chat:** include spectator name in Mercury chat messages ([#262](https://github.com/diangogav/EDOpro-server-ts/issues/262)) ([9dc33e8](https://github.com/diangogav/EDOpro-server-ts/commit/9dc33e811e25ce30fcf6d5b9bbd04ccc5d0222ab))
* **core:** resolve EDOPro scripts under RESOURCES_DIR (resources/current) ([#300](https://github.com/diangogav/EDOpro-server-ts/issues/300)) ([178107e](https://github.com/diangogav/EDOpro-server-ts/commit/178107edc8bb0530ee762c31df69805296429610))
* **deck:** encode deck error code so the client shows the specific error ([#263](https://github.com/diangogav/EDOpro-server-ts/issues/263)) ([41a6e7e](https://github.com/diangogav/EDOpro-server-ts/commit/41a6e7e16bb0cd8e33c02e27dafe8776e8258a18))
* **deps:** bump evolution-types submodule to fix postgres startup crash ([#252](https://github.com/diangogav/EDOpro-server-ts/issues/252)) ([a4d3d1f](https://github.com/diangogav/EDOpro-server-ts/commit/a4d3d1f15b9721c440b975194cc8393e9354eba7))
* **deps:** resolve two moderate npm audit vulnerabilities ([0337add](https://github.com/diangogav/EDOpro-server-ts/commit/0337addef34e5250c2ef91cbf9b3395c380c70d1))
* **emotes:** reject spectator emotes server-side ([#315](https://github.com/diangogav/EDOpro-server-ts/issues/315)) ([7a801b1](https://github.com/diangogav/EDOpro-server-ts/commit/7a801b141d6b03adef0ea6cf01993cdb316a6567))
* **genesys:** copy edopro genesys list as lowercase to overwrite in place ([a720913](https://github.com/diangogav/EDOpro-server-ts/commit/a720913390df0ff2856ed9131a28ab782eb726ce))
* **join:** close socket on join rejections that send a message ([#261](https://github.com/diangogav/EDOpro-server-ts/issues/261)) ([77aae5d](https://github.com/diangogav/EDOpro-server-ts/commit/77aae5da68ba22228a073f19d59136de8975dd6e))
* **join:** route blank join password to a default room instead of AI ([#278](https://github.com/diangogav/EDOpro-server-ts/issues/278)) ([d708d2b](https://github.com/diangogav/EDOpro-server-ts/commit/d708d2be9c8eb1aceffee7fab14f8ba3d3239dcd))
* **join:** send ranked-reject JOINERROR with the ygopro serializer ([#265](https://github.com/diangogav/EDOpro-server-ts/issues/265)) ([3e2a083](https://github.com/diangogav/EDOpro-server-ts/commit/3e2a0836106467bb16577d9c953c92043471ff20))
* **matchmaking:** abort incomplete room reservations ([#310](https://github.com/diangogav/EDOpro-server-ts/issues/310)) ([7850081](https://github.com/diangogav/EDOpro-server-ts/commit/785008157110519c811e2add7c2f7616f348b41b))
* **ranked:** require room password on ticket-authenticated joins ([#260](https://github.com/diangogav/EDOpro-server-ts/issues/260)) ([61d4f8f](https://github.com/diangogav/EDOpro-server-ts/commit/61d4f8f51ca4a679686c72fe70285de7634da237))
* **ranking:** key rank by banlist name to unify formats across paths ([#318](https://github.com/diangogav/EDOpro-server-ts/issues/318)) ([21f8177](https://github.com/diangogav/EDOpro-server-ts/commit/21f81774c90932d7c4f8ab43c106e19597c31b31))
* **reconnect:** allow ranked by-name reconnect over half-open sockets ([#291](https://github.com/diangogav/EDOpro-server-ts/issues/291)) ([c8f82ff](https://github.com/diangogav/EDOpro-server-ts/commit/c8f82ff3117733db3d806c3b17cbb9136d8010e3))
* **reconnect:** revoke reconnection tokens on room teardown to stop TokenIndex leak ([#268](https://github.com/diangogav/EDOpro-server-ts/issues/268)) ([b2502b7](https://github.com/diangogav/EDOpro-server-ts/commit/b2502b71cdc4ba24e310cc62159bf2cbbec2419f))
* **setup:** re-clone repository when directory exists without .git ([a730820](https://github.com/diangogav/EDOpro-server-ts/commit/a7308204b4af5658d8b4d5ef3ff564b63fabf90c))
* update permissions for libocgcore.so to executable ([052a050](https://github.com/diangogav/EDOpro-server-ts/commit/052a050d273a41edd1be414f14bfca4e43b21f84))
* update ygopro-scripts repository source in Dockerfile ([f86d185](https://github.com/diangogav/EDOpro-server-ts/commit/f86d185b6a2b4ecf4a3da5ff6e2719ef66a9899a))
* **ygopro:** resolve genesys ban list by alias and reject join when missing ([#304](https://github.com/diangogav/EDOpro-server-ts/issues/304)) ([98d5ff0](https://github.com/diangogav/EDOpro-server-ts/commit/98d5ff0e179dbc22db3be93383d6276cce920dfc))
* **ygopro:** send the banlist hash in STOC_JOIN_GAME, not the in-memory index ([ff34797](https://github.com/diangogav/EDOpro-server-ts/commit/ff347973dfb773c75514514a1ca34864d25b8a22))
## [2.13.2](https://github.com/diangogav/EDOpro-server-ts/compare/v2.13.1...v2.13.2) (2026-05-01)

View file

@ -1,64 +1,24 @@
# 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
RUN apt-get update -y && \
apt-get install -y --no-install-recommends wget git ca-certificates && \
apt-get install -y --no-install-recommends wget git ca-certificates jq && \
rm -rf /var/lib/apt/lists/*
WORKDIR /repositories
WORKDIR /build
# Clone required repositories
RUN git clone --depth 1 --branch master https://github.com/ProjectIgnis/CardScripts.git edopro-card-scripts && \
git clone --depth 1 --branch master https://github.com/ProjectIgnis/BabelCDB.git edopro-card-databases && \
git clone --depth 1 --branch master https://github.com/ProjectIgnis/LFLists edopro-banlists-ignis && \
git clone --depth 1 --branch main https://github.com/termitaklk/lflist edopro-banlists-evolution && \
git clone --depth 1 https://code.moenext.com/nanahira/ygopro-scripts ygopro-scripts && \
git clone --depth 1 --branch master https://github.com/evolutionygo/pre-release-database-cdb ygopro-prereleases-cdb && \
git clone --depth 1 --branch main https://github.com/evolutionygo/cards-art-server ygopro-cards-art && \
git clone --depth 1 --branch main https://github.com/evolutionygo/server-formats-cdb.git ygopro-format-alternatives && \
wget -O ygopro-lflist.conf https://cdntx.moecube.com/ygopro-database/zh-CN/lflist.conf && \
wget -O ygopro-cards.cdb https://cdntx.moecube.com/ygopro-database/zh-CN/cards.cdb
# Copy selected banlists into corresponding alternative folders
RUN bash -c 'set -e; \
declare -A MAP=( \
["2010.03 Edison(Pre Errata)"]="edison" \
["2014.04 HAT (Pre Errata)"]="hat" \
["jtp-oficial"]="jtp" \
["GOAT"]="goat" \
["Rush"]="rush" \
["Speed"]="speed" \
["Tengu.Plant"]="tengu" \
["World"]="world" \
["MD.2025.03"]="md" \
["Genesys"]="genesys" \
); \
for name in "${!MAP[@]}"; do \
src="./edopro-banlists-evolution/${name}.lflist.conf"; \
[ -f "$src" ] || src="./edopro-banlists-ignis/${name}.lflist.conf"; \
cp "$src" "./ygopro-format-alternatives/${MAP[$name]}/lflist.conf"; \
done'
# Assemble final resources structure and strip .git dirs
RUN find . -name ".git" -type d -exec rm -rf {} + 2>/dev/null; \
mkdir -p /resources/edopro \
/resources/ygopro/base \
/resources/ygopro/ocg && \
\
# ── edopro ── \
cp -r edopro-card-scripts /resources/edopro/scripts && \
cp -r edopro-card-databases /resources/edopro/databases && \
cp -r edopro-banlists-ignis /resources/edopro/banlists-ignis && \
cp -r edopro-banlists-evolution /resources/edopro/banlists-evolution && \
\
# ── ygopro (each repo as its own independent folder) ── \
cp -r ygopro-scripts /resources/ygopro/base/script && \
cp ygopro-lflist.conf /resources/ygopro/base/lflist.conf && \
cp ygopro-cards.cdb /resources/ygopro/base/cards.cdb && \
cp -r ygopro-prereleases-cdb /resources/ygopro/prereleases-cdb && \
cp -r ygopro-cards-art /resources/ygopro/cards-art && \
cp -r ygopro-format-alternatives /resources/ygopro/alternatives && \
cp edopro-banlists-ignis/OCG.lflist.conf /resources/ygopro/ocg/lflist.conf
# Resource layout is owned by scripts/clone_repositories.sh + scripts/setup_resources.sh — the
# 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/
# 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
# Stage 2: Build CoreIntegrator (C++)
@ -102,7 +62,7 @@ RUN npm run build && \
FROM public.ecr.aws/docker/library/node:24.11.0-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends curl liblua5.3-dev libsqlite3-dev libevent-dev dumb-init && \
apt-get install -y --no-install-recommends curl wget git ca-certificates jq liblua5.3-dev libsqlite3-dev libevent-dev dumb-init && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
@ -112,11 +72,31 @@ COPY --from=server-builder /server/dist ./
COPY --from=server-builder /server/package.json ./package.json
COPY --from=server-builder /server/node_modules ./node_modules
# WindBot botlist (read at boot by FileBotlistRepository when ENABLE_WINDBOT=true).
# tsc only emits dist/, so config/ must be copied explicitly or the server crashes
# at boot with ENOENT when windbot is enabled. Replace botlist.example.json with a
# curated botlist whose deck names match the WindBot image's bots.json.
# IMPORTANT: every entry MUST also carry a "format" tag ("tcg" / "jtp" / "edison")
# matching resolveBotPool's pools. Format-scoped random join commands (e.g.
# "pre,ai", "ed,ai", "jtp,ai") call pickRandom(format) — a curated botlist without
# "format" tags makes that lookup return null for every user of those commands
# (JOINERROR). See config/botlist.example.json for the reference shape.
COPY --from=server-builder /server/config ./config
# CoreIntegrator binaries
COPY --from=core-builder /app/libocgcore.so ./core/libocgcore.so
COPY --from=core-builder /app/CoreIntegrator ./core/CoreIntegrator
# All resources (assembled in Stage 1)
COPY --from=resources-builder /resources ./resources
# All resources (assembled in Stage 1): releases/<id> + current symlink — the
# baked seed so the server boots immediately. The entrypoint's background loop
# then refreshes resources/current in place and the in-memory reload picks it up.
COPY --from=resources-builder /build/resources ./resources
CMD ["dumb-init", "node", "./src/index.js"]
# 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 ./
CMD ["dumb-init", "bash", "scripts/entrypoint.sh"]

View file

@ -50,13 +50,16 @@ For when you want full control, or Docker isn't an option.
- [Node.js](https://nodejs.org) >= 24
- [CMake](https://cmake.org/download/) >= 3.18
- A C++ compiler (g++ or clang++)
- [jq](https://jqlang.github.io/jq/) >= 1.6 — required by `scripts/clone_repositories.sh` and `scripts/setup_resources.sh` to read the resource manifest
On Ubuntu/Debian, the provided script installs everything you need:
```bash
sudo bash install_dependencies.sh
sudo bash scripts/install_dependencies.sh
```
> 💡 To install `jq` manually: `sudo apt-get install -y jq` (Debian/Ubuntu) or `brew install jq` (macOS).
### 📦 Step by step
```bash
@ -65,13 +68,13 @@ git clone https://github.com/diangogav/EDOpro-server-ts
cd EDOpro-server-ts
# 2⃣ Clone card scripts, databases, and banlists
bash clone_repositories.sh
bash scripts/clone_repositories.sh
# 3⃣ Organize everything into resources/
bash setup_resources.sh
bash scripts/setup_resources.sh
# 4⃣ Build the C++ duel core (used by the EDOPro engine)
bash build_core_integrator.sh
bash scripts/build_core_integrator.sh
# 5⃣ Install Node.js dependencies
npm install
@ -80,6 +83,8 @@ npm install
cp .env.example .env
```
> 📁 `scripts/setup_resources.sh` assembles each run into `resources/releases/<id>/` and points `resources/current` (a symlink) at it. Everything is read through `resources/current/…`, so refreshing resources is an atomic symlink swap — no restart needed. In Docker the container runs this refresh loop in the background (see `scripts/entrypoint.sh` + `scripts/resources-updater.sh`), so card/banlist updates are picked up live.
Now choose which engine(s) you want to run 👇
---
@ -104,7 +109,7 @@ WEBSOCKET_PORT=4000
**Resource structure used:**
```
📂 resources/edopro/
📂 resources/current/edopro/
├── 📜 scripts/ # ProjectIgnis/CardScripts
├── 🗄️ databases/ # ProjectIgnis/BabelCDB
├── 📋 banlists-ignis/ # ProjectIgnis/LFLists
@ -126,8 +131,7 @@ The YGOPro engine uses srvpro2-compatible protocol. Players connect using Koishi
**What you need:**
- ✅ Card scripts and databases from ygopro-scripts
- ✅ Ban lists and alternative format resources
- ✅ The `YGOPRO_FOLDERS` environment variable pointing to your resource directories
- ✅ (Optional) The `YGOPRO_EXTRA_FOLDERS` environment variable for pre-release and art card resource folders (cdbs + scripts)
- ✅ `resources.manifest.json` at repo root (already present — the server derives card paths from it automatically)
**Minimum `.env` configuration:**
@ -135,28 +139,23 @@ The YGOPro engine uses srvpro2-compatible protocol. Players connect using Koishi
YGOPRO_PORT=7711
HTTP_PORT=7922
WEBSOCKET_PORT=4000
YGOPRO_FOLDERS=./resources/ygopro/base
RESOURCES_DIR=./resources/current
```
**Resource structure used:**
```
📂 resources/ygopro/
├── 📜 base/ # Core scripts + lflist + cards.cdb (loaded by all modes)
├── 🌏 ocg/ # OCG-specific banlist
├── 🃏 alternatives/ # Format variants (Edison, GOAT, HAT, etc.)
├── 🆕 prereleases-cdb/ # Pre-release card databases + scripts (extra folder)
└── 🎨 cards-art/ # Custom card art databases (extra folder)
📂 resources/current/ygopro/
├── 📜 base/ # Core scripts + lflist + cards.cdb (loaded by all modes)
├── 🌏 formats/ocg/ # OCG-specific banlist
├── 🃏 formats/<name>/ # Format variants (Edison, HAT, JTP, MD, Tengu, World, Genesys, …)
├── 🆕 extensions/prereleases/ # Pre-release card databases + scripts (extra folder)
└── 🎨 extensions/custom-cards/ # Custom card art databases (extra folder)
```
**Standard card pool** (`YGOPRO_FOLDERS`) is loaded for all rooms. **Extra folders** (`YGOPRO_EXTRA_FOLDERS`) — which can contain cdbs, scripts, and other card assets — are only available in rooms that use PRE or ART formats. Standard rooms cannot use those cards.
**Standard card pool** (base + all served formats) is loaded for all rooms. **Extended pool** (standard + extension dirs) is only available in rooms that use PRE or ART formats. Standard rooms cannot use those cards.
To enable all formats and pre-releases, set:
```env
YGOPRO_FOLDERS=./resources/ygopro/base,./resources/ygopro/ocg,./resources/ygopro/alternatives
YGOPRO_EXTRA_FOLDERS=./resources/ygopro/prereleases-cdb,./resources/ygopro/cards-art
```
Both pools are **derived automatically** from `resources.manifest.json` (`runtime.ygopro.standard` / `.extended`). No environment variable is needed or supported for pool membership — the manifest is the sole source.
```bash
npm run dev
@ -175,8 +174,7 @@ HOST_PORT=7911
YGOPRO_PORT=7711
HTTP_PORT=7922
WEBSOCKET_PORT=4000
YGOPRO_FOLDERS=./resources/ygopro/base,./resources/ygopro/ocg,./resources/ygopro/alternatives
YGOPRO_EXTRA_FOLDERS=./resources/ygopro/prereleases-cdb,./resources/ygopro/cards-art
RESOURCES_DIR=./resources/current
```
```bash
@ -193,8 +191,8 @@ The YGOPro engine maintains **two separate card pools** in memory:
| Pool | Loaded from | Available to |
|------|-------------|--------------|
| **Standard** | `YGOPRO_FOLDERS` | All rooms |
| **Extended** | `YGOPRO_FOLDERS` + `YGOPRO_EXTRA_FOLDERS` | PRE/ART rooms only |
| **Standard** | `runtime.ygopro.standard` in `resources.manifest.json` | All rooms |
| **Extended** | standard + `runtime.ygopro.extended` in `resources.manifest.json` | PRE/ART rooms only |
When a player creates a room with a format like `PRE`, `TCGPRE`, `OCGPRE`, `TCGART`, or `OCGART`, the server uses the **extended** card pool for both deck validation and the duel engine. Standard rooms (`M`, `TCG`, `OT`, `GOAT`, etc.) use only the **standard** pool — any card not in that pool is rejected as unknown.
@ -210,8 +208,8 @@ Both pools are loaded at startup and refreshed every 10 minutes if the underlyin
| `YGOPRO_PORT` | YGOPro server port | `7711` |
| `HTTP_PORT` | HTTP API port | `7922` |
| `WEBSOCKET_PORT` | WebSocket port | `4000` |
| `YGOPRO_FOLDERS` | Comma-separated resource directories (standard card pool) | *(empty)* |
| `YGOPRO_EXTRA_FOLDERS` | Comma-separated extra resource directories (cdbs + scripts for pre-releases, art cards) — only loaded for PRE/ART room formats | *(empty)* |
| `RESOURCES_DIR` | Root of the assembled resource tree (symlink target) | `./resources/current` |
| `MANIFEST_PATH` | Path to `resources.manifest.json` used for pool derivation | `./resources.manifest.json` |
| `RANK_ENABLED` | Enable ranking system (requires PostgreSQL) | `false` |
| `POSTGRES_HOST` | PostgreSQL host | `localhost` |
| `POSTGRES_PORT` | PostgreSQL port | `5432` |
@ -223,6 +221,43 @@ Both pools are loaded at startup and refreshed every 10 minutes if the underlyin
---
## 🔥 Pre-deploy Smoke Check (RFD-008)
Run this manually before deploying to production to confirm the derived pools match the expected baselines (network required):
```bash
# 1. Assemble resources (must be done at least once)
bash scripts/clone_repositories.sh && bash scripts/setup_resources.sh
# 2. Start the server (RESOURCES_DIR and MANIFEST_PATH use their defaults)
npm run dev
```
Watch the startup log for lines like:
```
Merged standard database from N databases with M cards
Merged extended database from N databases with M cards
Total LFLists loaded: K
```
These counts should match the pre-change production baseline. Any significant difference (e.g. M cards drops to 0) indicates a pool derivation or container manifest issue.
You can also inspect the derived paths at any time:
```bash
node -e "
const { resolvePools } = require('./dist/src/ygopro/ygopro/ResourcePoolResolver');
const { config } = require('./dist/src/config');
const pools = resolvePools({ manifestPath: config.resources.manifestPath, resourcesDir: config.resources.dir, env: process.env, logger: console });
console.log('standard paths:', pools.standard.length);
console.log('extended paths:', pools.extended.length);
pools.standard.forEach(p => console.log(' S', p));
pools.extended.slice(pools.standard.length).forEach(p => console.log(' E', p));
"
```
---
## 🏗️ Project Architecture
```

165
biome.json Normal file
View file

@ -0,0 +1,165 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.0/schema.json",
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
"files": {
"includes": [
"**",
"!node_modules/**",
"!dist/**",
"!build/**",
"!coverage/**",
"!core/**",
"!databases/**",
"!repositories/**",
"!.agents/**",
"!.claude/**",
"!resources/**",
"!src/evolution-types/**",
"!**/*.config.js",
"!**/*.config.mjs",
"!test/fixtures/**"
]
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
"lineEnding": "lf",
"lineWidth": 100
},
"linter": {
"enabled": true,
"rules": {
"preset": "none",
"complexity": {
"noAdjacentSpacesInRegex": "error",
"noExtraBooleanCast": "error",
"noUselessCatch": "error",
"noUselessEscapeInRegex": "error",
"noUselessTypeConstraint": "error"
},
"correctness": {
"noConstAssign": "error",
"noConstantCondition": "error",
"noEmptyCharacterClassInRegex": "error",
"noEmptyPattern": "error",
"noGlobalObjectCalls": "error",
"noInvalidBuiltinInstantiation": "error",
"noInvalidConstructorSuper": "error",
"noNonoctalDecimalEscape": "error",
"noPrecisionLoss": "error",
"noSelfAssign": "error",
"noSetterReturn": "error",
"noSwitchDeclarations": "error",
"noUndeclaredVariables": "error",
"noUnreachable": "error",
"noUnreachableSuper": "error",
"noUnsafeFinally": "error",
"noUnsafeOptionalChaining": "error",
"noUnusedLabels": "error",
"noUnusedPrivateClassMembers": "error",
"noUnusedVariables": "error",
"useIsNan": "error",
"useValidForDirection": "error",
"useValidTypeof": "error",
"useYield": "error"
},
"style": {
"noCommonJs": "error",
"noNamespace": "error",
"useArrayLiterals": "error",
"useAsConstAssertion": "error"
},
"suspicious": {
"noAsyncPromiseExecutor": "error",
"noCatchAssign": "error",
"noClassAssign": "error",
"noCompareNegZero": "error",
"noConstantBinaryExpressions": "error",
"noControlCharactersInRegex": "error",
"noDebugger": "error",
"noDuplicateCase": "error",
"noDuplicateClassMembers": "error",
"noDuplicateElseIf": "error",
"noDuplicateEnumValues": "error",
"noDuplicateObjectKeys": "error",
"noDuplicateParameters": "error",
"noEmptyBlockStatements": "error",
"noExplicitAny": "error",
"noExtraNonNullAssertion": "error",
"noFallthroughSwitchClause": "error",
"noFunctionAssign": "error",
"noGlobalAssign": "error",
"noImportAssign": "error",
"noIrregularWhitespace": "error",
"noMisleadingCharacterClass": "error",
"noMisleadingInstantiator": "error",
"noNonNullAssertedOptionalChain": "error",
"noPrototypeBuiltins": "error",
"noRedeclare": "error",
"noShadowRestrictedNames": "error",
"noSparseArray": "error",
"noUnsafeDeclarationMerging": "error",
"noUnsafeNegation": "error",
"noUnusedExpressions": "off",
"noUselessRegexBackrefs": "error",
"noWith": "error",
"useGetterReturn": "error",
"useNamespaceKeyword": "error"
}
}
},
"javascript": {
"parser": { "unsafeParameterDecoratorsEnabled": true },
"formatter": { "quoteStyle": "double" },
"globals": ["exports"]
},
"overrides": [
{
"includes": ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"],
"linter": {
"rules": {
"complexity": { "noArguments": "error" },
"correctness": {
"noConstAssign": "off",
"noGlobalObjectCalls": "off",
"noInvalidBuiltinInstantiation": "off",
"noInvalidConstructorSuper": "off",
"noSetterReturn": "off",
"noUndeclaredVariables": "off",
"noUnreachable": "off",
"noUnreachableSuper": "off",
"noUnusedVariables": "off"
},
"style": { "useConst": "error", "useSpreadOverApply": "error" },
"suspicious": {
"noClassAssign": "off",
"noDuplicateClassMembers": "off",
"noDuplicateObjectKeys": "off",
"noDuplicateParameters": "off",
"noExplicitAny": "off",
"noFunctionAssign": "off",
"noImportAssign": "off",
"noRedeclare": "off",
"noUnsafeNegation": "off",
"noVar": "error",
"noWith": "off",
"useGetterReturn": "off"
}
}
}
},
{
"includes": ["**/*.test.ts", "**/*.spec.ts", "tests/**/*.ts"],
"linter": {
"rules": {
"style": { "noDoneCallback": "error" },
"suspicious": { "noExplicitAny": "off" }
}
}
}
],
"assist": {
"enabled": true,
"actions": { "source": { "organizeImports": "off" } }
}
}

View file

@ -1,22 +0,0 @@
#!/bin/bash
set -e
echo "Cloning repositories..."
rm -rf repositories
mkdir repositories
cd repositories
git clone --depth 1 --branch master https://github.com/ProjectIgnis/CardScripts.git edopro-card-scripts
git clone --depth 1 --branch master https://github.com/ProjectIgnis/BabelCDB.git edopro-card-databases
git clone --depth 1 --branch master https://github.com/ProjectIgnis/LFLists edopro-banlists-ignis
git clone --depth 1 --branch main https://github.com/termitaklk/lflist edopro-banlists-evolution
git clone --depth 1 https://code.moenext.com/nanahira/ygopro-scripts ygopro-scripts
git clone --depth 1 --branch master https://github.com/evolutionygo/pre-release-database-cdb ygopro-prereleases-cdb
git clone --depth 1 --branch main https://github.com/evolutionygo/cards-art-server ygopro-cards-art
git clone --depth 1 --branch main https://github.com/evolutionygo/server-formats-cdb.git ygopro-format-alternatives
wget -O ygopro-lflist.conf https://cdntx.moecube.com/ygopro-database/zh-CN/lflist.conf
wget -O ygopro-cards.cdb https://cdntx.moecube.com/ygopro-database/zh-CN/cards.cdb
echo "Repositories cloned successfully."

View file

@ -0,0 +1,65 @@
[
{
"name": "Joey",
"deck": "JTP",
"format": "jtp"
},
{
"name": "Yugi",
"deck": "Yugi",
"format": "jtp"
},
{
"name": "Salamangreat",
"deck": "Salamangreat",
"format": "tcg"
},
{
"name": "Sky Striker",
"deck": "SkyStriker",
"format": "tcg"
},
{
"name": "Labrynth",
"deck": "Labrynth",
"format": "tcg"
},
{
"name": "Yubel",
"deck": "Yubel",
"format": "tcg"
},
{
"name": "Swordsoul",
"deck": "Swordsoul",
"format": "tcg"
},
{
"name": "Ryzeal",
"deck": "Ryzeal",
"format": "tcg"
},
{
"name": "Maliss",
"deck": "Maliss",
"format": "tcg"
},
{
"name": "Blackwing",
"deck": "EdisonBlackwing",
"hidden": true,
"format": "edison"
},
{
"name": "Lightsworn",
"deck": "EdisonLightsworn",
"hidden": true,
"format": "edison"
},
{
"name": "Machina",
"deck": "EdisonMachina",
"hidden": true,
"format": "edison"
}
]

Binary file not shown.

View file

@ -1,5 +1,6 @@
#include "../includes/duel.h"
#include <stdexcept>
#include <cstdlib>
template <>
constexpr LocInfo Read(const uint8_t *&ptr) noexcept
@ -60,7 +61,11 @@ void Duel::destroy()
void Duel::load_scripts()
{
std::filesystem::path current_path = std::filesystem::current_path();
std::filesystem::path scripts_path = current_path / "resources/edopro/scripts";
// Resources live under RESOURCES_DIR (default resources/current — the symlink
// maintained by the resource refresh), matching the TS config.resources.dir.
const char *resources_dir = std::getenv("RESOURCES_DIR");
std::filesystem::path scripts_path =
current_path / (resources_dir ? resources_dir : "resources/current") / "edopro/scripts";
const char *path = scripts_path.c_str();
std::vector<char> constants_buffer = this->file_reader.read(path, "constant.lua");

View file

@ -2,6 +2,7 @@
#include "ScriptReader.h"
#include "FileReader.h"
#include <filesystem>
#include <cstdlib>
namespace fs = std::filesystem;
ScriptReader::ScriptReader(OCGRepository repository) : repository(repository) {}
@ -16,7 +17,11 @@ int ScriptReader::read(void *duel, const char *name)
{
FileReader reader;
std::filesystem::path currentPath = std::filesystem::current_path();
std::filesystem::path scriptsPath = currentPath / "resources/edopro/scripts";
// Resources live under RESOURCES_DIR (default resources/current — the symlink
// maintained by the resource refresh), matching the TS config.resources.dir.
const char *resourcesDir = std::getenv("RESOURCES_DIR");
std::filesystem::path scriptsPath =
currentPath / (resourcesDir ? resourcesDir : "resources/current") / "edopro/scripts";
const char* path = scriptsPath.c_str();
std::vector<char> buffer = reader.read(path, name);

View file

@ -57,8 +57,14 @@ services:
MERCURY_PORT: ${MERCURY_PORT:-7711}
HTTP_PORT: ${HTTP_PORT:-7922}
WEBSOCKET_PORT: ${WEBSOCKET_PORT:-4000}
YGOPRO_FOLDERS: ./resources/ygopro/base,./resources/ygopro/ocg,./resources/ygopro/alternatives
YGOPRO_EXTRA_FOLDERS: ./resources/ygopro/prereleases-cdb,./resources/ygopro/cards-art
RESOURCES_DIR: ./resources/current
# WindBot (opt-in). The WindBot container is run separately (independent docker
# command). Off by default — set these in .env to enable. WINDBOT_MY_IP must be a
# host/IP the externally-run bot can reach back on; WINDBOT_ENDPOINT is the bot's HTTP API.
ENABLE_WINDBOT: ${ENABLE_WINDBOT:-false}
WINDBOT_ENDPOINT: ${WINDBOT_ENDPOINT:-}
WINDBOT_MY_IP: ${WINDBOT_MY_IP:-windbot}
WINDBOT_BOTLIST: ${WINDBOT_BOTLIST:-config/botlist.example.json}
ports:
- "${HOST_PORT:-7911}:${HOST_PORT:-7911}"
- "${MERCURY_PORT:-7711}:${MERCURY_PORT:-7711}"

View file

@ -19,5 +19,21 @@ services:
timeout: 5s
retries: 5
valkey:
image: valkey/valkey:9.0-alpine
container_name: evolution-valkey
ports:
- "6379:6379"
volumes:
- valkey_data:/data
restart: unless-stopped
healthcheck:
test: [ "CMD", "valkey-cli", "ping" ]
interval: 10s
timeout: 5s
retries: 5
command: valkey-server --appendonly yes
volumes:
postgres_data:
valkey_data:

View file

@ -1,53 +0,0 @@
# Add `command` field to `GET /api/rooms`
## What
Add a `command` string field to each room in the `GET /api/rooms` response.
## Why
The Evolution client needs the room's command string to join via WebSocket.
The join protocol (`CTOS_JOIN_GAME`) requires the room name as the `pass` field.
Without `command`, the client has no way to join a room from the browser.
## Source
```typescript
// YGOProRoom already has this as a public readonly field:
YGOProRoom.name // e.g. "M,tcg,lp8000,tm5"
```
This is the command string that created the room, **without** the `#password` suffix.
It's already stripped of the password during room creation.
## Change
In the `toRoomListDTO()` method (or wherever the room list response is built),
add one field:
```typescript
{
id: this.id,
command: this.name, // <-- ADD THIS LINE
status: ...,
// ... rest of fields
}
```
## Response example (before → after)
```diff
{
"id": 7659,
+ "command": "M,tcg,lp8000",
"status": "waiting",
"started": false,
...
}
```
## Security
`command` does NOT contain the password. The password portion after `#` is
stripped during `YGOProRoom.create()` and stored separately in `YGOProRoom.password`
(which is never exposed in any API response).

View file

@ -1,171 +0,0 @@
# Room List API — Specification
## Context
Evolution's client Room Browser needs to list Mercury rooms with all labels
pre-resolved by the server. The client only renders — no mapping, no lookups.
---
## Endpoint
```
GET /api/rooms
```
### Query Parameters (all optional)
| Param | Type | Default | Description |
|----------|--------|---------|-------------|
| `status` | `string` | — | Comma-separated: `waiting`, `dueling`, `rps`, `choosingOrder`, `sideDecking` |
| `open` | `bool` | — | `true` = no password only, `false` = password only |
| `mode` | `number` | — | `0` Single, `1` Match, `2` Tag |
No params → all Mercury rooms.
---
### Response
```jsonc
{
"rooms": [
{
"id": 1234,
"command": "M,tcg,lp8000", // join key — pass this as CTOS_JOIN_GAME.pass (not displayed)
"status": "waiting", // DuelState value
"started": false, // false=waiting, true=any other state
"private": false,
"canPlay": true, // waiting AND open slots
"canWatch": true, // always true
"banlist": "2026.04 TCG", // resolved name from BanList.name (via hash lookup)
"rule": "TCG", // resolved label: "OCG" | "TCG" | "OCG/TCG" | "Pre-release" | "Anything Goes"
"mode": 0, // 0=Single, 1=Match, 2=Tag
"bestOf": 1,
"duelRule": 5, // Master Rule 1-5
"startLp": 8000,
"timeLimit": 180,
"players": [
{ "name": "DarkMagician", "position": 0, "team": 0 },
{ "name": "BlueEyes99", "position": 1, "team": 1 }
],
"maxPlayers": 2,
"spectators": 0
}
]
}
```
---
### Field Reference
| Field | Type | Source | Description |
|-------------|-----------|--------|-------------|
| `id` | `number` | `YgoRoom.id` | Room ID |
| `command` | `string` | `YGOProRoom.name` | Join key — client sends this as `CTOS_JOIN_GAME.pass`. Not displayed in UI. |
| `status` | `string` | `YgoRoom.duelState` | Granular lifecycle state |
| `started` | `boolean` | computed | `duelState !== "waiting"` — quick filter for UI |
| `private` | `boolean` | `password.length > 0` | Password required |
| `canPlay` | `boolean` | computed | `!started && players.length < maxPlayers` |
| `canWatch` | `boolean` | computed | Always `true` |
| `banlist` | `string` | `BanListRepo.findByHash(hash).name` | Human-readable banlist name |
| `rule` | `string` | resolved from `HostInfo.rule` numeric | `"OCG"`, `"TCG"`, `"OCG/TCG"`, `"Pre-release"`, `"Anything Goes"` |
| `mode` | `number` | `HostInfo.mode` | `0`=Single, `1`=Match, `2`=Tag |
| `bestOf` | `number` | `YgoRoom.bestOf` | Match count |
| `duelRule` | `number` | `HostInfo.duel_rule` | Master Rule version |
| `startLp` | `number` | `HostInfo.start_lp` | Starting LP |
| `timeLimit` | `number` | `HostInfo.time_limit` | Seconds per turn |
| `players` | `array` | `YgoRoom._players` | Player list |
| `maxPlayers`| `number` | `team0 + team1` | Total player slots |
| `spectators`| `number` | `YgoRoom._spectators.length` | Spectator count |
#### `players[]`
| Field | Type | Source |
|------------|----------|--------|
| `name` | `string` | `YgoClient.name` (null bytes stripped) |
| `position` | `number` | `YgoClient.position` |
| `team` | `number` | `YgoClient.team` (0 or 1) |
#### Rule label resolution (server-side)
```
0 → "OCG"
1 → "TCG"
2 → "OCG/TCG"
3 → "Pre-release"
4 → "Anything Goes"
5 → "Anything Goes"
```
#### Banlist name resolution (server-side)
```typescript
YGOProBanListMemoryRepository.findByHash(room.banListHash)?.name ?? "No banlist"
```
---
## Implementation
### Server
1. **New controller**: `src/http-server/controllers/RoomListController.ts`
- `YGOProRoomList.getRooms()` → filter by query params → map `toRoomListDTO()`
2. **New route**: `GET /api/rooms` in routes
3. **`toRoomListDTO()` on `YGOProRoom`** — builds the response shape, resolves:
- `banlist`: lookup `BanListMemoryRepository.findByHash(hash).name`
- `rule`: map numeric → string label
- `started`: `duelState !== DuelState.WAITING`
- `canPlay`: `!started && players.length < maxPlayers`
- `canWatch`: `true`
### Data available (no model changes)
| Need | Source |
|------|--------|
| `duelState` | `YgoRoom.duelState` |
| `team0/team1` | `YgoRoom.team0/team1` |
| `players` with position, team | `YgoRoom._players``YgoClient` |
| `spectators.length` | `YgoRoom._spectators` |
| `password` | `YGOProRoom.password` |
| `HostInfo` fields | `YGOProRoom._hostInfo` |
| `banListHash` | `YGOProRoom._edoBanListHash ?? banListHash` |
| BanList name lookup | `YGOProBanListMemoryRepository.findByHash()` |
### Security
- Passwords NEVER in response
- Player IPs NEVER in response
---
## Client Usage
1. `GET /api/rooms` — all rooms, or `?status=waiting` for joinable only
2. Render room list — data is display-ready, no mapping needed
3. User clicks room:
- `canPlay`**Join** button
- `canWatch` (and `started`) → **Spectate** button
- `private` → prompt password first
4. Connect WebSocket → `CTOS_PLAYER_INFO` + `CTOS_JOIN_GAME`
---
## DuelState Reference
| Value | `started` | `canPlay` | `canWatch` |
|------------------|-----------|------------------|------------|
| `waiting` | `false` | if slots open | Yes |
| `rps` | `true` | No | Yes |
| `choosingOrder` | `true` | No | Yes |
| `dueling` | `true` | No | Yes |
| `sideDecking` | `true` | No | Yes |
Joining a `started` room → server assigns spectator role automatically.

287
docs/join-commands.md Normal file
View file

@ -0,0 +1,287 @@
# YGOPro Join Commands — Structure and Handling
How the server interprets the `CTOS_JOIN_GAME` password ("join command"), how rooms
are matched and created from it, and where player/spectator decisions happen.
Evidence references point at the current source; update them if files move.
## 1. Command wire format
```
<config-tokens>[#<room-password>]
```
- The whole string rides the `CTOS_JOIN_GAME` `pass` field: **UTF-16LE, fixed
`utf16[20]` — silently truncated past 20 characters**. Exactly-20 round-trips
(no NUL terminator required by `ygopro-msg-encode`).
- `YGOProJoinHandler` splits on `#` and keeps only the first two segments
(`src/ygopro/room/application/YGOProJoinHandler.ts:51`):
- `command` = raw segment before the first `#` (case preserved).
- `password` = segment after the first `#`, or `""`.
- Config tokens are comma-separated inside `command`. For rule resolution they
are trimmed and lowercased; for room identity they are **not** (see §4).
Examples: `tcg`, `edison#myroom`, `nc,ns,ai#Blackwing`, `m,tt#duel1`.
## 2. Join pipeline
```
socket → MessageEmitter (Commands.JOIN_GAME = 18) → YGOProJoinHandler
→ JoinStrategyRegistry.resolve(ctx) → strategy.handle(ctx)
```
Strategy chain, first `matches()` wins
(`src/ygopro/room/application/join-strategies/composeJoinStrategies.ts`):
| # | Strategy | Matches when | Behavior |
|---|----------|--------------|----------|
| 1 | `AIJoinTokenStrategy` | windbot enabled AND `rawPass` starts with `AIJOIN#` | The bot itself connecting back: consumes a one-shot token, finds the room **by id**, marks the client internal. |
| 2 | `WindBotJoinStrategy` | windbot enabled AND `ai` among config tokens | Creates an AI room, resolves the bot (by name after `#`, or format-scoped random via `resolveBotPool`), fires the bot request. Rejects tag mode. |
| 3 | `TicketJoinStrategy` | socket authenticated via WS ticket (`resolvedUserId`) | Shared `findOrCreateRoom` helper (`rankedOverride=true`) — see §5 for the pairing-vs-non-pairing lookup it performs. |
| 4 | `DefaultJoinStrategy` | always | Shared `findOrCreateRoom` helper (`rankedOverride=undefined`) — see §5 for the pairing-vs-non-pairing lookup it performs. Admission is delegated to the room state (§6). |
On strategy error: `JOINERROR` frame + `socket.close()`.
`TicketJoinStrategy` and `DefaultJoinStrategy` share one function,
`findOrCreateRoom` (`src/ygopro/room/application/join-strategies/findOrCreateRoom.ts`),
parameterized by `{ rankedOverride }`. The two strategies differ ONLY in that
parameter — the find-or-create logic itself, including the pairing-join
branch described in §5, is identical for both.
## 3. Rule resolution (token catalog)
`YGOProRoom.create` (`src/ygopro/room/domain/YGOProRoom.ts:198-233`) lowercases
each token and applies three tiers from
`src/ygopro/room/domain/RuleMappings.ts`, in order — later tiers overwrite
earlier ones for the same option; **two matches inside one tier throw**:
1. **`ruleMappings`** (mode): `m`/`match`, `t`/`tag`.
2. **`formatRuleMappings`** (format presets): `edison`/`ed`, `hat`, `tengu`,
`md`, `jtp`, `jtp-2007-03`, `jm`, `gx`, `mdc`, `goat`,
`genesys`/`g`/`g<N>`/`genesys<N>`, `rush`, `rushpre`, `speed`, `world`,
`pre`, `ocg`, `tcgpre`, `ocgpre`, `tcgart`, `ocgart`.
3. **`priorityRuleMappings`** (modifiers, win over format tokens): `bo<N>`,
`lp<N>`, `tm<N>`/`time<N>`, `mr<N>`/`duelrule<N>`, `ot`/`tcg`, `otto`,
`toot`/`tt`, `ns`, `nc`, `dr<N>`, `st<N>`, `to`/`tcgonly`/`tor`,
`lf*`/`lflist*`, `nf`/`nolflist`, `oor`/`oo`/`ocgonly`, `or`, `tr`, `oomr`,
`omr`, `tomr`, `tmr`.
Notes worth knowing:
- **`tcg` is an alias of `ot`** (priority tier): payload is `{ rule: 5 }` only —
banlist and duel rule stay at the host defaults (first TCG banlist,
duel_rule 5).
- `ocg``{ rule: 0, lflist: 0, time_limit: 450 }`;
`md``{ rule: 5, lflist: alias("md"), duel_rule: 5, time_limit: 450 }`;
`tcgpre``{ rule: 5, duel_rule: 5, time_limit: 450 }` (default TCG lflist);
`ocgpre``{ rule: 5, duel_rule: 5, lflist: 0, time_limit: 450 }`;
`tcgart`/`ocgart` are byte-identical to `tcgpre`/`ocgpre` in ruleset, but
kept as separate tokens on purpose — token = room identity (§4/§5), so
merging them would silently merge two distinct pairing pools.
- `g` resolves genesys (`rule: 1`, genesys banlist, `max_deck_points`
default 100) and **throws if the genesys banlist is not loaded**.
- `pre`, `tcgpre`, `ocgpre`, `tcgart`, `ocgart`, `rushpre` additionally enable
the **extended card pool** (`extendedCardPoolFormats`,
`RuleMappings.ts:707`) — an effect separate from the tier payloads.
- Banlist aliases resolve by **normalized substring inclusion**
(`MercuryBanListMemoryRepository.findIndexByAlias`), not exact match.
- Unrecognized tokens are silently ignored (host defaults apply:
`rule: 1`, `mode: SINGLE`, `duel_rule: 5`, first TCG banlist).
- AI-pool resolution for random windbots mirrors the tier precedence
(`src/ygopro/windbot/domain/resolveBotPool.ts`): priority TCG tokens win
over format tokens regardless of position.
## 4. Room identity and matching
- `room.name` = the **raw, case-preserved** config segment of whichever string
created the room. `room.password` = the raw segment after `#`.
- For **non-pairing** joins, room identity is the exact **(name, password)
pair**: `YGOProRoomList.findByNameAndPassword`
(`src/ygopro/room/infrastructure/YGOProRoomList.ts`) does plain `===` on
both `room.name` and `room.password` against the joiner's `command` and
`password`, and returns the **first** room matching both. A miss creates a
new room under that exact pair — it never rejects the join. Two rooms
sharing a name but differing in password are therefore always
distinguishable and independently reachable.
- `YGOProRoomList.findByName` (name-only match) remains available for callers
that need a name-only lookup (e.g. `MatchmakingRoomFactory`'s
name-collision check when minting a fresh matchmaking room name); the join
pipeline's non-pairing lookup uses `findByNameAndPassword` instead.
- Consequences:
- **Case-sensitive identity**: `tcg` and `TCG` are two different rooms that
resolve to the same ruleset.
- **A bare token is both config AND room identity**: two strangers sending
exactly `tcg` land in the same room and get seated as the two players.
This started as an emergent side effect of `findByName`'s first-match
lookup; §5 formalizes it into the designed pairing feature (still keyed
on the exact command string, now state- and seat-aware).
- `findByNameAndPassword` returns the **first** room matching the pair and
**ignores room state** — a room stays in the list (and stays matchable)
through `waiting → rps → choosingOrder → dueling → sideDecking` until
`FinalizeYGOProRoom.run` removes it at match end. A joiner who supplies
the correct password for a room mid-duel still resolves to that room
(spectating decided downstream — see §6); a joiner who mistypes the
password resolves to no existing room and gets a fresh, empty one of
their own instead of a rejection.
## 5. Pairing joins
Any client can pair players by having them send the same bare token command
(e.g. `TCG`, `edison`) with no password — the server matches them into the
same room or gives each side a fresh one.
Added on top of the pipeline in §2 — `findOrCreateRoom`
(`src/ygopro/room/application/join-strategies/findOrCreateRoom.ts`) branches
into a **pairing lookup** instead of the non-pairing `findByNameAndPassword`
lookup when the join qualifies as a pairing join.
**Placement in the pipeline:** this branch lives entirely inside step 4 of §2
(`JoinStrategyRegistry.resolve(ctx) → strategy.handle(ctx)`), specifically
inside the shared `findOrCreateRoom` helper called by both `TicketJoinStrategy`
and `DefaultJoinStrategy`. It runs AFTER strategy resolution (so `AIJoinTokenStrategy`
and `WindBotJoinStrategy` are never affected) and BEFORE room admission (§6) —
it only decides WHICH room object the join is routed to, never who gets
seated once there.
**Predicate** (`isPairingJoin`,
`src/ygopro/room/application/join-strategies/isPairingJoin.ts`): a join is a
pairing join when ALL of the following hold:
- the password segment is empty (no `#` in the command, or nothing after it);
- the command is non-empty;
- every comma-separated token, trimmed and lowercased, is **recognized**:
`isRecognizedToken(token)` (exported from `RuleMappings.ts` — true when the
token matches at least one tier's `validate()`, per §3) **or** the literal
token `"casual"`.
Concretely NOT pairing joins: `ai` (intercepted earlier by
`WindBotJoinStrategy` when windbot is enabled; when disabled it falls through
here and is simply unrecognized — conservative, not a special case),
`mm<...>` (the matchmaking queue's generated marker token), any arbitrary
unrecognized room name, and the blank command.
**Pairing key is the exact raw command string** — case and token order
matter. `edison,ns` and `ns,edison` are two different pairing pools and will
NOT pair with each other; `TCG` and `tcg` are likewise two different pools
(consistent with the case-sensitive room identity already described in §4).
This is a deliberate product decision: players expect to pair only with
someone who typed the exact same command.
**Lookup, inside `findOrCreateRoom`:**
- Pairing join → `YGOProRoomList.findJoinableByName(command, options)`. Every
candidate with a matching name is scanned (not just the first), and ALL of
the following are evaluated **per candidate, inside the same scan** — a
candidate that fails any one of them is simply skipped, it can never
shadow a later qualifying candidate:
- `duelState === WAITING` and has a free seat (state- and seat-aware —
see §4);
- `password === ""` (`requireEmptyPassword`) — a passworded same-named
room (e.g. `tcg#secret`) is skipped, not treated as a match-then-reject;
- league compatibility for **guest** joiners only (`excludeRankedForGuest`):
a joiner with no resolved ticket identity and no PIN skips any candidate
whose league is ranked (Verified/External), because `RoomAdmission`
hard-rejects a guest in a ranked room (JOINERROR + close, no spectator
fallback) rather than seating or spectating it. Non-guest joiners
(ticket or PIN) are not filtered by league — they keep the pre-existing
behavior.
- Found → join it.
- Not found (no same-named room at all, or every same-named room is
dueling/full/passworded/league-incompatible for this guest) → **create a
new room** with the same name (`rankedOverride` per the calling
strategy, same as any other creation).
- Non-pairing join → `findByNameAndPassword(command, password)` (first-match
on the exact pair, state-blind), exactly as in §4 — a match joins whatever
state that room is in (spectating included); no match **creates a new
room** under that name and password rather than rejecting. See §8 for the
trade-off this implies for a mistyped password.
**Never-spectate guarantee (mostly — see the seat-race caveat):** because a
pairing join either lands in a room that `findJoinableByName` just confirmed
is `WAITING` with a free seat, or creates a brand-new `WAITING` room, a
pairing join can **never** be routed into a dueling/mid-match room. The
unconditional-spectator path in `YGOProDuelingState.handleJoin` (§6) is
therefore unreachable for pairing joins — it only ever fires for non-pairing
joins (passworded rooms, or an unrecognized/arbitrary room name), where the
behavior is unchanged from before this feature.
Caveat: `hasFreeSeat()` (used by `findJoinableByName`) is an explicitly
**lock-free routing hint** — it does not hold the room's mutex. Two pairing
joiners can both observe the same last free seat as available and both get
routed to the same room; real admission (`RoomAdmission.decide`, inside
`room.mutex.runExclusive`) is the actual arbiter, and the loser of that race
degrades to **spectator** (no free seat left → `RoomAdmission` returns
`spectator`, per §6), not a rejection. This is the one gap in the
"never-spectate" guarantee: it holds for the routing decision, not for the
sub-mutex admission race on the very last seat.
## 6. Player vs spectator
Admission depends on the room state active when `JOIN` fires:
- **WAITING** (`YGOProWaitingState.handleJoin`): duplicate name → reject;
otherwise `RoomAdmission.decide`
(`src/shared/room/admission/domain/RoomAdmission.ts:32`):
ranked + guest → reject; league doesn't admit → spectator;
**no free seat → spectator**; else seated as player. A spectator can later be
promoted via `TO_DUEL` (waiting state only).
- **RPS / ChoosingOrder / SideDecking / Dueling**: joiners are matched against
existing players by `findReconnectingPlayer` (name, and for unranked rooms
also remote address + original socket closed). Reconnect on match; otherwise
**unconditionally a spectator** — mid-match joins never create players, and
there is no "room is busy → go elsewhere" path.
## 7. What join does NOT use
- The HTTP room listings (`GET /api/getrooms`, `GET /api/rooms`) are read-only
browse endpoints; the join pipeline never consults them.
- The HTTP matchmaking queue (`MatchmakingQueue`, `MatchmakingRoomFactory`) is
a separate engine: it pairs players per format, creates a room with a
generated `"<token>,mm<id>#<pass>"` string, and hands both players that exact
string to send through the normal socket join pipeline. The only room-side
marker is `room.isMatchmaking = true`. That generated string always carries
an `mm<id>` token, which `isRecognizedToken` does not recognize — so
matchmaking joins are never pairing joins (§5); they always go through the
`findByNameAndPassword` lookup (§4/§5), resolving to their room by the exact
generated `(name, password)` pair.
## 8. Known caveats (current behavior)
1. ~~Bare-token pairing works for the first two players, but a **third**
sender of the same token while the duel is running silently becomes a
spectator of two strangers.~~ **Fixed by the pairing feature (§5)** for any
command where every token is recognized (or `casual`): a third sender gets
a brand-new room instead of spectating, and a fourth sender pairs with the
third's room rather than the still-dueling first one. The caveat still
applies verbatim to commands with an unrecognized token or a password
segment — those keep using `findByNameAndPassword` (state-blind,
first-match on the pair) and can still land a joiner as a spectator of a
dueling room when the pair matches.
2. Room identity is case-sensitive while rule resolution is not; clients that
uppercase their commands partition themselves from clients that lowercase.
The pairing feature (§5) inherits this exactly — `TCG` and `tcg` are two
distinct pairing pools, not one.
3. `YGOProRoomList.findByName` (name-only, ignoring password) returns the
first name match regardless of state; a caller routing through it alone
can only ever reach the first same-named room. The join pipeline does not
have this problem: non-pairing joins resolve by the full `(name,
password)` pair via `findByNameAndPassword`, so a second, third, etc.
same-named room under a different password is always independently
reachable; pairing joins (§5) scan every same-named room via
`findJoinableByName` and skip dueling/full/passworded ones. The caveat
still applies to any other caller that uses plain `findByName` directly
(e.g. `MatchmakingRoomFactory`'s name-collision check when minting a new
matchmaking room name).
4. Because room identity for a non-pairing join is the exact `(name,
password)` pair, there is no "wrong password" rejection on this path: a
joiner who mistypes the password of an existing room does not get an
error — they get a brand-new, empty room under that name and their typo'd
password, indistinguishable in kind from any other room. This is a
deliberate trade-off (see §4/§5): treating the full command string as the
identity avoids `findByName`'s first-match collision with same-named
pairing/other rooms, at the cost of silent typo-driven room creation
instead of an explicit reject. Because there is no reject on this path, a
mismatched password also no longer terminates the connection: the socket
is never destroyed, and the joiner instead lands in the newly created room.
5. The 20-char `pass` ceiling applies to the entire command string; bot names
are boot-validated against a 13-char budget that assumes short (≤3 chars +
comma) format tokens.

View file

@ -1,185 +0,0 @@
# Revisión de arquitectura Node.js ↔ C++ (child_process)
## Resumen ejecutivo
El diseño actual funciona, pero tiene cuellos de botella y riesgos de robustez en el canal IPC:
1. Se mezclan **dos protocolos distintos** (entrada con JSON por línea y salida con JSON prefijado por longitud).
2. Hay manejo incompleto de **backpressure** en `stdin` desde Node.js.
3. El parser de salida en Node.js procesa **solo un mensaje por evento**, lo que puede dejar mensajes acumulados.
4. En C++ se emiten logs por `stdout` fuera del framing, lo que puede corromper el stream.
5. Se envía la configuración inicial por `argv[1]`, con riesgo de límite de tamaño y sin validación de `argc`.
---
## Hallazgos técnicos
### 1) Protocolo de entrada/salida asimétrico
- Node inicia el core con un JSON grande en los argumentos de proceso (`argv[1]`).
- Luego envía comandos por `stdin` como JSON terminado en `\n`.
- C++ responde por `stdout` con framing binario: `uint32_le + json`.
Esto obliga a mantener parsers distintos en ambos sentidos y complica diagnóstico, retries y evolución de versión de protocolo.
### 2) Backpressure: retries con `setTimeout` en vez de cola + `drain`
`writeToCppProcess` reintenta en 100ms si `stdin.write()` devuelve `false`, pero no espera evento `drain` ni serializa una cola explícita. En picos puede causar:
- Reintentos redundantes.
- Latencia artificial.
- Riesgo de duplicación/reordenamiento si cambian timings.
### 3) Consumo parcial de mensajes del core
En `DuelingState`, al recibir `stdout` se llama `processMessage()` una sola vez por chunk, y `processMessage()` no drena en bucle mientras haya mensajes listos. Si un chunk trae múltiples frames, se procesa el primero y los demás dependen de un evento futuro.
### 4) Posible corrupción del canal de salida del core
El core envía mensajes de protocolo por `stdout` con framing binario (`send_message`), pero también hay código que escribe logs a `stdout` (`Timer expired...`). Cualquier byte no enmarcado rompe el parser Node.
### 5) Inicialización frágil por argumentos de proceso
`main.cpp` usa `argv[1]` sin validar `argc`. Además, serializar toda la configuración y decks en la línea de comandos aumenta riesgo por límites del sistema operativo y dificulta observabilidad segura.
### 6) Mercury: bootstrap de puerto con `stdout.once("data")`
En Mercury se toma el puerto del primer chunk de `stdout` (`once("data")`). Si llegan bytes parciales/extra en el primer chunk, el parseo se vuelve frágil.
---
## Plan de mejora recomendado (priorizado)
## Fase 1 (alto impacto, bajo riesgo)
1. **Unificar logs fuera del canal IPC**
- Regla: protocolo exclusivamente por `stdout`; logs exclusivamente por `stderr`.
- Mover cualquier `std::cout` de diagnóstico a `std::cerr` en C++.
2. **Drenar completamente frames en Node**
- Cambiar `processMessage()` para iterar `while (isMessageReady())`.
- Mantener límite de seguridad por tick para evitar starvation (ej. 1k mensajes).
3. **Backpressure correcto**
- Implementar cola FIFO de comandos a C++.
- Escribir hasta que `write()` devuelva `false`, pausar y continuar en `duel.stdin.once("drain")`.
## Fase 2 (robustez de protocolo)
4. **Handshake/versionado de protocolo**
- Mensaje inicial `HELLO { protocolVersion, features }` en ambos sentidos.
- Rechazar versiones incompatibles explícitamente.
5. **Unificar framing en ambas direcciones**
- Opción recomendada: `length-prefixed JSON` para `stdin` y `stdout`.
- Evitar parser por saltos de línea para comandos entrantes.
6. **Mover bootstrap de configuración a `stdin`**
- En vez de `argv[1]`, enviar `INIT` por el mismo canal framed.
- Añadir validaciones de esquema y respuesta de `ACK_INIT`.
## Fase 3 (performance y operación)
7. **Codificación binaria para mensajes calientes**
- Mantener JSON para control-plane.
- Usar MessagePack/CBOR (o binario propio) para data-plane de alta frecuencia.
8. **Pool de workers C++ por matchmaker**
- Evaluar proceso por duelo vs. worker pool según throughput objetivo.
9. **Observabilidad de IPC**
- Métricas: cola IPC, `drain wait`, frames/s, parse errors, tamaño de frame p95/p99.
---
## ¿Se puede cambiar JSON por otro protocolo más rápido?
Sí. Para este caso (Node + C++ con mensajes frecuentes), las opciones más prácticas son:
### 1) MessagePack (recomendado)
- **Ventajas**: payload más pequeño que JSON, parseo más rápido, esquema flexible, librerías maduras en Node y C++.
- **Costo de migración**: medio.
- **Uso sugerido**: reemplazo directo para mensajes actuales (`START`, `TIME`, `CORE`, etc.) con framing por longitud.
### 2) FlatBuffers / Capn Proto
- **Ventajas**: muy alto rendimiento, acceso casi zero-copy.
- **Costo de migración**: alto (IDL, generación de código, versionado estricto).
- **Uso sugerido**: si el cuello de botella IPC ya está probado en profiling y se requiere latencia ultra baja.
### 3) Protobuf
- **Ventajas**: ecosistema excelente, buen versionado, rendimiento sólido.
- **Costo de migración**: medio/alto por definición de `.proto` y mapeo de tipos.
- **Uso sugerido**: si se prioriza interoperabilidad y contratos muy estables.
### Decisión práctica sugerida
1. Corto plazo: **JSON length-prefixed bidireccional** (homogeneizar primero).
2. Mediano plazo: migrar a **MessagePack length-prefixed** para data/control plane.
3. Largo plazo: evaluar **FlatBuffers/Capn Proto** solo con métricas que justifiquen la complejidad.
---
## ¿Hay un canal de comunicación mejor que `child_process` stdio?
Sí, dependiendo del objetivo.
### Opción A) Unix Domain Socket (UDS) / Named Pipe (recomendado si siguen procesos separados)
- **Pros**: canal dedicado, menor overhead que TCP local, fácil multiplexar, control más fino de reconexión/healthcheck.
- **Contras**: más complejidad operativa que stdio.
- **Cuándo usar**: cuando necesitan robustez, observabilidad y posibilidad de reinicio independiente del core.
### Opción B) TCP loopback (127.0.0.1)
- **Pros**: simple, portable, útil si ya hay arquitectura tipo Mercury con puertos.
- **Contras**: overhead mayor que UDS.
### Opción C) Node-API addon (in-process)
- **Pros**: máxima performance (sin serialización IPC entre procesos).
- **Contras**: riesgo de tumbar todo el proceso Node ante fallo nativo; despliegue y debugging más complejos.
- **Cuándo usar**: solo si priorizan latencia extrema y aceptan costo operacional alto.
### Opción D) gRPC local
- **Pros**: contratos claros, observabilidad, tooling.
- **Contras**: overhead y complejidad mayor para este tipo de motor de duelo de alta frecuencia.
### Recomendación de canal
1. Mantener procesos separados (aislamiento de fallos).
2. Migrar de stdio a **UDS + framing binario (MessagePack)**.
3. Reservar addon in-process para una fase posterior, solo si benchmarks reales lo exigen.
---
## Riesgos actuales visibles
- Deadlocks/lags intermitentes bajo carga por backpressure incompleto.
- Corrupción de stream por logs en `stdout` del proceso C++.
- Mensajes pendientes en buffer Node sin drenar de inmediato.
- Falla de arranque por `argv[1]` ausente o demasiado grande.
---
## Quick wins concretos (12 días)
1. Mover logs C++ de `stdout` a `stderr` y auditar todo uso de `std::cout` fuera de `send_message`.
2. Refactor de `DuelingState.processMessage()` para drenar en bucle.
3. Reemplazar retries temporizados por cola+`drain` en `Room.writeToCppProcess`.
4. Validar `argc` en `main.cpp` y emitir error estructurado por `stderr`.
---
## Referencias de código revisadas
- `src/edopro/room/domain/states/dueling/DuelingState.ts`
- `src/edopro/room/domain/Room.ts`
- `src/edopro/messages/JSONMessageProcessor.ts`
- `src/mercury/room/domain/MercuryRoom.ts`
- `core/src/main.cpp`
- `core/src/app/duel.cpp`
- `core/src/modules/shared/DuelTurnTimer.cpp`

View file

@ -1,69 +0,0 @@
# Node.js ↔ C++ IPC performance testing
This document adds reproducible local benchmarks for the child-process IPC channel.
## Included scripts
- `npm run perf:protocol`
- Microbenchmark for JSON serialization/parsing and length-prefixed frame encode/decode.
- `npm run perf:ipc`
- End-to-end IPC benchmark using a child-process echo worker that mimics current transport:
- parent writes line-delimited JSON commands,
- child replies with `uint32_le + json` frames.
## Environment variables
### `perf:protocol`
- `BENCH_ITERATIONS` (default `200000`)
- `BENCH_PAYLOAD_BYTES` (default `256`)
Example:
```bash
BENCH_ITERATIONS=500000 BENCH_PAYLOAD_BYTES=512 npm run perf:protocol
```
### `perf:ipc`
- `BENCH_MESSAGES` (default `20000`)
- `BENCH_CONCURRENCY` (default `512`)
- `BENCH_PAYLOAD_BYTES` (default `128`)
Example:
```bash
BENCH_MESSAGES=50000 BENCH_CONCURRENCY=1024 BENCH_PAYLOAD_BYTES=256 npm run perf:ipc
```
## Metrics reported
- Throughput (`throughputMsgPerSec`)
- End-to-end latency (`p50`, `p95`, `p99`)
- Backpressure stats (`drainCount`, `totalDrainWaitMs`)
## Suggested baseline workflow
1. Run both scripts on current branch.
2. Save JSON output artifacts.
3. Apply protocol changes (e.g. bidirectional length-prefix framing).
4. Repeat benchmarks with same env vars.
5. Compare p95/p99 latency and throughput deltas.
## Production metrics capture
You can enable periodic IPC metrics logs per room by setting:
```bash
IPC_METRICS_ENABLED=true
```
When enabled, each active EDO room logs a structured `IPC_METRICS` event every 60 seconds with:
- queue depth and max queue depth
- commands enqueued/written and stdin write errors
- drain count and total drain wait time
- stdout chunk/bytes counts
- processed frames, parse errors, and deferred ticks
Use this in production/staging to collect real IPC behavior and share snapshots for analysis.

107
docs/testing.md Normal file
View file

@ -0,0 +1,107 @@
# Testing conventions
How we write tests in EDOpro-server-ts: where they live, how we build test data, how we mock, and how we format them. The goal is one consistent pattern so any test reads like the one next to it.
> **Status:** Active standard. All tests are co-located under `src/` — the legacy `tests/` folder has been fully migrated and removed. New tests **must** follow this doc.
## Quick path — writing a new test
1. **Co-locate it.** Put `Thing.test.ts` next to `Thing.ts` inside `src/`. Never add tests to the root `tests/` folder.
2. **Build domain objects with a Mother.** Use (or create) an Object Mother for shared domain entities. Use a local `make*` factory only for stubs specific to that one suite.
3. **Mock infra with the shared doubles.** `LoggerMock`, `SocketMock`, `MessageRepositoryMock` for ubiquitous interfaces; `jest.mock()` for module singletons; `mock<T>()` (jest-mock-extended) for one-off interface mocks.
4. **Reset singletons** in `afterEach` (e.g. `WindbotModule.resetForTests()`, `JoinStrategyRegistry.reset()`).
5. **Format:** tab indentation — Biome applies it (`npm run format`), and your editor via `.editorconfig`.
## Where tests live
| Rule | Detail |
|------|--------|
| Co-location | `src/<feature>/Thing.test.ts` sits next to `src/<feature>/Thing.ts`. The test travels with the code it tests. |
| Shared test support | Mothers and mocks live in `src/test-support/` (`mothers/`, `mocks/`), importable from any co-located test. |
| Legacy `tests/` | Being phased out. Do not add to it. When you touch a module, migrate its legacy test to co-location. |
**Why co-location:** a mirrored `tests/` tree drifts from `src/` (it already did). The test next to the source is discovered together, refactored together, and never goes stale.
## Building test data
Decide by **what** you are building:
| You are building… | Use | Why |
|-------------------|-----|-----|
| A shared **domain entity** (Client, Player, Room, YGOProRoom, UserProfile, Game) | **Object Mother** | One canonical builder. Faker defaults expose hidden coupling; override only the field under test. |
| A **local stub** used by one suite (a fake repo, provider, socket) | **Inline `make*` factory** | Lightweight, self-contained, fresh per test. No need to share. |
### Object Mother (domain entities)
A static `create(params?: Partial<Props>)` returning a real domain object, with sensible faker-backed defaults:
```ts
export class ClientMother {
static create(params?: Partial<ClientMotherProps>): Client {
return new Client({
name: params?.name ?? faker.person.firstName(),
team: params?.team ?? faker.number.int({ min: 0, max: 1 }),
// ...other faker defaults
...params,
});
}
}
// usage — override only what the test cares about
const client = ClientMother.create({ id: "1" });
```
Rule: **one Mother per shared domain entity.** If an entity is built more than one way across the suite, that is a bug to consolidate (see Migration targets).
### Inline factory (local stubs)
For doubles that only one suite needs — compose with an `overrides` parameter:
```ts
const makeRepo = (overrides: Partial<BotlistRepository> = {}): BotlistRepository => ({
findAll: jest.fn().mockReturnValue([]),
findByName: jest.fn().mockReturnValue(null),
...overrides,
});
```
## Mocking
| Need | Use |
|------|-----|
| Ubiquitous infra interface (Logger, Socket, MessageRepository) | Shared **Mock class** from `src/test-support/mocks/` |
| A module-level singleton (e.g. `WebSocketSingleton`) | `jest.mock("...path...")` at the top of the file |
| A one-off interface mock | `mock<T>()` from **jest-mock-extended** (type-safe, no hand-rolled `jest.fn()` objects) |
| A spy on a real method | `jest.spyOn(obj, "method")` |
**Pick one for one-off interface mocks: `mock<T>()`.** Do not hand-roll ad-hoc `jest.fn()` stub objects for interfaces a Mock class or `mock<T>()` already covers.
Always reset shared singleton state in `afterEach` so suites don't leak into each other.
## Formatting & naming
| Topic | Decision |
|-------|----------|
| Indentation | **Tabs**, enforced by Biome (`biome.json`) and auto-applied on commit via lint-staged (`biome check --write`). |
| `describe` | The unit under test: `describe("WindbotModule")` or `describe("WindbotModule.requestBot()")`. |
| `it` | Behavior, present tense, no "should": `it("throws when the token is missing")`. |
| No scaffolding | No `PR-N` / `REQ-XXX` labels in comments or test names. Comments explain *why*, not *what*. |
## PR checklist
- [ ] Test is co-located in `src/`, next to its source.
- [ ] Shared domain objects built via a Mother; local stubs via `make*` factories.
- [ ] Infra mocked via shared Mock classes / `mock<T>()`; singletons reset in `afterEach`.
- [ ] Tab indentation; `describe`/`it` follow the naming convention.
- [ ] `npm run lint` and the test suite pass.
## Migration targets (incremental)
Known consolidation work, done opportunistically as modules are touched:
| Target | Status |
|--------|--------|
| `YGOProRoom` built two ways | ✅ Consolidated onto `YGOProRoomMother`. |
| Mothers & mocks under `tests/` | ✅ Moved to `src/test-support/`. |
| Tab formatting | ✅ Enforced by Biome (`biome check --write` on pre-commit via lint-staged). |
| 26 legacy tests in `tests/` | ✅ Migrated to co-location; `tests/` removed and dropped from `jest roots`. |

View file

@ -1,132 +0,0 @@
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
import importPlugin from "eslint-plugin-import";
import jestPlugin from "eslint-plugin-jest";
import globals from "globals";
export default tseslint.config(
// Base ESLint recommended config
eslint.configs.recommended,
// TypeScript ESLint recommended configs (only recommended, not stylistic)
...tseslint.configs.recommended,
// Global configuration
{
languageOptions: {
globals: {
...globals.node,
...globals.es2022,
},
parserOptions: {
project: "./tsconfig.eslint.json",
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
// Disable empty interface/object check globally
"@typescript-eslint/no-empty-interface": "off",
"@typescript-eslint/no-empty-object-type": "off",
},
},
// TypeScript files configuration
{
files: ["**/*.ts", "**/*.tsx"],
plugins: {
import: importPlugin,
},
settings: {
"import/parsers": {
"@typescript-eslint/parser": [".ts", ".tsx"],
},
"import/resolver": {
typescript: {
alwaysTryTypes: true,
project: "./tsconfig.eslint.json",
},
},
},
rules: {
// Import rules
"import/no-unresolved": "error",
// TypeScript rules - more permissive to match previous config
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": "off", // Completely disable to match previous behavior
"@typescript-eslint/no-empty-interface": "off", // Allow empty interfaces
// Disable stylistic rules that were causing errors
"@typescript-eslint/array-type": "off",
"@typescript-eslint/consistent-type-definitions": "off",
"@typescript-eslint/consistent-type-assertions": "off",
"@typescript-eslint/consistent-indexed-object-style": "off",
"@typescript-eslint/prefer-nullish-coalescing": "off",
"@typescript-eslint/prefer-regexp-exec": "off",
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-call": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unsafe-return": "off",
"@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/no-unsafe-enum-comparison": "off",
"@typescript-eslint/no-unused-expressions": "off",
"@typescript-eslint/restrict-template-expressions": "off",
"@typescript-eslint/no-unnecessary-condition": "off",
// Keep important rules enabled
"@typescript-eslint/no-floating-promises": "off",
"@typescript-eslint/no-misused-promises": "off",
"@typescript-eslint/await-thenable": "off",
"@typescript-eslint/require-await": "off",
"@typescript-eslint/no-unnecessary-type-assertion": "off",
// General code quality rules
"no-console": "off",
"no-debugger": "error",
"prefer-const": "error",
"no-var": "error",
"no-await-in-loop": "off",
"no-use-before-define": "off",
},
},
// Test files configuration
{
files: ["**/*.test.ts", "**/*.spec.ts", "tests/**/*.ts"],
plugins: {
jest: jestPlugin,
},
languageOptions: {
globals: {
...globals.jest,
},
},
rules: {
...jestPlugin.configs.recommended.rules,
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-call": "off",
"jest/no-identical-title": "off",
"jest/valid-title": "off",
},
},
// Ignore patterns
{
ignores: [
"node_modules/**",
"dist/**",
"build/**",
"coverage/**",
"*.config.js",
"*.config.mjs",
"core/**",
"databases/**",
"repositories/**",
".agents/**",
"resources/**",
],
},
);

File diff suppressed because it is too large Load diff

View file

@ -6,9 +6,9 @@ module.exports = {
testEnvironment: "node",
modulePaths: [compilerOptions.baseUrl],
moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths),
roots: ["<rootDir>/src", "<rootDir>/tests"],
roots: ["<rootDir>/src"],
maxWorkers: "50%",
transform: {
"^.+\\.tsx?$": "ts-jest",
"^.+\\.tsx?$": ["ts-jest", { tsconfig: "tsconfig.test.json" }],
},
};

24384
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,99 +1,89 @@
{
"engines": {
"node": ">=24.11.0"
},
"name": "edopro",
"version": "2.13.2",
"description": "",
"main": "index.js",
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"dev": "ts-node-dev --inspect=0.0.0.0:9229 -r tsconfig-paths/register --respawn --poll --require dotenv/config src/index.ts ",
"build": "tsc && tsc-alias -p tsconfig.json",
"start": "npm run build && node --env-file=.env ./dist/src/index.js",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"prepare": "husky",
"create-user": "ts-node-dev -r tsconfig-paths/register --respawn --poll --require dotenv/config src/utils/create-user.ts",
"genesys": "ts-node-dev -r tsconfig-paths/register --respawn --poll --require dotenv/config src/utils/generate-genesys-format-json.ts",
"generate-mercury-pre-releases-cdb": "ts-node -r tsconfig-paths/register --require dotenv/config src/utils/generate-mercury-pre-releases-cdb.ts",
"migrate-users": "ts-node-dev -r tsconfig-paths/register --respawn --poll --require dotenv/config src/utils/migrate-user-from-redis-to-postgres.ts",
"migration:generate": "ts-node -r tsconfig-paths/register --require dotenv/config ./node_modules/typeorm/cli.js migration:generate -d ./src/evolution-types/src/data-source.ts ./src/evolution-types/src/migrations/$npm_config_name",
"migration:run": "ts-node -r tsconfig-paths/register --require dotenv/config ./node_modules/typeorm/cli.js migration:run -d ./src/evolution-types/src/data-source.ts",
"migration:revert": "ts-node -r tsconfig-paths/register --require dotenv/config ./node_modules/typeorm/cli.js migration:revert -d ./src/evolution-types/src/data-source.ts",
"perf:protocol": "ts-node -r tsconfig-paths/register src/utils/perf/benchmark-protocol.ts",
"perf:ipc": "ts-node -r tsconfig-paths/register src/utils/perf/benchmark-node-cpp-ipc.ts"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@commitlint/cli": "^20.1.0",
"@commitlint/config-conventional": "^20.0.0",
"@eslint/compat": "^2.0.0",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.39.1",
"@faker-js/faker": "^9.2.0",
"@types/bcrypt": "^6.0.0",
"@types/better-sqlite3": "^7.6.13",
"@types/express": "^5.0.5",
"@types/jest": "^30.0.0",
"@types/node": "^24.10.1",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.48.0",
"@typescript-eslint/parser": "^8.48.0",
"eslint": "^9.39.1",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jest": "^29.2.1",
"globals": "^16.5.0",
"husky": "^9.1.7",
"jest": "^30.2.0",
"jest-mock-extended": "^4.0.0",
"lint-staged": "^16.2.7",
"prettier": "^3.6.2",
"swc-loader": "^0.2.6",
"ts-jest": "^29.4.5",
"ts-node-dev": "^2.0.0",
"tsc-alias": "^1.8.16",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.48.0"
},
"dependencies": {
"@types/shuffle-array": "^1.0.5",
"array-shuffle": "^4.0.0",
"async-mutex": "^0.5.0",
"bcrypt": "^6.0.0",
"better-lock": "^3.2.0",
"better-sqlite3": "^12.6.2",
"cheerio": "^1.1.2",
"diod": "^3.0.0",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"ioredis": "^5.8.2",
"koishipro-core.js": "^1.4.4",
"load-json-file": "^7.0.1",
"lzma-native": "^8.0.6",
"nfkit": "^1.0.37",
"pg": "^8.16.3",
"pino": "^10.1.0",
"pino-pretty": "^13.1.2",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"shuffle-array": "^1.0.1",
"simple-git": "^3.30.0",
"typeorm": "^0.3.27",
"winston": "^3.18.3",
"ws": "^8.18.3",
"ygopro-deck-encode": "^1.0.16",
"ygopro-lflist-encode": "^1.0.3",
"ygopro-msg-encode": "^1.2.2",
"yuzuthread": "^1.0.10",
"zod": "^4.3.6"
},
"lint-staged": {
"*.(js|ts)": "npm run lint:fix"
}
"engines": {
"node": ">=24.11.0"
},
"name": "edopro",
"version": "2.14.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"dev": "ts-node-dev --inspect=0.0.0.0:9229 -r tsconfig-paths/register --respawn --poll --require dotenv/config src/index.ts ",
"clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
"build": "npm run clean && tsc && tsc-alias -p tsconfig.json",
"start": "npm run build && node --env-file=.env ./dist/src/index.js",
"lint": "biome lint",
"lint:fix": "biome check --write",
"format": "biome format --write",
"check": "biome check",
"prepare": "husky",
"create-user": "ts-node-dev -r tsconfig-paths/register --respawn --poll --require dotenv/config src/utils/create-user.ts",
"migrate-users": "ts-node-dev -r tsconfig-paths/register --respawn --poll --require dotenv/config src/utils/migrate-user-from-redis-to-postgres.ts",
"migration:generate": "ts-node -r tsconfig-paths/register --require dotenv/config ./node_modules/typeorm/cli.js migration:generate -d ./src/evolution-types/src/data-source.ts ./src/evolution-types/src/migrations/$npm_config_name",
"migration:run": "ts-node -r tsconfig-paths/register --require dotenv/config ./node_modules/typeorm/cli.js migration:run -d ./src/evolution-types/src/data-source.ts",
"migration:revert": "ts-node -r tsconfig-paths/register --require dotenv/config ./node_modules/typeorm/cli.js migration:revert -d ./src/evolution-types/src/data-source.ts",
"perf:protocol": "ts-node -r tsconfig-paths/register src/utils/perf/benchmark-protocol.ts",
"perf:ipc": "ts-node -r tsconfig-paths/register src/utils/perf/benchmark-node-cpp-ipc.ts"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@biomejs/biome": "2.5.0",
"@commitlint/cli": "^20.1.0",
"@commitlint/config-conventional": "^20.0.0",
"@faker-js/faker": "^9.2.0",
"@types/bcrypt": "^6.0.0",
"@types/better-sqlite3": "^7.6.13",
"@types/express": "^5.0.5",
"@types/jest": "^30.0.0",
"@types/node": "^24.10.1",
"@types/ws": "^8.18.1",
"husky": "^9.1.7",
"jest": "^30.2.0",
"jest-mock-extended": "^4.0.1",
"lint-staged": "^16.2.7",
"swc-loader": "^0.2.6",
"ts-jest": "^29.4.11",
"ts-node-dev": "^2.0.0",
"tsc-alias": "^1.8.16",
"tsconfig-paths": "^4.2.0",
"typescript": "^6.0.3"
},
"dependencies": {
"@types/shuffle-array": "^1.0.5",
"array-shuffle": "^4.0.0",
"async-mutex": "^0.5.0",
"bcrypt": "^6.0.0",
"better-lock": "^3.2.0",
"better-sqlite3": "^12.6.2",
"cheerio": "^1.1.2",
"diod": "^3.0.0",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"ioredis": "^5.8.2",
"koishipro-core.js": "^1.5.2",
"load-json-file": "^7.0.1",
"lzma-native": "^8.0.6",
"nfkit": "^1.0.37",
"pg": "^8.16.3",
"pino": "^10.1.0",
"pino-pretty": "^13.1.2",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"shuffle-array": "^1.0.1",
"simple-git": "^3.30.0",
"typeorm": "^0.3.27",
"winston": "^3.18.3",
"ws": "^8.18.3",
"ygopro-deck-encode": "^1.0.16",
"ygopro-lflist-encode": "^1.0.3",
"ygopro-msg-encode": "^1.3.0",
"yuzuthread": "^1.0.10",
"zod": "^4.3.6"
},
"lint-staged": {
"*.{js,ts,mjs,cjs,json,jsonc}": "biome check --write --no-errors-on-unmatched"
}
}

215
resources.manifest.json Normal file
View file

@ -0,0 +1,215 @@
{
"comment": "Declarative resource manifest. Edit this file to add, remove, or change sources. See docs for schema.",
"sources": [
{
"id": "edopro-cdbs",
"type": "git",
"url": "https://github.com/ProjectIgnis/BabelCDB.git",
"branch": "master"
},
{
"id": "edopro-scripts",
"type": "git",
"url": "https://github.com/ProjectIgnis/CardScripts.git",
"branch": "master"
},
{
"id": "edopro-lflists",
"type": "git",
"url": "https://github.com/ProjectIgnis/LFLists",
"branch": "master"
},
{
"id": "evolution-lflists",
"type": "git",
"url": "https://github.com/termitaklk/lflist",
"branch": "main"
},
{
"id": "evolution-assets",
"type": "git",
"url": "https://github.com/diangogav/evolution-assets",
"branch": "main"
},
{
"id": "ygopro-moecube-cdb",
"type": "http",
"url": "https://cdntx.moecube.com/ygopro-database/zh-CN/cards.cdb",
"filename": "ygopro-moecube-cards.cdb"
},
{
"id": "ygopro-fluorohydride-scripts",
"type": "git",
"url": "https://github.com/Fluorohydride/ygopro-scripts"
},
{
"id": "moecube-lflist",
"type": "http",
"url": "https://cdntx.moecube.com/ygopro-database/zh-CN/lflist.conf",
"filename": "moecube-lflist.conf"
},
{
"id": "ygopro-moecube-prereleases",
"type": "git",
"url": "https://github.com/evolutionygo/pre-release-database-cdb",
"branch": "master"
},
{
"id": "custom-cards",
"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": [
{
"comment": "EDOPro lflists",
"target": "edopro/lflists",
"from": "edopro-lflists"
},
{
"comment": "EDOPro evolution lflists",
"target": "edopro/evolution-lflists",
"from": "evolution-lflists"
},
{
"comment": "EDOPro databases",
"target": "edopro/databases",
"from": "edopro-cdbs"
},
{
"comment": "EDOPro card scripts",
"target": "edopro/scripts",
"from": "edopro-scripts"
},
{
"comment": "YGOPro base lflist",
"target": "ygopro/base/lflist.conf",
"from": "moecube-lflist",
"file": "moecube-lflist.conf"
},
{
"comment": "JTP lflist from evolution-assets",
"target": "ygopro/formats/jtp/lflist.conf",
"from": "evolution-assets",
"file": "lflist/jtp.lflist.conf"
},
{
"comment": "JTP Advanced March 2007 lflist from evolution-assets",
"target": "ygopro/formats/jtp-adv-2007-03/lflist.conf",
"from": "evolution-assets",
"file": "lflist/jtp-advanced-marzo-2007.lflist.conf"
},
{
"comment": "Genesys lflist from evolution-assets",
"target": "ygopro/formats/genesys/lflist.conf",
"from": "evolution-assets",
"file": "lflist/genesys.lflist.conf"
},
{
"comment": "Edison lflist from evolution-assets",
"target": "ygopro/formats/edison/lflist.conf",
"from": "evolution-assets",
"file": "lflist/edison.lflist.conf"
},
{
"comment": "ocgcore fork WASM — placed at ygopro/core/ocgcore-worker; read by the card-load worker when (re)building card storage",
"target": "ygopro/core/ocgcore-worker",
"from": "edison-core",
"file": "ocgcore-worker"
},
{
"target": "ygopro/formats/md/lflist.conf",
"from": "evolution-lflists",
"file": "MD.2025.03.lflist.conf"
},
{
"target": "ygopro/formats/world/lflist.conf",
"from": "edopro-lflists",
"file": "World.lflist.conf"
},
{
"target": "ygopro/formats/tengu/lflist.conf",
"from": "evolution-lflists",
"file": "Tengu.Plant.lflist.conf"
},
{
"target": "ygopro/formats/speed/lflist.conf",
"from": "edopro-lflists",
"file": "Speed.lflist.conf"
},
{
"target": "ygopro/formats/rush/lflist.conf",
"from": "edopro-lflists",
"file": "Rush.lflist.conf"
},
{
"target": "ygopro/formats/goat/lflist.conf",
"from": "edopro-lflists",
"file": "GOAT.lflist.conf"
},
{
"target": "ygopro/formats/ocg/lflist.conf",
"from": "edopro-lflists",
"file": "OCG.lflist.conf"
},
{
"comment": "YGOPro base cards.cdb",
"target": "ygopro/base/cards.cdb",
"from": "ygopro-moecube-cdb",
"file": "ygopro-moecube-cards.cdb"
},
{
"comment": "YGOPro base scripts",
"target": "ygopro/base/script",
"from": "ygopro-fluorohydride-scripts"
},
{
"comment": "Classic (pre-errata) variants for edison/hat — alt-coded cards, format legality via lflists",
"target": "ygopro/classic/classic.cdb",
"from": "evolution-assets",
"file": "cdb/classic.cdb"
},
{
"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",
"file": "cdb/pre-errata.es.cdb"
},
{
"comment": "YGOPro Moecube prereleases (extended pool only)",
"target": "ygopro/extensions/prereleases",
"from": "ygopro-moecube-prereleases"
},
{
"comment": "YGOPro cards art (extended pool only)",
"target": "ygopro/extensions/custom-cards",
"from": "custom-cards"
}
],
"runtime": {
"comment": "TS loader pool membership. standard = base + served formats (load order; base first for FIRST-occurrence dedup). extended = extra members appended after standard. Formats assembled but omitted here (goat, rush, speed, gx, mdc) are assembled-but-not-served by design.",
"ygopro": {
"standard": [
"base",
"formats/jtp",
"formats/jtp-adv-2007-03",
"formats/genesys",
"formats/md",
"formats/world",
"formats/tengu",
"formats/edison",
"formats/hat",
"formats/ocg"
],
"extended": ["extensions/prereleases", "extensions/custom-cards"]
}
}
}

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"
}
]
}

58
scripts/clone_repositories.sh Executable file
View file

@ -0,0 +1,58 @@
#!/usr/bin/env bash
# clone_repositories.sh — FETCH stage: validate manifest, then fetch all sources.
#
# Stage: VALIDATE (RSM-005) then FETCH (RSM-006, RSM-007).
# Runs from repo root (D1 cwd invariant — no cd into subdirs).
# All checkout dirs are keyed by source id: repositories/<id> (D8).
# Sources are read exclusively from resources.manifest.json (RSM-010).
#
# Usage: bash clone_repositories.sh
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/resources-lib.sh"
echo "Validating manifest..."
validate_manifest "$MANIFEST_PATH" || exit 1
echo "Fetching sources..."
# Support test override for repos root; default matches setup_resources.sh.
REPOS="${REPOS_ROOT:-./repositories}"
mkdir -p "$REPOS"
# Iterate all sources from the manifest
source_count=$(manifest_get '.sources | length')
i=0
while [ "$i" -lt "$source_count" ]; do
src_id=$(manifest_get ".sources[$i].id")
src_type=$(manifest_get ".sources[$i].type")
src_url=$(manifest_get ".sources[$i].url")
case "$src_type" in
git)
# Optional branch — jq returns "null" when the field is absent
src_branch=$(manifest_get ".sources[$i].branch // \"\"")
echo " [git] $src_id"
sync_repo "$src_id" "$src_url" "$src_branch"
;;
http)
src_filename=$(manifest_get ".sources[$i].filename")
local_path="$REPOS/$src_filename"
echo " [http] $src_id -> $local_path"
wget -qO "$local_path" "$src_url" || fail "wget failed for $src_id ($src_url)"
http_integrity_check "$local_path" "$src_id" "$src_url" \
|| fail "Integrity check failed for $src_id — aborting fetch stage"
;;
*)
fail "Unknown source type '$src_type' for source '$src_id'"
;;
esac
i=$((i + 1))
done
echo "Repositories synced."

18
scripts/entrypoint.sh Normal file
View file

@ -0,0 +1,18 @@
#!/bin/bash
# Container entrypoint: run the resource updater loop in the background so card
# databases / banlists refresh live (picked up by the in-memory reload), and run
# the server as the main foreground process. The image ships a baked resource
# seed, so the server boots immediately while the first refresh runs in the
# background. dumb-init (PID 1) forwards signals to the server and reaps the loop.
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

444
scripts/resources-lib.sh Normal file
View file

@ -0,0 +1,444 @@
#!/usr/bin/env bash
# resources-lib.sh — Shared library for clone_repositories.sh and setup_resources.sh
#
# Source this file; do NOT execute it directly.
# Resolved manifest path is cwd-invariant: callers may set MANIFEST_PATH to override.
#
# Usage:
# source "$(dirname "${BASH_SOURCE[0]}")/resources-lib.sh"
# ---------------------------------------------------------------------------
# fail — log an error to stderr and exit 1
# ---------------------------------------------------------------------------
fail() {
echo "[resources][ERROR] $*" >&2
exit 1
}
# ---------------------------------------------------------------------------
# Default manifest path — resolved relative to CWD (repo root).
# All resource scripts must run from repo root (D1 cwd invariant).
# The manifest lives at repo root; scripts now live in scripts/ but the
# manifest stays at root, so a CWD-relative default is correct here.
# Callers may override by setting MANIFEST_PATH before sourcing.
# ---------------------------------------------------------------------------
MANIFEST_PATH="${MANIFEST_PATH:-resources.manifest.json}"
# ---------------------------------------------------------------------------
# jq preflight — fail fast with a clear message if jq is not available
# ---------------------------------------------------------------------------
_check_jq() {
command -v jq >/dev/null 2>&1 || fail "jq is required but not found in PATH. Install jq before running resource scripts."
}
_check_jq
# ---------------------------------------------------------------------------
# Private override merge — keeps PRIVATE git sources (private repos, e.g. the
# pre-errata scripts) 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]
# ---------------------------------------------------------------------------
manifest_get() {
local filter="$1"
local path="${2:-$MANIFEST_PATH}"
jq -r "$filter" "$path"
}
# ---------------------------------------------------------------------------
# validate_manifest — RSM-005 a-e fail-fast validation
# Usage: validate_manifest <manifest-path>
# Exits 0 on success, 1 on any violation (with message to stderr).
# ---------------------------------------------------------------------------
validate_manifest() {
local manifest="${1:-$MANIFEST_PATH}"
# (a) Well-formed JSON
if ! jq empty "$manifest" 2>/dev/null; then
echo "[resources][ERROR] Manifest validation failed (a): malformed JSON in $manifest" >&2
return 1
fi
# (structural guard) sources and assembly must be arrays
local sources_type assembly_type
sources_type=$(jq -r '(.sources | type)' "$manifest" 2>/dev/null)
if [ $? -ne 0 ] || [ "$sources_type" != "array" ]; then
echo "[resources][ERROR] Manifest validation failed (structural): .sources must be an array, got ${sources_type:-error}" >&2
return 1
fi
assembly_type=$(jq -r '(.assembly | type)' "$manifest" 2>/dev/null)
if [ $? -ne 0 ] || [ "$assembly_type" != "array" ]; then
echo "[resources][ERROR] Manifest validation failed (structural): .assembly must be an array, got ${assembly_type:-error}" >&2
return 1
fi
# (structural guard) every assembly[] element must be an object — a bare string,
# number, or null would pass the array check but silently produce wrong behaviour
# when later jq queries assume object keys (.target, .from, etc.). Name the index.
local non_object_elements
non_object_elements=$(jq -r '[.assembly | to_entries[] | select((.value | type) != "object") | .key] | .[]' "$manifest" 2>/dev/null)
if [ -n "$non_object_elements" ]; then
echo "[resources][ERROR] Manifest validation failed (structural): assembly element(s) at index ${non_object_elements} must be objects, not $(jq -r ".assembly[${non_object_elements}] | type" "$manifest" 2>/dev/null)" >&2
return 1
fi
# (RSM-001) assembly[] target must be non-empty — a rule with a missing/empty
# target resolves to a literal "null/" (or bare) publish dir. Name the rule index.
local empty_targets
empty_targets=$(jq -r '[.assembly | to_entries[] | select((.value.target // "") == "") | .key] | .[]' "$manifest" 2>/dev/null)
if [ -n "$empty_targets" ]; then
echo "[resources][ERROR] Manifest validation failed (RSM-001): assembly rule(s) with empty/missing target at index: $empty_targets" >&2
return 1
fi
# (RSM-001) sources[] field validation
# (1) every source id must be non-empty
local empty_ids
empty_ids=$(jq -r '[.sources[] | select((.id // "") == "")] | length' "$manifest" 2>/dev/null)
if [ "$empty_ids" != "0" ]; then
echo "[resources][ERROR] Manifest validation failed (RSM-001): source with empty/missing id in $manifest" >&2
return 1
fi
# (2) source ids must be unique
local dup_ids
dup_ids=$(jq -r '[.sources[].id] | (group_by(.) | map(select(length > 1) | .[0])) | .[]' "$manifest" 2>/dev/null)
if [ -n "$dup_ids" ]; then
echo "[resources][ERROR] Manifest validation failed (RSM-001): duplicate source id(s): $dup_ids" >&2
return 1
fi
# (3) every source url must be non-empty
local empty_urls
empty_urls=$(jq -r '[.sources[] | select((.url // "") == "") | .id] | .[]' "$manifest" 2>/dev/null)
if [ -n "$empty_urls" ]; then
echo "[resources][ERROR] Manifest validation failed (RSM-001): empty/missing url in source(s): $empty_urls" >&2
return 1
fi
# (4) http sources must include a non-empty filename
local missing_filename
missing_filename=$(jq -r '[.sources[] | select(.type == "http") | select((.filename // "") == "") | .id] | .[]' "$manifest" 2>/dev/null)
if [ -n "$missing_filename" ]; then
echo "[resources][ERROR] Manifest validation failed (RSM-001): http source(s) missing filename: $missing_filename" >&2
return 1
fi
# (5) whole-source fallback is unsupported in this slice — an assembly rule
# whose from is an array MUST specify a file
local array_no_file
array_no_file=$(jq -r '[.assembly[] | select((.from | type) == "array") | select(has("file") | not) | .target] | .[]' "$manifest" 2>/dev/null)
if [ -n "$array_no_file" ]; then
echo "[resources][ERROR] Manifest validation failed (RSM-001): array from without file (whole-source fallback unsupported) in rule(s): $array_no_file" >&2
return 1
fi
# (6) An http source resolves to a flat file under repositories/ (its "source
# dir" is repositories/ itself), so a rule referencing an http source
# without a `file` key would copy the whole repositories/ tree. Reject any
# rule whose from (string or any array element) points at an http source
# when the rule has no `file`. Name the offending rule target.
local http_no_file
http_no_file=$(jq -r '
(.sources | map(select(.type == "http") | .id)) as $http |
[
.assembly[] |
select(has("file") | not) |
.target as $t |
.from as $from |
(if ($from | type) == "array" then $from[] else $from end) |
select(. as $f | ($http | index($f)) != null) |
$t
] | unique | .[]
' "$manifest" 2>/dev/null)
if [ -n "$http_no_file" ]; then
echo "[resources][ERROR] Manifest validation failed (RSM-001): rule(s) referencing an http source without a file key (whole-source/dir/only over a flat http file unsupported): $http_no_file" >&2
return 1
fi
# (7) An http source lands as a flat file named <filename> under repositories/,
# so a rule whose resolved from (string or array element) is an http source
# MUST reference that exact file via `file` == source.filename. A mismatch
# would look for a non-existent file. Name the rule target + source id.
local http_file_mismatch
http_file_mismatch=$(jq -r '
(.sources | map(select(.type == "http") | {id, filename})) as $http |
($http | map(.id)) as $http_ids |
[
.assembly[] |
.target as $t |
(.file // "") as $rfile |
.from as $from |
(if ($from | type) == "array" then $from[] else $from end) |
select(. as $f | ($http_ids | index($f)) != null) as $sid |
($http[] | select(.id == $sid)) as $src |
select($rfile != $src.filename) |
($t + " (source " + $sid + ": file=\"" + $rfile + "\" != filename=\"" + ($src.filename // "") + "\")")
] | unique | .[]
' "$manifest" 2>/dev/null)
if [ -n "$http_file_mismatch" ]; then
echo "[resources][ERROR] Manifest validation failed (RSM-001): rule file must equal http source filename: $http_file_mismatch" >&2
return 1
fi
# (b) No unknown source types — only "git" and "http" are allowed.
# Use (.type|tostring) so non-string type values (e.g. integers) are safely
# converted rather than causing jq to error. Capture jq exit status explicitly
# so a jq crash (non-zero exit) is treated as a hard validation failure
# instead of silently passing (fail-open prevention).
local bad_types jq_rc
bad_types=$(jq -r '[.sources[] | select((.type|tostring) != "git" and (.type|tostring) != "http")] | map(.id + " (type=" + (.type|tostring) + ")") | .[]' "$manifest" 2>/dev/null)
jq_rc=$?
if [ $jq_rc -ne 0 ]; then
echo "[resources][ERROR] Manifest validation failed (b): jq error while checking source types (exit $jq_rc)" >&2
return 1
fi
if [ -n "$bad_types" ]; then
echo "[resources][ERROR] Manifest validation failed (b): unknown source type(s): $bad_types" >&2
return 1
fi
# (c) No dangling from-ids — every from value (string or array) must reference a declared source id
local dangling
dangling=$(jq -r '
(.sources | map(.id)) as $ids |
[
.assembly[] |
.from as $from |
if ($from | type) == "array" then $from[] else $from end |
select(. as $f | ($ids | index($f)) == null)
] | unique | .[]
' "$manifest" 2>/dev/null)
if [ -n "$dangling" ]; then
echo "[resources][ERROR] Manifest validation failed (c): dangling from-id(s): $dangling" >&2
return 1
fi
# (d) file must be mutually exclusive with dir and only
local bad_combos
bad_combos=$(jq -r '
[
.assembly[] |
select(
(has("file") and (has("dir") or has("only")))
) |
.target
] | .[]
' "$manifest" 2>/dev/null)
if [ -n "$bad_combos" ]; then
echo "[resources][ERROR] Manifest validation failed (d): file+dir or file+only combo in rule(s): $bad_combos" >&2
return 1
fi
# (e) Target collision — two rules with the same target are only legal if the
# later rule (higher array index) has overwrite:true
local collision_targets
collision_targets=$(jq -r '
[
.assembly | to_entries |
group_by(.value.target) |
.[] |
select(length > 1) |
. as $group |
# All entries after the first must carry overwrite:true
# If any entry after the first does NOT have overwrite:true, report the target
if ($group[1:] | map(select(.value.overwrite != true)) | length) > 0
then $group[0].value.target
else empty
end
] | .[]
' "$manifest" 2>/dev/null)
if [ -n "$collision_targets" ]; then
echo "[resources][ERROR] Manifest validation failed (e): target collision without overwrite: $collision_targets" >&2
return 1
fi
return 0
}
# ---------------------------------------------------------------------------
# http_integrity_check — RSM-006
# Usage: http_integrity_check <filepath> <source-id> <url>
# Checks: non-empty AND (for *.cdb) SQLite magic header.
# ---------------------------------------------------------------------------
http_integrity_check() {
local filepath="$1"
local src_id="$2"
local url="$3"
# (a) Non-empty
if [ ! -s "$filepath" ]; then
echo "[resources][ERROR] Integrity check failed for $src_id ($url): file is empty" >&2
return 1
fi
# (b) For .cdb files: first 16 bytes must be the SQLite3 magic header.
# Case-insensitive match (${filepath,,}) so both .cdb and .CDB trigger the check.
# (D4) Binary comparison via cmp — command substitution strips the trailing
# NUL byte, so string comparison would only check 15 of 16 magic bytes and
# accept a file truncated to exactly "SQLite format 3". cmp compares the raw
# first 16 bytes; a file shorter than 16 bytes fails because cmp reports EOF.
case "${filepath,,}" in
*.cdb)
if ! head -c 16 "$filepath" | cmp -s - <(printf 'SQLite format 3\000'); then
echo "[resources][ERROR] Integrity check failed for $src_id ($url): not a valid SQLite3 .cdb (bad magic header)" >&2
return 1
fi
;;
esac
return 0
}
# ---------------------------------------------------------------------------
# apply_rule — RSM-002/003 assembly rule executor
# Usage: apply_rule <src_dir> <dir> <file> <only> <target>
# src_dir : resolved source directory (repositories/<id>)
# dir : subdirectory within source (may be empty)
# file : single file path within source (may be empty)
# only : glob pattern for find -name (may be empty)
# target : destination path relative to staging root (caller provides full path)
#
# All four rule shapes (whole-source / dir / file / only-glob) are handled.
# find -name is used for glob to handle filenames with spaces and parentheses.
# Overwrite legality is enforced at validate time (RSM-004); at copy time cp
# overwrites unconditionally, so no per-rule overwrite flag is consumed here.
# A missing single-file, or a missing named subdirectory, is a hard failure
# (exit 1) — distinct from an empty glob match in an existing dir, which is a
# non-fatal exit 0 per RSM-002.
# ---------------------------------------------------------------------------
apply_rule() {
local src_dir="$1"
local dir="$2"
local file="$3"
local only="$4"
local target="$5"
if [ -n "$file" ]; then
# Single-file copy — target is the full destination file path (including name)
local src_file="$src_dir/$file"
if [ ! -f "$src_file" ]; then
echo "[resources][ERROR] apply_rule: file '$file' not found in source '$src_dir'" >&2
return 1
fi
mkdir -p "$(dirname "$target")"
cp "$src_file" "$target"
elif [ -n "$only" ]; then
# Glob via find -name — handles spaces and parentheses safely
local search_root="$src_dir"
if [ -n "$dir" ]; then
search_root="$src_dir/$dir"
# Missing named subdirectory is a hard failure (distinct from empty glob)
if [ ! -d "$search_root" ]; then
echo "[resources][ERROR] apply_rule: subdirectory '$dir' not found in source '$src_dir'" >&2
return 1
fi
fi
mkdir -p "$target"
# find copies nothing when there are no matches — non-fatal per RSM-002
find "$search_root" -maxdepth 1 -type f -name "$only" -exec cp -t "$target/" {} +
elif [ -n "$dir" ]; then
# Directory-only copy
local src_subdir="$src_dir/$dir"
if [ ! -d "$src_subdir" ]; then
echo "[resources][ERROR] apply_rule: subdirectory '$dir' not found in source '$src_dir'" >&2
return 1
fi
mkdir -p "$target"
cp -r "$src_subdir/." "$target/"
else
# Whole-source copy
mkdir -p "$target"
cp -r "$src_dir/." "$target/"
fi
}
# ---------------------------------------------------------------------------
# apply_rule_with_fallback — RSM-003 ordered from[] fallback chain
# Usage: apply_rule_with_fallback <file> <target> <src_dir1> [<src_dir2> ...]
# file : file name to look for in each source dir
# target : destination file path (full path)
# src_dirN: one or more source directories to try in order
#
# Uses the first source that contains the file. Aborts if no source has it.
# ---------------------------------------------------------------------------
apply_rule_with_fallback() {
local file="$1"
local target="$2"
shift 2
local sources=("$@")
for src_dir in "${sources[@]}"; do
local candidate="$src_dir/$file"
if [ -f "$candidate" ]; then
mkdir -p "$(dirname "$target")"
cp "$candidate" "$target"
return 0
fi
done
echo "[resources][ERROR] Fallback chain exhausted: '$file' not found in any of: ${sources[*]}" >&2
return 1
}
# ---------------------------------------------------------------------------
# sync_repo — RSM-007 git source behavior (moved verbatim from clone_repositories.sh)
# Usage: sync_repo <id> <url> <branch>
# id : source id — used as checkout directory name (repositories/<id>)
# url : git remote url
# branch : branch name, or "" to use remote default branch
#
# Checkout dir is repositories/<id> relative to repo root (D1/D8: id-keyed, cwd-invariant).
# Caller must run from repo root.
# ---------------------------------------------------------------------------
sync_repo() {
local id="$1"
local url="$2"
local branch="$3"
# Checkout root honors REPOS_ROOT (default ./repositories), matching
# setup_resources.sh, so both stages agree on where sources land.
local repos_root="${REPOS_ROOT:-./repositories}"
local dir="$repos_root/$id"
if [ -d "$dir/.git" ]; then
if [ -n "$branch" ]; then
git -C "$dir" fetch --depth 1 origin "$branch" >/dev/null 2>&1 &&
git -C "$dir" reset --hard FETCH_HEAD >/dev/null 2>&1 && return 0
else
git -C "$dir" fetch --depth 1 >/dev/null 2>&1 &&
git -C "$dir" reset --hard "@{u}" >/dev/null 2>&1 && return 0
fi
echo "[resources] update failed for $id — re-cloning"
rm -rf "$dir"
fi
# Directory exists without .git (partial checkout, etc.) — start clean
[ -d "$dir" ] && rm -rf "$dir"
if [ -n "$branch" ]; then
git clone --depth 1 --branch "$branch" "$url" "$dir"
else
git clone --depth 1 "$url" "$dir"
fi
}

View file

@ -0,0 +1,24 @@
#!/bin/bash
# Runtime resource updater (sidecar). Periodically re-clones the upstream card
# databases / banlists and publishes a fresh release via setup_resources.sh,
# which atomically repoints resources/current. The server reads resources/current
# read-only and picks up changes through its in-memory reload — no restart, no
# dropped connections.
#
# A failed refresh leaves the previous release live (clone/assemble run with
# `set -e`; on non-zero we keep going and retry next interval).
set -u
INTERVAL="${RESOURCES_REFRESH_SECONDS:-600}"
while true; do
echo "[updater] refreshing resources..."
if bash scripts/clone_repositories.sh && bash scripts/setup_resources.sh; then
echo "[updater] refresh ok"
else
echo "[updater] refresh FAILED — keeping previous resources/current" >&2
fi
sleep "$INTERVAL"
done

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

113
scripts/setup_resources.sh Executable file
View file

@ -0,0 +1,113 @@
#!/usr/bin/env bash
# setup_resources.sh — ASSEMBLE + PUBLISH stage.
#
# Builds a fresh resource set into resources/releases/<id>, then atomically
# repoints resources/current at it. Safe to run while the server is reading
# resources/current: the swap is a single atomic rename, and in-flight reads
# keep their old release via open file handles (POSIX). Old releases are GC'd.
#
# Single source of truth for the resource layout — used by local dev, the
# Docker build (seed), and the runtime updater sidecar.
#
# CWD invariant (D1): runs from repo root; all paths are repo-root-relative.
# No cd into subdirs. Sources are read exclusively from resources.manifest.json
# (RSM-010). Assembly rules from manifest assembly[] (RSM-002/003/004).
#
# Usage: bash setup_resources.sh
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/resources-lib.sh"
# Support test overrides for repos root and releases root.
REPOS="${REPOS_ROOT:-./repositories}"
RELEASES="${RELEASES_ROOT:-./resources/releases}"
ID="$(date +%Y%m%d-%H%M%S-%N)"
STAGING="$RELEASES/$ID"
KEEP="${RESOURCES_KEEP_RELEASES:-2}"
echo "Assembling resources into $STAGING ..."
mkdir -p "$STAGING"
# === ASSEMBLE: iterate assembly[] rules from the manifest ===
rule_count=$(manifest_get '.assembly | length')
# Resolve source directory for a given source id.
# git sources land at repositories/<id>; http sources land as a flat file
# at repositories/<filename>, so their "source dir" is repositories/ itself.
# Defined once before the loop; src_id is passed via jq --arg to avoid string
# interpolation into the filter.
_resolve_src_dir() {
local src_id="$1"
local src_type
src_type=$(jq -r --arg id "$src_id" '.sources[] | select(.id == $id) | .type' "$MANIFEST_PATH")
case "$src_type" in
http) echo "$REPOS" ;;
*) echo "$REPOS/$src_id" ;;
esac
}
i=0
while [ "$i" -lt "$rule_count" ]; do
rule_target=$(manifest_get ".assembly[$i].target")
rule_from=$(manifest_get ".assembly[$i].from")
rule_from_type=$(manifest_get ".assembly[$i].from | type")
rule_dir=$(manifest_get ".assembly[$i].dir // \"\"")
rule_file=$(manifest_get ".assembly[$i].file // \"\"")
rule_only=$(manifest_get ".assembly[$i].only // \"\"")
full_target="$STAGING/$rule_target"
if [ "$rule_from_type" = "array" ]; then
# RSM-003 fallback chain — from is an array; a file key is required
# (validated at manifest-validation time).
# Build list of source dirs in order.
from_count=$(manifest_get ".assembly[$i].from | length")
src_dirs=()
j=0
while [ "$j" -lt "$from_count" ]; do
from_id=$(manifest_get ".assembly[$i].from[$j]")
src_dirs+=("$(_resolve_src_dir "$from_id")")
j=$((j + 1))
done
apply_rule_with_fallback "$rule_file" "$full_target" "${src_dirs[@]}" \
|| fail "Assembly rule $i (target=$rule_target): fallback chain exhausted"
else
# Single source — from is a string source id
src_dir="$(_resolve_src_dir "$rule_from")"
apply_rule "$src_dir" "$rule_dir" "$rule_file" "$rule_only" "$full_target" \
|| fail "Assembly rule $i (target=$rule_target) failed"
fi
i=$((i + 1))
done
# === PUBLISH: strip .git, atomic swap, GC ===
# Strip .git from the assembled release (keep it in repositories/ so
# refreshes can pull deltas).
find "$STAGING" -name ".git" -type d -exec rm -rf {} +
# Atomic publish: repoint resources/current -> releases/<id>.
# Build the symlink under a temp name, then rename over the live one.
# rename(2) is atomic on the same filesystem, so readers never observe a
# missing or half-updated current.
# If a previous build left `current` as a real directory (e.g. a Docker COPY
# that dereferenced the symlink), drop it so the atomic symlink swap can land.
RESOURCES_DIR="$(dirname "$RELEASES")"
if [ -d "$RESOURCES_DIR/current" ] && [ ! -L "$RESOURCES_DIR/current" ]; then
rm -rf "$RESOURCES_DIR/current"
fi
ln -sfn "$(basename "$RELEASES")/$ID" "$RESOURCES_DIR/.current.tmp"
mv -T "$RESOURCES_DIR/.current.tmp" "$RESOURCES_DIR/current"
echo "Published $RESOURCES_DIR/current -> releases/$ID"
# === GC: keep only the newest $KEEP releases ===
# The just-published release is the newest, so current is always retained.
ls -1dt "$RELEASES"/*/ 2>/dev/null | tail -n +"$((KEEP + 1))" | xargs -r rm -rf
echo "Resources assembled successfully."

View file

@ -1,55 +0,0 @@
#!/bin/bash
set -e
echo "Assembling resources structure..."
REPOS=./repositories
# Strip .git dirs
find "$REPOS" -name ".git" -type d -exec rm -rf {} + 2>/dev/null || true
# === EDOPro ===
mkdir -p ./resources/edopro
cp -r "$REPOS/edopro-card-scripts" ./resources/edopro/scripts
cp -r "$REPOS/edopro-card-databases" ./resources/edopro/databases
cp -r "$REPOS/edopro-banlists-ignis" ./resources/edopro/banlists-ignis
cp -r "$REPOS/edopro-banlists-evolution" ./resources/edopro/banlists-evolution
# === YGOPro Base (scripts + cards.cdb + lflist propagate to all variants) ===
mkdir -p ./resources/ygopro/base
cp -r "$REPOS/ygopro-scripts" ./resources/ygopro/base/script
cp "$REPOS/ygopro-lflist.conf" ./resources/ygopro/base/lflist.conf
cp "$REPOS/ygopro-cards.cdb" ./resources/ygopro/base/cards.cdb
# === YGOPro Prereleases (each repo as independent folder) ===
cp -r "$REPOS/ygopro-prereleases-cdb" ./resources/ygopro/prereleases-cdb
cp -r "$REPOS/ygopro-cards-art" ./resources/ygopro/cards-art
# === YGOPro OCG (only own lflist) ===
mkdir -p ./resources/ygopro/ocg
cp "$REPOS/edopro-banlists-ignis/OCG.lflist.conf" ./resources/ygopro/ocg/lflist.conf
# === YGOPro Alternatives (repo as-is + banlists) ===
cp -r "$REPOS/ygopro-format-alternatives" ./resources/ygopro/alternatives
declare -A MAP=(
["2010.03 Edison(Pre Errata)"]="edison"
["2014.04 HAT (Pre Errata)"]="hat"
["jtp-oficial"]="jtp"
["GOAT"]="goat"
["Rush"]="rush"
["Speed"]="speed"
["Tengu.Plant"]="tengu"
["World"]="world"
["MD.2025.03"]="md"
["Genesys"]="genesys"
)
for name in "${!MAP[@]}"; do
src="$REPOS/edopro-banlists-evolution/${name}.lflist.conf"
[ -f "$src" ] || src="$REPOS/edopro-banlists-ignis/${name}.lflist.conf"
cp "$src" "./resources/ygopro/alternatives/${MAP[$name]}/lflist.conf"
done
echo "Resources assembled successfully."

View file

@ -0,0 +1,160 @@
// Tests for the re-callable loader functions extracted from bootstrap.
// The functions are pure builders: they parse into a local temp array and do NOT touch
// the live BanListMemoryRepository. Mocking at the class level to avoid fs / ygopro deps.
jest.mock("@edopro/ban-list/infrastructure/BanListLoader", () => ({
EdoProBanListLoader: jest.fn(),
}));
jest.mock("@ygopro/ban-list/infrastructure/YGOProBanListLoader", () => ({
YGOProBanListLoader: jest.fn(),
}));
// Prevent config from loading env that doesn't exist in test env
jest.mock("src/config", () => ({
config: {
resources: { dir: "/fake/resources" },
},
}));
import { EdoProBanListLoader } from "@edopro/ban-list/infrastructure/BanListLoader";
import { YGOProBanListLoader } from "@ygopro/ban-list/infrastructure/YGOProBanListLoader";
import { EdoproBanList } from "@edopro/ban-list/domain/BanList";
import { YGOProBanList } from "@ygopro/ban-list/domain/YGOProBanList";
// Import the functions AFTER mocks are set up
import { loadEdoproBanLists, loadYgoproBanLists } from "./bootstrapBanListLoaders";
// Cast to jest.Mock so we can control the constructor's returned instance via mockImplementation.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const MockEdoProBanListLoader = EdoProBanListLoader as jest.Mock<any>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const MockYGOProBanListLoader = YGOProBanListLoader as jest.Mock<any>;
function makeEdoList(name: string): EdoproBanList {
const list = new EdoproBanList();
list.setName(name);
return list;
}
function makeYgoList(name: string): YGOProBanList {
const list = new YGOProBanList();
list.setName(name);
return list;
}
describe("loadEdoproBanLists", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("calls loadDirectory for both evolution-lflists and lflists paths", async () => {
const mockLoadDirectory = jest.fn().mockResolvedValue(undefined);
MockEdoProBanListLoader.mockImplementation(() => ({
loadDirectory: mockLoadDirectory,
getLoaded: () => [],
}));
await loadEdoproBanLists();
expect(mockLoadDirectory).toHaveBeenCalledTimes(2);
expect(mockLoadDirectory).toHaveBeenCalledWith(
expect.stringContaining("edopro/evolution-lflists"),
);
expect(mockLoadDirectory).toHaveBeenCalledWith(expect.stringContaining("edopro/lflists"));
});
it("resolves without throwing when loader succeeds", async () => {
MockEdoProBanListLoader.mockImplementation(() => ({
loadDirectory: jest.fn().mockResolvedValue(undefined),
getLoaded: () => [],
}));
await expect(loadEdoproBanLists()).resolves.not.toThrow();
});
it("propagates the error when loadDirectory throws — caller is responsible for error handling", async () => {
MockEdoProBanListLoader.mockImplementation(() => ({
loadDirectory: jest.fn().mockRejectedValue(new Error("parse error")),
getLoaded: () => [],
}));
await expect(loadEdoproBanLists()).rejects.toThrow("parse error");
});
it("returns loaded EdoproBanList array from the loader", async () => {
const listA = makeEdoList("List A");
const listB = makeEdoList("List B");
const mockLoadDirectory = jest.fn().mockResolvedValue(undefined);
MockEdoProBanListLoader.mockImplementation(() => ({
loadDirectory: mockLoadDirectory,
getLoaded: () => [listA, listB],
}));
const result = await loadEdoproBanLists();
expect(result).toHaveLength(2);
expect(result).toContain(listA);
expect(result).toContain(listB);
});
});
describe("loadYgoproBanLists", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("calls load() once on YGOProBanListLoader", async () => {
const mockLoad = jest.fn().mockResolvedValue(undefined);
MockYGOProBanListLoader.mockImplementation(() => ({
load: mockLoad,
getLoaded: () => [],
}));
await loadYgoproBanLists();
expect(mockLoad).toHaveBeenCalledTimes(1);
});
it("returns loaded YGOProBanList array from the loader", async () => {
const listX = makeYgoList("TCG 2026.04");
const mockLoad = jest.fn().mockResolvedValue(undefined);
MockYGOProBanListLoader.mockImplementation(() => ({
load: mockLoad,
getLoaded: () => [listX],
}));
const result = await loadYgoproBanLists();
expect(result).toHaveLength(1);
expect(result[0]).toBe(listX);
});
});
describe("edopro loader called before ygopro loader", () => {
it("edopro load completes before ygopro load begins", async () => {
const callOrder: string[] = [];
MockEdoProBanListLoader.mockImplementation(() => ({
loadDirectory: jest.fn().mockImplementation(async () => {
callOrder.push("edopro:loadDirectory");
}),
getLoaded: () => [],
}));
MockYGOProBanListLoader.mockImplementation(() => ({
load: jest.fn().mockImplementation(async () => {
callOrder.push("ygopro:load");
}),
getLoaded: () => [],
}));
// Caller must always call edopro first, then ygopro.
await loadEdoproBanLists();
await loadYgoproBanLists();
expect(callOrder[0]).toBe("edopro:loadDirectory");
// Both edopro loadDirectory calls complete before ygopro:load
expect(callOrder.indexOf("ygopro:load")).toBeGreaterThan(
callOrder.lastIndexOf("edopro:loadDirectory"),
);
});
});

View file

@ -0,0 +1,40 @@
// Re-callable pure builders for edopro and ygopro ban lists.
//
// These functions parse ban lists into a local temporary array and do NOT touch
// the live BanListMemoryRepository or YGOProBanListMemoryRepository. The caller
// (bootstrapBanListReloader) is responsible for atomically swapping the repo via
// replaceAll() once both arrays are successfully built.
import { EdoProBanListLoader } from "@edopro/ban-list/infrastructure/BanListLoader";
import { EdoproBanList } from "@edopro/ban-list/domain/BanList";
import { YGOProBanListLoader } from "@ygopro/ban-list/infrastructure/YGOProBanListLoader";
import { YGOProBanList } from "@ygopro/ban-list/domain/YGOProBanList";
import { config } from "src/config";
/**
* Loads edopro ban lists into a fresh temporary array.
* Does NOT write to BanListMemoryRepository.
* Throws on parse error callers are responsible for error handling.
*/
export async function loadEdoproBanLists(): Promise<EdoproBanList[]> {
const tmp: EdoproBanList[] = [];
const loader = new EdoProBanListLoader(tmp);
await loader.loadDirectory(`${config.resources.dir}/edopro/evolution-lflists`);
await loader.loadDirectory(`${config.resources.dir}/edopro/lflists`);
return loader.getLoaded();
}
/**
* Loads ygopro ban lists into a fresh temporary array.
* Does NOT write to YGOProBanListMemoryRepository.
* Throws on parse error callers are responsible for error handling.
*
* Precondition: edopro ban lists must already be present in BanListMemoryRepository
* before this is called YGOProRoom cross-references them by name at construction.
*/
export async function loadYgoproBanLists(): Promise<YGOProBanList[]> {
const tmp: YGOProBanList[] = [];
const loader = new YGOProBanListLoader(tmp);
await loader.load();
return loader.getLoaded();
}

View file

@ -0,0 +1,186 @@
// Tests for the ban-list reloader core cycle. reloadBanListsOnce is exercised
// directly with injected ports so no filesystem, timers, or singletons are touched.
jest.mock("src/config", () => ({
config: { resources: { dir: "/fake/resources" } },
}));
jest.mock("./bootstrapBanListLoaders", () => ({
loadEdoproBanLists: jest.fn(),
loadYgoproBanLists: jest.fn(),
}));
import { EdoproBanList } from "@edopro/ban-list/domain/BanList";
import { Logger } from "@shared/logger/domain/Logger";
import { YGOProBanList } from "@ygopro/ban-list/domain/YGOProBanList";
import {
type BanListReloaderPorts,
getBanListReloadedAt,
reloadBanListsOnce,
} from "./bootstrapBanListReloader";
function fakeLogger(): Logger {
return {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
} as unknown as Logger;
}
function makeEdoList(name: string): EdoproBanList {
const list = new EdoproBanList();
list.setName(name);
return list;
}
function makeYgoList(name: string): YGOProBanList {
const list = new YGOProBanList();
list.setName(name);
return list;
}
interface Recorder {
calls: string[];
edoproReplaced: EdoproBanList[] | null;
ygoproReplaced: YGOProBanList[] | null;
}
function makePorts(overrides: Partial<BanListReloaderPorts> & { recorder?: Recorder } = {}): {
ports: BanListReloaderPorts;
recorder: Recorder;
} {
const recorder: Recorder = overrides.recorder ?? {
calls: [],
edoproReplaced: null,
ygoproReplaced: null,
};
const ports: BanListReloaderPorts = {
fingerprint: overrides.fingerprint ?? jest.fn().mockResolvedValue("fp-new"),
loadEdopro:
overrides.loadEdopro ??
jest.fn().mockImplementation(async () => {
recorder.calls.push("loadEdopro");
return [makeEdoList("Edo A")];
}),
loadYgopro:
overrides.loadYgopro ??
jest.fn().mockImplementation(async () => {
recorder.calls.push("loadYgopro");
return [makeYgoList("Ygo A")];
}),
replaceEdopro:
overrides.replaceEdopro ??
((next) => {
recorder.calls.push("replaceEdopro");
recorder.edoproReplaced = next;
}),
replaceYgopro:
overrides.replaceYgopro ??
((next) => {
recorder.calls.push("replaceYgopro");
recorder.ygoproReplaced = next;
}),
now: overrides.now ?? (() => "2026-07-14T12:00:00.000Z"),
};
return { ports, recorder };
}
describe("reloadBanListsOnce — change detection", () => {
it("skips the rebuild when the fingerprint is unchanged", async () => {
const { ports, recorder } = makePorts({
fingerprint: jest.fn().mockResolvedValue("fp-same"),
});
const outcome = await reloadBanListsOnce(ports, fakeLogger(), "fp-same");
expect(outcome.changed).toBe(false);
expect(outcome.fingerprint).toBe("fp-same");
expect(recorder.calls).toEqual([]); // no load, no replace
});
it("rebuilds and swaps when the fingerprint changed", async () => {
const { ports, recorder } = makePorts();
const outcome = await reloadBanListsOnce(ports, fakeLogger(), "fp-old");
expect(outcome.changed).toBe(true);
expect(outcome.fingerprint).toBe("fp-new");
expect(recorder.edoproReplaced).toHaveLength(1);
expect(recorder.ygoproReplaced).toHaveLength(1);
});
});
describe("reloadBanListsOnce — atomic swap ordering", () => {
it("replaces edopro before ygopro", async () => {
const { ports, recorder } = makePorts();
await reloadBanListsOnce(ports, fakeLogger(), "fp-old");
expect(recorder.calls).toEqual(["loadEdopro", "loadYgopro", "replaceEdopro", "replaceYgopro"]);
expect(recorder.calls.indexOf("replaceEdopro")).toBeLessThan(
recorder.calls.indexOf("replaceYgopro"),
);
});
});
describe("reloadBanListsOnce — empty-result safety", () => {
it("keeps previous lists and does NOT swap when edopro rebuild is empty", async () => {
const { ports, recorder } = makePorts({
loadEdopro: jest.fn().mockResolvedValue([]),
});
const outcome = await reloadBanListsOnce(ports, fakeLogger(), "fp-old");
expect(outcome.changed).toBe(false);
// old fingerprint retained so the next cycle retries
expect(outcome.fingerprint).toBe("fp-old");
expect(recorder.edoproReplaced).toBeNull();
expect(recorder.ygoproReplaced).toBeNull();
});
it("keeps previous lists and does NOT swap when ygopro rebuild is empty", async () => {
const { ports, recorder } = makePorts({
loadYgopro: jest.fn().mockResolvedValue([]),
});
const outcome = await reloadBanListsOnce(ports, fakeLogger(), "fp-old");
expect(outcome.changed).toBe(false);
expect(recorder.edoproReplaced).toBeNull();
expect(recorder.ygoproReplaced).toBeNull();
});
});
describe("reloadBanListsOnce — error propagation", () => {
it("propagates loader errors so the scheduler can keep previous lists", async () => {
const { ports } = makePorts({
loadEdopro: jest.fn().mockRejectedValue(new Error("parse boom")),
});
await expect(reloadBanListsOnce(ports, fakeLogger(), "fp-old")).rejects.toThrow("parse boom");
});
});
describe("getBanListReloadedAt — timestamp", () => {
it("advances after a successful reload", async () => {
const { ports } = makePorts({ now: () => "2026-07-14T15:30:00.000Z" });
await reloadBanListsOnce(ports, fakeLogger(), "fp-old");
expect(getBanListReloadedAt()).toBe("2026-07-14T15:30:00.000Z");
});
it("does not advance when the rebuild is skipped (unchanged fingerprint)", async () => {
const { ports: first } = makePorts({ now: () => "2026-07-14T15:30:00.000Z" });
await reloadBanListsOnce(first, fakeLogger(), "fp-old"); // sets a known value
const { ports: second } = makePorts({
fingerprint: jest.fn().mockResolvedValue("fp-same"),
now: () => "2026-07-14T99:99:99.000Z",
});
await reloadBanListsOnce(second, fakeLogger(), "fp-same");
expect(getBanListReloadedAt()).toBe("2026-07-14T15:30:00.000Z");
});
});

View file

@ -0,0 +1,193 @@
// Periodic ban-list hot-reload.
//
// Ban lists are loaded once at boot (bootstrapEdoproResources / bootstrapYgoproResources).
// The resources sidecar refreshes the underlying .conf files on disk, but the Node
// process never re-reads them — so a new ban list historically required a full restart.
// This reloader closes that gap: on an interval it detects on-disk changes via a
// size+mtime fingerprint and, when something changed, rebuilds both ban-list arrays
// into local temporaries and atomically swaps them into the live repositories.
//
// Safety properties:
// - Double-buffer swap (replaceAll): the swap is synchronous with no await between the
// two repositories, so no concurrent HTTP read can observe an empty or half-updated list.
// - edopro BEFORE ygopro: ygopro rooms cross-reference edopro ban lists by name to resolve
// _edoBanListHash (see bootstrapYgoproResources), so edopro must be current first.
// - In-flight rooms are unaffected: they snapshot their ban list at construction
// (YGOProRoom), not a live repository reference.
// - Never swaps in an empty parse result: if a rebuild yields zero lists the previous
// in-memory lists are kept and the change is retried next cycle.
import { readdir, stat } from "node:fs/promises";
import { join } from "node:path";
import { EdoproBanList } from "@edopro/ban-list/domain/BanList";
import BanListMemoryRepository from "@edopro/ban-list/infrastructure/BanListMemoryRepository";
import { Logger } from "@shared/logger/domain/Logger";
import { YGOProBanList } from "@ygopro/ban-list/domain/YGOProBanList";
import YGOProBanListMemoryRepository from "@ygopro/ban-list/infrastructure/YGOProBanListMemoryRepository";
import { config } from "src/config";
import { loadEdoproBanLists, loadYgoproBanLists } from "./bootstrapBanListLoaders";
const DEFAULT_INTERVAL_MS = (Number(process.env.RESOURCES_REFRESH_SECONDS) || 600) * 1000;
// Timestamp of when the in-memory ban lists were last (re)loaded. Set at reloader
// start (lists are current as of boot) and updated on every successful reload.
// Exposed via getBanListReloadedAt() for a future resource-version endpoint.
let reloadedAt: string | null = null;
export function getBanListReloadedAt(): string | null {
return reloadedAt;
}
/** Injectable seam so the reload logic can be tested without the filesystem or timers. */
export interface BanListReloaderPorts {
loadEdopro(): Promise<EdoproBanList[]>;
loadYgopro(): Promise<YGOProBanList[]>;
replaceEdopro(next: EdoproBanList[]): void;
replaceYgopro(next: YGOProBanList[]): void;
fingerprint(): Promise<string>;
now(): string;
}
export interface ReloadOutcome {
changed: boolean;
fingerprint: string;
}
/**
* Runs a single reload cycle. Pure with respect to timers the scheduler calls this.
* Skips the rebuild when the fingerprint is unchanged; keeps the previous lists when a
* rebuild yields an empty result (returns the OLD fingerprint so the next cycle retries).
*/
export async function reloadBanListsOnce(
ports: BanListReloaderPorts,
logger: Logger,
lastFingerprint: string,
): Promise<ReloadOutcome> {
const fingerprint = await ports.fingerprint();
if (fingerprint === lastFingerprint) {
return { changed: false, fingerprint };
}
const edoproNext = await ports.loadEdopro();
const ygoproNext = await ports.loadYgopro();
if (edoproNext.length === 0 || ygoproNext.length === 0) {
logger.error(
`[banlist-reloader] rebuild produced empty ban lists (edopro=${edoproNext.length}, ygopro=${ygoproNext.length}) — keeping previous lists`,
);
// Do not adopt the new fingerprint: retry on the next cycle.
return { changed: false, fingerprint: lastFingerprint };
}
// Double-buffer swap — edopro before ygopro, no await between the two.
ports.replaceEdopro(edoproNext);
ports.replaceYgopro(ygoproNext);
reloadedAt = ports.now();
logger.info(
`[banlist-reloader] reloaded ${edoproNext.length} edopro + ${ygoproNext.length} ygopro ban lists`,
);
return { changed: true, fingerprint };
}
export interface BanListReloaderOptions {
intervalMs?: number;
ports?: BanListReloaderPorts;
}
export interface BanListReloaderHandle {
stop(): void;
}
/**
* Starts the periodic ban-list reloader. Captures the current on-disk fingerprint as the
* baseline (boot already loaded the lists) and only reloads when it changes thereafter.
* Returns a handle whose stop() clears the timer the clean rollback boundary.
*/
export async function bootstrapBanListReloader(
logger: Logger,
options: BanListReloaderOptions = {},
): Promise<BanListReloaderHandle> {
const ports = options.ports ?? createDefaultPorts();
const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
let lastFingerprint = await ports.fingerprint().catch(() => "");
reloadedAt = ports.now();
let running = false;
const tick = async (): Promise<void> => {
if (running) {
return; // overlap guard: never run two reloads concurrently
}
running = true;
try {
const outcome = await reloadBanListsOnce(ports, logger, lastFingerprint);
lastFingerprint = outcome.fingerprint;
} catch (error) {
logger.error("[banlist-reloader] reload cycle failed — keeping previous lists");
logger.error(error);
} finally {
running = false;
}
};
const timer = setInterval(() => void tick(), intervalMs);
timer.unref();
logger.info(`🕒 Ban-list reloader started (every ${Math.round(intervalMs / 1000)}s)`);
return { stop: (): void => clearInterval(timer) };
}
function createDefaultPorts(): BanListReloaderPorts {
return {
loadEdopro: loadEdoproBanLists,
loadYgopro: loadYgoproBanLists,
replaceEdopro: (next) => BanListMemoryRepository.replaceAll(next),
replaceYgopro: (next) => YGOProBanListMemoryRepository.replaceAll(next),
fingerprint: () => fingerprintLflists(config.resources.dir),
now: () => new Date().toISOString(),
};
}
/**
* Fingerprint every lflist .conf under the resource directories that feed the two loaders,
* using size + mtime (never reads/hashes contents on the event loop). Mirrors
* EdoProCardDbPorts.fingerprint(). Missing directories are skipped, not fatal.
*/
async function fingerprintLflists(baseDir: string): Promise<string> {
const roots = [
join(baseDir, "edopro", "evolution-lflists"),
join(baseDir, "edopro", "lflists"),
join(baseDir, "ygopro", "formats"),
];
const parts: string[] = [];
for (const root of roots) {
await collectConfFingerprints(root, parts);
}
parts.sort();
return parts.join("|");
}
async function collectConfFingerprints(dir: string, out: string[]): Promise<void> {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return; // directory absent — skip
}
for (const entry of entries) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
await collectConfFingerprints(full, out);
} else if (entry.name.endsWith(".conf")) {
try {
const { size, mtimeMs } = await stat(full);
out.push(`${full}:${size}:${mtimeMs}`);
} catch {
// file vanished between readdir and stat — skip it
}
}
}
}

View file

@ -0,0 +1,13 @@
import { Logger } from "@shared/logger/domain/Logger";
import BanListMemoryRepository from "@edopro/ban-list/infrastructure/BanListMemoryRepository";
import { loadEdoproBanLists } from "./bootstrapBanListLoaders";
// Loads edopro ban lists into BanListMemoryRepository via loadEdoproBanLists().
// Note: these are also read by the ygopro path (see bootstrapYgoproResources),
// not only by edopro.
export async function bootstrapEdoproResources(logger: Logger): Promise<void> {
const tmp = await loadEdoproBanLists();
BanListMemoryRepository.replaceAll(tmp);
logger.info("🎴 EdoPro ban lists loaded");
}

View file

@ -0,0 +1,137 @@
// Verifies the composition-root wiring: the spawnBot dependency injected into
// MatchmakingQueue must request a bot using a (name, deck) identity pair from the
// per-format roster. The bot is requested with an EXPLICIT name and a deckOverride
// equal to the roster pair's deck.
//
// All singleton collaborators are mocked so no timers, sockets, or real windbot
// are touched. We capture the deps object passed to MatchmakingQueue.init and
// invoke its spawnBot directly.
import { Logger } from "@shared/logger/domain/Logger";
import {
MatchmakingQueue,
MatchmakingQueueDeps,
} from "@ygopro/matchmaking/domain/MatchmakingQueue";
import { MATCHMAKING_BOT_ROSTER } from "@ygopro/matchmaking/domain/MatchmakingBotRoster";
import { MatchmakingFormat } from "@ygopro/matchmaking/domain/QueueEntry";
import YGOProRoomList from "@ygopro/room/infrastructure/YGOProRoomList";
import { WindbotModule } from "@ygopro/windbot/application/WindbotModule";
import { bootstrapMatchmaking } from "./bootstrapMatchmaking";
jest.mock("@ygopro/matchmaking/application/MatchmakingRoomFactory", () => ({
createMatchmakingRoom: jest.fn(),
}));
jest.mock("@ygopro/room/application/FinalizeYGOProRoom", () => ({
FinalizeYGOProRoom: { run: jest.fn() },
}));
function fakeLogger(): Logger {
const logger = {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
child: jest.fn(),
} as unknown as Logger;
(logger.child as jest.Mock).mockReturnValue(logger);
return logger;
}
describe("bootstrapMatchmaking — spawnBot roster identity-pair wiring", () => {
afterEach(() => {
jest.restoreAllMocks();
});
function captureSpawnBot(): (roomId: number, format: MatchmakingFormat) => void {
const captured: { spawnBot?: (roomId: number, format: MatchmakingFormat) => void } = {};
jest.spyOn(MatchmakingQueue, "init").mockImplementation((d) => {
captured.spawnBot = (
d as unknown as { spawnBot: (roomId: number, format: MatchmakingFormat) => void }
).spawnBot;
});
jest
.spyOn(MatchmakingQueue, "getInstance")
.mockReturnValue({ start: jest.fn() } as unknown as MatchmakingQueue);
bootstrapMatchmaking(fakeLogger());
if (!captured.spawnBot) throw new Error("MatchmakingQueue.init was not called");
return captured.spawnBot;
}
it("requests a TCG bot with explicit name and deckOverride from the TCG roster", () => {
const requestBot = jest
.fn()
.mockResolvedValue({ bot: { name: "Salamangreat", deck: "Salamangreat" } });
jest.spyOn(WindbotModule, "isInitialized").mockReturnValue(true);
jest.spyOn(WindbotModule, "getInstance").mockReturnValue({
isEnabled: () => true,
requestBot,
} as unknown as WindbotModule);
jest
.spyOn(YGOProRoomList, "findById")
.mockReturnValue({ finalizing: false } as unknown as ReturnType<
typeof YGOProRoomList.findById
>);
const spawnBot = captureSpawnBot();
spawnBot(123, "tcg");
expect(requestBot).toHaveBeenCalledTimes(1);
const [roomId, botName, isFinalizing, deckOverride] = requestBot.mock.calls[0];
expect(roomId).toBe(123);
// Explicit name from the roster (not null)
expect(typeof botName).toBe("string");
expect(botName.length).toBeGreaterThan(0);
expect(typeof isFinalizing).toBe("function");
// deckOverride must be from the TCG roster
const tcgDecks = MATCHMAKING_BOT_ROSTER.tcg.map((p) => p.deck);
expect(tcgDecks).toContain(deckOverride);
// name and deck must come from the SAME pair
const pair = MATCHMAKING_BOT_ROSTER.tcg.find((p) => p.name === botName);
expect(pair).toBeDefined();
expect(pair?.deck).toBe(deckOverride);
});
it("requests a JTP bot with explicit name and deckOverride from the JTP roster", () => {
const requestBot = jest.fn().mockResolvedValue({ bot: { name: "Joey", deck: "JTP" } });
jest.spyOn(WindbotModule, "isInitialized").mockReturnValue(true);
jest.spyOn(WindbotModule, "getInstance").mockReturnValue({
isEnabled: () => true,
requestBot,
} as unknown as WindbotModule);
jest
.spyOn(YGOProRoomList, "findById")
.mockReturnValue({ finalizing: false } as unknown as ReturnType<
typeof YGOProRoomList.findById
>);
const spawnBot = captureSpawnBot();
spawnBot(456, "jtp");
expect(requestBot).toHaveBeenCalledTimes(1);
const [roomId, botName, isFinalizing, deckOverride] = requestBot.mock.calls[0];
expect(roomId).toBe(456);
expect(typeof botName).toBe("string");
expect(typeof isFinalizing).toBe("function");
// deckOverride must be from the JTP roster
const jtpDecks = MATCHMAKING_BOT_ROSTER.jtp.map((p) => p.deck);
expect(jtpDecks).toContain(deckOverride);
// name and deck must come from the SAME pair
const pair = MATCHMAKING_BOT_ROSTER.jtp.find((p) => p.name === botName);
expect(pair).toBeDefined();
expect(pair?.deck).toBe(deckOverride);
});
it("is a no-op when windbot is not initialized", () => {
jest.spyOn(WindbotModule, "isInitialized").mockReturnValue(false);
const getInstance = jest.spyOn(WindbotModule, "getInstance");
const spawnBot = captureSpawnBot();
spawnBot(123, "tcg");
expect(getInstance).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,117 @@
import { EventEmitter } from "stream";
import { Logger } from "@shared/logger/domain/Logger";
import { createMatchmakingRoom } from "@ygopro/matchmaking/application/MatchmakingRoomFactory";
import { MatchmakingRoomReaper } from "@ygopro/matchmaking/application/MatchmakingRoomReaper";
import { AbortMatchmakingRoom } from "@ygopro/matchmaking/application/AbortMatchmakingRoom";
import { pickBotFromRoster } from "@ygopro/matchmaking/domain/MatchmakingBotRoster";
import { CLEANUP_INTERVAL_MS, MatchmakingFormat } from "@ygopro/matchmaking/domain/QueueEntry";
import { MatchmakingQueue } from "@ygopro/matchmaking/domain/MatchmakingQueue";
import YGOProRoomList from "@ygopro/room/infrastructure/YGOProRoomList";
import { WindbotModule } from "@ygopro/windbot/application/WindbotModule";
/**
* Wires the matchmaking queue's ports to concrete infrastructure and starts the
* background sweep. Kept in the composition root so the queue domain stays free
* of YGOProRoom, windbot, and Date.now dependencies.
*
* - createRankedRoom(format) / createBotRoom(format) additive YGOProRoom factory
* (matchmaking seam), now format-aware via FORMAT_ROOM_TOKEN.
* - spawnBot(roomId, format) windbot fire-and-forget, using a (name, deck) identity
* pair from MATCHMAKING_BOT_ROSTER so name and deck always come from the same pair.
*/
export function bootstrapMatchmaking(logger: Logger): void {
const mmLogger = logger.child({ file: "Matchmaking" });
// Reaps matchmaking-created rooms that are never joined (rage-quit before join,
// ticket expiry between match and WS handshake, network drop). Reuses the SAME
// canonical teardown as every other reap path.
const reaper = new MatchmakingRoomReaper({
now: () => Date.now(),
finalize: (room) => AbortMatchmakingRoom.run(room),
});
MatchmakingQueue.init({
now: () => Date.now(),
createRankedRoom: (format: MatchmakingFormat) => {
const { room, roomPassword } = createMatchmakingRoom({
format,
// Ranked human pairs play best-of-3 (MATCH room with side-decking).
matchMode: true,
rankedOverride: true,
logger: mmLogger,
emitter: new EventEmitter(),
onRoomCreated: (room) => reaper.track(room),
});
return { roomId: room.id, roomPassword };
},
createBotRoom: (format: MatchmakingFormat) => {
const { room, roomPassword } = createMatchmakingRoom({
format,
// Bot fallback stays best-of-1: windbot has no side-deck support,
// so a MATCH room would stall in side-decking until the timeout.
matchMode: false,
rankedOverride: false,
logger: mmLogger,
emitter: new EventEmitter(),
onRoomCreated: (room) => reaper.track(room),
});
return { roomPassword, roomId: room.id };
},
spawnBot: (roomId: number, format: MatchmakingFormat) => {
if (!WindbotModule.isInitialized() || !WindbotModule.getInstance().isEnabled()) {
return;
}
const room = YGOProRoomList.findById(roomId);
if (!room) return;
// Pick an identity pair from the per-format roster. Name and deck always
// come from the same pair (identity coherence). Pass the explicit name so
// requestBot finds the right bot by name, and pass deck as deckOverride so
// windbot uses the correct deck and deckcode is cleared.
const pair = pickBotFromRoster(format);
// Fire-and-forget, mirroring WindBotJoinStrategy: abort retries once the
// room begins teardown. On failure, tear the empty bot room down so it
// does not linger in the lobby.
void WindbotModule.getInstance()
.requestBot(roomId, pair.name, () => room.finalizing, pair.deck)
.then(({ bot }) => {
room.windbot = { name: bot.name, deck: bot.deck };
})
.catch((error: unknown) => {
mmLogger.error(
`Matchmaking bot spawn failed for room ${roomId}: ${
error instanceof Error ? error.message : String(error)
}`,
);
AbortMatchmakingRoom.run(room);
});
},
// Bot fallback only makes sense when windbot is up; otherwise entries keep
// waiting for a human (or TTL-drop) instead of dead-ending on a bot game.
botAvailable: () => WindbotModule.isInitialized() && WindbotModule.getInstance().isEnabled(),
// A synchronous room-creation failure is caught inside the queue's sweep so
// it never aborts the sweep or 500s an unrelated poller; log it here.
onRoomCreationError: (error: unknown) => {
mmLogger.error(
`Matchmaking room creation failed during sweep: ${
error instanceof Error ? error.message : String(error)
}`,
);
},
});
MatchmakingQueue.getInstance().start();
// Drive the empty-room sweep on its own unref'd timer so it never keeps the
// process alive. Reuses the queue's cleanup cadence.
const sweepTimer = setInterval(() => reaper.sweep(), CLEANUP_INTERVAL_MS);
sweepTimer.unref();
}

View file

@ -0,0 +1,29 @@
import { Redis } from "@shared/db/redis/infrastructure/Redis";
import { EdoProCardDbHotReload } from "@edopro/card/infrastructure/sqlite/EdoProCardDbHotReload";
import { EdoProSQLiteTypeORM } from "@edopro/card/infrastructure/sqlite/EdoProSQLiteTypeORM";
import { Logger } from "@shared/logger/domain/Logger";
import { config } from "src/config";
import { PostgresTypeORM } from "src/evolution-types/src/PostgresTypeORM";
// Opens every datastore connection the server depends on. Postgres is only
// touched when ranking is enabled; SQLite and Redis are always required.
export async function bootstrapPersistence(logger: Logger): Promise<void> {
const sqlite = new EdoProSQLiteTypeORM();
await sqlite.connect();
await sqlite.initialize();
logger.info("🗄️ SQLite connected");
// Hot-reload the EDOPro card DB when the .cdb files change at runtime, refreshing
// evolution_cards.db in place so the C++ core (which opens that fixed path) sees it.
await new EdoProCardDbHotReload().start();
if (config.ranking.enabled) {
const postgres = new PostgresTypeORM();
await postgres.connect();
logger.info("🗄️ Postgres connected · ranking ON");
}
const redis = new Redis();
await redis.connect();
}

View file

@ -0,0 +1,12 @@
import { Logger } from "@shared/logger/domain/Logger";
import { bootstrapEdoproResources } from "./bootstrapEdoproResources";
import { bootstrapYgoproResources } from "./bootstrapYgoproResources";
// Order is mandatory: ygopro resources cross-reference edopro ban lists by name
// (YGOProRoom resolves _edoBanListHash from BanListMemoryRepository), so edopro
// ban lists must load first. Do not reorder these two calls.
export async function bootstrapResources(logger: Logger): Promise<void> {
await bootstrapEdoproResources(logger);
await bootstrapYgoproResources(logger);
}

View file

@ -0,0 +1,20 @@
import { Logger } from "@shared/logger/domain/Logger";
import { YGOProResourceLoader } from "@ygopro/ygopro/YGOProResourceLoader";
import YGOProBanListMemoryRepository from "@ygopro/ban-list/infrastructure/YGOProBanListMemoryRepository";
import { loadYgoproBanLists } from "./bootstrapBanListLoaders";
// Loads ygopro card resources and ban lists.
//
// Precondition: edopro ban lists must already be loaded. YGOProRoom cross-
// references them by name (BanListMemoryRepository) to resolve _edoBanListHash,
// so calling this before bootstrapEdoproResources yields empty hashes. The
// order is enforced by bootstrapResources.
export async function bootstrapYgoproResources(logger: Logger): Promise<void> {
await YGOProResourceLoader.start();
await YGOProResourceLoader.get().logLFLists();
const tmp = await loadYgoproBanLists();
YGOProBanListMemoryRepository.replaceAll(tmp);
logger.info("🎴 YGOPro resources & ban lists loaded");
}

View file

@ -1,49 +1,53 @@
import { parseWindbotConfig } from "../ygopro/windbot/infrastructure/WindbotConfig";
export const config = {
redis: {
use: process.env.USE_REDIS === "true",
uri: process.env.REDIS_URI,
},
env: process.env.NODE_ENV,
adminApiKey: process.env.ADMIN_API_KEY,
postgres: {
username: process.env.POSTGRES_USER,
password: process.env.POSTGRES_PASSWORD,
database: process.env.POSTGRES_DB,
host: process.env.POSTGRES_HOST ?? "localhost",
port: process.env.POSTGRES_PORT ? Number(process.env.POSTGRES_PORT) : 5432,
},
ranking: {
enabled: process.env.RANK_ENABLED === "true",
},
season: Number(process.env.SEASON),
allowedOrigins: process.env.ALLOWED_ORIGINS?.split(",") ?? ["*"],
rateLimit: {
enabled: process.env.RATE_LIMIT_ENABLED === "true",
limit: Number(process.env.RATE_LIMIT),
window: Number(process.env.RATE_LIMIT_WINDOW),
},
servers: {
host: {
port: Number(process.env.HOST_PORT),
},
mercury: {
port: Number(process.env.YGOPRO_PORT),
wsPort: Number(process.env.YGOPRO_WEBSOCKET_PORT) || 4002,
},
http: {
port: Number(process.env.HTTP_PORT),
},
websocket: {
port: Number(process.env.WEBSOCKET_PORT),
duelPort: Number(process.env.WEBSOCKET_DUEL_PORT) || 4001,
},
},
resources: {
ygopro: {
folders: process.env?.YGOPRO_FOLDERS?.split(",") ?? [],
extraFolders: process.env?.YGOPRO_EXTRA_FOLDERS?.split(",") ?? [],
extraScripts: process.env?.YGOPRO_EXTRA_SCRIPTS?.split(",") ?? [],
}
},
sideTimeoutMinutes: Number(process.env.SIDE_TIMEOUT_MINUTES) || 3,
redis: {
use: process.env.USE_REDIS === "true",
uri: process.env.REDIS_URI,
},
env: process.env.NODE_ENV,
adminApiKey: process.env.ADMIN_API_KEY,
postgres: {
username: process.env.POSTGRES_USER,
password: process.env.POSTGRES_PASSWORD,
database: process.env.POSTGRES_DB,
host: process.env.POSTGRES_HOST ?? "localhost",
port: process.env.POSTGRES_PORT ? Number(process.env.POSTGRES_PORT) : 5432,
},
ranking: {
enabled: process.env.RANK_ENABLED === "true",
},
season: Number(process.env.SEASON),
allowedOrigins: process.env.ALLOWED_ORIGINS?.split(",") ?? ["*"],
rateLimit: {
enabled: process.env.RATE_LIMIT_ENABLED === "true",
limit: Number(process.env.RATE_LIMIT),
window: Number(process.env.RATE_LIMIT_WINDOW),
},
servers: {
host: {
port: Number(process.env.HOST_PORT),
},
mercury: {
port: Number(process.env.YGOPRO_PORT),
wsPort: Number(process.env.YGOPRO_WEBSOCKET_PORT) || 4002,
wsHeartbeatIntervalMs: Number(process.env.YGOPRO_WEBSOCKET_HEARTBEAT_MS) || 30000,
},
http: {
port: Number(process.env.HTTP_PORT),
},
websocket: {
port: Number(process.env.WEBSOCKET_PORT),
duelPort: Number(process.env.WEBSOCKET_DUEL_PORT) || 4001,
},
},
resources: {
dir: process.env.RESOURCES_DIR ?? "./resources/current",
manifestPath: process.env.MANIFEST_PATH ?? "./resources.manifest.json",
ygopro: {
extraScripts: process.env?.YGOPRO_EXTRA_SCRIPTS?.split(",") ?? [],
},
},
sideTimeoutMinutes: Number(process.env.SIDE_TIMEOUT_MINUTES) || 3,
windbot: parseWindbotConfig(process.env),
};

View file

@ -11,7 +11,7 @@ export class MessageEmitter {
private readonly logger: Logger,
private readonly eventEmitter: EventEmitter,
private readonly createGameListener: (roomId: number) => void,
private readonly joinGameListener: () => void
private readonly joinGameListener: () => void,
) {
this.messageProcessor = new MessageProcessor();
this.logger = logger.child({ file: "MessageEmitter" });
@ -39,7 +39,7 @@ export class MessageEmitter {
if (this.messageProcessor.command === Commands.PLAYER_INFO) {
this.eventEmitter.emit(
this.messageProcessor.command as unknown as string,
this.messageProcessor.payload
this.messageProcessor.payload,
);
}
@ -48,7 +48,7 @@ export class MessageEmitter {
this.createGameListener(roomId);
this.eventEmitter.emit(
this.messageProcessor.command as unknown as string,
this.messageProcessor.payload
this.messageProcessor.payload,
);
}
@ -56,7 +56,7 @@ export class MessageEmitter {
this.joinGameListener();
this.eventEmitter.emit(
this.messageProcessor.command as unknown as string,
this.messageProcessor.payload
this.messageProcessor.payload,
);
}
@ -64,7 +64,7 @@ export class MessageEmitter {
this.logger.info(`Emitting RECONNECT event for command ${Commands.RECONNECT}`);
this.eventEmitter.emit(
this.messageProcessor.command as unknown as string,
this.messageProcessor.payload
this.messageProcessor.payload,
);
}

View file

@ -8,7 +8,10 @@ const PONG_COMMAND = 0xfe;
export class RoomMessageEmitter {
private readonly messageProcessor: MessageProcessor;
constructor(private readonly client: Client, private readonly room: Room) {
constructor(
private readonly client: Client,
private readonly room: Room,
) {
this.messageProcessor = new MessageProcessor();
}
@ -26,7 +29,7 @@ export class RoomMessageEmitter {
this.room.emitRoomEvent(
this.messageProcessor.command as unknown as string,
this.messageProcessor.payload,
this.client
this.client,
);
this.processMessage();
}

View file

@ -0,0 +1,99 @@
import { EdoproBanList } from "./BanList";
describe("EdoproBanList", () => {
let banList: EdoproBanList;
beforeEach(() => {
banList = new EdoproBanList();
});
describe("add", () => {
it("should ignore NaN cardId", () => {
banList.add(NaN, 1);
expect(banList.limited).toHaveLength(0);
});
it("should add to forbidden list when quantity is 0", () => {
const cardId = 123;
banList.add(cardId, 0);
expect(banList.forbidden).toContain(cardId);
expect(banList.limited).not.toContain(cardId);
expect(banList.semiLimited).not.toContain(cardId);
expect(banList.all).not.toContain(cardId);
});
it("should add to limited list when quantity is 1", () => {
const cardId = 456;
banList.add(cardId, 1);
expect(banList.forbidden).not.toContain(cardId);
expect(banList.limited).toContain(cardId);
expect(banList.semiLimited).not.toContain(cardId);
expect(banList.all).not.toContain(cardId);
});
it("should add to semiLimited list when quantity is 2", () => {
const cardId = 789;
banList.add(cardId, 2);
expect(banList.forbidden).not.toContain(cardId);
expect(banList.limited).not.toContain(cardId);
expect(banList.semiLimited).toContain(cardId);
expect(banList.all).not.toContain(cardId);
});
it("should add to all list when quantity is 3", () => {
const cardId = 101112;
banList.add(cardId, 3);
expect(banList.forbidden).not.toContain(cardId);
expect(banList.limited).not.toContain(cardId);
expect(banList.semiLimited).not.toContain(cardId);
expect(banList.all).toContain(cardId);
});
it("should update hash when adding a card", () => {
const initialHash = banList.hash;
banList.add(123, 1);
expect(banList.hash).not.toBe(initialHash);
});
});
describe("points (Genesys third column)", () => {
it("should store the point cost when provided", () => {
const cardId = 21044178;
banList.add(cardId, 3, 100);
expect(banList.points.get(cardId)).toBe(100);
expect(banList.all).toContain(cardId);
});
it("should not store points when the third column is absent", () => {
const cardId = 456;
banList.add(cardId, 3);
expect(banList.points.has(cardId)).toBe(false);
});
it("should ignore a non-numeric point value", () => {
const cardId = 789;
banList.add(cardId, 3, NaN);
expect(banList.points.has(cardId)).toBe(false);
});
it("should not let points affect the hash", () => {
const withPoints = new EdoproBanList();
const withoutPoints = new EdoproBanList();
withPoints.add(123, 3, 50);
withoutPoints.add(123, 3);
expect(withPoints.hash).toBe(withoutPoints.hash);
});
});
describe("isGenesys", () => {
it("should return true if name is Genesys", () => {
banList.setName("Genesys");
expect(banList.isGenesys()).toBe(true);
});
it("should return false if name is not Genesys", () => {
banList.setName("OCG");
expect(banList.isGenesys()).toBe(false);
});
});
});

View file

@ -1,7 +1,7 @@
import { BanList } from "src/shared/ban-list/BanList";
export class EdoproBanList extends BanList {
add(cardId: number, quantity: number): void {
add(cardId: number, quantity: number, points?: number): void {
if (isNaN(cardId)) {
return;
}
@ -22,6 +22,10 @@ export class EdoproBanList extends BanList {
this.all.push(cardId);
}
if (points !== undefined && !isNaN(points)) {
this.points.set(cardId, points);
}
this._hash =
this._hash ^
((((cardId >>> 0) << 18) >> 0) | (cardId >> 14)) ^

View file

@ -5,8 +5,22 @@ import { join } from "path";
import { EdoproBanList } from "../domain/BanList";
import BanListMemoryRepository from "./BanListMemoryRepository";
import { BanListLoader } from "src/shared/ban-list/BanListLoader";
import { parseBanListEntry } from "src/shared/ban-list/parseBanListEntry";
export class EdoProBanListLoader extends BanListLoader {
/**
* When a target array is provided, parsed banlists are pushed into it instead
* of the shared BanListMemoryRepository. This enables the re-callable pure-builder
* pattern used by loadEdoproBanLists() (bootstrapBanListLoaders.ts) for hot-reload.
*/
private readonly _target: EdoproBanList[] | null;
private readonly _loaded: EdoproBanList[] = [];
constructor(target?: EdoproBanList[]) {
super();
this._target = target ?? null;
}
async loadDirectory(path: string): Promise<void> {
const directoryPath = path;
const files = await readdir(directoryPath);
@ -17,6 +31,11 @@ export class EdoProBanListLoader extends BanListLoader {
}
}
/** Returns all banlists parsed by this loader instance. */
getLoaded(): EdoproBanList[] {
return this._loaded;
}
private load(path: string): void {
const banList = new EdoproBanList();
@ -38,18 +57,24 @@ export class EdoProBanListLoader extends BanListLoader {
banList.setName(line.substring(1));
}
if (!line.includes(" ")) {
continue;
}
if (banList.name === null) {
continue;
}
const [cardId, quantity] = line.split(" ");
banList.add(Number(cardId), Number(quantity));
const entry = parseBanListEntry(line);
if (!entry) {
continue;
}
banList.add(entry.code, entry.limit, entry.points);
}
BanListMemoryRepository.add(banList);
this._loaded.push(banList);
if (this._target !== null) {
this._target.push(banList);
} else {
BanListMemoryRepository.add(banList);
}
}
}

View file

@ -0,0 +1,108 @@
import { EdoproBanList } from "../domain/BanList";
import BanListMemoryRepository from "./BanListMemoryRepository";
function makeList(name: string, hash?: number): EdoproBanList {
const list = new EdoproBanList();
list.setName(name);
if (hash !== undefined) {
list.add(hash, 1); // adds a card so hash is non-zero
}
return list;
}
describe("EdoproBanListMemoryRepository.replaceAll", () => {
beforeEach(() => {
// Clear the repository between tests using the internal array trick.
// replaceAll([]) empties it — once it exists; before it does we rely on the
// fact that the module array starts empty at process start and Jest isolates
// modules per test file only when configured with resetModules. We clear it
// via replaceAll once the method is available, but for the red-bar tests we
// seed and then call replaceAll.
//
// NOTE: The module-level array is shared across tests within one file.
// Use replaceAll([]) in afterEach once the method exists.
});
afterEach(() => {
// Reset to a clean state after each test.
// This call is intentionally calling the method under test — if it does not
// exist yet the teardown will also fail, which is correct for the red bar.
BanListMemoryRepository.replaceAll([]);
});
describe("basic replacement", () => {
it("replaceAll([a, b]) → get() returns [a, b]", () => {
const a = makeList("List A");
const b = makeList("List B");
BanListMemoryRepository.replaceAll([a, b]);
expect(BanListMemoryRepository.get()).toEqual([a, b]);
});
it("replaceAll([]) on a non-empty repo → get() returns []", () => {
const a = makeList("List A");
BanListMemoryRepository.replaceAll([a]);
BanListMemoryRepository.replaceAll([]);
expect(BanListMemoryRepository.get()).toHaveLength(0);
});
it("replaceAll called twice — second call overwrites first", () => {
const a = makeList("List A");
const b = makeList("List B");
BanListMemoryRepository.replaceAll([a]);
BanListMemoryRepository.replaceAll([b]);
expect(BanListMemoryRepository.get()).toEqual([b]);
});
});
describe("atomicity — synchronous swap invariant", () => {
it("get() returns the new list immediately after replaceAll returns — no empty window", () => {
// This test asserts the synchronous contract.
// replaceAll MUST NOT contain any await between emptying and refilling the array.
// Because JS is single-threaded, reading get() right after replaceAll()
// returns MUST yield the new list, never [].
const initial = makeList("Initial");
BanListMemoryRepository.replaceAll([initial]);
const next = makeList("Next");
BanListMemoryRepository.replaceAll([next]);
// Immediately after the call, the list is the new one — never empty.
const result = BanListMemoryRepository.get();
expect(result).toHaveLength(1);
expect(result[0]).toBe(next);
});
});
describe("findByHash / findByName operate on new list after replaceAll", () => {
it("findByName returns item from the new list", () => {
const old = makeList("Old List");
BanListMemoryRepository.replaceAll([old]);
const fresh = makeList("Fresh List");
BanListMemoryRepository.replaceAll([fresh]);
expect(BanListMemoryRepository.findByName("Fresh List")).toBe(fresh);
expect(BanListMemoryRepository.findByName("Old List")).toBeNull();
});
it("findByHash returns item from the new list", () => {
const a = makeList("A");
BanListMemoryRepository.replaceAll([a]);
const aHash = a.hash;
const b = new EdoproBanList();
b.setName("B");
b.add(99999, 1); // different card → different hash
BanListMemoryRepository.replaceAll([b]);
expect(BanListMemoryRepository.findByHash(aHash)).toBeNull();
expect(BanListMemoryRepository.findByHash(b.hash)).toBe(b);
});
});
});

View file

@ -32,4 +32,14 @@ export default {
getOnlyWithName(): string[] {
return banLists.filter((banList) => banList.name).map((item) => item.name as string);
},
/**
* Atomically replaces the entire banlist array with a new one.
* Uses a synchronous in-place swap (no await between truncation and fill)
* so no concurrent HTTP request can observe an empty-list window.
*/
replaceAll(next: EdoproBanList[]): void {
banLists.length = 0;
banLists.push(...next);
},
};

View file

@ -0,0 +1,36 @@
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import {
CdbCardSearchRepository,
CdbFile,
} from "@shared/card/infrastructure/cdb/CdbCardSearchRepository";
import { config } from "src/config";
export class EdoProCardSearchRepository extends CdbCardSearchRepository {
constructor(
private readonly directoryPaths: string[] = [`${config.resources.dir}/edopro/databases`],
) {
super({ lastSourceWins: true });
}
protected async *cdbFiles(): AsyncIterable<CdbFile> {
for (const directoryPath of this.directoryPaths) {
let files: string[];
try {
files = await readdir(directoryPath);
} catch {
continue;
}
for (const file of files) {
if (!file.endsWith(".cdb")) {
continue;
}
const path = join(directoryPath, file);
yield { path, read: () => readFile(path) };
}
}
}
}

View file

@ -1,11 +1,11 @@
import { dataSource } from "../../../../shared/db/sqlite/infrastructure/data-source";
import { getCardDataSource } from "../../../../shared/db/sqlite/infrastructure/data-source";
import { Card } from "../../../../shared/card/domain/Card";
import { CardRepository } from "../../../../shared/card/domain/CardRepository";
import { CardEntity } from "./CardEntity";
import { CardEntity } from "@shared/db/sqlite/infrastructure/CardEntity";
export class CardSQLiteTYpeORMRepository implements CardRepository {
async findByCode(code: string): Promise<Card | null> {
const repository = dataSource.getRepository(CardEntity);
const repository = getCardDataSource().getRepository(CardEntity);
const card = await repository.findOneBy({ id: code });
if (!card) {
return null;

View file

@ -0,0 +1,48 @@
import { CardDbReloader } from "@shared/db/sqlite/infrastructure/CardDbReloader";
import { Logger } from "@shared/logger/domain/Logger";
import LoggerFactory from "@shared/logger/infrastructure/LoggerFactory";
import { config } from "src/config";
import { EdoProCardDbPorts } from "./EdoProCardDbPorts";
import { EdoProSQLiteTypeORM } from "./EdoProSQLiteTypeORM";
const RELOAD_INTERVAL_MS = 10 * 60 * 1000;
// Reload mechanics (atomic in-place replace) live in EdoProCardDbPorts. Mirrors
// the YGOPro reload timer.
export class EdoProCardDbHotReload {
private static shared: EdoProCardDbHotReload | null = null;
private readonly logger: Logger = LoggerFactory.getLogger();
private readonly reloader: CardDbReloader;
constructor(directory: string = `${config.resources.dir}/edopro/databases`) {
this.reloader = new CardDbReloader(
new EdoProCardDbPorts(new EdoProSQLiteTypeORM([directory]), directory),
);
}
// The running instance, or null before start() runs. Lets the resource-version
// endpoint reach the card-db fingerprint without threading the instance through DI.
static getShared(): EdoProCardDbHotReload | null {
return EdoProCardDbHotReload.shared;
}
// The fingerprint of the currently loaded EDOPro card DB, or null before the first prime().
get fingerprint(): string | null {
return this.reloader.currentFingerprintValue;
}
// Record the boot fingerprint, then poll for changes. The boot datasource is
// already built/merged by bootstrapPersistence, so we only prime here.
async start(): Promise<void> {
EdoProCardDbHotReload.shared = this;
await this.reloader.prime();
setInterval(() => {
this.reloader.reloadIfChanged().catch((error) => {
this.logger.error("Failed reloading EDOPro card DB");
this.logger.error(error);
});
}, RELOAD_INTERVAL_MS);
}
}

View file

@ -0,0 +1,83 @@
import { rename, rm } from "node:fs/promises";
import type { DataSource } from "typeorm";
import { CARD_DB_FILE } from "@shared/db/sqlite/infrastructure/data-source";
import { EdoProCardDbPorts } from "./EdoProCardDbPorts";
import type { EdoProSQLiteTypeORM } from "./EdoProSQLiteTypeORM";
jest.mock("node:fs/promises", () => ({
rename: jest.fn(async () => undefined),
rm: jest.fn(async () => undefined),
readdir: jest.fn(async () => []),
stat: jest.fn(async () => ({ size: 0, mtimeMs: 0 })),
}));
const renameMock = rename as jest.Mock;
const rmMock = rm as jest.Mock;
const fakeOrm = (build: jest.Mock): EdoProSQLiteTypeORM =>
({ build }) as unknown as EdoProSQLiteTypeORM;
describe("EdoProCardDbPorts", () => {
beforeEach(() => {
renameMock.mockClear();
renameMock.mockResolvedValue(undefined);
rmMock.mockClear();
rmMock.mockResolvedValue(undefined);
});
it("builds into a temp file then renames it onto the canonical evolution_cards.db", async () => {
const builtDs = { isInitialized: true } as unknown as DataSource;
const build = jest.fn((_file: string) => Promise.resolve(builtDs));
const ports = new EdoProCardDbPorts(fakeOrm(build), "dir");
const result = await ports.build();
expect(build).toHaveBeenCalledTimes(1);
const tempFile = build.mock.calls[0][0] as string;
expect(tempFile).not.toBe(CARD_DB_FILE);
expect(renameMock).toHaveBeenCalledWith(tempFile, CARD_DB_FILE);
expect(result).toBe(builtDs);
});
it("propagates an orm.build failure and never renames a failed build onto the canonical file", async () => {
const build = jest.fn((_file: string) => Promise.reject(new Error("merge failed")));
const ports = new EdoProCardDbPorts(fakeOrm(build), "dir");
await expect(ports.build()).rejects.toThrow("merge failed");
expect(renameMock).not.toHaveBeenCalled();
});
it("never deletes the canonical file when disposing the previous datasource", async () => {
jest.useFakeTimers();
const previous = { destroy: jest.fn(async () => undefined) } as unknown as DataSource;
const ports = new EdoProCardDbPorts(fakeOrm(jest.fn()), "dir", 1000);
await ports.destroy(previous);
expect(previous.destroy).not.toHaveBeenCalled(); // deferred by the grace
await jest.advanceTimersByTimeAsync(1000);
expect(previous.destroy).toHaveBeenCalledTimes(1);
expect(rmMock).not.toHaveBeenCalled(); // the C++ core depends on evolution_cards.db
jest.useRealTimers();
});
it("cleans up the temp and rethrows if the rename fails, leaving the canonical file untouched", async () => {
const builtDs = {
isInitialized: true,
destroy: jest.fn(async () => undefined),
} as unknown as DataSource;
const build = jest.fn((_file: string) => Promise.resolve(builtDs));
renameMock.mockRejectedValueOnce(new Error("EXDEV"));
const ports = new EdoProCardDbPorts(fakeOrm(build), "dir");
await expect(ports.build()).rejects.toThrow("EXDEV");
const tempFile = build.mock.calls[0][0] as string;
expect(builtDs.destroy).toHaveBeenCalledTimes(1);
expect(rmMock).toHaveBeenCalledWith(tempFile, { force: true });
expect(rmMock).not.toHaveBeenCalledWith(CARD_DB_FILE, expect.anything());
});
});

View file

@ -0,0 +1,86 @@
import { readdir, rename, rm, stat } from "node:fs/promises";
import { join } from "node:path";
import { DataSource } from "typeorm";
import { CardDbReloaderPorts } from "@shared/db/sqlite/infrastructure/CardDbReloader";
import { CARD_DB_FILE, swapCardDataSource } from "@shared/db/sqlite/infrastructure/data-source";
import { Logger } from "@shared/logger/domain/Logger";
import LoggerFactory from "@shared/logger/infrastructure/LoggerFactory";
import type { EdoProSQLiteTypeORM } from "./EdoProSQLiteTypeORM";
const DISPOSE_GRACE_MS = 60 * 1000;
const TEMP_DB_FILE = `${CARD_DB_FILE}.tmp`;
// CardDbReloader ports for the EDOPro card DB. The C++ core (CardSqliteRepository)
// opens the fixed path CARD_DB_FILE fresh per duel, so the reload must keep that
// file as the single canonical artifact: build into a temp, then atomically rename
// it onto CARD_DB_FILE. A running duel keeps its already-open inode (untouched); a
// new duel opens the replaced file. The file is never a sidecar the core can't see
// and is never deleted.
export class EdoProCardDbPorts implements CardDbReloaderPorts {
private readonly logger: Logger = LoggerFactory.getLogger();
constructor(
private readonly orm: EdoProSQLiteTypeORM,
private readonly directory: string,
private readonly graceMs: number = DISPOSE_GRACE_MS,
) {}
// Fingerprint from each file's size + mtime instead of hashing contents, so the
// periodic check never reads/hashes hundreds of MB on the shared event loop.
async fingerprint(): Promise<string> {
const files = (await readdir(this.directory)).filter((file) => file.endsWith(".cdb")).sort();
const parts: string[] = [];
for (const file of files) {
try {
const { size, mtimeMs } = await stat(join(this.directory, file));
parts.push(`${file}:${size}:${mtimeMs}`);
} catch {
// file vanished between readdir and stat — skip it
}
}
return parts.join("|");
}
async build(): Promise<DataSource> {
// Drop any temp left by a crashed earlier build so we merge from a clean slate.
await rm(TEMP_DB_FILE, { force: true }).catch(() => undefined);
const dataSource = await this.orm.build(TEMP_DB_FILE);
try {
// Atomic on the same filesystem: the just-built datasource's open fd follows
// the inode, so it becomes CARD_DB_FILE; the previous file's inode lives on
// (held by the old datasource) until it is disposed.
await rename(TEMP_DB_FILE, CARD_DB_FILE);
return dataSource;
} catch (error) {
await dataSource.destroy().catch(() => undefined);
await rm(TEMP_DB_FILE, { force: true }).catch(() => undefined);
throw error;
}
}
swap(next: DataSource): DataSource {
return swapCardDataSource(next);
}
// Defer the close so in-flight findByCode calls on the old datasource finish.
// Only the connection is closed — CARD_DB_FILE is never removed (build() already
// replaced it in place, and the C++ core opens that path).
destroy(previous: DataSource): Promise<void> {
setTimeout(() => void this.retire(previous), this.graceMs).unref();
return Promise.resolve();
}
private async retire(previous: DataSource): Promise<void> {
try {
await previous.destroy();
} catch (error) {
this.logger.error("Failed disposing the previous EDOPro card datasource");
this.logger.error(error);
}
}
}

View file

@ -0,0 +1,74 @@
import { readdir, rm } from "fs/promises";
import { join } from "path";
import { DataSource } from "typeorm";
import { Database } from "../../../../evolution-types/src/Database";
import {
buildCardDataSource,
getCardDataSource,
} from "@shared/db/sqlite/infrastructure/data-source";
import { config } from "src/config";
export class EdoProSQLiteTypeORM implements Database {
private readonly directoryPaths: string[];
constructor(directoryPaths?: string[]) {
this.directoryPaths = directoryPaths ?? [`${config.resources.dir}/edopro/databases`];
}
async connect(): Promise<void> {
await getCardDataSource().initialize();
}
async initialize(): Promise<void> {
await this.mergeAll(getCardDataSource());
}
// Build a fresh datasource backed by `databaseFile`, merge every .cdb into it,
// and return it ready to be swapped in. Never touches the live datasource, so a
// rebuild can run while the current one is still serving lookups.
async build(databaseFile: string): Promise<DataSource> {
const dataSource = buildCardDataSource(databaseFile);
try {
await dataSource.initialize();
await this.mergeAll(dataSource);
return dataSource;
} catch (error) {
// A half-built datasource would otherwise leak its connection + file.
if (dataSource.isInitialized) {
await dataSource.destroy().catch(() => undefined);
}
await rm(databaseFile, { force: true }).catch(() => undefined);
throw error;
}
}
private async mergeAll(dataSource: DataSource): Promise<void> {
for (const directoryPath of this.directoryPaths) {
const files = await readdir(directoryPath);
const cdbFiles = files.filter((file) => file.endsWith(".cdb"));
for (const file of cdbFiles) {
await this.merge(dataSource, join(directoryPath, file));
}
}
}
private async merge(dataSource: DataSource, path: string): Promise<void> {
const queryRunner = dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
await queryRunner.query(`ATTACH DATABASE '${path}' AS toMerge`);
await queryRunner.query("INSERT OR REPLACE INTO datas SELECT * FROM toMerge.datas");
await queryRunner.query("INSERT OR REPLACE INTO texts SELECT * FROM toMerge.texts");
await queryRunner.commitTransaction();
await queryRunner.query("DETACH toMerge");
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
}

View file

@ -0,0 +1,168 @@
import { Logger } from "@shared/logger/domain/Logger";
import { ISocket } from "@shared/socket/domain/ISocket";
import { Client } from "./Client";
import { Deck } from "@shared/deck/domain/Deck";
import { Choose } from "@edopro/rock-paper-scissor/RockPaperScissor";
import { Room } from "@edopro/room/domain/Room";
import { RoomMessageEmitter } from "@edopro/RoomMessageEmitter";
import { MessageProcessor, ClientMessage } from "@shared/messages/MessageProcessor";
jest.mock("@edopro/RoomMessageEmitter");
jest.mock("@shared/messages/MessageProcessor");
describe("Client", () => {
let client: Client;
let mockSocket: jest.Mocked<ISocket>;
let mockLogger: jest.Mocked<Logger>;
let mockRoom: jest.Mocked<Room>;
beforeEach(() => {
mockSocket = {
id: "socket-id",
send: jest.fn(),
onMessage: jest.fn(),
onClose: jest.fn(),
close: jest.fn(),
destroy: jest.fn(),
remoteAddress: "127.0.0.1",
roomId: 1,
closed: false,
removeAllListeners: jest.fn(),
};
mockLogger = {
debug: jest.fn(),
info: jest.fn(),
error: jest.fn(),
child: jest.fn().mockReturnThis(),
} as unknown as jest.Mocked<Logger>;
mockRoom = {} as unknown as jest.Mocked<Room>;
client = new Client({
socket: mockSocket,
host: true,
name: "TestPlayer",
position: 1,
roomId: 100,
team: 0,
logger: mockLogger,
id: "user-id",
});
});
it("should initialize correctly", () => {
expect(client.name).toBe("TestPlayer");
expect(client.position).toBe(1);
expect(client.roomId).toBe(100);
expect(client.team).toBe(0);
expect(client.host).toBe(true);
expect(mockLogger.child).toHaveBeenCalled();
});
it("should set socket and attach listeners", () => {
const newSocket = { ...mockSocket, id: "new-socket-id" };
const mockHandleMessage = jest.fn();
const mockRead = jest.fn();
(RoomMessageEmitter as jest.Mock).mockImplementation(() => ({
handleMessage: mockHandleMessage,
}));
(MessageProcessor as jest.Mock).mockImplementation(() => ({
read: mockRead,
}));
client.setSocket(newSocket, [], mockRoom);
expect(client.socket.id).toBe("new-socket-id");
expect(newSocket.onMessage).toHaveBeenCalled();
// Simulate message
const messageHandler = (newSocket.onMessage as jest.Mock).mock.calls[0][0];
const data = Buffer.from("test");
messageHandler(data);
expect(mockHandleMessage).toHaveBeenCalledWith(data);
expect(mockRead).toHaveBeenCalledWith(data);
});
it("should handle socket without remote address", () => {
const socketWithoutIp = { ...mockSocket, remoteAddress: undefined };
client.setSocket(socketWithoutIp, [], mockRoom);
expect((client as any)._ipAddress).toBeNull();
});
it("should manage RPS choice", () => {
expect(client.rpsChoise).toBeNull();
client.setRpsChosen("SCISSOR");
expect(client.rpsChoise).toBe("SCISSOR");
client.clearRpsChoise();
expect(client.rpsChoise).toBeNull();
});
it("should set readiness", () => {
client.ready();
expect(client.isReady).toBe(true);
client.notReady();
expect(client.isReady).toBe(false);
});
it("should manage deck", () => {
const mockDeck = {} as Deck;
client.setDeck(mockDeck);
expect(client.deck).toBe(mockDeck);
});
it("should manage duel position", () => {
client.setDuelPosition(5);
expect(client.duelPosition).toBe(5);
});
it("should manage turn state", () => {
client.turn();
expect(client.inTurn).toBe(true);
client.clearTurn();
expect(client.inTurn).toBe(false);
});
it("should manage reconnect flag", () => {
client.setCanReconnect(true);
expect(client.canReconnect).toBe(true);
client.setCanReconnect(false);
expect(client.canReconnect).toBe(false);
});
it("should send message via socket", () => {
const message = Buffer.from("hello");
client.sendMessage(message);
expect(mockSocket.send).toHaveBeenCalledWith(message);
});
it("should manage deck update state", () => {
expect(client.isUpdatingDeck).toBeFalsy();
client.updatingDeck();
expect(client.isUpdatingDeck).toBe(true);
client.deckUpdated();
expect(client.isUpdatingDeck).toBe(false);
});
it("should manage ready command", () => {
const mockMessage = {} as ClientMessage;
expect(client.haveReadyCommand).toBeFalsy();
client.saveReadyCommand(mockMessage);
expect(client.haveReadyCommand).toBe(true);
expect(client.readyMessage).toBe(mockMessage);
client.clearReadyCommand();
expect(client.haveReadyCommand).toBe(false);
});
});

View file

@ -7,7 +7,7 @@ import { Choose } from "../../rock-paper-scissor/RockPaperScissor";
import { Room } from "../../room/domain/Room";
import { RoomMessageEmitter } from "../../RoomMessageEmitter";
export class Listener { }
export class Listener {}
export class Client extends YgoClient {
public readonly listener: Listener;

View file

@ -0,0 +1,176 @@
import { EdoproBanList } from "@edopro/ban-list/domain/BanList";
import BanListMemoryRepository from "@edopro/ban-list/infrastructure/BanListMemoryRepository";
import { Card } from "@shared/card/domain/Card";
import { CardRepository } from "@shared/card/domain/CardRepository";
import { CardTypes } from "@shared/card/domain/CardTypes";
import { Rule } from "@shared/deck/domain/Rule";
import { DeckCreator } from "./DeckCreator";
import { DeckRules } from "@shared/room/domain/YgoRoom";
// Mock dependencies
const mockCardRepository: jest.Mocked<CardRepository> = {
findByCode: jest.fn(),
};
jest.mock("@edopro/ban-list/infrastructure/BanListMemoryRepository");
describe("DeckCreator", () => {
let deckCreator: DeckCreator;
const duelFlags = 0n;
const deckRules = new DeckRules({
mainMin: 40,
mainMax: 60,
extraMin: 0,
extraMax: 15,
sideMin: 0,
sideMax: 15,
rule: Rule.OCG_TCG,
maxDeckPoints: 100,
});
beforeEach(() => {
jest.clearAllMocks();
deckCreator = new DeckCreator(mockCardRepository, deckRules, duelFlags);
});
const createCard = (code: string, type: number): Card => {
return new Card({
code,
type,
alias: "0",
category: 0,
variant: 0,
});
};
it("should build a deck correctly with main and side cards", async () => {
const mainCodes = [123, 456];
const sideCodes = [789];
const banListHash = 111;
const card1 = createCard("123", CardTypes.TYPE_MONSTER);
const card2 = createCard("456", CardTypes.TYPE_SPELL);
const card3 = createCard("789", CardTypes.TYPE_TRAP);
mockCardRepository.findByCode.mockResolvedValueOnce(card1);
mockCardRepository.findByCode.mockResolvedValueOnce(card2);
mockCardRepository.findByCode.mockResolvedValueOnce(card3);
const mockBanList = new EdoproBanList();
(BanListMemoryRepository.findByHash as jest.Mock).mockReturnValue(mockBanList);
const deck = await deckCreator.build({
main: mainCodes,
side: sideCodes,
banListHash,
});
expect(deck.main).toHaveLength(2);
expect(deck.main).toContain(card1);
expect(deck.main).toContain(card2);
expect(deck.side).toHaveLength(1);
expect(deck.side).toContain(card3);
expect(deck.extra).toHaveLength(0);
expect(BanListMemoryRepository.findByHash).toHaveBeenCalledWith(banListHash);
});
it("should separate extra deck cards from main deck", async () => {
const mainCodes = [100, 200];
const sideCodes = [];
const banListHash = 111;
const fusionCard = createCard("100", CardTypes.TYPE_MONSTER | CardTypes.TYPE_FUSION);
const normalCard = createCard("200", CardTypes.TYPE_MONSTER);
mockCardRepository.findByCode.mockResolvedValueOnce(fusionCard);
mockCardRepository.findByCode.mockResolvedValueOnce(normalCard);
const deck = await deckCreator.build({
main: mainCodes,
side: sideCodes,
banListHash,
});
expect(deck.main).toHaveLength(1);
expect(deck.main).toContain(normalCard);
expect(deck.extra).toHaveLength(1);
expect(deck.extra).toContain(fusionCard);
});
it("should handle Ritual cards in extra deck when flag is enabled", async () => {
const ritualFlag = 0x800000000n;
deckCreator = new DeckCreator(mockCardRepository, deckRules, ritualFlag);
const mainCodes = [300];
const sideCodes = [];
const banListHash = 111;
const ritualCard = createCard("300", CardTypes.TYPE_MONSTER | CardTypes.TYPE_RITUAL);
mockCardRepository.findByCode.mockResolvedValueOnce(ritualCard);
const deck = await deckCreator.build({
main: mainCodes,
side: sideCodes,
banListHash,
});
expect(deck.main).toHaveLength(0);
expect(deck.extra).toHaveLength(1);
expect(deck.extra).toContain(ritualCard);
});
it("should keep Ritual cards in main deck when flag is disabled", async () => {
const mainCodes = [300];
const sideCodes = [];
const banListHash = 111;
const ritualCard = createCard("300", CardTypes.TYPE_MONSTER | CardTypes.TYPE_RITUAL);
mockCardRepository.findByCode.mockResolvedValueOnce(ritualCard);
const deck = await deckCreator.build({
main: mainCodes,
side: sideCodes,
banListHash,
});
expect(deck.main).toHaveLength(1);
expect(deck.main).toContain(ritualCard);
expect(deck.extra).toHaveLength(0);
});
it("should ignore missing cards", async () => {
const mainCodes = [999];
const sideCodes = [888];
const banListHash = 111;
mockCardRepository.findByCode.mockResolvedValue(null);
const deck = await deckCreator.build({
main: mainCodes,
side: sideCodes,
banListHash,
});
expect(deck.main).toHaveLength(0);
expect(deck.side).toHaveLength(0);
});
it("should use default BanList if hash is not found", async () => {
const mainCodes: number[] = [];
const sideCodes: number[] = [];
const banListHash = 999;
(BanListMemoryRepository.findByHash as jest.Mock).mockReturnValue(null);
const deck = await deckCreator.build({
main: mainCodes,
side: sideCodes,
banListHash,
});
expect(deck).toBeDefined();
expect(BanListMemoryRepository.findByHash).toHaveBeenCalledWith(banListHash);
});
});

View file

@ -32,7 +32,6 @@ export class DeckCreator {
const placeRitualInExtraDeckEnabled = this.placeRitualInExtraDeckEnabled();
for (const code of main) {
const card = await this.cardRepository.findByCode(code.toString());
if (!card) {
continue;
@ -46,7 +45,6 @@ export class DeckCreator {
}
for (const code of side) {
const card = await this.cardRepository.findByCode(code.toString());
if (!card) {
continue;

View file

@ -0,0 +1,41 @@
import { UpdateDeckMessageParser } from "./UpdateDeckMessageSizeCalculator";
describe("UpdateDeckMessageParser", () => {
it("should parse deck correctly", () => {
const mainAndExtraCount = 2;
const sideCount = 1;
const card1 = 123;
const card2 = 456;
const card3 = 789;
const buffer = Buffer.alloc(8 + (mainAndExtraCount + sideCount) * 4);
buffer.writeUInt32LE(mainAndExtraCount, 0);
buffer.writeUInt32LE(sideCount, 4);
buffer.writeUInt32LE(card1, 8);
buffer.writeUInt32LE(card2, 12);
buffer.writeUInt32LE(card3, 16);
const parser = new UpdateDeckMessageParser(buffer);
const [mainDeck, sideDeck] = parser.getDeck();
expect(mainDeck).toHaveLength(2);
expect(mainDeck).toEqual([card1, card2]);
expect(sideDeck).toHaveLength(1);
expect(sideDeck).toEqual([card3]);
});
it("should handle empty decks", () => {
const mainAndExtraCount = 0;
const sideCount = 0;
const buffer = Buffer.alloc(8);
buffer.writeUInt32LE(mainAndExtraCount, 0);
buffer.writeUInt32LE(sideCount, 4);
const parser = new UpdateDeckMessageParser(buffer);
const [mainDeck, sideDeck] = parser.getDeck();
expect(mainDeck).toHaveLength(0);
expect(sideDeck).toHaveLength(0);
});
});

View file

@ -0,0 +1,147 @@
import { JSONMessageProcessor } from "./JSONMessageProcessor";
describe("JSONMessageProcessor", () => {
let processor: JSONMessageProcessor;
beforeEach(() => {
processor = new JSONMessageProcessor();
});
it("should initialize with empty buffer", () => {
expect(processor.currentBuffer.length).toBe(0);
expect(processor.bufferLength).toBe(0);
});
it("should accumulate data in buffer", () => {
processor.read(Buffer.from("abc"));
expect(processor.bufferLength).toBe(3);
processor.read(Buffer.from("def"));
expect(processor.bufferLength).toBe(6);
});
it("should identify when a message is ready", () => {
// Size (4 bytes LE) + Data
const data = Buffer.from("test");
const size = Buffer.alloc(4);
size.writeUInt32LE(data.length, 0);
// Not enough for size header
processor.read(size.subarray(0, 2));
expect(processor.isMessageReady()).toBe(false);
// Size header complete, but not enough data
processor.read(size.subarray(2));
expect(processor.isMessageReady()).toBe(false);
// Full message
processor.read(data);
expect(processor.isMessageReady()).toBe(true);
});
it("should process a valid message", () => {
const payload = "test message";
const data = Buffer.from(payload, "utf-8");
const size = Buffer.alloc(4);
size.writeUInt32LE(data.length, 0);
processor.read(size);
processor.read(data);
processor.process();
expect(processor.size).toBe(data.length);
expect(processor.payload.data).toBe(payload);
expect(processor.bufferLength).toBe(0);
});
it("should handle partial message processing", () => {
const payload = "short";
const data = Buffer.from(payload, "utf-8");
const size = Buffer.alloc(4);
size.writeUInt32LE(data.length, 0);
processor.read(size);
// Don't read full data yet
processor.read(data.subarray(0, 2));
processor.process();
// Message not ready, nothing should happen
expect(processor.size).toBeUndefined();
expect(processor.payload.data).toBe("");
// Read rest
processor.read(data.subarray(2));
processor.process();
expect(processor.size).toBe(data.length);
expect(processor.payload.data).toBe(payload);
});
it("should handle multiple messages in stream", () => {
const msg1 = "message1";
const msg2 = "message2";
const buf1 = Buffer.concat([
(() => {
const b = Buffer.alloc(4);
b.writeUInt32LE(msg1.length);
return b;
})(),
Buffer.from(msg1),
]);
const buf2 = Buffer.concat([
(() => {
const b = Buffer.alloc(4);
b.writeUInt32LE(msg2.length);
return b;
})(),
Buffer.from(msg2),
]);
processor.read(buf1);
processor.read(buf2);
expect(processor.isMessageReady()).toBe(true);
processor.process();
expect(processor.payload.data).toBe(msg1);
expect(processor.isMessageReady()).toBe(true);
processor.process();
expect(processor.payload.data).toBe(msg2);
expect(processor.bufferLength).toBe(0);
});
it("should clear state", () => {
processor.read(Buffer.from([1, 2, 3]));
processor.clear();
expect(processor.bufferLength).toBe(0);
expect(processor.size).toBe(0);
expect(processor.payload.data).toBe("");
});
it("should handle previous message (though logic is currently commented out or minimal)", () => {
// This test is to cover lines 30-32 if they were active, but currently they are mostly empty.
// We'll simulate processing twice to ensure stability.
const msg = "test";
const buf = Buffer.concat([
(() => {
const b = Buffer.alloc(4);
b.writeUInt32LE(msg.length);
return b;
})(),
Buffer.from(msg),
]);
processor.read(buf);
processor.process();
// Mocking a second read/process to hit the line 30 `if (this._data.length)` path potentially
processor.read(buf);
processor.process();
expect(processor.payload.data).toBe(msg);
});
});

View file

@ -1,6 +1,3 @@
export interface JSONClientMessage {
data: string;
// previousMessage: Buffer;
@ -10,9 +7,7 @@ export interface JSONClientMessage {
export class JSONMessageProcessor {
private buffer: Buffer;
private _size: number;
private readonly _command: number;
private _data: string;
private readonly _previousMessage: Buffer;
constructor() {
this.buffer = Buffer.alloc(0);
@ -27,13 +22,8 @@ export class JSONMessageProcessor {
if (!this.isMessageReady()) {
return;
}
if (this._data.length) {
// this._previousMessage = this._data;
}
this._size = this.buffer.readUint32LE(0);
this._data = this.buffer
.subarray(4, this._size + 4)
.toString("utf-8");
this._data = this.buffer.subarray(4, this._size + 4).toString("utf-8");
this.buffer = this.buffer.subarray(this._size + 4);
}

View file

@ -1,2 +1 @@
export interface Message {}

View file

@ -0,0 +1,33 @@
import { JoinGameMessage } from "./JoinGameMessage";
describe("JoinGameMessage", () => {
it("should parse correctly", () => {
const buffer = Buffer.alloc(JoinGameMessage.MAX_BYTES_LENGTH);
buffer.writeUInt16LE(1, 0); // version2
// Skip 2 bytes padding?
buffer.writeUInt32LE(12345, 4); // id
const password = "password";
const passwordBuffer = Buffer.from(password, "utf16le");
passwordBuffer.copy(buffer, 8);
buffer.writeUInt32LE(54321, 46); // clientVersion
const message = new JoinGameMessage(buffer);
expect(message.version2).toBe(1);
expect(message.id).toBe(12345);
// Note: The logic in JoinGameMessage is:
// this.password = Buffer.from(new TextVO(buffer.subarray(8, 48)).value).toString("utf16le");
// TextVO probably strips null bytes. Let's see if we can match the password.
// Assuming TextVO handles utf16le correctly or strips zeros and then we interpret as utf16le?
// Wait, TextVO usually treats input as string or bytes and cleans it.
// If TextVO cleans nulls, then Buffer.from(...).toString("utf16le") might be tricky.
// Let's assume simpler check first.
// If the source code says Buffer.from(new TextVO(...).value).toString("utf16le"), it implies TextVO returns a string (utf8?) or buffer?
// Let's check TextVO.
expect(message.clientVersion).toBe(54321);
});
});

View file

@ -0,0 +1,48 @@
import { PlayerInfoMessage } from "./PlayerInfoMessage";
describe("PlayerInfoMessage", () => {
it("should parse name only", () => {
const name = "Player1";
const buffer = Buffer.from(name, "utf16le");
const message = new PlayerInfoMessage(buffer, buffer.length);
expect(message.name).toBe(name);
expect(message.password).toBeNull();
expect(message.hasMercurySignature).toBe(false);
});
it("should parse name and password", () => {
const name = "Player1";
const password = "1234";
const fullString = `${name}:${password}`;
const buffer = Buffer.from(fullString, "utf16le");
const message = new PlayerInfoMessage(buffer, buffer.length);
expect(message.name).toBe(name);
expect(message.password).toBe(password);
expect(message.hasMercurySignature).toBe(false);
});
it("should ignore extra characters after the first 4 password characters", () => {
const name = "Player1";
const fullString = `${name}:1234$5678`;
const buffer = Buffer.from(fullString, "utf16le");
const message = new PlayerInfoMessage(buffer, buffer.length);
expect(message.name).toBe(name);
expect(message.password).toBe("1234");
expect(message.hasMercurySignature).toBe(true);
});
it("should ignore extra characters in the name when no password is provided", () => {
const buffer = Buffer.from("Player1$5678", "utf16le");
const message = new PlayerInfoMessage(buffer, buffer.length);
expect(message.name).toBe("Player1");
expect(message.password).toBeNull();
expect(message.hasMercurySignature).toBe(true);
});
});

View file

@ -1,13 +1,9 @@
import * as lzma from "lzma-native";
import { promisify } from "util";
import { UTF8ToUTF16 } from "../../utils/UTF8ToUTF16";
import { Client } from "../client/domain/Client";
const lzmaCompress = promisify(lzma.compress);
enum ReplayTypes {
@ -170,15 +166,13 @@ export class Replay {
size += 4 + this._extraCards.length * 4;
size += this._responses.reduce(
(responseSize, response) => responseSize + response.length + 1,
0
0,
);
return size;
}
async compressData(data: Buffer): Promise<Buffer> {
return lzmaCompress(data, {
preset: 5,
dictSize: 1 << 24,

View file

@ -1,5 +1,5 @@
import { Choose, Result, RockPaperScissor } from "../../../src/edopro/rock-paper-scissor/RockPaperScissor";
import { RuleNotFoundError } from "../../../src/edopro/rock-paper-scissor/RuleNotFoundError";
import { Choose, Result, RockPaperScissor } from "./RockPaperScissor";
import { RuleNotFoundError } from "./RuleNotFoundError";
describe("<RockPaperScissor>", () => {
let SUT: RockPaperScissor;

View file

@ -1,48 +0,0 @@
import { EventEmitter } from "stream";
import { Logger } from "../../../shared/logger/domain/Logger";
import { ISocket } from "../../../shared/socket/domain/ISocket";
import { Commands } from "../../../shared/messages/Commands";
import { ClientMessage } from "../../../shared/messages/MessageProcessor";
import RoomList from "../infrastructure/RoomList";
import { Client } from "../../client/domain/Client";
import { TokenIndex } from "../../../shared/room/domain/TokenIndex";
export class ExpressReconnectHandler {
constructor(
private readonly eventEmitter: EventEmitter,
private readonly logger: Logger,
private readonly socket: ISocket
) {
this.eventEmitter.on(
Commands.RECONNECT as unknown as string,
(message: ClientMessage) => void this.handle(message)
);
}
async handle(message: ClientMessage): Promise<void> {
this.logger.info("Express reconnect handle started");
const token = message.data.toString("utf8");
this.logger.info(`Checking token: ${token}`);
const entry = TokenIndex.getInstance().find(token);
if (entry && entry.client instanceof Client) {
const player = entry.client as Client;
this.logger.info(`MATCH! Found player ${player.name} in room ${entry.roomId}`);
const room = RoomList.getRooms().find(r => r.id === entry.roomId);
if (room) {
room.emit("EXPRESS_RECONNECT", message, this.socket);
return;
}
}
this.logger.info(`FAILED: No player found for token: ${token}`);
const type = Buffer.from([0xfd]);
const status = Buffer.from([0x01]);
const data = Buffer.concat([type, status]);
const size = Buffer.alloc(2);
size.writeUint16LE(data.length);
this.socket.send(Buffer.concat([size, data]));
this.socket.destroy();
}
}

View file

@ -0,0 +1,177 @@
import "reflect-metadata";
import { container } from "@shared/dependency-injection";
import { EventBus } from "@shared/event-bus/EventBus";
import WebSocketSingleton from "../../../web-socket-server/WebSocketSingleton";
import { Client } from "@edopro/client/domain/Client";
import { FinishDuelHandler } from "./FinishDuelHandler";
import { DuelFinishReason } from "@edopro/room/domain/DuelFinishReason";
import { Room } from "@edopro/room/domain/Room";
import { Replay } from "@edopro/replay/Replay";
// Mock dependencies
jest.mock("@shared/dependency-injection");
jest.mock("@shared/event-bus/EventBus");
jest.mock("../../../web-socket-server/WebSocketSingleton");
jest.mock("@edopro/client/domain/Client");
jest.mock("@edopro/room/domain/Room");
describe("FinishDuelHandler", () => {
let handler: FinishDuelHandler;
let mockRoom: jest.Mocked<Room>;
let mockEventBus: jest.Mocked<EventBus>;
let mockWebSocketSingleton: jest.Mocked<WebSocketSingleton>;
let mockClient1: jest.Mocked<Client>;
let mockClient2: jest.Mocked<Client>;
let mockSpectator: jest.Mocked<Client>;
let mockReplay: jest.Mocked<Replay>;
beforeEach(() => {
// Mock WebSocketSingleton
mockWebSocketSingleton = {
broadcast: jest.fn(),
} as unknown as jest.Mocked<WebSocketSingleton>;
(WebSocketSingleton.getInstance as jest.Mock).mockReturnValue(mockWebSocketSingleton);
// Mock EventBus
mockEventBus = {
publish: jest.fn(),
} as unknown as jest.Mocked<EventBus>;
(container.get as jest.Mock).mockReturnValue(mockEventBus);
// Mock Clients
mockClient1 = new Client({} as any) as jest.Mocked<Client>;
Object.assign(mockClient1, {
sendMessage: jest.fn(),
notReady: jest.fn(),
position: 0,
team: 0,
});
mockClient2 = new Client({} as any) as jest.Mocked<Client>;
Object.assign(mockClient2, {
sendMessage: jest.fn(),
notReady: jest.fn(),
position: 1,
team: 1,
});
mockSpectator = new Client({} as any) as jest.Mocked<Client>;
Object.assign(mockSpectator, {
sendMessage: jest.fn(),
});
// Mock Replay
mockReplay = {
addMessage: jest.fn(),
addPlayers: jest.fn(),
serialize: jest.fn().mockResolvedValue(Buffer.from("replay-data")),
} as unknown as jest.Mocked<Replay>;
// Mock Room
mockRoom = {
duelWinner: jest.fn(),
toRealTimePresentation: jest.fn().mockReturnValue({}),
stopRoomTimer: jest.fn(),
stopTimer: jest.fn(),
clearSpectatorCache: jest.fn(),
players: [mockClient1, mockClient2],
spectators: [mockSpectator],
clients: [mockClient1, mockClient2, mockSpectator],
score: "0-0",
firstToPlay: 0,
replay: mockReplay,
resetReplay: jest.fn(),
isMatchFinished: jest.fn().mockReturnValue(false),
sideDecking: jest.fn(),
setClientWhoChoosesTurn: jest.fn(),
destroy: jest.fn(),
team0: 1,
team1: 1,
} as unknown as jest.Mocked<Room>;
handler = new FinishDuelHandler({
reason: DuelFinishReason.SURRENDERED,
winner: 1,
room: mockRoom,
});
});
it("should handle duel finish with surrender (not match finish)", async () => {
await handler.run();
expect(mockRoom.duelWinner).toHaveBeenCalledWith(1);
expect(mockWebSocketSingleton.broadcast).toHaveBeenCalledWith({
action: "UPDATE-ROOM",
data: {},
});
expect(mockRoom.stopRoomTimer).toHaveBeenCalled();
expect(mockRoom.stopTimer).toHaveBeenCalledTimes(2);
expect(mockRoom.clearSpectatorCache).toHaveBeenCalled();
// Verify score message sent
expect(mockClient1.sendMessage).toHaveBeenCalled();
expect(mockSpectator.sendMessage).toHaveBeenCalled();
// Verify win message (due to surrender)
expect(mockRoom.replay.addMessage).toHaveBeenCalled();
// Verify replay handling
expect(mockRoom.replay.addPlayers).toHaveBeenCalled();
expect(mockRoom.replay.serialize).toHaveBeenCalled();
expect(mockRoom.resetReplay).toHaveBeenCalled();
// Verify side decking
expect(mockRoom.sideDecking).toHaveBeenCalled();
expect(mockClient1.sendMessage).toHaveBeenCalled(); // SideDeckClientMessage
expect(mockClient1.notReady).toHaveBeenCalled();
expect(mockSpectator.sendMessage).toHaveBeenCalled(); // SideDeckWaitClientMessage
// Verify choosing turn logic
// Winner is 1 (Team 1). Looser is 0 (Team 0).
// Team 0 player is mockClient1 (position 0).
expect(mockRoom.setClientWhoChoosesTurn).toHaveBeenCalledWith(mockClient1);
});
it("should handle duel finish with surrender when winner is team 0", async () => {
const handlerTeam0 = new FinishDuelHandler({
reason: DuelFinishReason.SURRENDERED,
winner: 0,
room: mockRoom,
});
await handlerTeam0.run();
expect(mockRoom.duelWinner).toHaveBeenCalledWith(0);
// Winner is 0 (Team 0). Looser is 1 (Team 1).
// Team 1 player is mockClient2 (position 1).
// mockRoom.team1 is 1. position % team1 === 0? 1 % 1 === 0. True.
expect(mockRoom.setClientWhoChoosesTurn).toHaveBeenCalledWith(mockClient2);
});
it("should handle match finish", async () => {
mockRoom.isMatchFinished.mockReturnValue(true);
Object.defineProperty(mockRoom, "matchPlayersHistory", { value: [] });
Object.defineProperty(mockRoom, "bestOf", { value: 3 });
Object.defineProperty(mockRoom, "banListHash", { value: 123 });
Object.defineProperty(mockRoom, "ranked", { value: true });
await handler.run();
expect(mockRoom.isMatchFinished).toHaveBeenCalled();
// DuelEndMessage sent
expect(mockClient1.sendMessage).toHaveBeenCalled();
expect(mockSpectator.sendMessage).toHaveBeenCalled();
// Event published
expect(mockEventBus.publish).toHaveBeenCalled();
// Room removed broadcast
expect(mockWebSocketSingleton.broadcast).toHaveBeenCalledWith({
action: "REMOVE-ROOM",
data: {},
});
// Should NOT side deck
expect(mockRoom.sideDecking).not.toHaveBeenCalled();
});
});

View file

@ -105,8 +105,9 @@ export class FinishDuelHandler {
players: this.room.matchPlayersHistory,
date: new Date(),
banListHash: this.room.banListHash,
banListName: this.room.banListName ?? "N/A",
ranked: this.room.ranked,
})
}),
);
WebSocketSingleton.getInstance().broadcast({
@ -123,14 +124,14 @@ export class FinishDuelHandler {
if (this.winner === 0) {
const looser = this.room.players.find(
(_client: Client) => _client.position % this.room.team1 === 0 && _client.team === 1
(_client: Client) => _client.position % this.room.team1 === 0 && _client.team === 1,
);
if (looser && looser instanceof Client) {
this.room.setClientWhoChoosesTurn(looser);
}
} else {
const looser = this.room.players.find(
(_client: Client) => _client.position % this.room.team0 === 0 && _client.team === 0
(_client: Client) => _client.position % this.room.team0 === 0 && _client.team === 0,
);
if (looser && looser instanceof Client) {
this.room.setClientWhoChoosesTurn(looser);

View file

@ -31,7 +31,7 @@ export class GameCreatorHandler implements GameCreatorMessageHandler {
logger: Logger,
socket: ISocket,
userAuth: UserAuth,
roomId: number
roomId: number,
) {
this.eventEmitter = eventEmitter;
this.logger = logger.child({ file: "GameCreatorHandler", roomId });
@ -69,14 +69,14 @@ export class GameCreatorHandler implements GameCreatorMessageHandler {
private async create(
message: CreateGameMessage,
playerInfoMessage: PlayerInfoMessage,
userId: string | null
userId: string | null,
): Promise<void> {
const room = Room.createFromCreateGameMessage(
message,
playerInfoMessage,
this.roomId,
this.eventEmitter,
this.logger
this.logger,
);
room.waiting();
@ -99,19 +99,23 @@ export class GameCreatorHandler implements GameCreatorMessageHandler {
}
private sendRankedMessage(): void {
this.socket.send(ServerMessageClientMessage.create(
`${ServerInfoMessage.WELCOME} - ${ServerInfoMessage.RANKED_ROOM_CREATION_SUCCESS} - ${ServerInfoMessage.GAIN_POINTS_CALL_TO_ACTION}`
));
this.socket.send(
ServerMessageClientMessage.create(
`${ServerInfoMessage.WELCOME} - ${ServerInfoMessage.RANKED_ROOM_CREATION_SUCCESS} - ${ServerInfoMessage.GAIN_POINTS_CALL_TO_ACTION}`,
),
);
}
private sendUnrankedMessage(): void {
this.socket.send(ServerMessageClientMessage.create(
`${ServerInfoMessage.WELCOME} - ${ServerInfoMessage.UN_RANKED_ROOM_CREATION_SUCCESS}`
));
this.socket.send(
ServerMessageClientMessage.create(
`${ServerInfoMessage.WELCOME} - ${ServerInfoMessage.UN_RANKED_ROOM_CREATION_SUCCESS}`,
),
);
if (!config.ranking.enabled) {
this.socket.send(
ServerMessageClientMessage.create(ServerInfoMessage.UNAVAILABLE_RANKING_SYSTEM)
ServerMessageClientMessage.create(ServerInfoMessage.UNAVAILABLE_RANKING_SYSTEM),
);
}
}

View file

@ -28,7 +28,7 @@ export class JoinHandler implements JoinMessageHandler {
eventEmitter: EventEmitter,
logger: Logger,
socket: ISocket,
checkIfUseCanJoin: CheckIfUseCanJoin
checkIfUseCanJoin: CheckIfUseCanJoin,
) {
this.eventEmitter = eventEmitter;
this.logger = logger.child({ file: "JoinHandler" });
@ -36,7 +36,7 @@ export class JoinHandler implements JoinMessageHandler {
this.checkIfUseCanJoin = checkIfUseCanJoin;
this.eventEmitter.on(
Commands.JOIN_GAME as unknown as string,
(message: ClientMessage) => void this.handleJoinGame(message)
(message: ClientMessage) => void this.handleJoinGame(message),
);
}
@ -45,7 +45,7 @@ export class JoinHandler implements JoinMessageHandler {
const joinMessage = new JoinGameMessage(message.data);
const playerInfoMessage = new PlayerInfoMessage(message.previousMessage, message.data.length);
this.logger.info(
`player: ${playerInfoMessage.name} trying to join to room: ${joinMessage.id} with room pass: ${joinMessage.password}`
`player: ${playerInfoMessage.name} trying to join to room: ${joinMessage.id} with room pass: ${joinMessage.password}`,
);
const room = this.findRoom(joinMessage);
@ -67,10 +67,10 @@ export class JoinHandler implements JoinMessageHandler {
if (attempts >= config.rateLimit.limit) {
this.logger.info(
`player: ${playerInfoMessage.name} with ip: ${ip} tried to join to room: ${room.id} and was already rate limited`
`player: ${playerInfoMessage.name} with ip: ${ip} tried to join to room: ${room.id} and was already rate limited`,
);
this.socket.send(
ServerErrorClientMessage.create("Too many attempts. Please try again in a few minutes.")
ServerErrorClientMessage.create("Too many attempts. Please try again in a few minutes."),
);
this.socket.send(ErrorClientMessage.create(ErrorMessages.JOIN_ERROR));
this.socket.destroy();
@ -81,6 +81,9 @@ export class JoinHandler implements JoinMessageHandler {
if (room.ranked) {
if (!(await this.checkIfUseCanJoin.check(playerInfoMessage, this.socket))) {
// CheckIfUseCanJoin no longer sends the JOINERROR itself (its wire format is
// client-specific). edopro/desktop clients use the @edopro ErrorClientMessage.
this.socket.send(ErrorClientMessage.create(ErrorMessages.JOIN_ERROR));
return;
}
}
@ -95,7 +98,7 @@ export class JoinHandler implements JoinMessageHandler {
}
}
this.logger.info(
`player: ${playerInfoMessage.name} tried to join to room: ${room.id} with wrong password: ${joinMessage.password}`
`player: ${playerInfoMessage.name} tried to join to room: ${room.id} with wrong password: ${joinMessage.password}`,
);
this.socket.send(ServerErrorClientMessage.create("Wrong password"));
this.socket.send(ErrorClientMessage.create(ErrorMessages.JOIN_ERROR));
@ -110,7 +113,7 @@ export class JoinHandler implements JoinMessageHandler {
}
this.logger.info(
`player: ${playerInfoMessage.name} joined to room: ${room.id} with password: ${joinMessage.password}`
`player: ${playerInfoMessage.name} joined to room: ${room.id} with password: ${joinMessage.password}`,
);
room.emit("JOIN", message, this.socket);
}

View file

@ -0,0 +1,80 @@
import { ISocket } from "@shared/socket/domain/ISocket";
import { Client } from "@edopro/client/domain/Client";
import { JoinGameMessage } from "@edopro/messages/client-to-server/JoinGameMessage";
import { PlayerInfoMessage } from "@edopro/messages/client-to-server/PlayerInfoMessage";
import { JoinToDuelAsSpectator } from "./JoinToDuelAsSpectator";
import { Room } from "@edopro/room/domain/Room";
// Mock dependencies
jest.mock("@edopro/room/domain/Room");
jest.mock("@edopro/client/domain/Client");
jest.mock("@edopro/messages/server-to-client/JoinGameClientMessage");
jest.mock("@shared/messages/server-to-client/DuelStartClientMessage");
jest.mock("@edopro/messages/server-to-client/CatchUpClientMessage");
jest.mock("@edopro/messages/server-to-client/ServerMessageClientMessage");
describe("JoinToDuelAsSpectator", () => {
let handler: JoinToDuelAsSpectator;
let mockSocket: jest.Mocked<ISocket>;
let mockRoom: jest.Mocked<Room>;
let mockSpectator: jest.Mocked<Client>;
let mockClient1: jest.Mocked<Client>;
let mockClient2: jest.Mocked<Client>;
beforeEach(() => {
handler = new JoinToDuelAsSpectator();
mockSocket = {
send: jest.fn(),
} as unknown as jest.Mocked<ISocket>;
mockSpectator = {
name: "Spectator\0",
sendMessage: jest.fn(),
} as unknown as jest.Mocked<Client>;
mockClient1 = {
name: "Player1\0",
team: 0,
sendMessage: jest.fn(),
} as unknown as jest.Mocked<Client>;
mockClient2 = {
name: "Player2",
team: 1,
sendMessage: jest.fn(),
} as unknown as jest.Mocked<Client>;
mockRoom = {
createSpectatorUnsafe: jest.fn().mockReturnValue(mockSpectator),
addSpectatorUnsafe: jest.fn(),
notifyToAllPlayers: jest.fn(),
spectatorCache: [Buffer.from("cache1"), Buffer.from("cache2")],
players: [mockClient1, mockClient2],
spectators: [mockSpectator],
matchScore: jest.fn().mockReturnValue({ team0: 1, team1: 0 }),
} as unknown as jest.Mocked<Room>;
});
it("should handle spectator join correctly", async () => {
const joinMessage = {} as JoinGameMessage;
const playerInfoMessage = { name: "Spectator" } as PlayerInfoMessage;
await handler.run(joinMessage, playerInfoMessage, mockSocket, mockRoom);
expect(mockRoom.createSpectatorUnsafe).toHaveBeenCalledWith(mockSocket, "Spectator");
expect(mockRoom.addSpectatorUnsafe).toHaveBeenCalledWith(mockSpectator);
expect(mockRoom.notifyToAllPlayers).toHaveBeenCalledWith(mockSpectator);
// Verify messages sent to spectator
expect(mockSpectator.sendMessage).toHaveBeenCalledTimes(5); // JoinGame, DuelStart, CatchUp(true), CatchUp(false), ServerMessage(has entered)
// Verify socket messages (cache + welcome + score)
expect(mockSocket.send).toHaveBeenCalledTimes(4); // 2 cache items + Welcome + Score
// Verify notification to other clients
expect(mockClient1.sendMessage).toHaveBeenCalled();
expect(mockClient2.sendMessage).toHaveBeenCalled();
expect(mockSpectator.sendMessage).toHaveBeenCalled(); // Also notified self? Logic says [...room.clients, ...room.spectators]
});
});

View file

@ -15,7 +15,7 @@ export class JoinToDuelAsSpectator {
joinMessage: JoinGameMessage,
playerInfoMessage: PlayerInfoMessage,
socket: ISocket,
room: Room
room: Room,
): Promise<void> {
const spectator = room.createSpectatorUnsafe(socket, playerInfoMessage.name);
spectator.sendMessage(JoinGameClientMessage.createFromRoom(joinMessage, room));
@ -40,20 +40,21 @@ export class JoinToDuelAsSpectator {
.map((item) => item.name.replace(/\0/g, "").trim());
socket.send(
ServerMessageClientMessage.create(`Welcome ${spectator.name.replace(/\0/g, "").trim()}`)
ServerMessageClientMessage.create(`Welcome ${spectator.name.replace(/\0/g, "").trim()}`),
);
socket.send(
ServerMessageClientMessage.create(
`Score: ${team0.join(",")}: ${room.matchScore().team0} vs ${team1.join(",")}: ${room.matchScore().team1
}`
)
`Score: ${team0.join(",")}: ${room.matchScore().team0} vs ${team1.join(",")}: ${
room.matchScore().team1
}`,
),
);
[...room.players, ...room.spectators].forEach((_client: Client) => {
_client.sendMessage(
ServerMessageClientMessage.create(
`${spectator.name} ${ServerInfoMessage.HAS_ENTERED_AS_A_SPECTATOR}`
)
`${spectator.name} ${ServerInfoMessage.HAS_ENTERED_AS_A_SPECTATOR}`,
),
);
});
}

View file

@ -0,0 +1,88 @@
import { ISocket } from "@shared/socket/domain/ISocket";
import { CheckIfUseCanJoin } from "@shared/user-auth/application/CheckIfUserCanJoin";
import { Client } from "@edopro/client/domain/Client";
import { JoinGameMessage } from "@edopro/messages/client-to-server/JoinGameMessage";
import { PlayerInfoMessage } from "@edopro/messages/client-to-server/PlayerInfoMessage";
import { Reconnect } from "./Reconnect";
import { Room } from "@edopro/room/domain/Room";
// Mock dependencies
jest.mock("@edopro/room/domain/Room");
jest.mock("@edopro/client/domain/Client");
jest.mock("@shared/user-auth/application/CheckIfUserCanJoin");
jest.mock("@edopro/messages/server-to-client/JoinGameClientMessage");
jest.mock("@shared/messages/server-to-client/TypeChangeClientMessage");
jest.mock("@shared/messages/server-to-client/PlayerEnterClientMessage");
describe("Reconnect", () => {
let reconnect: Reconnect;
let mockCheckIfUseCanJoin: jest.Mocked<CheckIfUseCanJoin>;
let mockSocket: jest.Mocked<ISocket>;
let mockRoom: jest.Mocked<Room>;
let mockPlayer: jest.Mocked<Client>;
beforeEach(() => {
mockCheckIfUseCanJoin = {
check: jest.fn(),
} as unknown as jest.Mocked<CheckIfUseCanJoin>;
reconnect = new Reconnect(mockCheckIfUseCanJoin);
mockSocket = {
id: "socket-id",
send: jest.fn(),
} as unknown as jest.Mocked<ISocket>;
mockPlayer = {
setSocket: jest.fn(),
reconnecting: jest.fn(),
sendMessage: jest.fn(),
host: true,
position: 0,
} as unknown as jest.Mocked<Client>;
mockRoom = {
ranked: false,
players: [mockPlayer],
} as unknown as jest.Mocked<Room>;
});
it("should allow reconnect for unranked room", async () => {
const joinMessage = {} as JoinGameMessage;
const playerInfoMessage = {} as PlayerInfoMessage;
await reconnect.run(playerInfoMessage, mockPlayer, joinMessage, mockSocket, mockRoom);
expect(mockPlayer.setSocket).toHaveBeenCalledWith(mockSocket, mockRoom.players, mockRoom);
expect(mockPlayer.reconnecting).toHaveBeenCalled();
expect(mockPlayer.sendMessage).toHaveBeenCalledTimes(3); // JoinGame, TypeChange, PlayerEnter
});
it("should allow reconnect for ranked room if check passes", async () => {
Object.defineProperty(mockRoom, "ranked", { value: true });
mockCheckIfUseCanJoin.check.mockResolvedValue(true);
const joinMessage = {} as JoinGameMessage;
const playerInfoMessage = {} as PlayerInfoMessage;
await reconnect.run(playerInfoMessage, mockPlayer, joinMessage, mockSocket, mockRoom);
expect(mockCheckIfUseCanJoin.check).toHaveBeenCalledWith(playerInfoMessage, mockSocket);
expect(mockPlayer.setSocket).toHaveBeenCalled();
});
it("should deny reconnect for ranked room if check fails", async () => {
Object.defineProperty(mockRoom, "ranked", { value: true });
mockCheckIfUseCanJoin.check.mockResolvedValue(false);
const joinMessage = {} as JoinGameMessage;
const playerInfoMessage = {} as PlayerInfoMessage;
await reconnect.run(playerInfoMessage, mockPlayer, joinMessage, mockSocket, mockRoom);
expect(mockCheckIfUseCanJoin.check).toHaveBeenCalled();
expect(mockPlayer.setSocket).not.toHaveBeenCalled();
});
});

View file

@ -7,28 +7,25 @@ import { Client } from "../../client/domain/Client";
import { JoinGameMessage } from "../../messages/client-to-server/JoinGameMessage";
import { PlayerInfoMessage } from "../../messages/client-to-server/PlayerInfoMessage";
import { JoinGameClientMessage } from "../../messages/server-to-client/JoinGameClientMessage";
import { ErrorClientMessage } from "../../messages/server-to-client/ErrorClientMessage";
import { ErrorMessages } from "../../messages/server-to-client/error-messages/ErrorMessages";
import { Room } from "../domain/Room";
export class Reconnect {
constructor(private readonly checkIfUseCanJoin: CheckIfUseCanJoin) { }
constructor(private readonly checkIfUseCanJoin: CheckIfUseCanJoin) {}
async run(
playerInfoMessage: PlayerInfoMessage,
player: Client,
joinMessage: JoinGameMessage,
socket: ISocket,
room: Room
room: Room,
): Promise<void> {
if (room.ranked && !(await this.checkIfUseCanJoin.check(playerInfoMessage, socket))) {
// CheckIfUseCanJoin no longer sends the JOINERROR itself (its wire format is
// client-specific). edopro/desktop clients use the @edopro ErrorClientMessage.
socket.send(ErrorClientMessage.create(ErrorMessages.JOIN_ERROR));
return;
// if (!player.socket.id || !player.socket.closed) {
// socket.send(ServerErrorClientMessage.create("Ya el jugador se encuentra en la partida."));
// socket.send(ErrorClientMessage.create(ErrorMessages.JOIN_ERROR));
// socket.destroy();
// return;
// }
}
player.setSocket(socket, room.players as Client[], room);
@ -40,7 +37,7 @@ export class Reconnect {
room.players.forEach((_client) => {
const playerEnterClientMessage = PlayerEnterClientMessage.create(
_client.name,
_client.position
_client.position,
);
player.sendMessage(playerEnterClientMessage);
});

View file

@ -3,79 +3,73 @@ import { EventEmitter } from "stream";
import { CreateRoomRequest } from "../../../http-server/controllers/CreateRoomController";
import { Logger } from "../../../shared/logger/domain/Logger";
import { ISocket } from "../../../shared/socket/domain/ISocket";
import { UTF8ToUTF16 } from "../../../utils/UTF8ToUTF16";
import BanListMemoryRepository from "../../ban-list/infrastructure/BanListMemoryRepository";
import { Room } from "../domain/Room";
import RoomList from "../infrastructure/RoomList";
export class RoomCreator {
private readonly socket: ISocket;
constructor(private readonly logger: Logger) {}
constructor(private readonly logger: Logger) {}
create(payload: CreateRoomRequest): { password: string } {
const banlist = BanListMemoryRepository.findByName(payload.banlist);
create(payload: CreateRoomRequest): { password: string } {
const banlist = BanListMemoryRepository.findByName(payload.banlist);
if (!banlist) {
throw new Error("Banlist not found");
}
if (!banlist) {
throw new Error("Banlist not found");
}
const emitter = new EventEmitter();
const utf8Password = this.generateUniqueId().toString();
const password = UTF8ToUTF16(
utf8Password,
utf8Password.length * 2,
).toString("utf16le");
const emitter = new EventEmitter();
const utf8Password = this.generateUniqueId().toString();
const password = UTF8ToUTF16(utf8Password, utf8Password.length * 2).toString("utf16le");
const data = {
id: this.generateUniqueId(),
name: payload.name,
notes:
(payload.tournament ? `[${payload.tournament}] ` : "") + payload.name,
mode: payload.mode || 0, // 0 = Single, 1 = Match, 2 = Tag
needPass: true,
team0: payload.teamQuantity || 1,
team1: payload.teamQuantity || 1,
bestOf: payload.bestOf || 1,
duelFlag: BigInt(853505),
forbiddenTypes: 83886080,
extraRules: 0,
startLp: 8000,
startHand: 5,
drawCount: 1,
timeLimit: 700,
rule: payload.rule || 4, // 0 = OCG, 1 = TCG, 2 = OCG/TCG, 3 = Prerelease, 4 = Anything Goes
noCheck: false,
noShuffle: false,
banListHash: banlist.hash,
isStart: "waiting",
mainMin: 40,
mainMax: 60,
extraMin: 0,
extraMax: 15,
sideMin: 0,
sideMax: 15,
duelRule: 0,
handshake: 4043399681,
password,
duelFlagsHight: 1,
duelFlagsLow: 853504,
ranked: payload.isRanked || false,
};
const data = {
id: this.generateUniqueId(),
name: payload.name,
notes: (payload.tournament ? `[${payload.tournament}] ` : "") + payload.name,
mode: payload.mode || 0, // 0 = Single, 1 = Match, 2 = Tag
needPass: true,
team0: payload.teamQuantity || 1,
team1: payload.teamQuantity || 1,
bestOf: payload.bestOf || 1,
duelFlag: BigInt(853505),
forbiddenTypes: 83886080,
extraRules: 0,
startLp: 8000,
startHand: 5,
drawCount: 1,
timeLimit: 700,
rule: payload.rule || 4, // 0 = OCG, 1 = TCG, 2 = OCG/TCG, 3 = Prerelease, 4 = Anything Goes
noCheck: false,
noShuffle: false,
banListHash: banlist.hash,
isStart: "waiting",
mainMin: 40,
mainMax: 60,
extraMin: 0,
extraMax: 15,
sideMin: 0,
sideMax: 15,
duelRule: 0,
handshake: 4043399681,
password,
duelFlagsHight: 1,
duelFlagsLow: 853504,
ranked: payload.isRanked || false,
};
const room = Room.create(data, emitter, this.logger);
room.waiting();
RoomList.addRoom(room);
const room = Room.create(data, emitter, this.logger);
room.waiting();
RoomList.addRoom(room);
return {
password,
};
}
return {
password,
};
}
private generateUniqueId(): number {
const min = 1000;
const max = 9999;
private generateUniqueId(): number {
const min = 1000;
const max = 9999;
return randomInt(min, max + 1);
}
return randomInt(min, max + 1);
}
}

View file

@ -1,7 +1,6 @@
import { Room } from "../../../../src/edopro/room/domain/Room";
import { ClientMother } from "../../shared/mothers/client/ClientMother";
import { RoomMother } from "../../shared/mothers/room/RoomMother";
import { Room } from "./Room";
import { ClientMother } from "@test-support/mothers/client/ClientMother";
import { RoomMother } from "@test-support/mothers/room/RoomMother";
describe("Room", () => {
let room: Room;

View file

@ -1,3 +1,4 @@
import BanListMemoryRepository from "@edopro/ban-list/infrastructure/BanListMemoryRepository";
import { PlayerChangeClientMessage } from "@edopro/messages/server-to-client/PlayerChangeClientMessage";
import { ChildProcessWithoutNullStreams } from "child_process";
import shuffle from "shuffle-array";
@ -116,7 +117,6 @@ export class Room extends YgoRoom {
private _playerExtraDeckSize: number;
private _opponentMainDeckSize: number;
private _opponentExtraDeckSize: number;
private readonly _turn = 0;
private readonly timers: Timer[];
private readonly roomTimer: Timer;
private roomState: RoomState | null = null;
@ -209,11 +209,11 @@ export class Room extends YgoRoom {
});
this.resetReplay();
this.checkIfUserCanReconnect = new CheckIfUseCanJoin(
new UserAuth(new UserProfilePostgresRepository())
new UserAuth(new UserProfilePostgresRepository()),
);
this.notifier = new RoomClientNotifier(
() => this._players as Client[],
() => this._spectators as Client[]
() => this._spectators as Client[],
);
}
@ -231,7 +231,7 @@ export class Room extends YgoRoom {
playerInfo: PlayerInfoMessage,
id: number,
emitter: EventEmitter,
logger: Logger
logger: Logger,
): Room {
const ranked = Room.isRanked(playerInfo.password);
@ -290,7 +290,6 @@ export class Room extends YgoRoom {
}
resetReplay(): void {
if (this._replay) {
this._replay.reset();
}
@ -511,6 +510,10 @@ export class Room extends YgoRoom {
return this._replay;
}
get banListName(): string | null {
return BanListMemoryRepository.findByHash(this.banListHash)?.name ?? null;
}
setDecksToPlayer(position: number, deck: Deck): void {
this.mutex.runExclusive(() => {
this.setDecksToPlayerUnsafe(position, deck);
@ -563,7 +566,7 @@ export class Room extends YgoRoom {
this.emitter,
this.logger,
new UserAuth(new UserProfilePostgresRepository()),
new DeckCreator(new CardSQLiteTYpeORMRepository(), this.deckRules, this.duelFlag)
new DeckCreator(new CardSQLiteTYpeORMRepository(), this.deckRules, this.duelFlag),
);
}
@ -577,7 +580,7 @@ export class Room extends YgoRoom {
new Reconnect(this.checkIfUserCanReconnect),
new JoinToDuelAsSpectator(),
this,
new JSONMessageProcessor()
new JSONMessageProcessor(),
);
}
@ -589,7 +592,7 @@ export class Room extends YgoRoom {
this.logger,
new Reconnect(this.checkIfUserCanReconnect),
new JoinToDuelAsSpectator(),
new DeckCreator(new CardSQLiteTYpeORMRepository(), this.deckRules, this.duelFlag)
new DeckCreator(new CardSQLiteTYpeORMRepository(), this.deckRules, this.duelFlag),
);
}
@ -606,7 +609,7 @@ export class Room extends YgoRoom {
this.emitter,
this.logger,
new Reconnect(this.checkIfUserCanReconnect),
new JoinToDuelAsSpectator()
new JoinToDuelAsSpectator(),
);
}
@ -617,7 +620,7 @@ export class Room extends YgoRoom {
this.emitter,
this.logger,
new Reconnect(this.checkIfUserCanReconnect),
new JoinToDuelAsSpectator()
new JoinToDuelAsSpectator(),
);
}
@ -688,16 +691,16 @@ export class Room extends YgoRoom {
prepareTurnOrder(): void {
const team0Players = this.players
.filter(p => p.team === 0)
.filter((p) => p.team === 0)
.sort((a, b) => a.position - b.position) as Client[];
const team1Players = this.players
.filter(p => p.team === 1)
.filter((p) => p.team === 1)
.sort((a, b) => a.position - b.position) as Client[];
// 0 = team0 empieza, 1 = team1 empieza
const offset0 = this.isRelay ? 0 : (this.firstToPlay === 1 ? 1 : 0);
const offset1 = this.isRelay ? 0 : (this.firstToPlay === 0 ? 1 : 0);
const offset0 = this.isRelay ? 0 : this.firstToPlay === 1 ? 1 : 0;
const offset1 = this.isRelay ? 0 : this.firstToPlay === 0 ? 1 : 0;
team0Players.forEach((p, idx) => {
p.clearTurn();
@ -709,22 +712,18 @@ export class Room extends YgoRoom {
p.setDuelPosition((idx + offset1) % this.team1);
});
team0Players.find(p => p.duelPosition === 0)?.turn();
team1Players.find(p => p.duelPosition === 0)?.turn();
this.players.forEach((element: Client) => {
})
team0Players.find((p) => p.duelPosition === 0)?.turn();
team1Players.find((p) => p.duelPosition === 0)?.turn();
}
nextTurn(team: number): void {
const teamPlayers = (this.players as Client[])
.filter(p => p.team === team)
.filter((p) => p.team === team)
.sort((a: Client, b: Client) => a.duelPosition - b.duelPosition);
if (teamPlayers.length === 0) return;
const currentIdx = teamPlayers.findIndex(p => p.inTurn);
const currentIdx = teamPlayers.findIndex((p) => p.inTurn);
if (currentIdx === -1) return;
const nextIdx = (currentIdx + 1) % teamPlayers.length;
@ -858,7 +857,7 @@ export class Room extends YgoRoom {
JSON.stringify({
command: "DESTROY_DUEL",
data: {},
})
}),
);
}
@ -958,7 +957,6 @@ export class Room extends YgoRoom {
}
}
private startIpcMetricsReporting(): void {
if (process.env.IPC_METRICS_ENABLED !== "true") {
return;
@ -1011,8 +1009,6 @@ export class Room extends YgoRoom {
return match ? Number(match[1]) : undefined;
}
private nextSpectatorPositionUnsafe(): number {
if (this._spectators.length === 0) {
return 8;

View file

@ -10,7 +10,7 @@ import { PlayerRoomState } from "./PlayerRoomState";
export class RoomClientNotifier {
constructor(
private readonly players: () => YgoClient[],
private readonly spectators: () => YgoClient[]
private readonly spectators: () => YgoClient[],
) {}
sendPlayerChange(player: Client, state: PlayerRoomState): void {

View file

@ -0,0 +1,126 @@
import { EventEmitter } from "stream";
import { RoomState } from "./RoomState";
import { Commands } from "../../../shared/messages/Commands";
import { RoomType } from "src/shared/room/domain/RoomType";
// RoomState is abstract but declares no abstract members — a bare concrete
// subclass is enough to exercise the inherited CHAT and EMOTE handlers
// (registered in the constructor). The handlers are private, so we drive them
// through the "CHAT" / "EMOTE" events, exactly as the runtime does.
class TestRoomState extends RoomState {}
const makeSocket = () => ({
send: jest.fn(),
destroy: jest.fn(),
close: jest.fn(),
});
const makeChatMessage = (text: string) => ({
data: Buffer.from(text, "utf16le"),
previousMessage: Buffer.alloc(0),
});
// Emote ids travel as raw utf-8 in the frame body (not utf16le like chat).
const makeEmoteMessage = (id: string) => ({
data: Buffer.from(id, "utf8"),
previousMessage: Buffer.alloc(0),
});
const makeMercuryRoom = (socket: { send: jest.Mock }) =>
({
roomType: RoomType.MERCURY,
isPositionSwapped: false,
clients: [{ socket }],
}) as unknown as never;
describe("RoomState — Mercury spectator chat (Option A: server prefixes name)", () => {
let eventEmitter: EventEmitter;
beforeEach(() => {
eventEmitter = new EventEmitter();
new TestRoomState(eventEmitter);
});
it("prefixes the spectator's name to the chat msg in a Mercury room", () => {
const socket = makeSocket();
const room = makeMercuryRoom(socket);
const spectator = {
name: "Duelista 5863",
isSpectator: true,
position: 7,
team: 3,
} as unknown as never;
eventEmitter.emit(Commands.CHAT as unknown as string, makeChatMessage("hola"), room, spectator);
expect(socket.send).toHaveBeenCalled();
const sent = socket.send.mock.calls[0][0] as Buffer;
// The outgoing STOC_CHAT msg must carry "Name: text" (UTF-16LE on the wire).
expect(sent.includes(Buffer.from("Duelista 5863: hola", "utf16le"))).toBe(true);
});
it("does NOT prefix a name for a duelist (player) chat", () => {
const socket = makeSocket();
const room = makeMercuryRoom(socket);
const player = {
name: "Jugador A",
isSpectator: false,
position: 0,
team: 0,
} as unknown as never;
eventEmitter.emit(Commands.CHAT as unknown as string, makeChatMessage("hola"), room, player);
expect(socket.send).toHaveBeenCalled();
const sent = socket.send.mock.calls[0][0] as Buffer;
expect(sent.includes(Buffer.from("Jugador A: hola", "utf16le"))).toBe(false);
expect(sent.includes(Buffer.from("hola", "utf16le"))).toBe(true);
});
});
describe("RoomState — Mercury emote gate (only seated duelists send)", () => {
let eventEmitter: EventEmitter;
beforeEach(() => {
eventEmitter = new EventEmitter();
new TestRoomState(eventEmitter);
});
it("broadcasts a duelist's valid emote to the room", () => {
const socket = makeSocket();
const room = makeMercuryRoom(socket);
const duelist = {
isSpectator: false,
position: 0,
tryEmote: () => true,
} as unknown as never;
eventEmitter.emit(Commands.EMOTE as unknown as string, makeEmoteMessage("wave"), room, duelist);
expect(socket.send).toHaveBeenCalledTimes(1);
});
it("does NOT broadcast a spectator's emote (rejected before rate-limit)", () => {
const socket = makeSocket();
const room = makeMercuryRoom(socket);
const spectator = {
isSpectator: true,
position: 7,
// The gate must short-circuit BEFORE the rate-limiter — if tryEmote is
// reached the spectator slipped through, so make that a hard failure.
tryEmote: () => {
throw new Error("tryEmote must not be reached for a spectator");
},
} as unknown as never;
eventEmitter.emit(
Commands.EMOTE as unknown as string,
makeEmoteMessage("wave"),
room,
spectator,
);
expect(socket.send).not.toHaveBeenCalled();
});
});

View file

@ -25,6 +25,12 @@ import { VersionErrorClientMessage } from "../../messages/server-to-client/Versi
import { RoomType } from "src/shared/room/domain/RoomType";
import { YGOProRoom } from "@ygopro/room/domain/YGOProRoom";
import { NetPlayerType, YGOProStocChat, YGOProStocSelectHand } from "ygopro-msg-encode";
import {
EMOTE_COOLDOWN_MS,
MAX_ID_LENGTH,
buildStocEmoteFrame,
isValidEmoteId,
} from "@ygopro/emote/emote-protocol";
export abstract class RoomState {
protected readonly eventEmitter: EventEmitter;
@ -35,7 +41,13 @@ export abstract class RoomState {
this.eventEmitter.on(
Commands.CHAT as unknown as string,
(message: ClientMessage, room: YgoRoom, client: Client) =>
this.handleChat(message, room, client)
this.handleChat(message, room, client),
);
this.eventEmitter.on(
Commands.EMOTE as unknown as string,
(message: ClientMessage, room: YgoRoom, client: Client) =>
this.handleEmote(message, room, client),
);
}
@ -43,38 +55,6 @@ export abstract class RoomState {
this.eventEmitter.removeAllListeners();
}
protected playerAlreadyInRoom(
playerInfoMessage: PlayerInfoMessage,
room: YgoRoom,
socket: ISocket
): YgoClient | null {
if (!room.ranked) {
const player = room.players.find((client) => {
return (
client.socket.remoteAddress === socket.remoteAddress &&
client.socket.closed &&
playerInfoMessage.name === client.name
);
});
if (!player) {
return null;
}
return player;
}
const player = room.players.find((client) => {
return playerInfoMessage.name === client.name;
});
if (!player) {
return null;
}
return player;
}
protected validateVersion(message: Buffer, socket: ISocket): void {
const joinMessage = new YGOProJoinGameMessage(message);
@ -87,12 +67,12 @@ export abstract class RoomState {
protected sendExistingPlayerErrorMessage(
playerInfoMessage: PlayerInfoMessage,
socket: ISocket
socket: ISocket,
): void {
socket.send(
ServerErrorClientMessage.create(
`Already exists a player with the name :${playerInfoMessage.name}`
)
`Already exists a player with the name :${playerInfoMessage.name}`,
),
);
socket.send(ErrorClientMessage.create(ErrorMessages.JOIN_ERROR));
socket.destroy();
@ -102,15 +82,19 @@ export abstract class RoomState {
protected sendWelcomeMessage(room: YgoRoom, socket: ISocket): void {
if (room.ranked) {
socket.send(YGOProPlayerChatMessage.create(
`${ServerInfoMessage.WELCOME} - ${ServerInfoMessage.RANKED_ROOM_CREATION_SUCCESS} - ${ServerInfoMessage.GAIN_POINTS_CALL_TO_ACTION}`
));
socket.send(
YGOProPlayerChatMessage.create(
`${ServerInfoMessage.WELCOME} - ${ServerInfoMessage.RANKED_ROOM_CREATION_SUCCESS} - ${ServerInfoMessage.GAIN_POINTS_CALL_TO_ACTION}`,
),
);
return;
}
socket.send(YGOProPlayerChatMessage.create(
`${ServerInfoMessage.WELCOME} - ${ServerInfoMessage.UN_RANKED_ROOM_CREATION_SUCCESS}`
));
socket.send(
YGOProPlayerChatMessage.create(
`${ServerInfoMessage.WELCOME} - ${ServerInfoMessage.UN_RANKED_ROOM_CREATION_SUCCESS}`,
),
);
}
protected processDuelMessage(messageType: CoreMessages, data: Buffer, room: YgoRoom): void {
@ -175,8 +159,15 @@ export abstract class RoomState {
: client.position;
const content = BufferToUTF16(message.data, message.data.length);
// STOC_CHAT (opcode 0x19) only carries player_type + msg — there is no name field,
// and every spectator shares player_type=7, so the client cannot tell which spectator
// spoke (it would fall back to a duelist's identity). Prefix the spectator's name into
// the text so the client can attribute the message to the right person.
const outgoing = client.isSpectator
? `${client.name.replace(/\0/g, "").trim()}: ${content}`
: content;
const chatMessage = Buffer.from(
new YGOProStocChat().fromPartial({ player_type: playerType, msg: content }).toFullPayload()
new YGOProStocChat().fromPartial({ player_type: playerType, msg: outgoing }).toFullPayload(),
);
room.clients.forEach((_client: YgoClient) => {
@ -184,6 +175,39 @@ export abstract class RoomState {
});
}
/**
* Relay an emote (custom CTOS 0xfc) to the whole room as STOC 0xfc. Mercury
* rooms only the opcode is understood solely by this project's client, and
* a standard ygopro client would neither send nor decode it. Validates the
* id against the catalog and rate-limits per client before broadcasting.
*/
private handleEmote(message: ClientMessage, room: YgoRoom, client: YgoClient): void {
if (room.roomType !== RoomType.MERCURY) return;
// Only seated duelists may emote. Spectators watch and receive emotes but
// cannot send them — reject here (the authoritative gate; the client also
// hides the picker for spectators).
if (client.isSpectator) return;
// Byte-length pre-check before the utf-8 conversion, so a garbage frame
// (megabytes of body) can't force a large string allocation just to fail.
if (message.data.length === 0 || message.data.length > MAX_ID_LENGTH) return;
const emoteId = message.data.toString("utf8");
if (!isValidEmoteId(emoteId)) return;
if (!client.tryEmote(Date.now(), EMOTE_COOLDOWN_MS)) return;
// Seat resolution mirrors handleMercuryChat so the client maps the sender
// to the correct HUD side (accounting for a swapped board).
const ygoproRoom = room as YGOProRoom;
const playerType = ygoproRoom.isPositionSwapped ? client.position ^ 1 : client.position;
const frame = buildStocEmoteFrame(playerType, emoteId);
room.clients.forEach((c: YgoClient) => {
c.socket.send(frame);
});
}
protected sendSystemErrorMessage(message: string, client: YgoClient): void {
client.socket.send(YGOProPlayerChatMessage.create(message));
}
@ -208,7 +232,7 @@ export abstract class RoomState {
if (client.isSpectator) {
const chatMessage = SpectatorMessageClientMessage.create(
client.name.replace(/\0/g, "").trim(),
message.data
message.data,
);
room.players.forEach((player: Client) => {
player.socket.send(chatMessage);
@ -224,12 +248,12 @@ export abstract class RoomState {
const playerMessage = PlayerMessageClientMessage.create(
client.name.replace(/\0/g, "").trim(),
message.data,
client.team
client.team,
);
const opponentMessage = PlayerMessageClientMessage.create(
client.name.replace(/\0/g, "").trim(),
message.data,
Number(!client.team)
Number(!client.team),
);
room.players.forEach((player: YgoClient) => {

View file

@ -1,5 +1,3 @@
import EventEmitter from "events";
import { Logger } from "../../../../../shared/logger/domain/Logger";
@ -15,13 +13,16 @@ import { JoinToDuelAsSpectator } from "../../../application/JoinToDuelAsSpectato
import { Reconnect } from "../../../application/Reconnect";
import { Room } from "../../Room";
import { RoomState } from "../../RoomState";
import { ReconnectionTokenIssuer } from "../../../../../shared/room/application/reconnect/ReconnectionTokenIssuer";
import { findReconnectingPlayer } from "../../../../../shared/room/domain/findReconnectingPlayer";
import { ReconnectionAckMessage } from "../../../../../shared/messages/server-to-client/ReconnectionAckMessage";
export class ChossingOrderState extends RoomState {
constructor(
eventEmitter: EventEmitter,
private readonly logger: Logger,
private readonly reconnect: Reconnect,
private readonly joinToDuelAsSpectator: JoinToDuelAsSpectator
private readonly joinToDuelAsSpectator: JoinToDuelAsSpectator,
) {
super(eventEmitter);
@ -30,20 +31,57 @@ export class ChossingOrderState extends RoomState {
this.eventEmitter.on(
"JOIN" as unknown as string,
(message: ClientMessage, room: Room, socket: ISocket) =>
this.handleJoin.bind(this)(message, room, socket)
this.handleJoin.bind(this)(message, room, socket),
);
this.eventEmitter.on(
Commands.READY as unknown as string,
(message: ClientMessage, room: Room, client: Client) =>
this.handleReady.bind(this)(message, room, client)
this.handleReady.bind(this)(message, room, client),
);
this.eventEmitter.on(
Commands.TURN_CHOICE as unknown as string,
(message: ClientMessage, room: Room, client: Client) =>
this.handle.bind(this)(message, room, client)
this.handle.bind(this)(message, room, client),
);
this.eventEmitter.on(
"EXPRESS_RECONNECT" as unknown as string,
(message: ClientMessage, room: Room, socket: ISocket) =>
this.handleExpressReconnect.bind(this)(message, room, socket),
);
}
private handleExpressReconnect(message: ClientMessage, room: Room, socket: ISocket): void {
this.logger.info("CHOSSING_ORDER: EXPRESS_RECONNECT");
const token = message.data.toString("utf8");
const player = ReconnectionTokenIssuer.resolve(
token,
room.id,
(client) => client instanceof Client,
) as Client | null;
if (!player) {
this.logger.info(`CHOSSING_ORDER: no player for token ${token}`);
socket.send(ReconnectionAckMessage.failure());
socket.destroy();
return;
}
player.setSocket(socket, room.players as Client[], room);
player.reconnecting();
socket.send(ReconnectionAckMessage.success());
// Re-sync mirrors the name-match reconnect for this phase (handleReady).
player.sendMessage(DuelStartClientMessage.create());
if (room.clientWhoChoosesTurn.position === player.position) {
player.sendMessage(ChooseOrderClientMessage.create());
}
// Rotate the token after a successful reconnection (single-use).
player.sendMessage(ReconnectionTokenIssuer.rotate(player, room.id));
player.clearReconnecting();
}
private handle(message: ClientMessage, room: Room, player: Client): void {
@ -84,7 +122,12 @@ export class ChossingOrderState extends RoomState {
this.logger.info("CHOSSING_ORDER: JOIN");
const playerInfoMessage = new PlayerInfoMessage(message.previousMessage, message.data.length);
const joinMessage = new JoinGameMessage(message.data);
const reconnectingPlayer = this.playerAlreadyInRoom(playerInfoMessage, room, socket);
const reconnectingPlayer = findReconnectingPlayer({
players: room.players,
name: playerInfoMessage.name,
remoteAddress: socket.remoteAddress,
ranked: room.ranked,
});
if (!(reconnectingPlayer instanceof Client)) {
await this.joinToDuelAsSpectator.run(joinMessage, playerInfoMessage, socket, room);

View file

@ -0,0 +1,387 @@
import "reflect-metadata";
import { EventEmitter } from "stream";
import { Logger } from "@shared/logger/domain/Logger";
import { Reconnect } from "@edopro/room/application/Reconnect";
import { JoinToDuelAsSpectator } from "@edopro/room/application/JoinToDuelAsSpectator";
import { Room } from "@edopro/room/domain/Room";
import { JSONMessageProcessor } from "@edopro/messages/JSONMessageProcessor";
import { DuelingState } from "./DuelingState";
import { Client } from "@edopro/client/domain/Client";
import { ClientMessage } from "@shared/messages/MessageProcessor";
import { FinishDuelHandler } from "@edopro/room/application/FinishDuelHandler";
import { UpdateDeckMessageParser } from "@edopro/deck/application/UpdateDeckMessageSizeCalculator";
import { ISocket } from "@shared/socket/domain/ISocket";
import { spawn } from "child_process";
import WebSocketSingleton from "../../../../../web-socket-server/WebSocketSingleton";
import { Commands } from "@shared/messages/Commands";
import { TokenIndex } from "@shared/room/domain/TokenIndex";
// Mocks
jest.mock("@shared/logger/domain/Logger");
jest.mock("@edopro/room/application/Reconnect");
jest.mock("@edopro/room/application/JoinToDuelAsSpectator");
jest.mock("@edopro/room/domain/Room");
jest.mock("@edopro/messages/JSONMessageProcessor");
jest.mock("@edopro/client/domain/Client");
jest.mock("@edopro/room/application/FinishDuelHandler");
jest.mock("@edopro/deck/application/UpdateDeckMessageSizeCalculator");
jest.mock("child_process");
jest.mock("../../../../../web-socket-server/WebSocketSingleton");
describe("DuelingState", () => {
let state: DuelingState;
let mockEmitter: EventEmitter;
let mockLogger: jest.Mocked<Logger>;
let mockReconnect: jest.Mocked<Reconnect>;
let mockJoinToDuelAsSpectator: jest.Mocked<JoinToDuelAsSpectator>;
let mockRoom: jest.Mocked<Room>;
let mockJsonMessageProcessor: jest.Mocked<JSONMessageProcessor>;
let mockClient: jest.Mocked<Client>;
let mockSocket: jest.Mocked<ISocket>;
let mockCore: any;
let mockWebSocketSingleton: jest.Mocked<WebSocketSingleton>;
beforeEach(() => {
mockEmitter = new EventEmitter();
mockWebSocketSingleton = {
broadcast: jest.fn(),
} as unknown as jest.Mocked<WebSocketSingleton>;
(WebSocketSingleton.getInstance as jest.Mock).mockReturnValue(mockWebSocketSingleton);
mockLogger = {
child: jest.fn().mockReturnThis(),
info: jest.fn(),
error: jest.fn(),
} as unknown as jest.Mocked<Logger>;
mockReconnect = {
run: jest.fn(),
} as unknown as jest.Mocked<Reconnect>;
mockJoinToDuelAsSpectator = {
run: jest.fn(),
} as unknown as jest.Mocked<JoinToDuelAsSpectator>;
mockJsonMessageProcessor = {
read: jest.fn(),
process: jest.fn(),
isMessageReady: jest.fn(),
payload: { data: "", size: 0 },
currentBuffer: Buffer.alloc(0),
clear: jest.fn(),
} as unknown as jest.Mocked<JSONMessageProcessor>;
mockSocket = {
send: jest.fn(),
remoteAddress: "127.0.0.1",
destroy: jest.fn(),
} as unknown as jest.Mocked<ISocket>;
mockClient = {
logger: mockLogger,
sendMessage: jest.fn(),
socket: mockSocket,
setCanReconnect: jest.fn(),
setReconnectionToken: jest.fn(),
reconnectionToken: null,
deck: { main: [], side: [], extra: [] },
position: 0,
team: 0,
duelPosition: 0,
host: true,
isSpectator: false,
isReconnecting: false,
canReconnect: false,
clearReconnecting: jest.fn(),
} as unknown as jest.Mocked<Client>;
mockRoom = {
players: [mockClient],
spectators: [],
startLp: 8000,
startHand: 5,
drawCount: 1,
timeLimit: 300,
duelFlag: 0n,
firstToPlay: 0,
banListHash: 123,
id: 1,
score: "0-0",
replay: {
setSeed: jest.fn(),
addMessage: jest.fn(),
addResponse: jest.fn(),
},
prepareTurnOrder: jest.fn(),
setDuel: jest.fn(),
createDuel: jest.fn(),
setPlayerDecksSize: jest.fn(),
setOpponentDecksSize: jest.fn(),
sendMessageToCpp: jest.fn(),
recordCppStdoutChunk: jest.fn(),
recordCppFrameProcessed: jest.fn(),
recordCppParseError: jest.fn(),
recordCppDeferredProcessTick: jest.fn(),
cacheTeamMessage: jest.fn(),
resetTimer: jest.fn(),
calculateTimeReceiver: jest.fn().mockReturnValue(0),
getTime: jest.fn().mockReturnValue(300),
nextTurn: jest.fn(),
isFinished: jest.fn().mockReturnValue(false),
finished: jest.fn(),
stopTimer: jest.fn(),
setLastPhaseMessage: jest.fn(),
lastPhaseMessage: null,
playerMainDeckSize: 40,
playerExtraDeckSize: 15,
opponentMainDeckSize: 40,
opponentExtraDeckSize: 15,
isFirstDuel: jest.fn().mockReturnValue(true),
toRealTimePresentation: jest.fn().mockReturnValue({}),
} as unknown as jest.Mocked<Room>;
mockCore = {
stderr: { on: jest.fn() },
stdout: { on: jest.fn() },
on: jest.fn(),
stdin: { write: jest.fn() },
};
(spawn as jest.Mock).mockReturnValue(mockCore);
state = new DuelingState(
mockEmitter,
mockLogger,
mockReconnect,
mockJoinToDuelAsSpectator,
mockRoom,
mockJsonMessageProcessor,
);
});
it("should initialize and start duel", () => {
expect(mockRoom.setDuel).toHaveBeenCalledWith(mockCore);
expect(mockRoom.createDuel).toHaveBeenCalled();
expect(mockLogger.info).toHaveBeenCalledWith("Starting Duel");
});
it("should handle UPDATE_DECK command (valid deck)", () => {
const message = { data: Buffer.alloc(10) } as ClientMessage;
const mockParser = {
getDeck: jest.fn().mockReturnValue([[1, 2], [3]]),
};
(UpdateDeckMessageParser as jest.Mock).mockReturnValue(mockParser);
Object.defineProperty(mockClient.deck, "main", {
value: [{ code: "1" }, { code: "2" }],
});
Object.defineProperty(mockClient.deck, "side", { value: [{ code: "3" }] });
Object.defineProperty(mockClient.deck, "extra", { value: [] });
mockEmitter.emit(Commands.UPDATE_DECK as unknown as string, message, mockRoom, mockClient);
expect(mockClient.setCanReconnect).toHaveBeenCalledWith(true);
});
it("should handle UPDATE_DECK command (invalid deck length)", () => {
const message = { data: Buffer.alloc(10) } as ClientMessage;
const mockParser = {
getDeck: jest.fn().mockReturnValue([[1], []]),
};
(UpdateDeckMessageParser as jest.Mock).mockReturnValue(mockParser);
Object.defineProperty(mockClient.deck, "main", {
value: [{ code: "1" }, { code: "2" }],
});
mockEmitter.emit(Commands.UPDATE_DECK as unknown as string, message, mockRoom, mockClient);
expect(mockClient.setCanReconnect).toHaveBeenCalledWith(false);
expect(mockClient.socket.send).toHaveBeenCalled(); // Error message
});
it("should handle SURRENDER command", () => {
const message = {} as ClientMessage;
mockEmitter.emit(Commands.SURRENDER as unknown as string, message, mockRoom, mockClient);
expect(mockRoom.sendMessageToCpp).toHaveBeenCalledWith(expect.stringContaining("DESTROY_DUEL"));
expect(FinishDuelHandler).toHaveBeenCalled();
});
it("should handle RESPONSE command", () => {
const message = {
data: Buffer.from([0x01, 0x02]),
} as ClientMessage;
mockEmitter.emit(Commands.RESPONSE as unknown as string, message, mockRoom, mockClient);
expect(mockRoom.replay.addResponse).toHaveBeenCalled();
expect(mockRoom.stopTimer).toHaveBeenCalled();
expect(mockRoom.sendMessageToCpp).toHaveBeenCalledWith(expect.stringContaining("RESPONSE"));
});
it("should handle READY command (reconnecting)", () => {
const message = {} as ClientMessage;
Object.defineProperty(mockClient, "isReconnecting", { value: true });
Object.defineProperty(mockClient, "canReconnect", { value: true });
mockEmitter.emit(Commands.READY as unknown as string, message, mockRoom, mockClient);
expect(mockClient.sendMessage).toHaveBeenCalled(); // StartDuel messages
expect(mockRoom.sendMessageToCpp).toHaveBeenCalledWith(expect.stringContaining("GET_FIELD"));
});
it("should handle JOIN command (password fail)", () => {
const message = {
data: Buffer.alloc(50),
previousMessage: Buffer.alloc(40),
} as ClientMessage;
Object.defineProperty(mockRoom, "password", { value: "secret" });
// Mock JoinGameMessage constructor behavior?
// It reads from buffer. We passed empty buffer, so password will be empty string.
// "secret" !== "" -> fail
mockEmitter.emit("JOIN", message, mockRoom, mockSocket);
expect(mockSocket.send).toHaveBeenCalled();
expect(mockSocket.destroy).toHaveBeenCalled();
});
it("should process all ready core messages in the same tick when under limit", () => {
Object.defineProperty(mockJsonMessageProcessor, "payload", {
value: {
data: JSON.stringify({ type: "UNKNOWN" }),
size: 0,
},
});
mockJsonMessageProcessor.isMessageReady
.mockReturnValueOnce(true)
.mockReturnValueOnce(true)
.mockReturnValueOnce(false)
.mockReturnValueOnce(false);
(state as any).processMessage();
expect(mockJsonMessageProcessor.process).toHaveBeenCalledTimes(2);
});
it("should defer remaining core messages to next tick when limit is reached", () => {
Object.defineProperty(mockJsonMessageProcessor, "payload", {
value: {
data: JSON.stringify({ type: "UNKNOWN" }),
size: 0,
},
});
let readyCalls = 0;
mockJsonMessageProcessor.isMessageReady.mockImplementation(() => {
readyCalls += 1;
return readyCalls <= 1001;
});
const setImmediateSpy = jest.spyOn(global, "setImmediate").mockImplementation(() => {
return 0 as unknown as NodeJS.Immediate;
});
(state as any).processMessage();
expect(mockJsonMessageProcessor.process).toHaveBeenCalledTimes(1000);
expect(setImmediateSpy).toHaveBeenCalledTimes(1);
setImmediateSpy.mockRestore();
});
// ---------------------------------------------------------------------------
// Characterization of the token-based express reconnect flow. These pin the
// CURRENT observable behavior before the shared reconnect layer is extracted,
// so the refactor must keep them green (identical behavior).
// ---------------------------------------------------------------------------
describe("EXPRESS_RECONNECT (characterization)", () => {
const SUCCESS_ACK = Buffer.from([0x02, 0x00, 0xfd, 0x00]);
const FAILURE_ACK = Buffer.from([0x02, 0x00, 0xfd, 0x01]);
const makeRealClient = (overrides: Record<string, unknown> = {}): Client => {
const client = new Client({} as any) as any; // instanceof Client === true
Object.assign(client, {
name: "P1",
position: 0,
team: 0,
cache: null,
reconnectionToken: null,
sendMessage: jest.fn(),
setSocket: jest.fn(),
reconnecting: jest.fn(),
clearReconnecting: jest.fn(),
setCanReconnect: jest.fn(),
setReconnectionToken: jest.fn(),
clearReconnectionToken: jest.fn(),
...overrides,
});
return client as Client;
};
beforeEach(() => {
TokenIndex.getInstance().clear();
});
afterEach(() => {
TokenIndex.getInstance().clear();
});
it("valid token: success ack + setSocket + REFRESH_FIELD to core", () => {
const player = makeRealClient();
TokenIndex.getInstance().register("tok", player, mockRoom.id);
const message = { data: Buffer.from("tok", "utf8") } as ClientMessage;
mockEmitter.emit("EXPRESS_RECONNECT", message, mockRoom, mockSocket);
expect(mockSocket.send).toHaveBeenCalledWith(SUCCESS_ACK);
expect(player.setSocket).toHaveBeenCalledWith(mockSocket, mockRoom.players, mockRoom);
expect(player.reconnecting).toHaveBeenCalled();
expect(mockRoom.sendMessageToCpp).toHaveBeenCalledWith(
expect.stringContaining("REFRESH_FIELD"),
);
});
it("unknown token: failure ack + socket destroyed", () => {
const message = { data: Buffer.from("ghost", "utf8") } as ClientMessage;
mockEmitter.emit("EXPRESS_RECONNECT", message, mockRoom, mockSocket);
expect(mockSocket.send).toHaveBeenCalledWith(FAILURE_ACK);
expect(mockSocket.destroy).toHaveBeenCalled();
});
it("token registered to another room: failure ack + socket destroyed", () => {
const player = makeRealClient();
TokenIndex.getInstance().register("tok", player, mockRoom.id + 999);
const message = { data: Buffer.from("tok", "utf8") } as ClientMessage;
mockEmitter.emit("EXPRESS_RECONNECT", message, mockRoom, mockSocket);
expect(mockSocket.send).toHaveBeenCalledWith(FAILURE_ACK);
expect(mockSocket.destroy).toHaveBeenCalled();
});
it("core RECONNECT: rotates the token (old gone, new 32-hex issued)", () => {
const player = makeRealClient({ reconnectionToken: "old", position: 0 });
(mockRoom as any).players = [player];
TokenIndex.getInstance().register("old", player, mockRoom.id);
(state as any).handleCoreReconnect({
position: 0,
team: 0,
cacheable: false,
});
expect(TokenIndex.getInstance().find("old")).toBeUndefined();
expect(player.setReconnectionToken).toHaveBeenCalledWith(
expect.stringMatching(/^[0-9a-f]{32}$/),
);
expect(player.sendMessage).toHaveBeenCalled();
});
});
});

View file

@ -1,5 +1,3 @@
import BanListMemoryRepository from "@edopro/ban-list/infrastructure/BanListMemoryRepository";
import { ServerInfoMessage } from "@edopro/messages/domain/ServerInfoMessage";
import { spawn } from "child_process";
@ -20,18 +18,20 @@ 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";
import { CatchUpClientMessage } from "../../../../messages/server-to-client/CatchUpClientMessage";
import { PlayerChangeClientMessage } from "../../../../messages/server-to-client/PlayerChangeClientMessage";
import { ReconnectionTokenClientMessage } from "../../../../messages/server-to-client/ReconnectionTokenClientMessage";
import { ReconnectionTokenIssuer } from "../../../../../shared/room/application/reconnect/ReconnectionTokenIssuer";
import { findReconnectingPlayer } from "../../../../../shared/room/domain/findReconnectingPlayer";
import { ReconnectionAckMessage } from "../../../../../shared/messages/server-to-client/ReconnectionAckMessage";
import { ServerErrorClientMessage } from "../../../../messages/server-to-client/ServerErrorMessageClientMessage";
import { ServerMessageClientMessage } from "../../../../messages/server-to-client/ServerMessageClientMessage";
import { FinishDuelHandler } from "../../../application/FinishDuelHandler";
import { JoinToDuelAsSpectator } from "../../../application/JoinToDuelAsSpectator";
import { Reconnect } from "../../../application/Reconnect";
import { TokenIndex } from "../../../../../shared/room/domain/TokenIndex";
import { DuelFinishReason } from "../../DuelFinishReason";
import { Room } from "../../Room";
import { RoomState } from "../../RoomState";
@ -105,7 +105,7 @@ export class DuelingState extends RoomState {
private readonly reconnect: Reconnect,
private readonly joinToDuelAsSpectator: JoinToDuelAsSpectator,
private readonly room: Room,
private readonly jsonMessageProcessor: JSONMessageProcessor
private readonly jsonMessageProcessor: JSONMessageProcessor,
) {
super(eventEmitter);
@ -116,37 +116,37 @@ export class DuelingState extends RoomState {
this.eventEmitter.on(
"JOIN" as unknown as string,
(message: ClientMessage, room: Room, socket: ISocket) =>
this.handleJoin.bind(this)(message, room, socket)
this.handleJoin.bind(this)(message, room, socket),
);
this.eventEmitter.on(
"EXPRESS_RECONNECT" as unknown as string,
(message: ClientMessage, room: Room, socket: ISocket) =>
this.handleExpressReconnect.bind(this)(message, room, socket)
this.handleExpressReconnect.bind(this)(message, room, socket),
);
this.eventEmitter.on(
Commands.SURRENDER as unknown as string,
(message: ClientMessage, _room: Room, client: Client) =>
this.handleSurrender.bind(this)(message, client)
this.handleSurrender.bind(this)(message, client),
);
this.eventEmitter.on(
Commands.RESPONSE as unknown as string,
(message: ClientMessage, _room: Room, client: Client) =>
this.handleResponse.bind(this)(message, client)
this.handleResponse.bind(this)(message, client),
);
this.eventEmitter.on(
Commands.READY as unknown as string,
(message: ClientMessage, room: Room, client: Client) =>
this.handleReady.bind(this)(message, room, client)
this.handleReady.bind(this)(message, room, client),
);
this.eventEmitter.on(
Commands.UPDATE_DECK as unknown as string,
(message: ClientMessage, room: Room, client: Client) =>
this.handleUpdateDeck.bind(this)(message, room, client)
this.handleUpdateDeck.bind(this)(message, room, client),
);
}
@ -165,8 +165,8 @@ export class DuelingState extends RoomState {
if (completeCurrentDeck.length !== completeIncomingDeck.length) {
client.socket.send(
ServerErrorClientMessage.create(
"Por favor selecciona el mismo deck de la partida en curso para poder reconectar"
)
"Por favor selecciona el mismo deck de la partida en curso para poder reconectar",
),
);
const message = ErrorClientMessage.create(ErrorMessages.DECK_ERROR);
client.socket.send(message);
@ -183,8 +183,8 @@ export class DuelingState extends RoomState {
if (!completeIncomingDeck.every((item) => completeCurrentDeck.includes(item))) {
client.socket.send(
ServerErrorClientMessage.create(
"Por favor selecciona el mismo deck de la partida en curso para poder reconectar"
)
"Por favor selecciona el mismo deck de la partida en curso para poder reconectar",
),
);
const message = ErrorClientMessage.create(ErrorMessages.DECK_ERROR);
@ -220,21 +220,20 @@ export class DuelingState extends RoomState {
this.logger.info("Starting Duel");
// The reconnection token is issued once at match start (WaitingState), not
// per-game, so it survives across RPS, side-decking and every duel in a
// match. It is only rotated after each successful reconnection.
this.room.players.forEach((item) => {
item.socket.send(ServerMessageClientMessage.create(ServerInfoMessage.STARTING_DUEL));
const reconnectionToken = crypto.randomBytes(16).toString("hex");
item.setReconnectionToken(reconnectionToken);
TokenIndex.getInstance().register(reconnectionToken, item, this.room.id);
item.socket.send(ReconnectionTokenClientMessage.create(reconnectionToken));
});
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,
@ -247,7 +246,7 @@ export class DuelingState extends RoomState {
],
{
cwd: process.cwd(),
}
},
);
this.room.setDuel(core);
@ -286,7 +285,7 @@ export class DuelingState extends RoomState {
this.logger.info(`payload:data ${payload.data}`);
this.logger.info(`payload:size ${payload.size}`);
this.logger.info(
`payload:buffer ${this.jsonMessageProcessor.currentBuffer.toString("hex")}`
`payload:buffer ${this.jsonMessageProcessor.currentBuffer.toString("hex")}`,
);
this.logger.info(`payload:buffer ${this.jsonMessageProcessor.currentBuffer.toString()}`);
this.logger.info(`score: ${this.room.score}`);
@ -349,7 +348,7 @@ export class DuelingState extends RoomState {
this.processDuelMessage(
_coreMessage.message,
Buffer.from(_coreMessage.data, "hex"),
this.room
this.room,
);
}
@ -419,18 +418,9 @@ export class DuelingState extends RoomState {
player.sendMessage(CatchUpClientMessage.create({ catchingUp: false }));
const oldToken = player.reconnectionToken;
// Rotate the token after a successful reconnection (single-use).
player.clearReconnecting();
player.clearReconnectionToken();
if (oldToken) {
TokenIndex.getInstance().unregister(oldToken);
}
// Generate new token for future reconnections
const newToken = crypto.randomBytes(16).toString("hex");
player.setReconnectionToken(newToken);
TokenIndex.getInstance().register(newToken, player, this.room.id);
player.sendMessage(ReconnectionTokenClientMessage.create(newToken));
player.sendMessage(ReconnectionTokenIssuer.rotate(player, this.room.id));
if (player.cache) {
this.logger.info(`Sending last cached message to ${player.name} after reconnection`);
@ -440,16 +430,16 @@ export class DuelingState extends RoomState {
this.room.players.forEach((client: Client) => {
client.sendMessage(
ServerMessageClientMessage.create(
`${player.name} ${ServerInfoMessage.HAS_ENTERED_TO_THE_DUEL}`
)
`${player.name} ${ServerInfoMessage.HAS_ENTERED_TO_THE_DUEL}`,
),
);
});
this.room.spectators.forEach((spectator: Client) => {
spectator.sendMessage(
ServerMessageClientMessage.create(
`${player.name} ${ServerInfoMessage.HAS_ENTERED_TO_THE_DUEL}`
)
`${player.name} ${ServerInfoMessage.HAS_ENTERED_TO_THE_DUEL}`,
),
);
});
}
@ -472,7 +462,7 @@ export class DuelingState extends RoomState {
position: player.position,
team: player.team,
},
})
}),
);
}
@ -489,7 +479,7 @@ export class DuelingState extends RoomState {
JSON.stringify({
command: "DESTROY_DUEL",
data: {},
})
}),
);
this.room.finished();
@ -553,7 +543,7 @@ export class DuelingState extends RoomState {
if (message.position) {
const player = [...this.room.players, ...this.room.spectators].find(
(player: Client) => player.position === message.position
(player: Client) => player.position === message.position,
);
(<Client | undefined>player)?.sendMessage(payload);
@ -585,7 +575,7 @@ export class DuelingState extends RoomState {
}
const player = [...this.room.players, ...this.room.spectators].find(
(player: Client) => player.inTurn && player.team === message.receiver
(player: Client) => player.inTurn && player.team === message.receiver,
);
(<Client | undefined>player)?.sendMessage(payload);
@ -645,43 +635,35 @@ export class DuelingState extends RoomState {
JSON.stringify({
command: "SET_DECKS",
data: {},
})
}),
);
}
private async handleExpressReconnect(
message: ClientMessage,
room: Room,
socket: ISocket
socket: ISocket,
): Promise<void> {
this.logger.info("DUELING_STATE: EXPRESS_RECONNECT - START");
const token = message.data.toString("utf8");
this.logger.info(`DUELING_STATE: Token received: ${token}`);
const entry = TokenIndex.getInstance().find(token);
if (!entry || !(entry.client instanceof Client) || entry.roomId !== room.id) {
const player = ReconnectionTokenIssuer.resolve(
token,
room.id,
(client) => client instanceof Client,
) as Client | null;
if (!player) {
this.logger.info(`DUELING_STATE: Player not found for token ${token} or room mismatch`);
const type = Buffer.from([0xfd]);
const status = Buffer.from([0x01]);
const dataStatus = Buffer.concat([type, status]);
const size = Buffer.alloc(2);
size.writeUint16LE(dataStatus.length);
socket.send(Buffer.concat([size, dataStatus]));
socket.send(ReconnectionAckMessage.failure());
socket.destroy();
return;
}
const player = entry.client as Client;
this.logger.info(`DUELING_STATE: Match found for player ${player.name}. Restoring session.`);
// 1. Send success status
const type = Buffer.from([0xfd]);
const status = Buffer.from([0x00]);
const dataStatus = Buffer.concat([type, status]);
const size = Buffer.alloc(2);
size.writeUint16LE(dataStatus.length);
socket.send(Buffer.concat([size, dataStatus]));
socket.send(ReconnectionAckMessage.success());
// 2. Perform the reconnection logic (similar to handleReady but without waiting for READY)
player.setSocket(socket, room.players as Client[], room);
@ -699,7 +681,7 @@ export class DuelingState extends RoomState {
playerExtraDeckSize: room.playerExtraDeckSize,
opponentMainDeckSize: room.opponentMainDeckSize,
opponentExtraDeckSize: room.opponentExtraDeckSize,
})
}),
);
player.sendMessage(Buffer.from("0300012800", "hex")); // MSG_NEW_TURN (0)
if (room.lastPhaseMessage) {
@ -713,7 +695,7 @@ export class DuelingState extends RoomState {
position: player.position,
team: player.team,
},
})
}),
);
this.logger.info("DUELING_STATE: EXPRESS_RECONNECT - COMPLETED");
@ -734,7 +716,7 @@ export class DuelingState extends RoomState {
playerExtraDeckSize: room.playerExtraDeckSize,
opponentMainDeckSize: room.opponentMainDeckSize,
opponentExtraDeckSize: room.opponentExtraDeckSize,
})
}),
);
player.sendMessage(Buffer.from("0300012800", "hex"));
if (room.lastPhaseMessage) {
@ -746,7 +728,7 @@ export class DuelingState extends RoomState {
data: {
position: player.position,
},
})
}),
);
}
@ -761,7 +743,12 @@ export class DuelingState extends RoomState {
return;
}
const reconnectingPlayer = this.playerAlreadyInRoom(playerInfoMessage, room, socket);
const reconnectingPlayer = findReconnectingPlayer({
players: room.players,
name: playerInfoMessage.name,
remoteAddress: socket.remoteAddress,
ranked: room.ranked,
});
if (!(reconnectingPlayer instanceof Client)) {
await this.joinToDuelAsSpectator.run(joinMessage, playerInfoMessage, socket, room);
@ -785,7 +772,7 @@ export class DuelingState extends RoomState {
JSON.stringify({
command: "DESTROY_DUEL",
data: {},
})
}),
);
const finishDuelHandler = new FinishDuelHandler({
@ -819,7 +806,7 @@ export class DuelingState extends RoomState {
replier: player.team,
message: data,
},
})
}),
);
}

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)}}`;
};

Some files were not shown because too many files have changed in this diff Show more