# ARCHITECTURE This document describes how `aex` works today. It is a snapshot, not a design spec: several pieces are stubs or unwired (see "Current gaps" at the end and the phased plan in [ROADMAP.md](./ROADMAP.md)). ## What it is A D&D 5e character sheet and campaign dashboard for one party's play-by-text game. The UI is in Russian; code and comments are in English. The app tracks the campaign (name, in-game date, session number), one player character (identity, class, HP, money, spell slots, inventory, hit dice, death saves), a party-shared stash, and — in the data model only, no UI yet — quests, notes, and contacts. ## Stack - Rust, edition 2024, nightly toolchain (`rust-toolchain.toml`, channel pinned to `nightly` without a date) - Leptos 0.8.20 (SSR + hydration), reactive_stores 0.4.3 for state - leptos_actix 0.8.7 + actix-web 4 for the server - nucleo-matcher for fuzzy item search, num-format for number display, chrono for dates, std::sync::LazyLock for one-time data loading - SCSS sources in `style/` (compiled by cargo-leptos) - Bundled game data in `data/` (5e.tools-format JSON, ~3.9 MB total) Features: `ssr` (server binary), `hydrate` (WASM), `csr` (optional pure-client mode for `trunk`). `Cargo.lock` is tracked for reproducible builds. ## Directory layout ``` src/ main.rs SSR entry point: actix-web server, favicon lib.rs crate root, module declarations, WASM hydrate() app.rs App component: context, shell, routes prelude.rs crate-wide re-exports and the Context alias state/ App state (one Store in context) entities/ Domain model (Store-derived structs) + mock data statics/ Bundled item data: ITEMS_REFS, ITEMS_NAMES utils/ read_items, adjust_checked, BasisPoints trait components/ header.rs Campaign header (name, date, session, Save/Load/Clear) nav.rs Route links sidebar.rs Character sidebar, composes the seven panels below sidebar/*.rs image, names, about, hit_points, hit_dice, death_saving_throws, spell_slots inventory.rs One inventory section (rows + money) item.rs One inventory row numput.rs Numeric input components (Numput, NumputChange) mod.rs Pages (Character, Inventories, Wiki) + item search ``` ## State model The whole dashboard is one reactive tree: - `entities::Dashboard` is the root struct: campaign fields, `player: PlayerData`, `common: CommonData`, plus quest/note/contact books and settings. - Every entity struct derives `reactive_stores::Store`, so every nested field is independently reactive (copy-on-write paths). - `App` creates `Store::new(Dashboard::mock())` and provides it as Leptos context. Components read it with `expect_context::()` and grab field handles, e.g. `let hp = state.player().hp();`. - Writes go through `field.update(|v| ...)`, `field.set(...)`, or `field.write()`. All arithmetic is saturating (`saturating_add/sub`, `checked_add_signed`) so values never underflow. The state is mock data only — `Dashboard::mock()` runs on every load and nothing is persisted. The header's Save/Load/Clear buttons do nothing yet (ROADMAP phase 1). ## Data layer (items) - `statics::ITEMS_REFS` is a `LazyLock` map of name → `BookItem`, built once by `utils::read_items` over the bundled JSON files (`items.json`, `base.json`, `foundry.json`, `magic.json`), roughly 5k items. - `BookItem` keeps `name`, `weight`, `rarity`, and `rest`, which holds all other JSON fields verbatim for future detail views. - Duplicate names merge: the later entry fills a missing weight and appends its `rest` fields. - `ITEMS_NAMES` is the flat name list fed to the fuzzy matcher. Weights are stored as `u32` basis points (100 = 1.0) in inventory items and formatted with the `utils::BasisPoints` trait; book items use `f64` from the JSON as-is. ## Rendering pipeline 1. `main.rs` (feature `ssr`) starts an actix-web server, generates the route list from `App`, and renders each route server-side through `leptos_routes`. It serves the WASM/CSS from `/pkg` and static assets from `/assets`. 2. The WASM bundle (`hydrate` feature) calls `hydrate()` in `lib.rs`, which mounts `App` with `hydrate_body` over the server-rendered DOM. 3. Routing: `/` → Character page, `/inv` → Inventories, `/wiki` → Wiki, anything else → `NotFound` (sets HTTP 404 during SSR). ## Component tree App │ Save (download)/Load (file import)/Clear (wipe), │ ↩ Undo/redo (buttons + Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y) ├─ Modal overlay dialog: confirm (action) or error (close) ├─ Sidebar │ ├─ Image portrait │ ├─ Names first / alias «» / last │ ├─ About class, level ±, XP │ ├─ HitPoints current/max with bar, temp HP │ ├─ HitDice die-kind select, per-level clickable dice │ ├─ DeathSavingThrows fail/success pips + reset │ └─ SpellSlots 9 levels of clickable pips ├─ Nav links to the three routes └─ Routes ├─ Character stub text ├─ Inventories two Inventory sections (Personal/Common) │ └─ Inventory balance (5 × Numput), rows (ForEnumerate) │ └─ Item name, quantity ±, weight ±, more/swap/remove └─ Wiki stub text ``` The inventories page reads `Context`, splits it into `Field` and `Field` handles, and passes them down. Item rows get move/remove closures by index; "move" transfers a row between the personal and common inventories, "remove" deletes it. The "…" (details) button and the search "+" button are unwired. Persistence lives in `state::persist`: on hydration a deferred timer loads the saved dashboard from localStorage (SSR markup stays authoritative), then a debounced (500 ms) effect writes on any store change via `store.track()`. `parse_dashboard` version-checks the JSON against `Settings::VERSION` (the migration hook). `ModalState` holds title/message/ action as signals; the confirm button reads the action at click time. Undo/redo lives in `state::undo`: the same store trigger feeds a snapshot history (`HistoryHandle` in context). The first change of a burst is remembered; when the burst goes quiet for 1 s, the pre-burst state becomes an undo step. Undo/redo restore via `replace_state` — the autosave effect persists the undone state too. Clear and import push a snapshot first, so they are undoable themselves. `Numput` is the shared numeric input: `+n`/`-n` adjust relative to the current value, a plain number sets absolutely, invalid input is reverted, and the value renders with thousands separators. `NumputChange` is the same pattern with caller-supplied display and parse functions (used for weights in basis points). Item search (`ItemSearchField`) re-runs a nucleo fuzzy match on every keystroke over `ITEMS_NAMES` and renders `ItemSearchCard`s. It is not currently mounted on any page. ## Conventions - UI strings are Russian and hardcoded in the views; ids like `id="hp"` exist for e2e tests. - Collections come from `std::collections` via the prelude (`HashMap`, `HashSet`). - `For` lists are keyed: inventories use `#[store(key)]` on `row.id`, sidebar pips key by index. - Logging goes through the `log` macros re-exported in the prelude (`info!`, `warn!`, ...). ## Current gaps - Quests, notes, contacts: models and mock data only - `Character` and `Wiki` pages render placeholders - Prepared spells list is empty; `Spell` is a name-only stub; attunements have no UI; `Settings` has only `version` - The item "+"/"…" buttons are dead - `foundry.json` was loaded twice (fixed); ~3.9 MB of JSON is bundled into the WASM and parsed on startup - Several unused dependencies (see `MODERNIZE.md`); kept for now by decision - `Cargo.lock` is tracked since 2026-08-04; builds are reproducible See [ROADMAP.md](./ROADMAP.md) for the ordered plan to fix these.