Reference lookup for phase.rs — build/test/cargo/clippy commands, cargo aliases, WASM build, card-data pipeline and jq lookups, frontend (pnpm) commands, coverage report, crate/workspace architecture, engine internals, WASM bridge, AI engine, multiplayer server, frontend layers, environment variables, releasing (cargo-release), and CI. Use when you need to look up how to build, test, run, generate card data, release, or where a crate, module, command, or env var lives.
Instal·la
npx skillscat add phase-rs/phase/project-reference Instal·la des del registre de SkillsCat.
phase.rs Project Reference
Lookup material for build commands, the verification cadence, architecture, environment variables, releasing, and CI. The always-on rules (design principles, Tilt-not-cargo, building blocks, CR annotations, Rust idioms) live in the root CLAUDE.md / AGENTS.md; this skill holds the on-demand reference that doesn't need to be resident every turn.
Tilt resources & operational rules
Tilt is always running and continuously rebuilds on file changes. Do NOT run cargo build, cargo clippy, cargo test -p phase-engine, pnpm run type-check, or pnpm lint directly — they compete for cargo target locks. Check Tilt logs instead.
Available Tilt resources (defined in Tiltfile):
| Resource | What it does | Triggers on |
|---|---|---|
clippy |
cargo clippy --all-targets -- -D warnings |
crates/ changes |
test-engine |
cargo test -p phase-engine |
crates/engine/src/ changes |
test-ai |
cargo test -p phase-ai |
crates/engine/src/ or crates/phase-ai/src/ changes |
wasm |
WASM build (depends on clippy) | engine/AI/WASM src changes |
card-data |
./scripts/gen-card-data.sh |
crates/engine/src/ changes |
check-frontend |
pnpm run type-check && pnpm lint |
client/src/ changes |
test-frontend |
pnpm test -- --run |
client/src/ changes |
server |
Build + serve phase-server | server src changes |
coverage |
cargo coverage |
Manual trigger only |
How to check results:
tilt logs clippy --tail 30 --since 2m # Recent clippy output
tilt logs test-engine --tail 50 --since 2m # Recent test results
tilt logs card-data --tail 20 --since 1m # Card data gen output
tilt logs check-frontend --tail 30 --since 2m # TS type-check + lintHow to wait for current results without dumping logs:
./scripts/tilt-wait.sh clippy test-engine card-data # wait until all settle (no timeout)
./scripts/tilt-wait.sh --interval 10 clippy # poll faster for a single fast resource
./scripts/tilt-wait.sh --timeout 600 clippy test-engine # bound the waitExit codes: 0 all ok, 1 a resource is in terminal error (updateStatus=error with no in-flight build), 2 usage error, 3 cannot answer the question, 124 timeout (only when --timeout is set). The script prints one <resource> status=… current=… last=… line per tick so you can see why a wait is taking time without paying for log payloads. After exit 1, fetch details with tilt logs <resource> --tail 50 --since 2m.
1 vs 3 is load-bearing — do not collapse them. 1 means your code is broken. 3 means I could not find out: Tilt is not reachable, or Tilt is watching a different checkout than the one you are in. The second case is the common one — repo_root is derived from the script's own path, so running a worktree's own copy of tilt-wait.sh while Tilt watches the main checkout yields 3, because no build over there can describe your edits here. Call the main checkout's script from a worktree (/path/to/forge.rs/scripts/tilt-wait.sh) and it resolves correctly. Treating a 3 as a build failure is how the gate gets distrusted, and a distrusted freshness gate gets bypassed — which restores the false green it exists to prevent.
A resource must be both TERMINAL and FRESH before its status is believed. Freshness is derived from the resource's Tilt deps, so deps must stay a superset of whatever actually changes the build (for the engine: src + data + build.rs + Cargo.toml — the same set scripts/engine-source-hash.sh hashes). Anything built-from but unwatched is doubly invisible: Tilt will not rebuild it, and the freshness scan will not look there — so the script answers "fresh + ok" for a change that was never compiled.
Rules:
- After saving files, wait ~10-30s for Tilt to detect changes and rebuild, then check logs.
- Do not wait for every Tilt resource to go green after every minor edit. Use the smallest relevant signal while iterating, and batch broader Tilt verification at natural checkpoints such as before marking an issue fixed, after a risky engine change, or before handing work off.
- Prefer the
tilt get uiresourcepolling loop when waiting on multiple resources; use logs after a resource reportserroror when you need detailed output. - Do not treat
.status.buildHistory[0].erroras actionable while.status.currentBuild.spanIDis present. Build history may still contain the previous failed run while Tilt is compiling a newer one. - Only diagnose/fix a resource error after
updateStatus == "error"andcurrentBuild.spanID == "none".pendingwith no current span usually means the resource is queued behind another resource or cargo lock; wait instead of starting manual cargo commands. - Use
--followonly when you need to stream live output (e.g., waiting for a build in progress). - Use
--sinceto limit output — don't dump entire build history. - If a resource shows errors, fix your code and Tilt will automatically rebuild.
- Only run cargo/pnpm commands directly if Tilt is confirmed not running. Detect with
tilt get uiresource clippy >/dev/null 2>&1(exit 0 = Tilt up; exit non-zero = Tilt down or unreachable).tilt statusis not a valid subcommand — do not use it. cargo fmt --allis the one exception — always run it directly since Tilt doesn't auto-format.
Verification cadence (risk-scaled)
cargo fmt --all is always run directly — Tilt does not auto-format. For everything else, prefer Tilt logs (tilt logs <resource>) / ./scripts/tilt-wait.sh when Tilt is up, and fall back to direct cargo/pnpm only when Tilt is confirmed down (tilt get uiresource clippy >/dev/null 2>&1; exit 0 = up).
# Always run fmt directly — Tilt does not auto-format.
cargo fmt --all
# Fast parser iteration: small parser/AST-only changes while moving through many
# bug reports. Broader Tilt resources may keep running in the background; do not
# wait on card-data for every tiny parser edit.
./scripts/check-parser-combinators.sh
if tilt get uiresource clippy >/dev/null 2>&1; then
./scripts/tilt-wait.sh --timeout 180 clippy
else
cargo clippy --all-targets -- -D warnings
fi
# Full Rust verification: before marking an issue fixed-unreleased, after
# non-trivial engine/runtime changes, before long handoffs, and before PR or
# release boundaries. Parser changes invalidate card-data, but card-data is a
# checkpoint resource, not a per-micro-edit throttle.
if tilt get uiresource clippy >/dev/null 2>&1; then
./scripts/tilt-wait.sh --timeout 240 clippy test-engine card-data
else
cargo clippy --all-targets -- -D warnings
cargo test -p phase-engine
./scripts/gen-card-data.sh
fi
# Frontend verification:
if tilt get uiresource clippy >/dev/null 2>&1; then
./scripts/tilt-wait.sh --timeout 180 check-frontend
else
(cd client && pnpm run type-check && pnpm lint)
fiAfter tilt-wait.sh returns non-zero, fetch details with tilt logs <resource> --tail 50 --since 2m. After direct cargo/pnpm failures, the output is already on stdout.
These blocks are designed for interactive use, where a non-zero exit from tilt-wait.sh or a cargo command is surfaced via the printed status line and the operator fixes it before re-verifying. In a set -e shell or scripted/CI harness, the if construct will swallow the inner non-zero exit — wrap each branch with || exit $? (or restructure as tilt get uiresource clippy >/dev/null 2>&1 && tilt-wait.sh ... || cargo ...) when copy-pasting into automation.
The one-shot audit binaries (cargo coverage, cargo semantic-audit, cargo parser-gaps, cargo rules-audit) are not continuous Tilt resources — invoke them directly in both modes.
Build & Development Commands
Run ./scripts/setup.sh for full onboarding (Scryfall sidecars → card data → WASM → pnpm install). Auto-detects Tilt and defers WASM + card-data to tilt up when present. Flags: --agent skips Scryfall art for LLM contributors (see docs/AI-CONTRIBUTOR.md); --no-tilt forces the inline build path.
Rust Engine
cargo test --all # Run all Rust tests
cargo test -p phase-engine # Test engine crate only
cargo test -p phase-engine -- test_name # Run single test
cargo clippy --all-targets -- -D warnings # Lint
cargo fmt --all -- --check # Format check
cargo fmt --all # Auto-formatCargo Aliases (.cargo/config.toml)
cargo wasm # Build WASM (debug)
cargo wasm-release # Build WASM (release)
cargo test-all # Run all tests (nextest, excludes phase-tauri)
cargo clippy-strict # clippy -D warnings
cargo serve # Run phase-server (release)
cargo coverage # Card support coverage report (reads data/card-data.json)
cargo parser-gaps # Parser gap analysis report
cargo rules-audit # MTG Comprehensive Rules audit (requires --features audit)
cargo semantic-audit # Semantic audit of parsed card data (outputs data/semantic-audit.json + .md)
cargo scrape-feeds # Scrape metagame feeds from MTGGoldfish
cargo tune-ai # Run AI weight tuning (requires --features tune)WASM Build
./scripts/build-wasm.sh # Build WASM (release): compile → wasm-bindgen → wasm-opt
./scripts/build-wasm.sh debug # Build WASM (debug)Requires wasm-bindgen-cli (v0.2.121) and optionally wasm-opt (binaryen). Output goes to client/src/wasm/ (gitignored, regenerated).
Card Data Pipeline
./scripts/gen-card-data.sh # export all cards → client/public/card-data.json
cargo run --bin oracle-gen -- data --filter "card name" # single card (debug)
cargo run --bin oracle-gen -- data --filter "name1|name2|name3" # multiple cards (pipe-separated, substring match)Measurement hazards — read before you export, diff, or commit
The export writes to tracked files and is not fully deterministic. Three consequences,
all of which have burned real measurement campaigns:
| Hazard | What you'll see | What to do |
|---|---|---|
cargo export-cards rewrites crates/engine/data/oracle-subtypes.json — a tracked parser input, and the rewrite is non-idempotent (it can drop subtypes) |
An unexplained diff in a file you never edited | Never commit it as a side effect. git checkout -- crates/engine/data/oracle-subtypes.json after the export unless changing the vocabulary is your actual intent. It is a parser input: a lossy rewrite silently changes how every card parses |
gen-card-data.sh dirties crates/engine/data/known-tokens.toml (also tracked) |
Same — a diff you didn't author | Same: leave it out of the commit. Commit by explicit pathspec, never git add -A |
| The export is nondeterministic on ~20 faces | Two exports of the same tree differ | That is the noise floor of any whole-pool ledger. A delta of ≲20 faces between two full-pool runs is not signal. Re-run, or diff against a snapshot taken from the same binary — never claim a sub-noise-floor coverage win |
Corollary for coverage work: snapshot card-data.json BEFORE you touch the parser, and
diff generated-vs-generated. Diffing a fresh export against a stale committed artifact
attributes the artifact's own drift to your change.
Card Data Lookup
jq '.["lightning bolt"]' client/public/card-data.json # Full card data
jq '.["card name"] | .abilities[]' client/public/card-data.json # Just abilities
jq '.["card name"] | {abilities: [.abilities[]? | select(.effect.type == "Unimplemented")], triggers: [.triggers[]? | select(.mode == "Unknown")]}' client/public/card-data.json # Unimplemented gapsFrontend (client/)
cd client
pnpm install # Install dependencies
pnpm dev # Vite dev server
pnpm build # TypeScript check + Vite build
pnpm lint # ESLint
pnpm run type-check # TypeScript only (no emit)
pnpm test # Vitest (watch mode)
pnpm test -- --run # Vitest (single run, used in CI)
pnpm tauri:dev # Tauri desktop dev
pnpm tauri:build # Tauri desktop buildCoverage Report
cargo coverage # Card support coverage (JSON report, alias)
cargo run --bin coverage-report -- data/ --ci # CI mode: exits 1 if gaps foundArchitecture
Rust Workspace (crates/)
engine — Core rules engine: types, game logic, parser, database
engine-wasm — WASM bindings (wasm-bindgen + tsify) exposing engine to JS
phase-ai — AI opponent: evaluation, legal actions, card hints, search
server-core — Server-side game session management (tokio)
phase-server — Axum WebSocket server for multiplayer
feed-scraper — Metagame deck scraper (MTGGoldfish)Crate dependency flow: engine ← phase-ai ← engine-wasm / server-core ← phase-server (feed-scraper is standalone)
Engine Internals (crates/engine/src/)
types/— Core data types:GameState,GameAction,GameEvent,GameObject,Phase,Zone,ManaPool, abilities, triggers. All types useserdefor serialization across the WASM boundary.game/engine.rs— Mainapply(state, action) -> ActionResultfunction. Pure reducer pattern: takes game state + action, returns events + new waiting_for state.game/— Game logic modules (turns, priority, stack, combat, SBA, targeting, mana, layers, triggers, replacement, static abilities, zones, casting, etc.).ls crates/engine/src/game/for the full set.game/effects/— One module per effect handler (deal_damage, counter, draw, destroy, bounce, change_zone, etc.). New effects are added as modules here following the existing handler pattern.ls crates/engine/src/game/effects/for the full set.parser/— Oracle text parser: converts MTGJSON Oracle text into typedAbilityDefinitionstructs. Main dispatcher inoracle.rs, with specialized sub-parsers (oracle_effect/,oracle_trigger.rs,oracle_static.rs,oracle_replacement.rs,oracle_cost.rs,oracle_keyword.rs,oracle_casting.rs,oracle_class.rs,oracle_saga.rs, etc.).oracle_nom/is the shared nom 8.0 combinator foundation — all parser branches delegate atomic and structural parsing operations to these combinators.dispatch_line_nominoracle.rsis the primary dispatch path for lines not caught by earlier priority checks. See.claude/skills/oracle-parser/SKILL.mdfor the authoritative parser reference.ai_support/— Engine-side AI support:legal_actions()generates validated candidate actions for allWaitingForstates. Lives in the engine crate so both WASM and server consumers share the same logic.database/— Card database.CardDatabase::load_json(mtgjson_path)loads MTGJSON;CardDatabase::from_export(path)loads the pre-builtcard-data.jsonused at runtime by WASM and server.
Card Data Format (data/)
mtgjson/— MTGJSON atomic card dataclient/public/card-data.json— Pre-built card data consumed at runtime by WASM and server
WASM Bridge (crates/engine-wasm/)
Thin layer using wasm-bindgen + serde-wasm-bindgen. Thread-local RefCell<Option<GameState>> holds game state. Key exports: initialize_game(), submit_action(), get_game_state(), get_ai_action(). Uses tsify for TypeScript type generation.
AI Engine (crates/phase-ai/)
Difficulty levels: VeryEasy (random) → Easy (basic heuristics) → Medium (combat-aware, 2-depth search) → Hard → VeryHard (deterministic best-move). Platform-aware budgeting reduces search limits on WASM vs native.
Key modules: legal_actions, combat_ai (attackers/blockers), eval (state/creature evaluation), search (minimax-like), card_hints (play-now hints for UI).
Multiplayer Server (crates/phase-server/, crates/server-core/)
Axum WebSocket server with lobby management. Protocol uses discriminated unions:
ClientMessage—CreateGameWithSettings,JoinGameWithPassword,Action,Reconnect,Concede,Emote,SubscribeLobbyServerMessage—GameCreated,GameStarted,StateUpdate,OpponentDisconnected,GameOver,LobbyUpdate,PlayerCount
State is filtered per-player (filter_state_for_player) to hide opponent's hand/library. Disconnected players get a 10-second reconnect grace period.
React Frontend (client/src/)
The frontend is strictly a display layer. It receives fully-resolved state from the engine and renders it. It must not compute derived game values, filter game objects by rules logic, or infer anything the engine should provide. If a component needs data the engine doesn't currently expose, the fix is to add it to the engine's output — not to compute it client-side.
adapter/— Transport-agnosticEngineAdapterinterface with five implementations:WasmAdapter— Direct WASM calls (browser/PWA), serialized through async queueTauriAdapter— Tauri IPC (desktop), dynamically imported to avoid bundling in webWebSocketAdapter— WebSocket to phase-server (multiplayer), with reconnection (3 attempts)P2PHostAdapter/P2PGuestAdapter— WebRTC peer-to-peer via PeerJScreateAdapter()auto-detects platform (Tauri vs browser)
stores/— Zustand stores (gameStore,uiStore,animationStore,multiplayerStore,preferencesStore).components/— React components organized by domain (board, card, combat, hand, lobby, stack, targeting, zone, etc.).ls client/src/components/for the full set.services/— Scryfall image fetching, IndexedDB image cache, deck parsing/compatibility, metagame feeds, game persistence, Tauri sidecar.hooks/— Game dispatch, card image, keyboard shortcuts, long-press, phase info, player ID, hover, etc.pages/— React Router pages (Menu, Game, GameSetup, Multiplayer, DeckBuilder, MyDecks, Coverage).
Key Patterns
- Discriminated unions everywhere: Rust
enumwith#[serde(tag = "type", content = "data")]maps to TS{ type: string; data: ... }unions. SeeGameAction,GameEvent,WaitingForinadapter/types.ts. - Persistent-container hot fields: Hot
GameStatezones useim(15.x,Arc-backed HAMT/RRB) soGameState::clone()is O(log n) structural share instead of deep-copy.imis re-exported asengine::im. Writes usepush_back/pop_back/iter_mut(not stdVec'spush/pop/values_mut). Caveat:im::Vector::truncate(n)panics ifn > len— guard the length or useim_exthelpers. Backing choice is localized totypes/game_state.rs+types/player.rs. - Event-driven updates:
submit_action()returnsActionResult { events, waiting_for }. The frontend processes events for animations/logging, then updates state. - AI is player 1: In WASM mode,
get_ai_action()always computes forPlayerId(1).
Environment Variables
PORT— phase-server listen port (default9374)PHASE_DATA_DIR— Card data root for phase-server (default"data")PHASE_CARDS_PATH— Override card data directory for binaries (coverage-report,card-data-export)PHASE_LOG_DIR— Log directory for phase-server. When set, logs to files instead of stdout (main log:<dir>/phase-server.log, per-game logs:<dir>/games/<code>.log)PHASE_CORS_ORIGIN— Custom CORS origin for phase-server (default: allows common dev ports)PHASE_LOG_JSON— Enable JSON-formatted log output for phase-serverPHASE_MAX_CONNECTIONS— phase-server concurrent WebSocket cap before upgrades get 503 (default200;--max-connections)PHASE_MAX_GAMES— phase-server concurrent game-session cap beforeCreateGameis refused (default100;--max-games)PHASE_METRICS_PORT— When set, phase-server serves Prometheus metrics at/metricson a second listener on this port (--metrics-port). Unset means no metrics listener; keep it off the public portPHASE_REPLICA_ORDINAL— This process's ordinal within a replica set, exposed as thephase_replica_ordinalgauge so an autoscaler can identify the highest replica still holding players (--replica-ordinal)
GitHub / git CLI (automation)
For any unattended/loop automation that shells out to gh/git (PR review/handler loops, ship-commits), prepend this prelude to every gh/git command — rtk does not merely corrupt gh pr diff, it can fabricate whole command outputs (fake pushes, invented hashes), so disable it outright rather than only "when output looks wrong":
export RTK_DISABLED=1; export GH_TOKEN=$(command gh auth token)Write GraphQL/JSON to /tmp before parsing, and fetch PR diffs via gh api repos/<owner/repo>/pulls/<N>.diff (or local git diff origin/main...pr/<N>), never gh pr diff.
Releasing
Use cargo-release via the workspace alias — never tag manually with git tag.
git pull --rebase origin main # Rebase onto latest (avoids push rejection from automated PRs)
cargo release-local 0.1.3 # Bump all versions, commit, and tag locally
git push origin main --follow-tags # Push the release commit + tagcargo release-local (alias for cargo release --workspace --execute --no-confirm --no-publish --no-push) handles:
- Workspace
Cargo.tomlversion (shared across all crates) client/package.json,client/src-tauri/Cargo.toml,client/src-tauri/tauri.conf.jsonviapre-release-replacements- Creating a
release: v{version}commit andv{version}tag
CI
GitHub Actions runs two parallel jobs:
- Rust: fmt → clippy → test → coverage-report → tarpaulin → WASM build → wasm-bindgen → wasm-opt → size report
- Frontend: pnpm install → lint → type-check → test with coverage
Planning
Project planning docs live in .planning/ with phase-based organization (phases 01-09+). Each phase has CONTEXT, RESEARCH, PLAN, SUMMARY, and VERIFICATION docs. PROJECT.md contains the project manifest with requirements and key decisions.