Skip to content

Repository files navigation

Tarot Duel: The Enigmatic Arcana

A deterministic two-player card game: 78-card Tarot deck, rules as TypeScript, SolidJS browser UI, optional network multiplayer server. No LLM, no card-text interpretation — every effect is an Op resolved by applyOp.

For AI assistants and new contributors: read this file first, then skim src/cards.ts and src/game.ts for ground truth. Older markdown plans in this folder may be wrong or partially implemented — see Documentation map below.


Repository layout

TAROT-GAME/
├── src/                    # Game engine (npm package: tarot-duel-engine)
├── dist/                   # Compiled engine — imported by UI and server
├── ui/                     # SolidJS + Vite frontend
├── server/                 # Express + WebSocket + SQLite multiplayer authority
├── assets/                 # Source card art, UI images, SFX/BGM
├── scripts/                # Asset import and publish helpers
├── docs/                   # Original engine spec (mostly accurate; verify against code)
├── run-Tarot.bat           # Production path: build all + serve on :8787
├── run-Tarot-dev.bat       # UI hot-reload on :3000 (needs server separately)
└── run-server.bat          # Alias for run-Tarot.bat

Parent repo (../) holds print/tabletop assets (GFX/, TarotGPT/, etc.) — not required to play once art is bootstrapped into assets/.


Quick start

Windows (recommended)

run-Tarot.bat

Builds engine + UI + server, bootstraps art if missing, opens http://localhost:8787.

Manual

npm install
npm run bootstrap-art          # first time — copies art from ../TarotGPT into assets/
npm run build:all              # engine + ui + server
npm run server:start           # serves UI + API + WebSocket on port 8787

UI dev with hot reload (two terminals):

npm run server                 # terminal 1 — API/WS on :8787
cd ui && npm run dev           # terminal 2 — Vite on :3000, proxies to server

Or use run-Tarot-dev.bat for the UI half only.

Card art: assets/cards/ → published to ui/public/cards/ on every UI build/dev via copy-card-art.mjs.


npm scripts

Location Script Purpose
root build tsc → compile src/ to dist/
root build:all Engine + UI + server production builds
root start build:all then start server
root bootstrap-art One-time import from parent repo → assets/
root copy-art Publish assets/ui/public/ (cards, sfx, bgm, UI images)
root server / server:start Dev watch / production node server
root test Run dist/**/*.test.js (smoke test)
ui/ dev Copy art + Vite dev server
ui/ build Copy art + tsc + Vite production bundle
server/ dev tsx watch hot reload
server/ build / start Compile and run on port 8787

Program flow

High-level path from player click to new game state:

flowchart TD
  UI[UI: GameBoard / stores] --> GS[gameStore actions]
  GS --> ENG[Engine: placeCard / advancePhase / resolveCombat / playInstant]
  ENG --> AR[ActionResult: state + pendingTriggerOps]
  AR --> RA[resolveAction in engine-interactive]
  RA --> CH{needsChoice?}
  CH -->|yes| IN[inputStore → InputOverlay]
  IN --> RA
  CH -->|no| AO[applyOp / applyOps in ops.ts]
  AO --> ST[New GameState]
  ST --> UI

  NET[network mode] --> NM[networkManager WebSocket]
  NM --> SRV[server actions.ts]
  SRV --> ENG
  SRV --> CHS[server choices.ts]
  CHS --> AO
  SRV --> NM
Loading

Layers

  1. Pure engine (src/game.ts, src/ops.ts, src/mimic.ts) — immutable GameState, no UI, no async. Returns { state, pendingTriggerOps } from player actions.
  2. Interactive layer (src/engine-interactive.ts) — walks pendingTriggerOps, prompts for targets (scry, discard, mimic, etc.) via resolveAction + ChoiceResolver, then calls applyOp.
  3. UI (ui/src/store/gameStore.ts) — wires engine + resolveAction + inputStore + sound/save/replay. Tutorial uses scripted Veil opponent from src/tutorial.ts.
  4. Server (server/src/) — canonical state in SQLite; clients send action verbs, server runs engine + choice resolution, broadcasts sanitized state.

Turn phases

untapdrawmaincombatresponseend → (next player's untap)

advancePhase in game.ts handles skips, delayed draws, heal_per_turn at untap, effect expiration, and win checks.


Engine modules (src/)

File Role
types.ts GameState, Card, FieldCard, Op, ActiveEffect, PlayerId, phases
cards.ts All 78 cards as data (cardUid, power, effects.on_play / on_field / …)
ops.ts applyOp dispatcher — one handler per verb; injects rng via nextOpRng
game.ts newGame, advancePhase, placeCard, resolveCombat, playInstant, permission queries
rng.ts Seeded splitmix64; GameState.rng_step advances on each op that needs randomness
mimic.ts Mimic execution, field aura application, revert on leave field / combat
engine-interactive.ts resolveAction, needsChoice, getValidTargets, mimic wrapper verbs
sanitize.ts Hide opponent hand/deck for network views
tutorial.ts newTutorialGame, scripted Veil moves (tutorialOpponentMove, block plan)
index.ts Barrel export for UI and server
smoke.test.ts Basic determinism / place / combat sanity check

Action → ops pattern

Player actions do not apply card effects directly:

const result = placeCard(state, "p1", 0);
// result.pendingTriggerOps = card's on_play ops (may need choices)
const next = await resolveAction(result, "p1", choiceResolver);

Same for playInstant, resolveCombat (on_attack / on_block triggers), and phase hooks inside advancePhase.

Op verbs (in HANDLERS)

draw, heal, damage, discard, destroy, add_power, steal_control, scry, shuffle_deck, hand_refresh, exile_graveyard, discard_and_redraw, … — see HANDLERS in ops.ts (~35 verbs).

Not in HANDLERS: mimic wrapper verbs (mimic_opponent, mimic_self, mimic_sword) and heal_per_turn — handled in mimic.ts / applyOnFieldAuras and the untap loop in game.ts.

Rules that are easy to get wrong

  • Major Arcana instants cost Pentacles tapped on your field (playInstant), not Cups.
  • One free field placement per turn in Main; extra plays via extra_play op; combat placement if you skipped field last turn.
  • Summoning sickness — field cards can't attack the turn they're played (turn_played).
  • DeterminismGameState.rng_step must be serialized with saves/replays/network; applyOp assigns op.rng from nextOpRng(state).
  • Mimic — copies a minor on field until it leaves; reverts in combat resolution before graveyard; source_field_id stamped on triggered ops for attribution.
  • Card identity — always use cardUid strings (e.g. three_of_swords), not display names.

UI (ui/src/)

Area Key files
Screens MainMenu, GameBoard, CardBrowser, Settings, NetworkMenu, NetworkLobby, LoginScreen
Stores gameStore (engine bridge), inputStore (choice UI), combatStore, tutorialStore, popupStore, settingsStore, authStore
Services networkManager, saveManager (IndexedDB), replayManager, soundManager
Components FieldZone, CardHand, CombatPanel, InputOverlay, GameLog, TutorialOverlay, PentacleTapPanel

Game modes (gameStore): solo, hotseat, network, tutorial.

Routes in ui/src/main.tsx: /, /game, /cards, /settings, /network, /login, etc.


Server (server/src/)

Area Role
index.ts HTTP + static UI + WebSocket /ws
routes/ REST: auth, users, game lobby CRUD
game/actions.ts Apply player verbs against DB-stored GameState
game/choices.ts Server-side choice resolution when ops need targets
game/combat.ts Two-step combat (declare attackers → declare blockers)
ws/handlers.ts Lobby, reconnect, state broadcast
db/ SQLite schema + queries

Server depends on tarot-duel-engine via "file:.."rebuild engine after engine changes before server picks them up.


Documentation map

Use this order when docs disagree with code:

Doc Trust Notes
src/*.ts Authoritative Especially cards.ts, game.ts, ops.ts, tutorial.ts
README.md (this file) High Kept aligned with code; update when architecture changes
docs/tarot_duel_engine_spec.md Medium Original design spec; good concepts, may lag new verbs/mechanics
tutorial-design.md Low–medium Narrative/script for tutorial popups; deck lists in src/tutorial.ts override
multiplayer-spec.md Medium Wire protocol intent; verify against server/src/ws and networkManager.ts
MIMIC_IMPLEMENTATION_SPEC_v2.md Historical Implementation plan for mimic + 10 card rewrites — done; use code, not spec, for behavior
MIMIC_IMPLEMENTATION_SPEC.md Stale v1; superseded by v2 — do not use for planning

When planning features, grep the codebase first. If a spec mentions APIs, verbs, or card effects that don't exist in src/, the spec is wrong.


Public engine API (common entry points)

import {
  newGame, advancePhase, placeCard, resolveCombat, playInstant,
  resolveAction, getPlayerPermissions, getLegalAttackers, getLegalBlockers,
  getPlayableInstants, sanitizeStateForPlayer, concede, passTurn,
  newTutorialGame, tutorialOpponentMove,
} from "tarot-duel-engine";
Function Description
newGame(seed?, startingLife?) Shuffled 78-card decks, opening hands, rng_step: 0
advancePhase(state, player?) Phase machine + hooks
placeCard(state, player, handIndex) Main/combat placement → ActionResult
resolveCombat(state, assignments) Attack/block resolution → ActionResult
playInstant(state, player, handIndex, tappedPentacles) Major during main/response
resolveAction(result, player, resolveChoice) Async: apply pending ops with UI/server choices
getPlayerPermissions / getLegalAttackers / getLegalBlockers UI gating
sanitizeStateForPlayer(state, viewer) Hide opponent secrets for network
newTutorialGame() / tutorialOpponentMove(state) Scripted tutorial

Win conditions

  • Opponent life ≤ 0
  • Opponent decks out on draw
  • Concede

Testing

npm run build
npm test

smoke.test.ts checks seeded shuffle determinism, a place + phase advance, and combat permissions — not full rules coverage.


License

MIT

About

Deterministic 78-card Tarot game: TypeScript engine, SolidJS UI, WebSocket multiplayer

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages