Files
paradox-save-parser-frontend/AGENTS.md
T
2026-09-23 17:26:40 +02:00

17 KiB

AGENTS.md

Guidance for AI coding agents working in this repository.

Project

Next.js frontend for a Paradox save file parser. It uploads a game save file to a backend service, polls until parsing finishes, and displays the result in a grid.

  • Next.js 16 (App Router, Turbopack) with React 19
  • TypeScript 6 and 7 side by side (see "Things that will bite you"), ESLint 9 (flat config), Prettier 3
  • ag-grid for the data grid, Bootstrap 5 for styling

Source files live in src/:

  • app/ for routes,
  • components/ for shared UI
  • services/ for backend calls
  • lib/ for helpers, with save compression in lib/saveArchive/
  • config/ for app config
  • types/ for .d.ts type declaration files

Architecture

The parsing backend is a separate service in its own repository, not part of this project. Start it manually before running the frontend; it is expected at http://127.0.0.1:5226.

The browser never calls that backend directly. Requests go through a Next route handler that forwards them:

SaveParserBackendService  ->  /api/proxy/saveParserBackend/*  ->  backend
   (client, fetch)              (server, route handler)

Save files on the wire

Saves are not sent as the game wrote them. The backend runs on a cheap two core server with little disk space, so the browser re-compresses every save before uploading it:

plain text save  ->  tar { "gamestate" }               -> zstd -> upload
archived save    ->  tar { "meta", "gamestate", ... }  -> zstd -> upload

Uploads are POST /uploadSave?game=…&originalFileType=text|zip&originalFileName=… with Content-Type: application/zstd. The browser cannot stream a request body outside Chrome, so the compressed result is sent as one blob; responses stream everywhere, so downloads need nothing special. The tar always holds the files the game itself would have in its archive, so the backend reads the game state from the gamestate entry either way, and getSaveStatus returns originalFileType and originalFileName so a download can rebuild the right shape.

The pieces all live in src/lib/saveArchive/: gameZip.ts (the game's own zip format, written here on the browser's CompressionStream('deflate-raw')), zstdStream.ts (compression, on @tootallnate/zstd-wasm), archive.ts (pack and rebuild, with the tar from modern-tar), messages.ts (the worker protocol), and worker.ts with client.ts (the worker and its main thread side).

The whole pipeline is streams. A save is never held in memory as a whole: the file is read, unzipped, tarred and compressed as it flows, and only the finished result becomes a Blob, which is browser managed storage rather than a heap allocation. A real 11 MB EU4 save (84 MB of gamestate) compresses to about 4.15 MB in two seconds. Keep it that way — an arrayBuffer() call on a save is a bug.

Three things there are easy to break:

  • The zip headers are not decoration. The games reject archives with NTFS timestamps or unexpected header fields, so every field matches a real save: deflate, zeroed DOS date, MS-DOS origin, version made by 63, version needed 20, no extra field, no data descriptor. That last pair is why no zip library is used: they all build the DOS date from a Date and clamp it to 1980, and the streaming writers add a data descriptor and a zip64 extra field to every entry. Writing the headers here is about a hundred lines, because deflate itself comes from the browser. One consequence to keep: each entry is deflated into a blob before its header is written, since the sizes and the CRC go in front of the data — the compressed bytes are buffered, never the save.
  • The zstd wasm is a runtime asset, not part of the bundle. @tootallnate/zstd-wasm deliberately loads no files of its own, so zstdStream.ts points new URL(…, import.meta.url) at node_modules/@tootallnate/zstd-wasm/dist/zstd.wasm and Turbopack emits it as a hashed asset the worker fetches. That path is relative to src/lib/saveArchive/zstdStream.ts, so moving the file means fixing it.
  • Three zstd libraries were tried and rejected before this one, so do not swap it lightly. @oneidentity/zstd-js compresses whole buffers and leaves a frame unterminated whenever the payload length is an exact multiple of 131072, zstd-codec's emscripten build never returns control to the event loop, and fzstd is the unreadable single-file kind of code this repository does not depend on.
  • Compression runs in a Web Worker because it would otherwise block the page for seconds on a large save.

COMPRESSION.md has every zstd level measured on a real save — size, time and the memory the encoder and decoder need. Consult it instead of measuring again when a device cannot cope with the current level.

Configuration comes from src/config/AppConfig.ts (defaults) merged with runtimeConfig.json at the repo root, which holds the backend URL and the save upload limits. That file is gitignored, so a fresh clone starts without one and falls back to the defaults.

AppConfig is server side only, so values the browser needs are passed down as props: src/app/page.tsx reads appConfig.saveUpload and hands it to the upload dialog.

Current status

Much of the app is still scaffold. Do not assume a feature works because the UI for it exists:

  • The grid is not wired up. src/components/BrowseSaveDataGrid.tsx renders two hardcoded rows and never calls the backend. /browse?id=… only uses the id for the download button so far.
  • The backend does not speak the new upload format yet. It has to accept the zstd compressed tar described above and serve getSaveFile; until it does, uploads fail against the running service.
  • Auth does not work. AuthService posts to /api/auth/login and /api/auth/register, which do not exist as routes. The service is slated for removal in favour of Discord OAuth.
  • hashPassword is a no-op that returns the password unchanged. Treat any current auth code as a placeholder, not as something to build on.

Tools and files

Prefer the editor's own tools over shell commands that do the same thing. Reading, editing, writing and searching files have dedicated tools; reaching for cat, sed, grep or a throwaway script instead does the same work while spending more tokens, and every command may need its own approval. Use the shell for what only the shell can do — running the build, the linter, git, a real program.

Work inside the project directory. For temporary files — scratch scripts, intermediate output, test data — create a TEMP/ directory in the project root and keep everything there, then delete that directory once the work is done. Ask before reading or writing anything outside the project directory.

Terminal

On Windows, prefer bash.exe — but only a real MSYS2, Cygwin, or MinGW/Git Bash installation, for example D:\msys2\usr\bin\bash.exe.

Never use C:\Windows\System32\bash.exe. Despite its name it is not a POSIX bash; it is the launcher stub for WSL. Do not start WSL to run ordinary commands.

If no MSYS/Cygwin/MinGW bash is installed, use PowerShell as the terminal. PowerShell is the fallback, never WSL.

If bash reports command not found for node or npm, that is a PATH problem rather than a missing install: MSYS2 defaults MSYS2_PATH_TYPE to minimal, which discards the Windows PATH. Setting it to inherit fixes it.

Commands

npm run dev      # dev server
npm run build    # production build
npm run lint     # eslint
npm run format   # prettier, rewrites everything except .gitignore/.prettierignore
npx tsc --noEmit # typecheck

Checking your work

Always run npm run lint and npm run build after changing code. They are the primary way to find errors in this project, and they catch different things:

  • npm run lint reports code errors and style violations. It must finish with zero problems — no errors and no warnings.
  • npm run build catches type errors and build-time problems that lint cannot see, such as Node APIs leaking into the edge runtime. It must exit cleanly and print no warnings.

npx tsc --noEmit is a faster way to check types alone while iterating, but it does not replace a full build.

Do not report a change as finished until both commands pass.

Version control

This project is managed with git.

Read-only git commands are fine to use without asking. git status, git log, git diff, git show, git blame and similar inspection commands may be run freely.

Never run a git command that changes the repository without the user's explicit permission. This is a strict ban, and it covers everything that writes to the history, the index, or the working tree including add, commit, checkout, restore, reset, stash, branch, merge, rebase, push, rm, etc.

Ask first and wait for a clear yes before running such git command.

Conventions

Prettier settings live in .prettierrc and are not negotiable: 4 spaces, no semicolons, single quotes, 80 column width. Run npm run format after editing, or match the surrounding style exactly.

Import from within src/ using the @/ alias, for example import { getMsgFromError } from '@/lib/errors'.

Catch blocks should not annotate the error. Let it be unknown and convert it with the shared helper:

} catch (err) {
    const errMsg = getMsgFromError(err)
    ...
}

Avoid any; no-explicit-any is enforced. Prefer unknown plus narrowing.

Comment the code you write. This codebase is read by human programmers who did not write it, and a wall of uncommented agent-written code is expensive to review. Keep comments simple and preferably single line, placed above the code they describe, and write them in plain language rather than in terms of the types. Say why the code does what it does — what is non-obvious, what would break if it were written the obvious way — and leave out what the code already states plainly. A tricky constant, a workaround for someone else's bug, or an ordering that matters always deserves a line.

Write doc comments as JSDoc (/** ... */), which editors show on hover. Plain // and /// comments do not appear in tooltips. In .ts files put only prose in the JSDoc and let the signature carry the types; do not duplicate types in {braces}.

Dependencies

Search for an existing library before writing your own implementation. Formats, protocols and codecs are full of details that only show up against real data, and a maintained library has already met them. This project once carried a hand-written tar reader/writer and zip writer; both were deleted in favour of libraries.

Read the library's source before adding it, and judge it on that. Download counts say nothing about quality. Even a popular package may turn out to be a single ten thousand line file whose variables and functions are all named a, b and c, with no comments anywhere. Such code cannot be read, cannot be maintained, and is very likely to be carrying bugs nobody can find — do not depend on it. Look for readable sources, comments that explain intent, and an API that fits the way the code here is written.

If nothing of good quality exists, say so and propose writing an implementation here instead, rather than pulling in the least bad option.

Do not add a second library for a job a package already in package.json can do. A thin wrapper that shapes an installed library's API to what the code needs is better than a new dependency. But if the API of the library doesn't fit the project, the wrapper can be bigger than your own implementation of the algorithm.

Checking browser support

Never use caniuse.com. Its data is wrong and incomplete, missing features that shipped five or more years ago, and a decision made from it will be wrong.

Use MDN's browser-compat-data, which is the source behind the MDN compatibility tables, and webstatus.dev for the baseline status of a feature. If those disagree or say nothing, the browsers' own release notes and bug trackers settle it.

Things that will bite you

Both TypeScript 6 and 7 are installed on purpose, under swapped names. TypeScript 7 is the native Go compiler and type-checks much faster, but typescript-eslint refuses to run under it, which would break npm run lint entirely. So each tool gets the version it can use:

package.json entry resolves to used by
typescript @typescript/typescript6 (TS 6 API, bin tsc6) typescript-eslint
@typescript/native typescript@7 (Go compiler) next build

The alias is deliberately reversed: typescript-eslint looks the compiler up by the package name typescript, so that name must stay on TS 6. Do not "fix" this by renaming the entries.

next.config.ts sets experimental.useTypeScriptCli, which makes Next spawn a tsc binary instead of using the compiler API. Next resolves that binary from the package named typescript too, so it would also get TS 6. The prebuild script scripts/replace-tsc6-with-tsc7.mjs works around this by rewriting node_modules/typescript/bin/tsc6 into a one-line stub that imports TS 7's lib/tsc.js.

Consequences to keep in mind:

  • The patch lives in node_modules, so npm install undoes it. prebuild reapplies it, so npm run build is always correct; nothing else needs doing.
  • npx tsc --noEmit is unaffected either way. node_modules/.bin/tsc links straight to @typescript/native, so the standalone typecheck is always TS 7.
  • Do not drop the prebuild script while leaving useTypeScriptCli enabled. Nothing breaks visibly — the build just silently goes back to type-checking with TS 6.

All of this is temporary. The only reason TS 6 is still here is that TypeScript 7.0 ships no stable programmatic JS API, so typescript-eslint cannot run on it. That API arrives in TypeScript 7.1, planned for 2026-11-24. Once it is out and typescript-eslint supports it, tear the whole arrangement down: drop @typescript/native and the @typescript/typescript6 alias for a plain "typescript": "^7.1", delete scripts/replace-tsc6-with-tsc7.mjs and the prebuild script, and re-check whether useTypeScriptCli is still worth keeping.

Env vars in npm scripts must go through cross-env. The dev and build scripts set NEXT_TELEMETRY_DISABLED=1, which stops Next from collecting anonymous usage data and saves the ~0.5s the telemetry flush adds to every build. The FOO=1 cmd prefix is POSIX shell syntax, and on Windows npm runs scripts through cmd, where it is a syntax error — so the prefix is written as cross-env NEXT_TELEMETRY_DISABLED=1 next ... instead. Keep cross-env on any new script that needs an env var.

Setting the variable from prebuild would not work: prebuild and build are sibling processes, so an env var exported by one is not visible to the other. Each script that needs it sets it itself. prebuild has no cross-env prefix because it only rewrites the tsc bin and never talks to telemetry.

instrumentation.ts is compiled for the edge runtime too and it can't be disabled. Anything using fs, path, or other Node APIs must be imported behind the process.env.NEXT_RUNTIME === 'nodejs' guard, or the build warns and pulls Node-only code into the edge bundle.