2026-08-04 06:21:25 +00:00
|
|
|
|
# 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,
|
2026-08-15 11:58:01 +00:00
|
|
|
|
chrono for dates, std::sync::LazyLock for one-time data loading
|
2026-08-04 06:21:25 +00:00
|
|
|
|
- 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
|
2026-08-15 11:58:01 +00:00
|
|
|
|
mode for `trunk`). `Cargo.lock` is tracked for reproducible builds.
|
2026-08-04 06:21:25 +00:00
|
|
|
|
|
|
|
|
|
|
## 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)
|
2026-08-16 10:08:03 +00:00
|
|
|
|
character/*.rs Character page: prepared_spells, attunements
|
2026-08-04 06:21:25 +00:00
|
|
|
|
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::<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)
|
|
|
|
|
|
|
2026-08-15 11:58:01 +00:00
|
|
|
|
- `statics::ITEMS_REFS` is a `LazyLock` map of name → `BookItem`, built
|
2026-08-04 06:21:25 +00:00
|
|
|
|
once by `utils::read_items` over the bundled JSON files (`items.json`,
|
2026-08-15 11:58:01 +00:00
|
|
|
|
`base.json`, `foundry.json`, `magic.json`), roughly 5k items.
|
2026-08-04 06:21:25 +00:00
|
|
|
|
- `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
|
2026-08-16 09:32:25 +00:00
|
|
|
|
│ Save (download)/Load (file import)/Clear (wipe),
|
|
|
|
|
|
│ ↩ Undo/redo (buttons + Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y)
|
2026-08-16 08:50:41 +00:00
|
|
|
|
├─ Modal overlay dialog: confirm (action) or error (close)
|
2026-08-04 06:21:25 +00:00
|
|
|
|
├─ 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
|
2026-08-16 10:08:03 +00:00
|
|
|
|
├─ Character prepared spells (vacant/occupied slots with
|
|
|
|
|
|
│ free-text entry); attunements (growable slots)
|
2026-08-04 06:21:25 +00:00
|
|
|
|
├─ 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.
|
|
|
|
|
|
|
2026-08-16 08:50:41 +00:00
|
|
|
|
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.
|
|
|
|
|
|
|
2026-08-16 09:32:25 +00:00
|
|
|
|
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.
|
|
|
|
|
|
|
2026-08-04 06:21:25 +00:00
|
|
|
|
`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
|
2026-08-16 09:44:31 +00:00
|
|
|
|
- `Wiki` page renders a placeholder
|
|
|
|
|
|
- `Settings` has only `version`
|
2026-08-16 08:50:41 +00:00
|
|
|
|
- The item "+"/"…" buttons are dead
|
2026-08-15 11:58:01 +00:00
|
|
|
|
- `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
|
2026-08-04 06:21:25 +00:00
|
|
|
|
|
|
|
|
|
|
See [ROADMAP.md](./ROADMAP.md) for the ordered plan to fix these.
|