- ROADMAP: all Phase 2 boxes checked; done-notes (HP block already in the sidebar; optional Spell fields keep old JSON parseable; rows are plain functions inside For's let children) - ARCHITECTURE: character/*.rs in the tree, Character route contents, gaps list drops the character-page items - README: character page in 'What works today'
8.6 KiB
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).
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 tonightlywithout 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<Dashboard> 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)
character/*.rs Character page: player_card, prepared_spells,
attunements
mod.rs Pages (Character, Inventories, Wiki) + item search
State model
The whole dashboard is one reactive tree:
entities::Dashboardis 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). AppcreatesStore::new(Dashboard::mock())and provides it as Leptos context. Components read it withexpect_context::<Context>()and grab field handles, e.g.let hp = state.player().hp();.- Writes go through
field.update(|v| ...),field.set(...), orfield.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_REFSis aLazyLockmap of name →BookItem, built once byutils::read_itemsover the bundled JSON files (items.json,base.json,foundry.json,magic.json), roughly 5k items.BookItemkeepsname,weight,rarity, andrest, which holds all other JSON fields verbatim for future detail views.- Duplicate names merge: the later entry fills a missing weight and appends
its
restfields. ITEMS_NAMESis 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
main.rs(featuressr) starts an actix-web server, generates the route list fromApp, and renders each route server-side throughleptos_routes. It serves the WASM/CSS from/pkgand static assets from/assets.- The WASM bundle (
hydratefeature) callshydrate()inlib.rs, which mountsAppwithhydrate_bodyover the server-rendered DOM. - 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 player card; prepared spells (vacant/occupied │ slots with free-text entry); attunements (3 slots) ├─ 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<Inventory>` and
`Field<Balance>` 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
- `Wiki` page renders a placeholder
- `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.