* 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.
16 KiB
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_GAMEpassfield: UTF-16LE, fixedutf16[20]— silently truncated past 20 characters. Exactly-20 round-trips (no NUL terminator required byygopro-msg-encode). YGOProJoinHandlersplits 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:
ruleMappings(mode):m/match,t/tag.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.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:
tcgis an alias ofot(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/ocgartare byte-identical totcgpre/ocgprein ruleset, but kept as separate tokens on purpose — token = room identity (§4/§5), so merging them would silently merge two distinct pairing pools.gresolves genesys (rule: 1, genesys banlist,max_deck_pointsdefault 100) and throws if the genesys banlist is not loaded.pre,tcgpre,ocgpre,tcgart,ocgart,rushpreadditionally 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 bothroom.nameandroom.passwordagainst the joiner'scommandandpassword, 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 usesfindByNameAndPasswordinstead.- Consequences:
- Case-sensitive identity:
tcgandTCGare two different rooms that resolve to the same ruleset. - A bare token is both config AND room identity: two strangers sending
exactly
tcgland in the same room and get seated as the two players. This started as an emergent side effect offindByName's first-match lookup; §5 formalizes it into the designed pairing feature (still keyed on the exact command string, now state- and seat-aware). findByNameAndPasswordreturns the first room matching the pair and ignores room state — a room stays in the list (and stays matchable) throughwaiting → rps → choosingOrder → dueling → sideDeckinguntilFinalizeYGOProRoom.runremoves 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.
- Case-sensitive identity:
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 fromRuleMappings.ts— true when the token matches at least one tier'svalidate(), 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 === WAITINGand 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), becauseRoomAdmissionhard-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 (
rankedOverrideper 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; otherwiseRoomAdmission.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 viaTO_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 isroom.isMatchmaking = true. That generated string always carries anmm<id>token, whichisRecognizedTokendoes not recognize — so matchmaking joins are never pairing joins (§5); they always go through thefindByNameAndPasswordlookup (§4/§5), resolving to their room by the exact generated(name, password)pair.
8. Known caveats (current behavior)
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 (orcasual): 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 usingfindByNameAndPassword(state-blind, first-match on the pair) and can still land a joiner as a spectator of a dueling room when the pair matches.- 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 —
TCGandtcgare two distinct pairing pools, not one. 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 viafindByNameAndPassword, so a second, third, etc. same-named room under a different password is always independently reachable; pairing joins (§5) scan every same-named room viafindJoinableByNameand skip dueling/full/passworded ones. The caveat still applies to any other caller that uses plainfindByNamedirectly (e.g.MatchmakingRoomFactory's name-collision check when minting a new matchmaking room name).- 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 avoidsfindByName'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. - The 20-char
passceiling applies to the entire command string; bot names are boot-validated against a 13-char budget that assumes short (≤3 chars + comma) format tokens.