CLAUDE.md #
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project #
Web client for TLE Community (The Lacuna Expanse), a community-run continuation of the Lacuna Expanse game. This repo talks to a separate backend server (tlecommunity/server) over JSON-RPC; it does not run the game logic itself. The project has gone through a couple of names over the years (originally "Lacuna", later "Kantigen"/"KA") — current naming is tlecommunity / "TLE Community", so avoid introducing old "Kantigen", "KA", "ka-web", or "ka-server" naming into new code, comments, or docs.
Commands #
npm start— start Vite dev server against a real backend (needsLACUNA_SERVER_URLetc. set, see.env.local)npm run start:stubbed— run the local stubbed server (stubs/server.ts, port 3005) and Vite (--mode stubbed, loading.env.stubbed) together viaconcurrently. The stub server runs undertsx watch.npm run serve:stubsruns just the stub server.npm run lint:check— Oxlint (config inoxlint.config.ts);npm run lint:fixapplies autofixesnpm run format:check— Prettier check;npm run format:fixwrites in placenpm run types:check—tsc --noEmitnpm test— Jest;npm run test:watchfor watch mode,npm run test:coveragefor coverage- Single file:
npx jest app/path/to/file.test.ts - Single test:
npx jest -t "test name"
- Single file:
npm run build— production build viavite build
Node >=24 and npm >=10 are required (engines in package.json). The @tlecommunity/client dependency is a private scoped package resolved from a self-hosted Gitea npm registry (see .npmrc), not the public npm registry.
Architecture #
The codebase is mid-migration from a legacy YUI2 client to React/TypeScript, so two UI layers currently coexist:
app/yui/**— legacy YUI2 + jQuery code, being incrementally eliminated. Oxlint ignores this directory entirely; don't hold it to current lint/style standards.app/components/**— current React/TypeScript UI. New feature work belongs here.
Backend data lives in TanStack Query; MobX holds only what a query cannot own. docs/ (see below) predates both and is inaccurate on this point.
Data fetching belongs in app/queries/* — one file per API module, exporting useQuery/useMutation hooks. Components must not call lacuna.* directly; add a hook instead. The rules:
- Every query key comes from
app/queryKeys.ts. Never inline a key at a call site — a key and the invalidation that busts it have to move together. Keys are hierarchical tuples, soqueryKeys.building.allinvalidates every building view whilequeryKeys.building.view(url, id)targets one. - Every
queryFn/mutationFnwraps its call inunwrap()(app/queries/unwrap.ts). The client never rejects — it resolves to{ result, error }— and React Query needs a rejection to see a failure.unwrapthrows aLacunaError; the user-facing alert is still the globalonResponsehandler inapp/lacuna.ts. - When a write makes a read stale, invalidate it in the mutation's
onSuccess. Prefermutatewith callbacks overawait mutateAsync(), so a server error cannot escape as an unhandled rejection. app/components/queryState.tsxrenders the loading and error branches, so components do not each grow their own spinner.app/queryClient.tsis a module-level singleton, which is how non-React code (the legacy YUI layer) invalidates.
Stores live under app/stores/:
app/stores/rpc/*— theserver,empireandbody"status blocks". These stay MobX on purpose: they are push-based, arriving on any response viasplitStatus()(app/server.ts), and are extrapolated every second byapp/stores/ticker.ts. No single query owns them. Anything that is the response to one specific call belongs inapp/queries/instead.app/stores/windows.ts+app/stores/window/*— the window manager.WindowsStoreholds a z-index-ordered stack ofWindowobjects, one perWindowType(enumerated inapp/interfaces/window.ts); adding a window of an already-open type replaces it rather than stacking a duplicate. Rendered byapp/components/menu/gameWindow.app/stores/menu.ts,session.ts,ticker.ts— global UI/session/clock state.
Server communication flow: app/lacuna.ts exports the configured @tlecommunity/client singleton — typed endpoints as properties (lacuna.empire.viewBoosts(), lacuna.body.getBuildings({...}), lacuna.buildingFromUrl(url).view({...})), each resolving to { result, error } rather than throwing. It also registers the one global onResponse handler, which routes status blocks into MobX, alerts on errors, and owns the captcha (1016), empire-not-founded (1100) and session-expired flows. It deliberately does not enable the client's enableRpcLimitHandler() auto-retry: that suits unattended scripts, whereas here the user initiated the action and should see the RPC limit (1010) error, wait it out, and decide what to do next. app/server.ts is now just splitStatus. There is no app/services/ layer any more; app/queries/* replaced it.
Other structural pieces:
app/interfaces/*— TS types for API params/responses and domain models (building, body, empire, window, tabber).app/hocs/*— React HOCs, e.g.withBuildingData, which runs the buildingviewquery and injects the result as a prop. It is the single fetch point behind all six building windows.app/shims/*— do notimport $ from 'jquery'directly; import the shim atapp/shims/jqueryinstead (it combines jQuery plugins used by legacy code into one object).- Env vars use a
LACUNA_prefix (ViteenvPrefix), typed inapp/env.d.ts..env.local/.env.stubbed/.env.productionselect which backend/assets URLs are used.
docs/ folder #
docs/ exists but its own docs/README.md warns "most of the information in here is rather out of date." It documents a Reflux/Gulp-era architecture (Actions → Stores → Components via Reflux, gulp dev, js/ source paths) that predates the current Vite/MobX/app/ setup. Treat it as historical background only — do not follow docs/guides/implementing-a-building.md or docs/add-buildings.md as literal instructions for adding a building; use an existing building implementation under app/components/building and app/queries as the template instead.
Testing #
- Jest is configured with
clearMocks: true(jest.config.js), so mocks are cleared automatically between tests — do not calljest.clearAllMocks()inbeforeEach/afterEach. - Prefer Testing Library's
userEvent(e.g.userEvent.click()) overfireEvent— it dispatches the fuller sequence of events a real browser/user interaction produces, so it more closely mirrors actual user behavior. Reach forfireEventonly when testing a specific low-level event thatuserEventdoesn't model.
Code style #
- Oxlint config defined in
oxlint.config.ts - Prettier: single quotes, semicolons, trailing commas (
es5), 100 print width, 2-space indent. - camelCase file names; no
index.ts/index.tsxbarrel files.