Development¶
Requires Rust (stable), Node.js 24 and pnpm 10.
git clone https://github.com/firsttris/spielstand && cd spielstand
cd web && pnpm install && pnpm build && cd ..
cargo run -p spielstand-server --example demo # server with demo data on :8467, password spielstand-demo
cd web && pnpm dev # UI with hot reload on :5173, proxied to :8467
Architecture¶
crates/
protocol/ types shared by server, agent and web UI (TypeScript through ts-rs)
core/ pure logic: the sync decision, packing, portable paths, retention
server/ axum + SQLite: API, web UI (embedded), mirror, Ludusavi import, retention
agent/ the PC side: Ludusavi scanner, disk writes, sync, daemon, launch wrapper
web/ React 19, TanStack Router and Query, Tailwind 4, shadcn/ui-style components, Paraglide
install/ install.sh, served by the server with its own address filled in
docker/ the image: server, agent binaries, rclone
- The decision (
core/src/decide.rs) is a pure function of the PC's base, the disk and the server's current state; everything around it only collects these and carries out the result. - The plan (
core/src/plan.rs) turns the decision and the game's mode into what to do: adopt a known state, report an overwrite, upload, load. In back up only it never writes; a property test holds it to that. - Wine prefixes (
core/src/portable.rs,agent/src/prefixes.rs): saves in a prefix are stored as<prefix>/drive_c/users/<user>/…and resolved into this PC's prefix when written. - The agent (
agent/src/agent.rs) holds what a PC knows: settings from the server, where saves are, which games are installed here (games_to_follow).sync.rshas the flows,watch.rsthe save-folder watcher,report.rswhat goes to the web UI,foreign.rsSyncthing and Stellwerk. The watcher, the server's pushes and the catch-up run as separate tasks; each flow holds its game's lock (lock_game), so none decides on what another one has only half done. - Switch emulators (
agent/src/emulators/):yuzu.rsandryujinx.rsread each family's layout (profiles, save folders, names),mod.rsfinds the installs and picks a title's save folder,scanner.rsadds them to Ludusavi's scanner. Saves are stored as<switch>/…. - Uploads (
server/src/versions.rs) move the current state withUPDATE games SET head = ? WHERE id = ? AND head IS ?; anything else becomes a branch. - Writing saves (
agent/src/disk.rs) snapshots first, stages, verifies, renames and rolls back. - Custom games (
core/src/custom.rschecks the paths,server/src/custom_games.rsstores them): a device's proposal goes only into that device'sAgentSettingsand is backed up only there until the web UI takes it over; a device proposes only names nobody knows yet (custom_games::is_new), so its versions reach no other device.disk.rsnever reads or writes a file in the folders for keys and passwords (custom::is_protected_file), whatever path or version names it. The agent adds them to Ludusavi's manifest (Scanner::set_custom_games). - The command line for people and assistants:
agent/src/inspect.rs(whatgamesandexplainshow),custom.rs(try out, propose),choices.rs(prefix, back up only),text.rs(the German answers);main.rsandcommands.rsare the binary.agent/skill/SKILL.mdis the skill for Claude Code thatskill installwrites. - Ludusavi is used as a library, pinned to one version (
ludusavi = "=0.31.0"), behind theScannertrait inagent/src/engine.rswith tests against the real crate.
The types in web/src/bindings are generated:
cargo test -p spielstand-protocol --features ts
Tests¶
cargo test -p spielstand-core |
decision table, packing, paths, retention (with property tests) |
cargo test -p spielstand-server |
API against a real server: CAS, parallel uploads, auth, mirror, import |
cargo test -p spielstand-agent |
sync scenarios with two PCs, the real Ludusavi scanner, the command line as a binary |
cargo test -p spielstand-agent --test simulation |
random histories of PCs, outages and restores; SPIELSTAND_SIM_CASES sets how many |
cd web && pnpm test |
unit tests (Vitest) |
cd web && pnpm e2e |
Playwright against the demo server |
cd web && pnpm e2e:stack |
Playwright against the whole chain: a server and two real agent processes with their own homes and Wine prefixes (cargo build -p spielstand-agent first) |
install/test.sh target/debug |
the one-line install against a fresh server (as a normal user) |
The simulation checks the central promise: whatever happens, every state a game's saves were ever in on any PC is still a version. CI runs 64 histories, a nightly job 3000.
Checks¶
cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
cd web && pnpm lint && pnpm typecheck && pnpm test && pnpm build && pnpm e2e && pnpm e2e:stack
mkdocs build --strict
The whole chain by hand¶
cargo build -p spielstand-agent
cargo run -p spielstand-server --example stack
starts a server on :8469 (password spielstand-stack) and two PCs, Desktop and Steam Deck,
each a real agent process with its own home folder, Prefixr library and Hades prefix. GET
http://127.0.0.1:8470/pcs shows each PC's save file; POST /pcs/{name}/stop and /start switch a
PC off and on. Edit a save file and watch it arrive on the other PC.
Screenshots¶
pnpm screenshots (in web/) takes the pictures in docs/screenshots from the demo server. On
GitHub: Actions → Update screenshots → Run workflow.
Releases¶
Actions → Bump version raises the version in Cargo.toml and web/package.json, tags it and
starts the release: the agent for x86_64 and aarch64, the image for Docker Hub
(tristanteu/spielstand) and GHCR, and the GitHub release with the agent binaries.