Imported from chinese-board-games/luzhanqi-web (
AGENTS.md). Install upstream withnpx skills add chinese-board-games/luzhanqi-web. Copyright stays with the author.
AGENTS.md — luzhanqi-web
React (v19) + Vite frontend for Luzhanqi, using Mantine v7 for UI,
socket.io-client for realtime gameplay, Firebase for optional auth, and
@dnd-kit for drag-and-drop piece placement. Installable as a PWA
(vite-plugin-pwa, auto-updating service worker).
Commands
npm run start— Vite dev server (http://localhost:5173)npm run build— production build (vite build)npm run lint/npm run lint:fix— ESLint across the reponpm run format— Prettier
There is no real test suite (npm test is a placeholder). Verify UI changes
by actually running the app and driving the feature in a browser. CI
(.github/workflows/ci.yml) runs lint + build + npm test on Node 24.x on
every PR/push to main, and is a required status check — see "Deployment"
below for what happens after a PR merges.
Needs a .env/.env.local with VITE_API pointing at a running
luzhanqi-backend instance (see README.md). Vite env vars are baked in at
build time — changing VITE_API requires a rebuild/redeploy, not just
an env var change on the host.
A pre-commit hook (husky + lint-staged) runs Prettier + ESLint (--fix) on
staged files.
Commit messages
Use Conventional Commits (feat:,
fix:, refactor:, style:, docs:, chore:, optionally with a scope).
Deployment
main is the staging trigger; production is the prod trigger (the
backend repo follows the same model). The pipeline:
- A PR into
mainmust passci.ymlbefore it can merge (branch protection). - Merging to
mainauto-deploys theluzhanqi-stagingNetlify site. .github/workflows/promote.yml, triggered by that same push, polls Netlify until that commit's staging deploy isready, then smoke-tests the staging URL (expects 200 and a<div id="root">in the body). Only on success does it fast-forwardproductionto match.- Netlify auto-deploys the
luzhanqisite (prod) fromproduction.
Never push directly to production — it only ever advances via step 3.
Which branch each Netlify site tracks is configured in the Netlify
dashboard (Site settings → Build & deploy), not in this repo.
Layout
src/views/— top-level pages (Game.jsxis the main game screen and owns move-rotation/turn logic plus optimistic move application;Menu.jsxhandles create/join/spectate, including the AI opponent's "Rules"/"Advanced" tabs)src/contexts/GameContext.jsx— the single source of truth for game state; owns the socket connection and every socket event handlersrc/contexts/FirebaseContext.jsx— Firebase Auth state; never fake/bypass this to get a logged-in user for testing (e.g. for a screenshot) — treat it as off-limits to modify for anything but real auth logicsrc/components/GameBoard/— the live gameplay board (move selection, last-move highlight, forfeit, theMoveConfirmCardpopover that appears once both origin and destination are selected)src/components/PieceInfoPanel/— desktop-only sidebar (hidden belowDESKTOP_PANEL_BREAKPOINT, currently 900px) showing hovered-piece info and, once a move is fully selected, the same combat-outcome predictionMoveConfirmCardshows — both readoutcomeMessagesfromPieceInfoPanel.jsxand callutils/predictOutcome.jssrc/components/BoardSetup/— the piece-placement (setup phase) board;HalfBoard.jsx's "Use Example" menu applies one ofdata/exampleBoards.js's curated layoutssrc/components/Position.jsx/SelectablePosition.jsx— a single board tile; shading priority (origin > destination > attackable > movable > last-move > hover) lives inSelectablePosition'sgetShadeColor. Only the last-move highlight should ease in/out (isLastMove-gated transition inPosition.jsx) — a click-driven highlight must apply with no delaysrc/components/ErrorBoundary/— top-level React error boundary (wraps the whole app inindex.jsx); shows a reload prompt instead of a blank white screen on an uncaught render errorsrc/data/pieceData.json/pieceInfo.js— piece metadata (title/ description/rules/icon) with parallel*_zhfields;getPieceInfo(isEnglish)picks between them. Every player-facing string in the app is gated on theisEnglishflag fromGameContext(English or Traditional Chinese) — new UI text needs both.src/theme.js— Mantine theme plus custom responsive sizing tokens (positionSizing,hqSizing,campSizing, etc.), each withmd/sm/xsvariants
Authentication
The client never sends a raw Firebase uid as a trust claim — it sends a verified ID token and lets the server establish identity.
src/index.jsxinstalls an axios request interceptor that attachesAuthorization: Bearer <token>(viagetAuth().currentUser.getIdToken()) to every REST call automatically, a no-op when nobody's logged in. New REST calls don't need to do anything extra to be authenticated.- Socket emits that need to identify the caller send
idToken: user ? await user.getIdToken() : null(seeGameContext.jsx'sattemptRejoin/checkActiveGames, orGame.jsx/Menu.jsx/Lobby.jsx/NavBar.jsx's various emits) — never a rawuser.uid.user.uiditself is still fine to use for local-only logic (React dependency arrays, deciding whether to call a function at all), just never as the payload proving identity to the server.
Gameplay data flow
- Optimistic moves:
Game.jsx'splayerMakeMoveapplies the move to local board state immediately viautils/predictOutcome.js'sapplyMoveOptimistically(reusing the same combat-outcome classificationpredictOutcomeuses for the UI preview), before the server round-trip resolves.GameContext'splayerMadeMovesocket handler then overwrites local state with the authoritative board — this also silently corrects any case the optimistic guess couldn't resolve (e.g. attacking a still-fogged enemy piece, where the true outcome isn't knowable client-side). - Because of this, a rule-variant change must be mirrored in
predictOutcome.js/getSuccessors.js(this repo) to matchpieceMovement/getSuccessors.ts(the backend) — a mismatch doesn't just make the outcome-prediction UI wrong, it makes the optimistic board update wrong too, until the server's correction arrives a moment later. - Board orientation. The guest's board is rotated 180° client-side for
display (
transformBoardinGame.jsx); move coordinates sent to/from the server are always host-perspective and must be rotated (rotateMove) for a non-host client before display or before sending a move.
Known gotchas
GameContext's socket handlers close over stale state. TheuseEffectthat registers allsocket.on(...)handlers has a narrow dependency array ([roomId, errors]), so it does not re-run when e.g.playerNameorisEnglishchanges. A handler that needs the current value of some other piece of state should read it from a ref kept in sync via its own effect (seeplayerNameRef/isEnglishRef), not from the closed-over state variable directly — otherwise it'll silently use a stale value.- Mantine v7 needs the Emotion compat layer for
sxto work.index.jsxwraps the app inMantineEmotionProviderand passesstylesTransform={emotionTransform}toMantineProvider— Mantine 7 dropped built-in Emotion support, and thesxprop (used throughout for ad hoc responsive styling) silently does nothing without this. Also needs an explicit@mantine/core/styles.cssimport (no longer automatic). - Responsive breakpoints are ad hoc, not Mantine's default system.
Small-screen handling is done via raw
@media (max-width: 450px)/(max-width: 375px)queries insidesxprops, matched against themd/sm/xsentries intheme.js's sizing objects. If a new tile type or sizing object is added, it needs matching entries at all three breakpoints — a missingxsentry previously caused the board to overflow narrow phone screens. - Don't recreate a "shared empty board" module constant. A prior bug
(
HalfBoard.jsx) built a single empty-board array once at module scope and reused it via[...emptyBoard]— that only shallow-copies the outer array, so writing into a cell of the "copy" mutated the shared row arrays for the rest of the browser tab's life. Always build a fresh board via a factory function (makeEmptyBoard()), not a shared constant. - Vite HMR and the socket connection.
GameContext.jsxcloses the shared socket onimport.meta.hot.dispose— without this, every hot reload during dev opens a new socket on top of the old one, which never gets closed (a no-op in production, whereimport.meta.hotdoesn't exist). VITE_APIis set per Netlify site in its environment variable settings, not in this repo — see "Deployment" above for which branch feeds which site.