Imported from Fyendal/fyendal.net (
AGENTS.md). Install upstream withnpx skills add Fyendal/fyendal.net. Copyright stays with the author.
Fyendal agent guide
Fyendal is a TypeScript/pnpm monorepo for playing Flesh and Blood. Preserve the boundaries below; they are release invariants, not suggestions.
Architecture invariants
- Production runs as one active gateway, but it must remain safe to scale
across multiple processes or instances. Live sockets and their delivery
bindings remain local; durable matchmaking, leases, commands, and compact
cross-instance events live in Postgres. Fan-out polls the event log; do not
add
LISTEN/NOTIFYor assume an external distributed queue. - Schema epoch 1 is the initial empty-database baseline. Unexpected application
tables or another epoch fail with
RESET_REQUIRED. Future changes append immutable migrations; never edit an applied migration. rulesetVersionis an operator-managed compatibility identifier, not the image SHA. Compatible deployments retain it; incompatible persisted-state or rules changes require an explicit bump. The explicit cutover deletes rooms from another ruleset rather than migrating active games. Cutover is fenced; startup never changes the active ruleset implicitly.packages/sharedcontains types only. All wire, HTTP-response,GameView, and replay decoding belongs in@fyendal/protocol.- Parse JSON and database JSON as
unknown, then decode it exhaustively. Never cast untrusted values to runtime types. - Card scripts observe deeply readonly state and mutate only through
ScriptCtx. Do not create mutable player helpers, engine-runtime imports, compatibility adapters, or internal escape hatches. - Public logs must not reveal identities from hands, face-down arsenal, private
deck positions, blind choices, or other hidden zones. Use
logPublic,logPrivate, orlogForSeatsdeliberately. - Every registered rules limitation has a source annotation and a focused,
executable
it.failsscenario.
Packages and applications
packages/shared— data contracts and stable protocol error codes.packages/protocol— hand-written exact-key runtime validators with bounds, safe-integer checks, nested validation, and depth limits.packages/engine— deterministic, zero-I/O, card-agnostic rules engine. Never reference a printing id, card name, class, or hero here. Readpackages/engine/DESIGN.mdbefore changing rules behavior. Import mechanics from their owning modules; do not turnutil.tsback into a compatibility barrel. Production engine modules must remain free of runtime and type-only dependency cycles; the architecture test enforces this without an allowlist.packages/cards— card JSON, functional scripts, vanilla registry, precons, and scenario tests. Use.agents/skills/import-cards/SKILL.mdfor imports.apps/server— HTTP/WebSocket gateway and Postgres stores.db.tsowns the schema,persistedState.tsowns the explicit persisted DTO,roomBroadcaster.tsowns local fan-out, andgateway/owns connection, matchmaking, routing, and composition.apps/client— React/Vite UI and one public Zustand store covering connection/session, account/decks, lobby/room, and replay state.
Internationalization
- React copy uses
react-intl. Source catalogs live inapps/client/src/i18n/catalogs/{en,zh-Hans}.json; compiled ICU AST catalogs live inapps/client/src/i18n/compiled/. Edit both source catalogs and never hand-edit compiled output. English is the canonical fallback, and every supported locale must have exactly the same keys. - Message ids are stable, lowercase, dot-separated semantic identifiers. Do
not encode rendered copy, a printing id, or runtime data in an id. Put
dynamic content in ICU values; use typed
GameMessagecard, player, and term references when the client must resolve visible names safely. A term reference composes another catalog message using the enclosing values; use it for reusable fragments such as trigger effects instead of duplicating every effect as a second log translation. - Client-only copy uses
useIntl,FormattedMessage,GameMessageText, orformatGameMessage. Do not branch on the current locale or construct translated sentences by concatenating fragments. - The engine, server, and card scripts never import locale catalogs or render a
locale. Wire-visible game copy retains a deterministic English fallback and
carries locale-neutral
GameMessagemetadata. This keeps old clients, persisted decisions, replay diagnostics, and newer catalogs compatible. - Card-script decisions use the helpers in
packages/cards/src/scripts/shared-helpers.ts:decisionPromptfor every player-facing prompt,decisionMessagefor semantic option labels, andcommonOptionMessagesfor shared fixed choices. Option values delivered toonChooseare stable engine values and must never be translated. Card choices may remain ids because the client renders their visible card names. - Player-facing card logs use
localizedLogorlocalizedCardLog. ChooselogPublic,logPrivate, orlogForSeatsindependently of localization; neither fallback text nor semantic values may reveal hidden information. - Engine
logPublicproducers must pass a semanticGameLogPayload, never a raw player-facing string. Server-generated game-log entries (such as undo or idle-victory markers) require the same payload.catalogs.test.tsscans every engine call site and referenced server log id; add matching English and localized catalog keys with the producer change. - Every new
TriggerDefsupplieslabelMessagebeside its Englishlabel. A custompublicLogalso requirespublicLogMessage.packages/cards/src/trigger-messages.tsenriches migrated legacy labels at registry composition and consolidates repeated effects through parameterized messages. Do not add a new raw trigger fallback without semantic metadata;trigger-messages.test.tsenforces a zero untranslated backlog. - To add a language, extend
SUPPORTED_LOCALES, its autonym and browser-locale matching inapps/client/src/i18n/locale.ts; add its source catalog and lazy import inI18nProvider.tsx; then update locale, picker, and catalog tests. Language-picker labels are autonyms, not translations in the active locale. - Preserve established Simplified Chinese terminology. Keep
go again,On hit,Links/Chain, andarsenalas established UI terms; render dominate as压制 (dominate)and Aura as灵气 (Aura)with a space before the parenthesis. Extend the terminology assertions incatalogs.test.tswhen standardizing another term. pnpm devcompiles all catalogs before Vite starts, then the Vite plugin recompiles a changed source catalog and reloads the page. Builds also compile first. Outside a running dev server, runpnpm --filter @fyendal/client i18n:compileafter catalog edits and include the generated compiled catalogs in the change.apps/client/src/i18n/catalogs.test.tsenforces locale key parity, safe ids, referenced card/engine message coverage, and terminology. For i18n changes, run the compiler, that focused client test, the relevant UI/card tests, and affected workspace typechecks. Pure catalog or presentation-metadata changes do not require arulesetVersionbump; rules behavior or incompatible persisted/wire changes still do.
Server and persistence
- Persisted game state uses the explicit
PersistedStateV1envelope.cardsRefandscriptsRefare process registries and must never enter room JSON. Hydration validates first, then reattaches both registries. roomsholds room-wide state,room_seatsholds relational membership,room_historyholds the newest 20 undo snapshots, androom_presenceholds per-socket leases. User-owned rows use cascading foreign keys.- Store only hashes of account sessions and reconnect credentials. Return raw tokens once and hash incoming credentials before comparison.
- Room mutations and history writes are transactional and use optimistic room
versions. Broadcast only after commit by reloading authoritative state.
Broadcast failure must not roll back a committed mutation; close affected
sockets with
RESYNC_REQUIREDso reconnect reloads state. - Durable data belongs in Postgres. Local queues and live sockets may disappear on instance recycle. Clients must reconnect safely.
- Registration uses only username/password. Passwords use Node scrypt; sessions are opaque 32-byte tokens stored as SHA-256 hashes with 30-day sliding expiry.
- Account export and password-confirmed deletion are self-service. New durable user data must be included in both. Deletion removes account-bound rooms and lets sessions/decks cascade.
- SQL is async and parameterized with
$1,$2, …; never interpolate values. Tests usepg-memthroughfreshDb().
Formats and decks
Formatisclassic-battles | cc | silver-age.- Classic Battles uses fixed Rhinar or Dorinthea pools; mirror matches are
allowed. Silver Age also exposes the precons in
packages/cards/src/data/precons.json. - A saved
DeckPoolis not a game presentation.PresentedDeckselects legal weapons, equipment, and main deck for one match;validatePresentationis authoritative. - Filling seat 1 enters prep. Both seats present decks, then the die winner chooses the first player. Leaving before start frees the seat; queued players may be requeued into the retained room.
- Deck import accepts Fabrary export text. Every card must resolve and be implemented by a functional script or verified vanilla entry.
Engine and card rules
- All randomness uses the persisted seeded RNG.
- Games change only through
applyIntent;legalIntentsis the source of truth, and every returned intent must apply successfully. - Played cards and abilities use the priority stack. Go again grants its action point at resolution. Resolved combat links remain until the chain closes.
- Combat behavior is divided among
attacks.ts,combatValues.ts,defense.ts,reactions.ts,hits.ts,wagers.ts,damage.ts, andcombatChain.ts; do not recreate acombat.tsfacade. - Stateful mechanics belong in their focused modules: token creation and
replacement in
tokens.ts, clash inclash.ts, zone movement and permanent destruction inzoneMoves.ts, and shared priority transitions inpriority.ts. sourceQueries.tsowns active-source enumeration;eventSources.tsowns event-trigger collection and hook dispatch.scriptContext.tsimplementsScriptCtx, whileengineRuntime.tsis the sole immutable composition root. Pass runtime services explicitly through internal flows; never attach them to game state or introduce a mutable module registry.- Cross-domain combat and turn continuations route through
flowDispatcher.ts. Domain flow modules may depend on lower-level mechanics and runtime port types, but must not import peer flow implementations. - State must survive JSON cloning: no functions, classes, maps, or process
references. Persistent card data belongs on
CardInstance; turn data belongs in player/link flags and is cleaned at the correct boundary. - Scripts are keyed by functional identity,
name lowercase|pitch, never by printing id. Reprints automatically reuse scripts. - Under
packages/cards/src, add card data todata/cards/<SET>.json, functional scripts belowscripts/, registrations in both indexes, and true vanilla keys todata/vanilla.json. Duplicate functional keys are errors. - Card images are hotlinked from
https://content.fabrary.net/cards/<printingId>.webp; do not store them. - Confirm rules against https://rules.fabtcg.com/en/cr/ before implementing a mechanic.
Rules limitations
Never add an unregistered TODO(engine) or approximation comment under the
engine or card scripts. For a real gap:
- Allocate the next
FYD-RULE-NNNindocs/rules-limitations.json. - Record
source, concreteexpectedbehavior,testFile,testName, andimplemented: false. - Annotate every related source comment with the id.
- Add the named
it.failstest asserting correct behavior. - Run
pnpm check:rules-limitations.
When fixed, convert the focused test to normal it, remove all annotations,
and remove the registry entry. Never leave an implemented limitation tracked.
Validation
Run the smallest relevant validation for the affected code. Focused tests and typechecks are the default; stop once checks proportional to the change pass.
pnpm --filter <workspace> test # focused package tests
pnpm --filter <workspace> typecheck # focused package typecheck
pnpm release:check # significant engine/server changes only
-
Do not run
pnpm release:checkfor routine or localized changes. Run it only after a significant change inpackages/engineorapps/server, or when the user explicitly requests the full release gate. -
Documentation-only and comment-only changes require diff review, not tests.
-
Card scenarios use
packages/cards/src/__tests__/harness.tsand drive games only through legal intents. -
Add tests for non-trivial behavior. Keep known bugs as correctly phrased
it.failstests. -
Do not build browser bots, Playwright flows, or screenshot rigs; UI smoke testing is manual.
-
pnpm --filter @fyendal/server seedis local-only. Never weaken its production-environment or remote-database guards.