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

7.1 KiB
Raw Blame History

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 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 ItemSearchCards. 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 for the ordered plan to fix these.