Skip to content

Client API ​

createSimulatorClient() returns the v0.2 resource-based client.

ts
import { 
createSimulatorClient
} from '@my-swu/simulator-client'
const
simulatorClient
=
createSimulatorClient
({
// HTTP origin for `/health` and `/api/*` routes.
baseUrl
: 'http://127.0.0.1:4000',
})

Options ​

OptionRequiredDescription
baseUrlyesHTTP origin of the simulator server. Trailing slash optional.
fetchnoCustom fetch implementation. Defaults to global fetch.
timeoutMsnoDefault timeout for every HTTP resource call. Unset means unbounded.
validatenoRuntime schema validator for every payload crossing the boundary. Off by default. See Runtime validation.
webSocketFactorynoCustom browser-style WebSocket factory used by match sessions.

Every HTTP resource method also accepts an optional trailing options argument ({ signal, timeoutMs }) for per-call cancellation. See Cancellation and timeouts.

Runtime validation ​

The client does not validate payloads at runtime by default. Responses, request bodies and socket frames are handed back as their generated TypeScript types without being checked against the JSON schemas first.

That default is about bundle size. The generated schema map and Ajv are 90% of the SDK's minified weight, and a consumer talking to a server it controls has little use for a second copy of the contract at runtime:

BundleMinifiedGzip
Default client39.1 KB9.9 KB
With validate478.2 KB102.0 KB

Measured with esbuild against dist/index.js, bundling a browser entry that constructs a client and one match session. The difference is 322 KB of generated schemas plus 115 KB of Ajv, neither of which is reachable from createSimulatorClient unless you name createSchemaValidator.

Per frame, on a real 3.2 KB server-message snapshot from the engine: decoding costs about 5 µs with validation off — essentially the JSON.parse the SDK has to do anyway — against 13-15 µs with it on, so validation adds 8-10 µs (roughly 2.6-3x) to every frame. Each outbound envelope costs about 0.9 µs to validate.

What the default gives up ​

  • A malformed or truncated server payload is not rejected. It reaches your code typed as if it were valid, and the mismatch surfaces later as an undefined field or a TypeError far from its cause.
  • A malformed request you build is not caught before it is sent. Instead of a local SdkValidationError naming the offending field, you get whatever the server answers — usually a 400 through EngineHttpError.
  • A server message type this SDK build does not know is no longer dropped during decode. It still cannot be mis-dispatched — the message routers have no case for it — but it does reach the raw message event.

Non-schema failures are unaffected: a frame that is not valid JSON still emits sdk_invalid_server_message, and a non-2xx response still throws EngineHttpError.

Turning it on ​

Pass createSchemaValidator(). Behaviour then matches the SDK before this option existed: every response, request body and socket frame is checked, and a mismatch throws SdkValidationError with the full error list in details.

ts
import {
  
createSchemaValidator
,
createSimulatorClient
,
} from '@my-swu/simulator-client' const
validatingSimulatorClient
=
createSimulatorClient
({
baseUrl
: 'http://127.0.0.1:4000',
// Checks every payload against the generated schemas.
validate
:
createSchemaValidator
(),
}) const
validatedHealth
= await
validatingSimulatorClient
.
system
.
health
()
console
.
log
(
validatedHealth
.
status
)

Turn it on when you do not control the server, when you are integrating against a new engine build, or in CI and staging where the extra bytes cost nothing and a contract drift should fail loudly. Ship it off in production once the contract is settled.

One validator serves the whole client. Every socket it opens inherits it — session.create(), lobbies.createSession(), lobbies.subscribeList(), and tournamentRooms.createSession() — so all of them answer a malformed frame the same way. See Socket-side decoding failures.

parseSchema() is unaffected by this option: an explicit call always validates, so you can keep checking a specific payload — a persisted save, a hand-built command — without paying for validation on every frame.

ts
import { 
parseSchema
} from '@my-swu/simulator-client'
// Explicit validation still works with the default client. const
persistedCommand
=
parseSchema
('game-command', 'pass')
console
.
log
(
persistedCommand
)

Warming the validator ​

Schema compilation is lazy, and the first compile pays for the whole shared definition pool: roughly 215 ms for server-message, against 2-42 ms for each schema after it. Left alone, that cost lands on the first frame a socket receives — during connect, on the browser's main thread.

createSimulatorClient() therefore schedules the validator's warmUp() on an idle callback as soon as the client is built (requestIdleCallback with a 2000 ms timeout, falling back to an unref'd setTimeout where that API is absent), which compiles the four socket protocol roots before the handshake needs them. Measured on the connect path, the first validated frame drops from 226 ms to a 7.9 ms median — about 218 ms moved off connect.

This needs nothing from you. It never blocks the constructor, never rejects, and a failed warm-up is swallowed because the same compile is retried against a real payload on first use. With validation off it is a genuine no-op rather than skipped work: the default pass-through validator does not implement warmUp at all, so nothing is scheduled and neither Ajv nor the schema map becomes reachable.

Call it yourself to compile a different set — for a tool that only ever reads HTTP responses, for example:

ts
import { 
createSchemaValidator
} from '@my-swu/simulator-client'
const
schemaValidator
=
createSchemaValidator
()
// Compiles now instead of on the first payload that needs these schemas.
schemaValidator
.
warmUp
?.(['get-match-response', 'cards-response'])

warmUp is optional on SchemaValidator, and calls are idempotent: a schema already compiled costs nothing.

system ​

ts
// System health checks are useful for startup gates.
const 
health
= await
simulatorClient
.
system
.
health
()
console
.
log
(
health
.
status
)
  • health() calls /health.

cards ​

ts
// Card ids are loaded by the simulator server.
// 46102 = Leia Organa - Someone Who Loves You; 308 = Echo Base.
const 
requestedCardMetadata
= await
simulatorClient
.
cards
.
get
([46102, 308])
const
catalogCards
= await
simulatorClient
.
cards
.
catalog
()
const
krennicCardId
= await
simulatorClient
.
cards
.
resolveId
({
setCode
: 'SOR',
cardNumber
: 1,
cardTypes
: ['leader'],
}) const
importedDeck
= await
simulatorClient
.
cards
.
convertDeckJson
({
leader
: {
id
: 'SOR_001',
count
: 1 },
base
: {
id
: 'SOR_025',
count
: 1 },
deck
: [{
id
: 'SOR_010',
count
: 3 }],
})
console
.
log
(
requestedCardMetadata
[0],
catalogCards
.
length
,
krennicCardId
,
importedDeck
.
cards
.
length
)
  • get(ids) returns canonical card metadata loaded from the server cache.
  • catalog() returns canonical metadata for every production card.
  • resolveId(reference) converts one set code plus printed card number into a card id. Always pass cardTypes; use [] when the set number is not type-filtered.
  • resolveIds(references) performs the same lookup in batches and returns not-found or ambiguous match details.
  • convertDeckJson(deckJson) converts my-swu JSON deck exports from SET_001 references into simulator DeckInput.

matches ​

ts
// Deck payloads are validated before a match or seat is created.
// `leaders` is the Twin Suns multi-leader list and `sideboard` is accepted only
// by formats whose validation allows one; both stay empty for plain Premier.
const 
premierHostDeck
= {
leader
: 46102, // Leia Organa - Someone Who Loves You
leaders
: [],
base
: 308, // Echo Base
cards
:
Array
.
from
({
length
: 50 }, () => 45), // Alliance X-Wing
sideboard
: [],
} const
premierGuestDeck
= {
leader
: 46107, // Darth Vader - Unstoppable
leaders
: [],
base
: 309, // Tarkintown
cards
:
Array
.
from
({
length
: 50 }, () => 35), // TIE/ln Fighter
sideboard
: [],
} const
hostAccess
= await
simulatorClient
.
matches
.
create
({
format
: 'premier',
// Twin Suns supports 2-4 players; every other format is exactly 2.
playerCount
: 2,
hostDeck
:
premierHostDeck
,
hostName
: 'Host',
// True only when the seat arrived from an authenticated app session.
hostAuthenticated
: false,
// Identities that should own the seat's server-side history entry.
historyIdentities
: [],
// One entry per AI-controlled seat; empty means a human opponent joins.
aiOpponents
: [],
}) // `join()` accepts the host access object and extracts the public match id. const
guestAccess
= await
simulatorClient
.
matches
.
join
(
hostAccess
, {
deck
:
premierGuestDeck
,
name
: 'Guest',
authenticated
: false,
historyIdentities
: [],
}) const
publicMatchSnapshot
= await
simulatorClient
.
matches
.
get
(
hostAccess
)
// Sessions own socket state for one private seat. const
matchSession
=
simulatorClient
.
session
.
create
(
hostAccess
)
await
matchSession
.
connect
()
// Format events handle out-of-game flows such as readiness between games. await
simulatorClient
.
matches
.
applyFormatEvent
(
hostAccess
, {
type
: 'readyNextGame',
ready
: true })
console
.
log
(
guestAccess
.
seat
,
publicMatchSnapshot
.
status
,
matchSession
.
connectionState
)

Match access objects returned by create() and join() can be passed directly to join(), get(), applyFormatEvent(), and session.create().

session ​

ts
const 
matchSession
=
simulatorClient
.
session
.
create
(
hostAccess
)
matchSession
.
on
('change',
sessionState
=> {
console
.
log
(
sessionState
.
connectionState
,
sessionState
.
seat
)
}) await
matchSession
.
connect
()
matchSession
.
ready
()

Sessions keep private access out of renderable state while exposing connectionState, seat, snapshot, events, and errorMessages.

Action Helpers ​

ts
import { 
canDeployAnyLeader
,
getDeployableLeaderIndices
} from '@my-swu/simulator-client'
const
leaderCanDeploy
=
canDeployAnyLeader
(
matchSnapshot
)
const
deployableLeaderIndices
=
getDeployableLeaderIndices
(
matchSnapshot
)
console
.
log
(
leaderCanDeploy
,
deployableLeaderIndices
)

Leader deploy helpers read snapshot.availableLeaderDeploys, which is computed by the engine. Prefer it over leaderDeployAvailable or leaders[].deployAvailable when enabling deploy controls.

targetGroupsOf(), flattenTargetGroups(), and isSingleTargetPlan() cover the targeting side of the same surface — see Walking target groups.

limitedEvents ​

ts
// Limited Events create draft or sealed pools before you create a match.
const 
draftEventAccess
= await
simulatorClient
.
limitedEvents
.
create
({
kind
: 'draft',
setCode
: 'SOR',
playerCount
: 4,
// Per-round booster sets for chaos draft; a plain draft leaves this empty.
draftRounds
: [],
// Enables the per-pick countdown that auto-picks on timeout.
pickTimerEnabled
: false,
seed
: '7',
}) const
draftSeatAccess
= await
simulatorClient
.
limitedEvents
.
join
(
draftEventAccess
)
// Passing seat access includes viewer-private draft state. const
limitedEventSnapshot
= await
simulatorClient
.
limitedEvents
.
get
(
draftSeatAccess
)
const
nextLimitedEventSnapshot
= await
simulatorClient
.
limitedEvents
.
applyCommand
(
draftSeatAccess
,
{
type
: 'readyForNextDraft' },
)
console
.
log
(
limitedEventSnapshot
.
phase
.
kind
,
nextLimitedEventSnapshot
.
phase
.
kind
)

Limited Event snapshots expose public pod state plus viewer-private pack, pick, and completed pool details when a valid seat token is supplied.

Released under the MIT License.