aex/ARCHITECTURE.md
yaroslav k ec4e771792 add ARCHITECTURE.md: concise overview of the codebase
State model (Store<Dashboard> via context), bundled item data, SSR/hydrate
pipeline, component tree, conventions, and current gaps.
2026-08-04 09:21:25 +03:00

166 lines
7.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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, lazy_static 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 gitignored.
## 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)
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)
- `statics::ITEMS_REFS` is a `lazy_static` map of name → `BookItem`, built
once by `utils::read_items` over the bundled JSON files (`items.json`,
`base.json`, `foundry.json` loaded twice — a known duplication — and
`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
├─ Header name, image, date steppers, session steppers, Save/Load/Clear (dead)
├─ 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<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.
`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
- No persistence; state is recreated from `mock()` each load
- `Character` and `Wiki` pages render placeholders
- Quests, notes, contacts: models and mock data only
- Prepared spells list is empty; `Spell` is a name-only stub; attunements
have no UI; `Settings` is empty
- Save/Load/Clear buttons and the item "+"/"…" buttons are dead
- `foundry.json` is loaded twice; ~3.9 MB of JSON is bundled into the WASM
and parsed on startup
- Several unused dependencies (`leptos-use`, `dotenvy`,
`pretty_env_logger`, `seq_macro`, `itertools`, `thiserror`) and 12
compiler warnings
- `Cargo.lock` is gitignored, so builds are not reproducible
See [ROADMAP.md](./ROADMAP.md) for the ordered plan to fix these.