Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .erb/configs/webpack.config.base.mts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ const configuration: webpack.Configuration = {
resolve: {
extensions: ['.js', '.jsx', '.json', '.ts', '.tsx'],
modules: [webpackPaths.srcPath, 'node_modules'],
alias: {
// wasm-bindgen glue of the tactical map viewer, see src/renderer/lib/mapViewer.ts
'ja2-map-editor$': webpackPaths.mapEditorGluePath,
},
},

plugins: [
Expand Down
5 changes: 5 additions & 0 deletions .erb/configs/webpack.config.renderer.prod.mts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ const configuration: webpack.Configuration = {
test: /\.(png|jpg|jpeg|gif)$/i,
type: 'asset/resource',
},
// WebAssembly, loaded by the wasm-bindgen glue of the tactical map viewer
{
test: /\.wasm$/,
type: 'asset/resource',
},
// SVG
{
test: /\.svg$/,
Expand Down
5 changes: 5 additions & 0 deletions .erb/configs/webpack.module.renderer.mts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ const rendererModuleConfig: webpack.ModuleOptions = {
test: /\.(png|jpg|jpeg|gif)$/i,
type: 'asset/resource',
},
// WebAssembly, loaded by the wasm-bindgen glue of the tactical map viewer
{
test: /\.wasm$/,
type: 'asset/resource',
},
// SVG
{
test: /\.svg$/,
Expand Down
8 changes: 8 additions & 0 deletions .erb/configs/webpack.paths.mts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ const distRendererPath = path.join(distPath, 'renderer');

const buildPath = path.join(releasePath, 'build');

const mapEditorGluePath = path.join(
rootPath,
'src-map-editor',
'pkg',
'ja2_map_editor.js',
);

export default {
rootPath,
erbNodeModulesPath,
Expand All @@ -41,4 +48,5 @@ export default {
distMainPath,
distRendererPath,
buildPath,
mapEditorGluePath,
};
77 changes: 77 additions & 0 deletions .erb/scripts/build-map-editor.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { execFileSync, execSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import chalk from 'chalk';
import webpackPaths from '../configs/webpack.paths.mts';

// Builds the tactical map viewer (src-map-editor) to wasm and generates the JS glue in src-map-editor/pkg
// Pass `--dist` to build a smaller wasm with LTO for packaging, which takes much longer

const profile = process.argv.includes('--dist') ? 'wasm-dist' : 'release';
const mapEditorPath = path.join(webpackPaths.rootPath, 'src-map-editor');
const outDir = path.join(mapEditorPath, 'pkg');
const wasmPath = path.join(
webpackPaths.rootPath,
'target',
'wasm32-unknown-unknown',
profile,
'ja2_map_editor.wasm',
);

// The wasm-bindgen CLI has to match the version of the wasm-bindgen crate exactly
const cargoToml = fs.readFileSync(
path.join(mapEditorPath, 'Cargo.toml'),
'utf8',
);
const pinnedVersion = cargoToml.match(/^wasm-bindgen = "=([^"]+)"/m)?.[1];
if (!pinnedVersion) {
throw new Error('Could not find pinned wasm-bindgen version in Cargo.toml');
}

function wasmBindgenVersion(command: string): string | null {
try {
const output = execFileSync(command, ['--version'], { encoding: 'utf8' });
return output.trim().split(' ')[1] ?? null;
} catch {
return null;
}
}

// `cargo install` puts binaries into $CARGO_HOME/bin, which is not always on the PATH
const exe = process.platform === 'win32' ? '.exe' : '';
const cargoHome = process.env.CARGO_HOME ?? path.join(os.homedir(), '.cargo');
const candidates = [
`wasm-bindgen${exe}`,
path.join(cargoHome, 'bin', `wasm-bindgen${exe}`),
];
const wasmBindgen = candidates.find(
(candidate) => wasmBindgenVersion(candidate) === pinnedVersion,
);
if (!wasmBindgen) {
const found = candidates
.map((candidate) => wasmBindgenVersion(candidate))
.find((version) => version !== null);
console.error(
chalk.whiteBright.bgRed.bold(
`wasm-bindgen ${pinnedVersion} is required to build the map editor${found ? `, found ${found}` : ''}. Install it with:`,
),
);
console.error(
`cargo install wasm-bindgen-cli --version ${pinnedVersion} --locked`,
);
process.exit(1);
}

execSync(
// Always pass `-p`, the Neon addon cannot be built for wasm
`cargo build --profile ${profile} -p ja2-map-editor --target wasm32-unknown-unknown`,
{
cwd: webpackPaths.rootPath,
stdio: 'inherit',
},
);
execFileSync(wasmBindgen, ['--target', 'web', '--out-dir', outDir, wasmPath], {
cwd: webpackPaths.rootPath,
stdio: 'inherit',
});
6 changes: 6 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
push:
branches:
- main
pull_request:

jobs:
publish:
Expand All @@ -23,6 +24,11 @@ jobs:
node-version: 22
cache: npm

# Needed to build the tactical map viewer, must match the version in src-map-editor/Cargo.toml
- name: Install wasm-bindgen CLI
run: |
cargo install wasm-bindgen-cli --version 0.2.129 --locked

- name: Install and build
run: |
npm install
Expand Down
14 changes: 10 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
name: Test

on: [push, pull_request]
on:
push:
branches:
- main
pull_request:

jobs:
test:
Expand All @@ -20,15 +24,17 @@ jobs:
node-version: 22
cache: npm

# Needed to build the tactical map viewer, must match the version in src-map-editor/Cargo.toml
- name: Install wasm-bindgen CLI
run: |
cargo install wasm-bindgen-cli --version 0.2.129 --locked

- name: npm install
run: |
npm install

- name: npm test
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npm run lint
npm run tsc:check
npm test
npm run package
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@ npm-debug.log.*
*.css.d.ts
*.sass.d.ts
*.scss.d.ts
src-map-editor/pkg
63 changes: 63 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project

Electron desktop app (React 19 + Ant Design + Redux Toolkit) for creating mods for JA2 Stracciatella. Most heavy lifting (file I/O, VFS, schemas, image/sound decoding, mod management) is done in a Rust native module (`src-rust/`, built with Neon) that depends on the `stracciatella` crate from the main game repo, pinned to a git rev in `src-rust/Cargo.toml`. The tactical map viewer (`src-map-editor/`, Bevy compiled to wasm, see its README) runs inside the renderer. Both crates are members of the Cargo workspace in the repo root and share `Cargo.lock`, the `stracciatella` dependency and the toolchain (`rust-toolchain.toml`). The build is based on electron-react-boilerplate; webpack configs and helper scripts live in `.erb/`.

## Commands

- `npm install`: install deps (postinstall builds the renderer DLL bundle)
- `npm start`: dev mode (rebuilds Rust, builds main, serves renderer with hot reload; electronmon restarts on `src/main/**` changes)
- `npm run rebuild:rust`: `cargo build --release` in `src-rust/`, then copies the cdylib to `src-rust/target/stracciatella-toolset.node`. Rerun this after any Rust change.
- `npm run rebuild:map-editor`: builds the map viewer wasm and its wasm-bindgen glue into `src-map-editor/pkg/` (needs the pinned `wasm-bindgen-cli`). Rerun this after any change in `src-map-editor/`. `prestart` and `build:renderer` run it.
- `cargo run -p ja2-map-editor --example bench --features bench --release -- --scenario idle --duration 60`: native GPU memory and frame time benchmark of the map viewer on a synthetic map, prints JSON lines (see `src-map-editor/README.md`, `--list` for the scenarios)
- `npm run tsc:check`: typecheck the app and `.erb/`
- `npm run lint`: ESLint with Prettier (single quotes)
- `npm test` / `npm run test:watch`: Vitest (jsdom)
- Single test: `npx cross-env NODE_ENV=development vitest run src/__tests__/renderer/state/files.test.tsx` (add `-t "<name>"` to filter)
- `npm run package`: build and package with electron-builder

CI (`.github/workflows/test.yml`) runs lint, tsc:check, test and package on macOS, Windows and Linux.

## Architecture

### IPC "invokables" (the core pattern)

All renderer → backend calls go through a single IPC channel (`invoke`) with a `{ name, input }` payload, where names look like `category/action` (e.g. `json/read`).

1. **Definition** (`src/common/invokables/<category>.ts`): an `InvokableDefinition` with zod `inputSchema` / `outputSchema`. It must be added to `ALL_INVOKABLES` in `src/common/invokables/index.ts`, which drives the typing of `invoke()`.
2. **Renderer** (`src/renderer/lib/invoke.tsx`): `invoke(name, input)` calls `window.electronAPI.invoke` (exposed in `src/main/preload.ts`) and validates the result against `outputSchema`.
3. **Main** (`src/main/invokables/index.ts`, `handleInvoke`): first checks the JS invokables in `src/main/invokables/` (Electron-only things such as dialogs and window close). Anything else is forwarded as JSON (`{ func, params }`) to the Rust `invoke`.
4. **Rust** (`src-rust/src/invokables/`): each invokable is a serde-deserializable struct implementing the `Invokable` trait (`name()`, optional `validate()`, `invoke(&AppState)`). It must be registered in `Invokables::new()` in `invokables/mod.rs`. It runs on a Neon task thread and returns a JS promise.

A new backend call therefore touches the common definition, `ALL_INVOKABLES`, and either a main-process JS invokable or a Rust struct plus its registration. Rust and TS schemas are kept in sync by hand.

Main → renderer events work the same way: definitions live in `src/common/events/`, main sends with `sendEventToRenderer` (`src/main/events.ts`) on channel `event/<name>`, and the renderer subscribes via the `Listen` components.

### Rust state

`AppState` (`src-rust/src/state.rs`) is created once through `newAppState` and passed as a boxed handle on every invoke. It holds the toolset config (read from the user config dir, `config.rs`) and the currently opened mod (`OpenedMod`: a `stracciatella` `Vfs` layered over vanilla data plus the mod). JSON schemas and vanilla data come from the `stracciatella` crate's `SchemaManager` and VFS.

### JSON editing model

- A mod can override a vanilla JSON file with a full replacement (`foo.json`) or a JSON patch (`foo.patch.json`, RFC 6902). `json/read` returns `schema`, `vanilla`, and the mod's `value` or `patch`. `json/persist` writes one of the two.
- `src/renderer/state/files.tsx` (Redux slice) tracks `disk` (loaded data, wrapped in `Persistable`/loadable) and `open` (editor buffers). Each open file has a `saveMode` (`patch` | `replace`) and an `editMode` (`visual` | `text`). In visual patch mode, the patch is generated with `fast-json-patch` `compare` against vanilla on save.
- Components read and write state through the many small `useFile*` hooks in `src/renderer/hooks/`, not directly through the store.

### Editor UI

`src/renderer/EditorRoutes.tsx` defines the sidebar `MENU`: each JSON file is mapped via `makeFileItem` to an editor component (`JsonForm`, `JsonItemsForm`, `JsonStrategicMapForm`, or custom forms) plus an RJSF `uiSchema`. Forms are rendered with `@rjsf/antd` from the schema supplied by Rust. Cross-references between files use custom widgets in `components/visual/form/` (`StringReferenceWidget`, `ResourceReferenceWidget`, sector selectors). Adding support for a new JSON file usually means adding a `makeFileItem` entry here.

### Tests

Renderer tests live in `src/__tests__/renderer/`. `window.electronAPI` is stubbed with `vi.fn()` in `src/__tests__/vitest.setup.ts`. Use `InvokeMock` (`test-utils/invoke.tsx`) to declare expected invoke calls and their results, and `renderWithTestProviders` (`test-utils/render.tsx`) to render with a fresh Redux store. Unexpected invoke calls throw. The map viewer has Rust unit tests (`cargo test -p ja2-map-editor`), which run natively; the Neon module has none.

## Project rules (from `.rules`)

- Don't add new dependencies unless asked to.
- Run typechecking, linting, building and tests before committing. Only commit to the current branch.
- Run `npm install` after changing dependencies in `package.json`.
- React: use `useMemo` / `useCallback` for performance.
- Rust: return errors (`anyhow`) instead of panicking.
Loading
Loading