docs(openup): move WI-2 OpenUP work set verbatim from skara-brae-server
All OpenUP artifacts are versioned in skara-brae-common under docs/openup/ per the 2026-10-03 decision reversal in design-wi-2-architecture-cleanup.md (supersedes the earlier in-server-docs decision). Verbatim move, no content changes: analysis-design/, requirements/, test/ (6 files).
This commit is contained in:
parent
9cd8e2f717
commit
4798f55660
6 changed files with 1066 additions and 0 deletions
664
docs/openup/analysis-design/design-wi-2-architecture-cleanup.md
Normal file
664
docs/openup/analysis-design/design-wi-2-architecture-cleanup.md
Normal file
|
|
@ -0,0 +1,664 @@
|
|||
# WI-2 Design — Architecture Cleanup + OpenUP Work Set (skara-brae-server#2, PSP1)
|
||||
|
||||
Status: DESIGN (interface-first; implementation by psp-coder).
|
||||
Baseline verified 2026-10-03 on `wi-2-architecture-cleanup` @ `899fde8`: `cargo check --workspace` green, 45/45 sb-core tests green, working tree clean.
|
||||
Standards: "Clean Architecture & OpenUP A&D — Standards" (Joplin `414f323e04b448f18e2ea39ecef57b1f`), WI-1 design conventions (`design-wi-1-injectable-rng.md`).
|
||||
|
||||
Hard constraint: **behavior-identical**. JSON field names, field order, status codes, game semantics, and RNG draw order are frozen. The current `json!` literals and handler code are the wire-format source of truth (§4.3).
|
||||
|
||||
**Decision reversal record (2026-10-03, later same day — user decision):** ALL OpenUP docs are versioned in the **skara-brae-common repo** under `docs/openup/` — one canonical project-docs home, no split rule. This **supersedes** the earlier "OpenUP docs inside skara-brae-server" decision and §11.a's original in-repo recommendation. Consequently: the drafted WI-2 artifacts currently sit **untracked** in `skara-brae-server/docs/openup/` and will be **moved verbatim (no content changes) to `skara-brae-common/docs/openup/` by the coder in session 2**; the WI-1 artifacts at the old project-top `docs/openup/` migrate there as well. `roadmap.md` remains at the **project-top folder** per the earlier PM decision (the design never placed it in a docs tree) — that location is unversioned; if PM later wants it versioned, the pre-agreed fallback is the skara-brae-common repo root next to `docs/` (PM-confirmable default).
|
||||
|
||||
---
|
||||
|
||||
## 1. Requirements traceability
|
||||
|
||||
Source: Forgejo issue #2 (WI scope), acceptance per PM decision.
|
||||
|
||||
| WI scope item | Design section | UC realization (see `uc-realization-mapping.md`) |
|
||||
|---|---|---|
|
||||
| 1. Typed responses (4 handlers, 6 `json!` sites) | §4.3 | UC-08 Adventure, UC-09 Temple Heal, UC-11 Study Magic |
|
||||
| 2. `run_adventure` service extraction | §4.2 | UC-08 (all scenarios) |
|
||||
| 3. Named constants (~25 magic numbers) | §4.4 | all gameplay UCs (cross-cutting) |
|
||||
| 4. `unwrap()` cleanup + error enum | §4.1, §6 | all request-path UCs (cross-cutting) |
|
||||
| 5. `cargo fmt` baseline | §9 | — (process) |
|
||||
| 6. EA model + generator pipeline (scope **added at 1B rework**) | §11 | UC-01…UC-12 (all realizations) |
|
||||
|
||||
Out of scope (recorded as follow-up candidates, §10): heal/spell-learn business logic still living in handlers — **roadmap.md candidate** per Q3 resolution (roadmap.md itself stays at the project-top folder, see the decision-reversal record above); migration of WI-1 docs and the drafted WI-2 docs into `skara-brae-common/docs/openup/` (the canonical home per the reversal; verbatim move, coder, session 2).
|
||||
|
||||
---
|
||||
|
||||
## 2. Target module layout
|
||||
|
||||
```
|
||||
crates/sb-core/src/
|
||||
lib.rs + pub mod adventure; + pub mod constants; + pub mod error;
|
||||
adventure.rs NEW domain use case: Run Adventure (UC-08)
|
||||
constants.rs NEW named game-balance constants (single audit home)
|
||||
error.rs NEW DomainError enum (thiserror 2 — already a dependency)
|
||||
character.rs MOD constants inlined from constants.rs; level_up -> DomainError
|
||||
combat.rs MOD constants; no signature change
|
||||
services.rs MOD train/equip/unequip/pick_enemy -> DomainError
|
||||
spells.rs MOD + caster_spell_level(); constants
|
||||
songs.rs unchanged
|
||||
items.rs, data.rs production code unchanged (loader pin tests added, §6)
|
||||
|
||||
crates/sb-shared/src/
|
||||
item.rs MOD + ItemType::as_str(), Quality::as_str() (+ parity tests)
|
||||
|
||||
crates/sb-api/src/routes/
|
||||
mod.rs MOD handlers keep HTTP concerns only; + domain_status(), now_unix_ms()
|
||||
responses.rs NEW typed response DTOs for the 4 untyped handlers (private mod)
|
||||
```
|
||||
|
||||
Visibility rules:
|
||||
- `sb_core::{adventure, constants, error}` — `pub` modules; consumed by sb-api.
|
||||
- `sb_api::routes::responses` — private `mod responses;` inside `routes` (used only by handlers). Not `pub`; not in sb-shared.
|
||||
- No new crate, no new external dependency. `thiserror = "2"` already in sb-core's Cargo.toml (verified).
|
||||
|
||||
Placement decisions (designer decision records):
|
||||
- **Response DTOs in sb-api, not sb-shared.** sb-core depends on sb-shared; putting HTTP-only DTOs there would make the domain crate transitively carry HTTP wire types it never uses. Existing sb-shared response types stay where they are (moving them is churn outside WI-2). See QUESTION Q2 for the future OpenAPI/utoipa consideration.
|
||||
- **Constants centralized in `sb_core::constants`** even when single-use, so the "no magic numbers" audit is one grep in one module. Static *data tables* (`RACES`, `CLASSES`, `BARD_SONGS`, `TRAINING_GROUNDS`) are data, not tuning scalars — they stay in their modules.
|
||||
- **`AdventureOutcome` derives nothing** (no serde) — pure domain output. The HTTP DTO maps from it in sb-api (`impl From<AdventureOutcome> for AdventureRunResponse`). This is the only new place domain output crosses to wire format.
|
||||
|
||||
---
|
||||
|
||||
## 3. HTTP ↔ domain boundary rules
|
||||
|
||||
**routes/ (sb-api) may import:** `sb_core` (incl. new `adventure`, `constants`, `error`), `sb_shared`, `sb_common`, `sb_db`, `sb_auth`, axum/tokio/tower, `rand` **only** via the WI-1 single construction point `request_rng()` (`StdRng::from_os_rng`; never `rand::rng()`).
|
||||
|
||||
**sb-core must not reference:** axum, `http`, `StatusCode`, `serde_json::json!`, tokio, tracing, sb-db, sb-api. Serde derives on *existing* sb-shared types (e.g. `CombatLog`) are an accepted legacy trade-off — **not extended**: no new serde derives on new sb-core types (`AdventureOutcome`, `DomainError` carries only `thiserror::Error`, which is `Display`, not wire).
|
||||
|
||||
**Status mapping lives in exactly one place:** `routes::domain_status` (§4.1). Handlers translate store misses/authz to 404/403/… themselves (they own the store); domain rule failures flow through `DomainError`.
|
||||
|
||||
**Accepted legacy debt (unchanged by WI-2):** heal (routes/mod.rs:862–888) and spell learn/activate (routes/mod.rs:1107–1180) keep business logic in handlers. Typed responses only. Flagged for a follow-up WI (§10, Q3).
|
||||
|
||||
---
|
||||
|
||||
## 4. Interface specifications (the contracts psp-coder implements)
|
||||
|
||||
### 4.1 Domain error enum + central status mapping
|
||||
|
||||
```rust
|
||||
// crates/sb-core/src/error.rs
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum DomainError {
|
||||
#[error("character is incapacitated")]
|
||||
CharacterIncapacitated,
|
||||
#[error("not enough nerve")]
|
||||
NotEnoughNerve,
|
||||
#[error("not enough energy")]
|
||||
NotEnoughEnergy,
|
||||
#[error("not enough gold")]
|
||||
NotEnoughGold,
|
||||
#[error("not enough XP")]
|
||||
NotEnoughXp,
|
||||
#[error("already at max level")]
|
||||
AlreadyAtMaxLevel,
|
||||
#[error("invalid stat name")]
|
||||
InvalidStat,
|
||||
#[error("item not in inventory")]
|
||||
ItemNotInInventory,
|
||||
#[error("nothing equipped in slot")]
|
||||
NothingEquippedInSlot,
|
||||
#[error("encounter references unknown enemy")]
|
||||
UnknownEncounterEnemy,
|
||||
}
|
||||
```
|
||||
|
||||
One enum at the domain boundary (standards: no per-function error types, no error-enum fragmentation). Fieldless + `Copy` so tests assert with `assert_eq!`.
|
||||
|
||||
```rust
|
||||
// crates/sb-api/src/routes/mod.rs
|
||||
fn domain_status(error: sb_core::error::DomainError) -> axum::http::StatusCode
|
||||
```
|
||||
|
||||
Mapping table (frozen to current behavior — each row cites today's code):
|
||||
|
||||
| Variant | Status | Current source (routes/mod.rs) |
|
||||
|---|---|---|
|
||||
| `CharacterIncapacitated` | 400 BAD_REQUEST | L444–446 (`hp <= 0`) |
|
||||
| `InvalidStat` | 400 BAD_REQUEST | L648 (`_ => BAD_REQUEST` in train) |
|
||||
| `ItemNotInInventory` | 400 BAD_REQUEST | L365 (`map_err(\|_\| BAD_REQUEST)`) |
|
||||
| `NothingEquippedInSlot` | 400 BAD_REQUEST | L400 |
|
||||
| `NotEnoughNerve` | 422 UNPROCESSABLE_ENTITY | L447–449 |
|
||||
| `NotEnoughEnergy` | 422 | L646 |
|
||||
| `NotEnoughGold` | 422 | L647 (train), L1077–1079 (level_up: ALL errors 422), heal L880 (422, handler-side) |
|
||||
| `NotEnoughXp` | 422 | L1077 |
|
||||
| `AlreadyAtMaxLevel` | 422 | L1078 (level_up maps every `Err` to 422) |
|
||||
| `UnknownEncounterEnemy` | 500 INTERNAL_SERVER_ERROR | new (see §6, pick_enemy) |
|
||||
|
||||
The match in `domain_status` must be non-wildcard and exhaustive: adding a `DomainError` variant then fails compilation until a status is chosen (taxonomy designed once).
|
||||
|
||||
**Signature conversions** (behavior-identical; only the error type changes):
|
||||
|
||||
| Function (current) | New signature |
|
||||
|---|---|
|
||||
| `character::level_up(&mut Character) -> Result<i32, &'static str>` | `Result<i32, DomainError>` (AlreadyAtMaxLevel, NotEnoughXp, NotEnoughGold) |
|
||||
| `services::train_stat(&mut Character, &str) -> Result<i64, &'static str>` | `Result<i64, DomainError>` (NotEnoughEnergy, InvalidStat, NotEnoughGold) |
|
||||
| `services::equip_item(...) -> Result<(), &'static str>` | `Result<(), DomainError>` (ItemNotInInventory) |
|
||||
| `services::unequip_item(...) -> Result<String, &'static str>` | `Result<String, DomainError>` (NothingEquippedInSlot) |
|
||||
| `services::pick_enemy(...) -> EnemyDef` | `Result<EnemyDef, DomainError>` (UnknownEncounterEnemy; also empty `encounters` / `total_weight <= 0`) |
|
||||
|
||||
Test-assertion updates this forces (mechanical `Err("…")` → `Err(DomainError::…)`, no test logic changes):
|
||||
- character.rs: `level_up_fails_at_max_level`, `level_up_fails_without_enough_xp`, `level_up_fails_without_enough_gold`
|
||||
- services.rs: `train_stat_fails_without_energy`, `train_stat_fails_without_gold`, `train_stat_fails_invalid_stat`, `equip_item_fails_if_not_in_inventory`, `unequip_item_fails_empty_slot`, `pick_enemy_respects_weights` (now `unwrap()`s the Ok)
|
||||
|
||||
### 4.2 Adventure service (UC-08)
|
||||
|
||||
```rust
|
||||
// crates/sb-core/src/adventure.rs
|
||||
use rand::Rng;
|
||||
use sb_shared::{Character, CombatLog, Item};
|
||||
use crate::data::{EnemyDef, ZoneDef};
|
||||
use crate::error::DomainError;
|
||||
|
||||
/// Domain output of one adventure run. No serde derives — the HTTP DTO
|
||||
/// (sb-api `AdventureRunResponse`) maps from this at the boundary.
|
||||
pub struct AdventureOutcome {
|
||||
pub zone_id: String, // echoes zone.id (== requested zone_id; lookup is by id)
|
||||
pub enemy_name: String,
|
||||
pub combat_log: CombatLog,
|
||||
pub loot_item_names: Vec<String>, // display names, in drop-roll order
|
||||
}
|
||||
|
||||
pub fn run_adventure(
|
||||
rng: &mut impl Rng,
|
||||
character: &mut Character,
|
||||
zone: &ZoneDef,
|
||||
enemies: &[EnemyDef],
|
||||
items: &[Item],
|
||||
now_ms: i64,
|
||||
) -> Result<AdventureOutcome, DomainError>;
|
||||
```
|
||||
|
||||
**Behavior contract** (ordered; RNG draw order is normative — the two deliberate semantic deltas against routes/mod.rs:430–555 are recorded immediately after the list):
|
||||
|
||||
1. `character::apply_regen(character, now_ms)` — regen happens **before** the guards (current L442).
|
||||
2. `character.hp <= 0` → `Err(CharacterIncapacitated)`.
|
||||
3. `character.nerve < zone.nerve_cost` → `Err(NotEnoughNerve)`.
|
||||
4. `character.nerve -= zone.nerve_cost`.
|
||||
5. `enemy_def = services::pick_enemy(rng, &zone.encounters, enemies)?` — draw #1: one `random_range(0..total_weight)`. On `Err` the nerve is already spent (matches today's ordering where the panic occurred after deduction; the 500 replaces a panic — see §6).
|
||||
6. Character combat stats from the *mutated* character + `items`: `services::equipped_attack_type`, `services::equipped_damage` (ob_base), `services::total_defense` (db_base); hp = post-regen hp; `CombatStats { … }` as L460–471.
|
||||
7. Enemy `CombatStats` per L473–484 (`parse_attack_type(&enemy_def.attack_type)`).
|
||||
8. `spell_effect`: `character.active_spell` → `spells::find_spell` → `combat::SpellEffect { … }` per L486–494.
|
||||
9. `song_effect`: `character.active_song` → `songs::BARD_SONGS` find → `.combat_effect` per L502–504.
|
||||
10. `log = combat::resolve_combat_full(char_stats, enemy_stats, enemy_def.xp, enemy_def.gold_min, enemy_def.gold_max, song_effect, spell_effect.as_ref(), rng)` — draws #2..n: open-ended rolls, per-round spell damage, final gold roll, in engine order.
|
||||
11. If `log.winner == character.name` (win):
|
||||
- `character.xp += log.xp_gained as i64`; `character.gold += log.gold_gained`;
|
||||
- `character.hp = log.rounds.iter().rfind(|r| r.defender == character.name).map(|r| r.defender_hp)` — last round in which the character was defender (i.e. the final enemy attack against the character), if any round exists;
|
||||
- loot: for each `drop` in `enemy_def.loot` **in order**: `rng.random_bool(drop.chance)` (draw) **and** `items.iter().any(|i| i.id == drop.item_id)` → push `drop.item_id` into inventory and record it.
|
||||
Else (loss): `character.hp = 0`, no xp/gold/loot.
|
||||
12. Spell-point upkeep (regardless of win/loss, L542–547): if `character.active_spell.is_some()` and `spells::find_spell(active_spell)` → `character.spell_points = (spell_points - sp.spell_points).max(0)`.
|
||||
13. `loot_item_names` = recorded ids mapped through `items` (id → `name`), preserving order.
|
||||
14. Return `AdventureOutcome { zone_id: zone.id.clone(), enemy_name: enemy_def.name.clone(), combat_log: log, loot_item_names }`.
|
||||
|
||||
Mutation semantics: `character` is mutated in place through step 1 even when an `Err` is returned (regen side effect stays in memory) — identical to today, where the handler applies regen, returns early, and never persists. **The handler persists only on `Ok`** (unchanged).
|
||||
|
||||
**Decided semantic deltas vs the current handler (1B review finding 5 — recorded, wire-invisible):**
|
||||
1. *Lock consolidation.* Today the handler acquires locks three times (authz read-lock; write-lock #1 for regen/nerve/enemy-pick; write-lock #2 for outcome application), computing combat stats in between. The refactored handler holds **one** write-lock span around the service call. The mid-request "character vanished between acquisitions" branch (today: second lookup misses → 200 with empty loot and no persist, routes/mod.rs:509–555) becomes structurally impossible. That branch is dead code today — no character-deletion endpoint exists and nothing else removes characters from the store — so no reachable behavior changes; the delta is recorded rather than claimed away.
|
||||
2. *Enemy-pick failure.* See §6: impossible-data `UnknownEncounterEnemy` now yields a 500 where the current code panics (same in-memory side effects).
|
||||
|
||||
**Handler after refactor (shape):**
|
||||
|
||||
```rust
|
||||
async fn run_adventure(/* extractors unchanged */)
|
||||
-> Result<Json<responses::AdventureRunResponse>, axum::http::StatusCode>
|
||||
{
|
||||
let zone_id = query.zone_id.as_deref().unwrap_or(DEFAULT_ZONE_ID);
|
||||
// authz read-guard block: unchanged (404 / 403)
|
||||
let zone = state.zones.iter().find(|z| z.id == zone_id)
|
||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||
let now_ms = now_unix_ms();
|
||||
let mut rng = request_rng();
|
||||
let (outcome, updated) = {
|
||||
let mut chars = state.characters.write().await;
|
||||
let character = chars.iter_mut().find(|c| c.id == char_id)
|
||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||
let outcome = sb_core::adventure::run_adventure(
|
||||
&mut rng, character, zone, &state.enemies, &state.items, now_ms)
|
||||
.map_err(domain_status)?;
|
||||
(outcome, character.clone())
|
||||
};
|
||||
persist_character(&state, &updated).await;
|
||||
Ok(Json(outcome.into())) // From<AdventureOutcome> for AdventureRunResponse
|
||||
}
|
||||
```
|
||||
|
||||
The handler owns: extractors, authz guard, store lookup/locking, rng construction (WI-1 point), status mapping, persistence, DTO mapping. Nothing else.
|
||||
|
||||
### 4.3 Typed responses (`crates/sb-api/src/routes/responses.rs`)
|
||||
|
||||
**Wire-order rule (corrected at 1B review, finding 1 — blocker).** This workspace resolves `serde_json` v1.0.151 **without** the `preserve_order` feature (verified: `cargo tree -p serde_json` → `default,raw_value,std`). `json!` therefore builds `Map<String, Value>` backed by `BTreeMap`, and **every `json!` object in the current handlers serializes its keys in lexicographic (byte) order, regardless of the literal's key order**.
|
||||
|
||||
Rule for the coder: **declare struct fields in lexicographic order of their JSON keys** — serde struct serialization follows declaration order, so lexicographic declaration order reproduces the current bytes exactly. The field orders below are already correct; do not "reorder for readability."
|
||||
|
||||
Two consequences:
|
||||
- **`json!` sorts keys at EVERY nesting level, not just the top level** (defect #14, found by psp-coder during implementation prep): `json!({"combat_log": &log})` first converts the struct via `to_value` into a `Value::Object(BTreeMap)`, so today's wire shows nested structs sorted too — e.g. `combat_log` emits `{"gold_gained":…, "loser":…, "rounds":[…], "winner":…, "xp_gained":…}` with each `CombatRound`/`CritResult`/`CritEntry` lexicographic. A DTO field typed as the struct would emit **declaration order** (serde derive) and change the wire bytes. Therefore `AdventureRunResponse.combat_log` is a `serde_json::Value` **bridge**, populated via `serde_json::to_value(&outcome.combat_log)` in the `From<AdventureOutcome>` impl (PM decision, option A) — reproducing today's sorted bytes exactly. `CombatLog` itself stays a typed struct in the domain and in `AdventureOutcome`; only the wire DTO field is the `Value` bridge.
|
||||
- `learn_costs` uses `BTreeMap<String, i32>`: its string keys "2"…"7" sort lexicographically exactly as today's `json!` inner object emits them.
|
||||
|
||||
```rust
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AdventureRunResponse {
|
||||
pub combat_log: serde_json::Value, // Value bridge — see From impl below (defect #14)
|
||||
pub enemy: String,
|
||||
pub loot: Vec<String>,
|
||||
pub zone: String,
|
||||
}
|
||||
// Current actual emission (routes/mod.rs:561–566 under BTreeMap ordering):
|
||||
// {"combat_log":…, "enemy":…, "loot":[…], "zone":…}
|
||||
// (the literal writes zone, enemy, combat_log, loot — the wire bytes are sorted)
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct HealResponse {
|
||||
pub gold: i32,
|
||||
pub healed: bool,
|
||||
pub hp: i32,
|
||||
pub max_hp: i32,
|
||||
}
|
||||
// Current actual emission (routes/mod.rs:891–896): {"gold":…, "healed":true, "hp":…, "max_hp":…}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SpellEntryResponse {
|
||||
pub code: String,
|
||||
pub combat_damage_max: i32,
|
||||
pub combat_damage_min: i32,
|
||||
pub combat_effect: String,
|
||||
pub description: String,
|
||||
pub heal_max: i32,
|
||||
pub heal_min: i32,
|
||||
pub id: String,
|
||||
pub known: bool,
|
||||
pub name: String,
|
||||
pub range: String,
|
||||
pub spell_level: i32,
|
||||
pub spell_points: i32,
|
||||
}
|
||||
// Current actual emission (routes/mod.rs:1015–1029) — sorted keys; the literal
|
||||
// order (id, code, name, …) is NOT what the wire shows today.
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SpellbookResponse {
|
||||
pub caster_class: String,
|
||||
pub learn_costs: BTreeMap<String, i32>,
|
||||
pub max_spell_points: i32,
|
||||
pub spell_level: i32,
|
||||
pub spell_points: i32,
|
||||
pub spells: Vec<SpellEntryResponse>,
|
||||
}
|
||||
// Current actual emission (routes/mod.rs:1031–1045); learn_costs keys exactly
|
||||
// "2".."7" (spell_learn_cost per level), sorted like today.
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ActivateSpellResponse {
|
||||
pub active_spell: Option<String>, // None → JSON null (json! identical)
|
||||
pub spell_points: i32,
|
||||
}
|
||||
// routes/mod.rs:1128–1131 — the only site whose literal order already was
|
||||
// lexicographic; field order unchanged by this rule.
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LearnSpellsResponse {
|
||||
pub gold: i32,
|
||||
pub learned_count: usize,
|
||||
pub max_spell_points: i32,
|
||||
pub spell_points: i32,
|
||||
}
|
||||
// Current actual emission (routes/mod.rs:1183–1188): gold, learned_count,
|
||||
// max_spell_points, spell_points. NOTE (behavior wart, kept): "learned_count"
|
||||
// carries updated.known_spells.len() — the TOTAL known spells after learning,
|
||||
// not the newly-learned count (Q1 resolution: fenced wart, keep verbatim).
|
||||
```
|
||||
|
||||
Handler return types change from `Result<Json<serde_json::Value>, StatusCode>` to:
|
||||
- `run_adventure` → `Result<Json<AdventureRunResponse>, StatusCode>`
|
||||
- `heal_character` → `Result<Json<HealResponse>, StatusCode>`
|
||||
- `get_spells` → `Result<Json<SpellbookResponse>, StatusCode>`
|
||||
- `learn_spell_level` → returns an **enum** (two different bodies):
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum LearnSpellResponse {
|
||||
Activate(ActivateSpellResponse), // body present when request had spell_id
|
||||
Learn(LearnSpellsResponse), // body present when request had spell_level
|
||||
}
|
||||
```
|
||||
`#[serde(untagged)]` serializes the inner struct — byte-identical to the two current `json!` shapes. Handler returns `Result<Json<LearnSpellResponse>, StatusCode>`.
|
||||
|
||||
**Value bridge impl (defect #14 — populates the `combat_log` field):**
|
||||
|
||||
```rust
|
||||
// crates/sb-api/src/routes/responses.rs — mapping happens at the HTTP boundary
|
||||
impl From<sb_core::adventure::AdventureOutcome> for AdventureRunResponse {
|
||||
fn from(outcome: sb_core::adventure::AdventureOutcome) -> Self {
|
||||
AdventureRunResponse {
|
||||
// to_value mirrors exactly what json! does to &log today: struct →
|
||||
// Value::Object with BTreeMap ordering — nested keys sorted, byte-identical wire.
|
||||
combat_log: serde_json::to_value(&outcome.combat_log)
|
||||
.expect("CombatLog graph contains no map types; to_value is infallible"),
|
||||
enemy: outcome.enemy_name,
|
||||
loot: outcome.loot_item_names,
|
||||
zone: outcome.zone_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The single `expect` is a §6 keep-with-reason invariant: `serde_json::to_value` fails only on non-string map keys, and the `CombatLog`/`CombatRound`/`CritResult`/`CritEntry` graph contains no map types (String, i32/u8/f64, Vec, Option only) — infallible by construction, and pinned by the W1 golden test.
|
||||
|
||||
`Debug + Serialize` only (no `Deserialize`, no `Clone`) — these types are write-only at the HTTP edge.
|
||||
|
||||
**Wire-identity proof obligation (1B review finding 1):** byte-comparison tests W1–W6 (§7) must serialize each DTO with fixed representative values and assert equality against golden strings recorded from the **current** `json!` expressions before the refactor — sorted-key bytes, not literal-order bytes.
|
||||
|
||||
Known wire-format wart preserved deliberately (do **not** “fix”): shop/inventory `attack_type` renders Debug-form `"Slash"`/`"Crush"`/`"Puncture"` (routes/mod.rs:693, 708 via `format!("{:?}", at)`), while `item_type`/`quality` render snake_case (`"weapon"`, `"common"`). The new `as_str()` helpers (§4.5) must NOT be applied to those two `attack_type` sites.
|
||||
|
||||
### 4.4 Constants module (`crates/sb-core/src/constants.rs`)
|
||||
|
||||
All `pub const`, `SCREAMING_SNAKE_CASE`, with a doc comment carrying the origin. Value column is normative; origin = current file:line of the literal.
|
||||
|
||||
**Combat (combat.rs):**
|
||||
|
||||
| Name | Value | Origin |
|
||||
|---|---|---|
|
||||
| `ATTACK_ROLL_MIN` / `ATTACK_ROLL_MAX` | `1` / `100` | combat.rs:66 |
|
||||
| `OPEN_END_HIGH_THRESHOLD` | `96` (re-roll upward) | combat.rs:68 |
|
||||
| `OPEN_END_LOW_THRESHOLD` | `5` (re-roll downward) | combat.rs:70 |
|
||||
| `CRIT_SEVERITY_A_MIN` | `21` | combat.rs:129 |
|
||||
| `CRIT_SEVERITY_B_MIN` | `51` | combat.rs:127 |
|
||||
| `CRIT_SEVERITY_C_MIN` | `101` | combat.rs:125 |
|
||||
| `CRIT_SEVERITY_D_MIN` | `151` | combat.rs:123 |
|
||||
| `CRIT_CAP_DISABLED_MAX_LEVEL_GAP` | `9` (gaps 1..=9 → no cap) | combat.rs:56 |
|
||||
| `CRIT_CAP_TIER1_MIN_GAP` / `CRIT_CAP_TIER1_MAX_GAP` | `10` / `19` | combat.rs:57 |
|
||||
| `CRIT_CAP_TIER2_MIN_GAP` / `CRIT_CAP_TIER2_MAX_GAP` | `20` / `49` | combat.rs:58 |
|
||||
| `CRIT_CAP_TIER3_MIN_GAP` / `CRIT_CAP_TIER3_MAX_GAP` | `50` / `99` | combat.rs:59 |
|
||||
| `CRIT_CAP_TIER4_MIN_GAP` | `100` (and above → 0.05 ratio) | combat.rs:60 |
|
||||
| `CRIT_CAP_TIER1_RATIO` | `0.60` (gap 10–19) | combat.rs:57 |
|
||||
| `CRIT_CAP_TIER2_RATIO` | `0.40` (gap 20–49) | combat.rs:58 |
|
||||
| `CRIT_CAP_TIER3_RATIO` | `0.15` (gap 50–99) | combat.rs:59 |
|
||||
| `CRIT_CAP_TIER4_RATIO` | `0.05` (gap ≥ 100) | combat.rs:60 |
|
||||
| `DEFENSIVE_CON_BONUS_DIVISOR` | `2` | combat.rs:36 |
|
||||
| `SONG_ACCURACY_OB_BONUS` | `15` | combat.rs:246 |
|
||||
| `SONG_DAMAGE_OB_BONUS` | `20` | combat.rs:247 |
|
||||
| `SONG_DEFENSE_DB_BONUS` | `10` | combat.rs:256 |
|
||||
| `SONG_WEAKEN_OB_FACTOR` | `0.7` | combat.rs:257 |
|
||||
| `BARD_SONG_HEAL_FRACTION` | `0.03` (of max HP per round) | combat.rs:230 |
|
||||
|
||||
**Character (character.rs):**
|
||||
|
||||
| Name | Value | Origin |
|
||||
|---|---|---|
|
||||
| `STAT_BONUS_NEUTRAL` | `20` | character.rs:59 |
|
||||
| `STAT_BONUS_STEP` | `2` | character.rs:59 |
|
||||
| `STAT_ROLL_MIN` / `STAT_ROLL_MAX` | `6` / `16` | character.rs:63 |
|
||||
| `BASE_MAX_ENERGY` | `30` | character.rs:85 |
|
||||
| `MAX_ENERGY_PER_BASE_HP_DIVISOR` | `3` | character.rs:85 |
|
||||
| `BASE_MAX_NERVE` | `100` | character.rs:90 |
|
||||
| `MAX_NERVE_PER_BASE_HP_DIVISOR` | `2` | character.rs:90 |
|
||||
| `ENERGY_REGEN_INTERVAL_MS` | `180_000` | character.rs:94 |
|
||||
| `NERVE_REGEN_INTERVAL_MS` | `300_000` | character.rs:98 |
|
||||
| `HP_REGEN_INTERVAL_MS` | `300_000` | character.rs:102 |
|
||||
| `HP_REGEN_FRACTION_PER_TICK` | `0.05` | character.rs:118 |
|
||||
| `TRAINING_BASE_COST` | `5.0` | character.rs:126 |
|
||||
| `TRAINING_GROWTH_FACTOR` | `1.3` | character.rs:126 |
|
||||
| `TRAINING_STAT_SCALER` | `10.0` | character.rs:125 |
|
||||
| `TRAINING_ENERGY_COST` | `1` (energy per training session) | services.rs:85, 100 |
|
||||
| `STARTING_LEVEL` | `1` | character.rs:157 |
|
||||
| `STARTING_GOLD` | `50` | character.rs:166 |
|
||||
| `XP_CURVE_COEFFICIENT` | `100.0` | character.rs:196 |
|
||||
| `LEVEL_UP_GOLD_PER_LEVEL` | `500` | character.rs:200 |
|
||||
|
||||
(`MAX_LEVEL = 100`, `XP_FACTOR = 18` already exist as named consts — re-export/keep.)
|
||||
|
||||
**Spells (spells.rs):**
|
||||
|
||||
| Name | Value | Origin |
|
||||
|---|---|---|
|
||||
| `CASTER_LEVELS_PER_SPELL_LEVEL` | `3` ((level/3)+1 curve) | spells.rs:59 |
|
||||
| `SPELL_POINTS_PER_SPELL_LEVEL` | `10` | spells.rs:62 |
|
||||
| `SPELL_LEARN_COST_BY_LEVEL` | `[0, 1000, 2000, 4000, 7000, 10000, 20000]` (index = level−1) | spells.rs:44–52 |
|
||||
| `SPELL_LEARN_COST_UNKNOWN_LEVEL` | `999_999` | spells.rs:52 |
|
||||
| `MIN_SPELL_LEVEL` / `MAX_SPELL_LEVEL` | `1` / `7` — **learn-request validation only** (routes/mod.rs:1146); the `caster_spell_level` formula itself is uncapped | routes/mod.rs:1146 |
|
||||
|
||||
Guard semantics for `spell_learn_cost` (1B review finding 9): the function stays **total**. It indexes `SPELL_LEARN_COST_BY_LEVEL` only when `MIN_SPELL_LEVEL <= level <= MAX_SPELL_LEVEL` (1..=7 — the range check gates the subtraction, so no `usize` underflow for level ≤ 0); any other input, including level ≤ 0 and level > 7, returns `SPELL_LEARN_COST_UNKNOWN_LEVEL` (999_999) — identical to today's catch-all match arm (spells.rs:52).
|
||||
|
||||
**HTTP-layer policy constants (stay in sb-api, `routes/mod.rs` — request-validation policy, not game balance):**
|
||||
|
||||
| Name | Value | Origin |
|
||||
|---|---|---|
|
||||
| `DEFAULT_ZONE_ID` | `"skara_brae_streets"` | routes/mod.rs:415 |
|
||||
| `CHARACTER_NAME_MAX_LEN` | `32` | routes/mod.rs:289 |
|
||||
| `REGISTER_NAME_MIN_LEN` / `REGISTER_NAME_MAX_LEN` | `3` / `32` | routes/mod.rs:1215 |
|
||||
| `PASSWORD_MIN_LEN` | `6` | routes/mod.rs:1218 |
|
||||
| `PARTIAL_HEAL_FRACTION` | `0.5` | routes/mod.rs:874 |
|
||||
| `MIN_HEAL_COST` | `1` | routes/mod.rs:878 |
|
||||
| `SPELL_LEARN_COST_DISPLAY_MIN/MAX` | `2` / `7` (learn_costs response keys) | routes/mod.rs:1037–1043 |
|
||||
|
||||
### 4.5 Shared label helpers + small domain helpers
|
||||
|
||||
```rust
|
||||
// crates/sb-shared/src/item.rs — additions (mirror Race::as_str/Class::as_str precedent)
|
||||
impl ItemType { pub fn as_str(&self) -> &'static str } // "weapon"|"armor"|"accessory"|"consumable"|"material"
|
||||
impl Quality { pub fn as_str(&self) -> &'static str } // "common"|"uncommon"|"rare"|"epic"|"legendary"
|
||||
```
|
||||
Parity unit tests (in sb-shared): for every variant, `serde_json::to_value(variant).unwrap() == serde_json::json!(variant.as_str())`. Kills the four `serde_json::to_value(&x).unwrap().as_str().unwrap()` chains (routes/mod.rs:688–689, 703–704).
|
||||
|
||||
```rust
|
||||
// sb-core/src/character.rs
|
||||
pub const GYM_STAT_DISPLAY_ORDER: &[&str] =
|
||||
&["strength", "intelligence", "dexterity", "constitution", "luck"];
|
||||
// 1B review finding 2: this sequence is PINNED to the handler's fixed array
|
||||
// (routes/mod.rs:588–594) and deliberately differs from TRAINING_GROUNDS order
|
||||
// (strength, dexterity, constitution, intelligence, luck). get_gym iterates
|
||||
// GYM_STAT_DISPLAY_ORDER (NOT TRAINING_GROUNDS) and resolves the training
|
||||
// ground and stat value per entry, so the wire order of GymResponse.stats
|
||||
// is unchanged.
|
||||
|
||||
pub fn stat_value(stats: &CharacterStats, stat: &str) -> Option<i64>;
|
||||
// Strength/Intelligence/Dexterity/Constitution/Luck by name; None otherwise.
|
||||
// Replaces the get_gym expect("valid stat") pattern (routes/mod.rs:596):
|
||||
// unknown ground or stat → skip (unreachable: add test
|
||||
// gym_display_order_covers_all_training_grounds asserting set equality between
|
||||
// GYM_STAT_DISPLAY_ORDER entries and TRAINING_GROUNDS stats, each resolvable
|
||||
// via stat_value).
|
||||
|
||||
// sb-core/src/spells.rs
|
||||
pub fn caster_spell_level(character_level: i32) -> i32; // (level / CASTER_LEVELS_PER_SPELL_LEVEL) + 1
|
||||
// Replaces the three inline copies: spells.rs:59, routes/mod.rs:1012, routes/mod.rs:1150.
|
||||
// Uncapped by design (level 100 → 34); see use-case-model UC-11.
|
||||
```
|
||||
|
||||
```rust
|
||||
// sb-api/src/routes/mod.rs
|
||||
fn now_unix_ms() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|elapsed| elapsed.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
// Replaces 4 unwrap() sites (routes/mod.rs:261, 326, 432, 635). Clock-before-epoch
|
||||
// (unreachable on real hardware) yields 0 → apply_regen no-op, instead of panic.
|
||||
```
|
||||
|
||||
### 4.6 Interface verification protocol (for psp-coder)
|
||||
|
||||
This WI is docs-only in DES, so the interface crate cannot be `cargo check`ed here (per constraints: no src/ edits in design phase). Coder protocol, stated unambiguously:
|
||||
|
||||
1. **No stubs, ever.** Never commit `todo!()`, `unimplemented!()`, or placeholder `panic!()` bodies. Code lands complete or not at all.
|
||||
2. **Every commit compiles.** `cargo check --workspace` is green at each step of the §8 sequence; never leave the tree broken between commits.
|
||||
3. **Land order per §8:** standalone-landable types first (`error.rs`, `constants.rs`, `responses.rs` — they compile unreferenced thanks to the existing crate-level `dead_code` allow), then signature conversions with their test updates in the same commit, then `adventure.rs` complete (body + tests A1–A8 in one commit), then the handler rewiring commit that finally references everything.
|
||||
4. **Tests move with signatures.** A signature change and the updates to the tests exercising it are one commit — never split.
|
||||
|
||||
---
|
||||
|
||||
## 5. Signature-conversion ledger
|
||||
|
||||
See §4.1 table. Exactly **9** existing tests change text (3 `level_up` assertions in character.rs; `train_stat_fails_without_energy`, `train_stat_fails_without_gold`, `train_stat_fails_invalid_stat`, `equip_item_fails_if_not_in_inventory`, `unequip_item_fails_empty_slot`, and `pick_enemy_respects_weights` in services.rs — the last because `pick_enemy` becomes `Result`-returning and its call site gains a `.unwrap()` on the Ok path). No test gains or loses logic. All 45 tests must pass before and after every commit.
|
||||
|
||||
---
|
||||
|
||||
## 6. unwrap/expect ledger (full classification)
|
||||
|
||||
**Removed — sb-api (8 `unwrap()` + 1 `expect()`):**
|
||||
|
||||
| Site | Replacement |
|
||||
|---|---|
|
||||
| routes/mod.rs:261, 326, 432, 635 (`SystemTime…unwrap()`) | `now_unix_ms()` (§4.5) |
|
||||
| routes/mod.rs:688, 689, 703, 704 (`to_value(…).unwrap().as_str().unwrap()`) | `ItemType::as_str()` / `Quality::as_str()` (§4.5) |
|
||||
| routes/mod.rs:596 (`expect("valid stat")`) | `stat_value()` + TRAINING_GROUNDS iteration (§4.5) |
|
||||
|
||||
**Converted to `Result` — sb-core:**
|
||||
- `services::pick_enemy` (services.rs:17, 21, 22): `expect`/`unwrap` on data-dependent lookups (zones.json → enemies.json referential integrity) → `DomainError::UnknownEncounterEnemy` → 500. Empty `encounters` (today: `random_range(0..0)` panics) → same `Err`. This is a deliberate defensive improvement on a currently-panicking, data-impossible path; all shipped zones have ≥6 encounters.
|
||||
|
||||
**Kept with rationale + new pinning tests — sb-core (startup/static invariants, fail-fast is correct):**
|
||||
- Static embedded-JSON parses behind `LazyLock` (combat.rs:11, data.rs:58/62, items.rs:14/18/22, spells.rs:25): compiled-in data must parse; a `LazyLock` cannot propagate `Result`. New tests `crit_tables_parse`, `enemies_json_parses`, `zones_json_parses`, `all_items_json_parses`, `spells_json_parses` pin the invariant.
|
||||
- Enum→table lookups (character.rs:68, 79, 84, 89, 230, 234): `Race`/`Class` are enums; every variant has a table row. New tests `race_table_covers_all_variants`, `class_table_covers_all_variants`.
|
||||
- Composition-root password-hash `expect`s (routes/mod.rs:1304, 1306): server cannot operate with a broken hashing config; fail-fast at startup. Documented, unchanged.
|
||||
|
||||
**sb-db — 8× `conn.lock().unwrap()` (added at 1B review, finding 3; all KEEP-WITH-REASON):**
|
||||
|
||||
Sites: lib.rs:20 (`run_migrations`, startup via `open`), 68 (`load_users`, startup/seed), 91 (`save_user`, request path via `persist_user` on register), 104 (`load_characters`, startup), 167 (`save_character`, request path on every mutating character action), 196 (`load_shop_stock`, startup), 210 (`save_shop_stock`, request path on buy/sell), 219 (`next_character_id`, startup).
|
||||
|
||||
Rationale (uniform for all eight — `std::sync::Mutex` fails here only via *poisoning*, i.e. after another thread already panicked while holding the lock):
|
||||
1. A poisoned connection mutex means the process is in an unrecoverable post-panic state; fail-fast is the correct posture (same invariant class as the startup expects above).
|
||||
2. Folding the poison error into the existing `Result<_, rusqlite::Error>` is impossible without either a new sb-db error enum (violates the one-error-enum-per-boundary standard and CON-04's no-new-dependency rule — sb-db has no `thiserror`) or lossy mapping.
|
||||
3. The realistic in-lock panic sources are already gone: `save_character` serializes equipment/inventory/known_spells with `unwrap_or_default()` (lib.rs:168–170), so no serde panic can occur under the lock; remaining sources are rusqlite-internal bugs.
|
||||
4. The three request-path sites sit under `persist_*`, where the architecture already treats persistence as best-effort (NFR-06) — but a poison there still indicates global corruption, not a transient write failure.
|
||||
|
||||
Rejected alternative (recorded): `lock().unwrap_or_else(|poisoned| poisoned.into_inner())` poison-recovery — it would convert request-path panics into continued operation, but silently reusing a `Connection` after an arbitrary mid-statement panic risks undefined SQLite statement state; rejected as corruption-over-availability. **PM note:** this extends the issue #2 item-4 *ledger* to sb-db/sb-auth for completeness; the classification requires **no code change** in those crates. If PM prefers active conversion over keep-with-reason, that is a scoped follow-up (needs an sb-db error type decision).
|
||||
|
||||
**sb-auth — 3 `expect`s (added at 1B review, finding 3; all KEEP-WITH-REASON):**
|
||||
|
||||
| Site | Path | Rationale |
|
||||
|---|---|---|
|
||||
| jwt.rs:22 `checked_add_signed(Duration::hours(24)).expect(…)` | request path (register/login) inside `create_token` | Returns `None` only when now+24 h exceeds `DateTime` bounds (year ≈ 262 000) — unreachable for any real clock. Converting would require changing `create_token`'s public error type (`jsonwebtoken::Error` cannot carry it) — new type for an impossible case. |
|
||||
| hash.rs:8 `getrandom::fill(&mut salt_bytes).expect(…)` | request path (register) + startup seeding, inside `Result`-returning `hash_password` | Failure = OS entropy source unavailable — the environment can no longer service authentication at all; fail-fast matches the startup hashing expects. Mapping into `argon2::password_hash::Error` would fabricate a misleading variant. |
|
||||
| hash.rs:9 `SaltString::encode_b64(&mut salt_bytes).expect(…)` | same | Statically total for the fixed 16-byte input (PHC b64 covers all byte values at this length); unreachable by construction. |
|
||||
|
||||
**New designed site (defect #14, §4.3):** `responses.rs` From-impl `serde_json::to_value(&outcome.combat_log).expect(…)` — keep-with-reason: `to_value` fails only on non-string map keys and the CombatLog graph has no map types; infallible by construction; pinned by the W1 wire-identity golden.
|
||||
|
||||
---
|
||||
|
||||
## 7. Test strategy
|
||||
|
||||
**Existing coverage of moved code (must stay green — regression net):**
|
||||
- `character.rs` tests (21): regen application/no-op, XP curve, level-up success/fail, training cost, deterministic creation (seeded StdRng — WI-1).
|
||||
- `combat.rs` tests (9): open-ended roll bounds, OB/DB composition, crit caps, winner production.
|
||||
- `services.rs` tests (15): pick_enemy weights, equipped damage/defense/attack type, train/equip/unequip.
|
||||
|
||||
**New tests — `adventure.rs` (deterministic; `StdRng::seed_from_u64` per WI-1 pattern):**
|
||||
|
||||
| # | Vector | Setup | Assertions |
|
||||
|---|---|---|---|
|
||||
| A1 | win path | seeded rng; strong char (str 50, hp 100, sword equipped) vs weak enemy def (hp 10, xp 5, gold 1–2, 1 loot drop chance 1.0 with item in catalog); nerve ≥ cost | `Ok`; `combat_log.winner == char.name`; `char.xp == before + 5`; `char.gold ∈ [before+1, before+2]` and `== before + log.gold_gained`; `char.nerve == before − zone.nerve_cost`; loot item in `char.inventory` and its name in `loot_item_names`; `enemy_name == enemy_def.name`; `zone_id == zone.id` |
|
||||
| A2 | loss path | weak char vs overwhelming enemy (hp 500, damage 200) | `Ok`; `combat_log.winner == enemy name`; `char.hp == 0`; xp/gold/inventory unchanged; `loot_item_names` empty |
|
||||
| A3 | incapacitated | `char.hp = 0` | `Err(CharacterIncapacitated)`; nerve unchanged |
|
||||
| A4 | insufficient nerve | `char.nerve < zone.nerve_cost` | `Err(NotEnoughNerve)`; nerve unchanged |
|
||||
| A5 | regen before guards | hp 40/80, `last_regen = 0`, `now_ms = 10 × HP_REGEN_INTERVAL_MS`; enemy fixture with `damage = 0` and a seed **verified at fixture-recording time to yield zero enemy hits** | (i) seed-independent core assertion: `character.last_regen == now_ms` (apply_regen ran with elapsed > 0; it advances `last_regen` only when it actually regenerates, character.rs:107/121); (ii) under the recorded no-hit seed additionally: `character.hp == min(40 + ceil(80·0.05)·10, 80) = 80`. Do NOT assert `hp > starting hp` alone — an enemy lucky roll can drop HP below the post-regen start (1B review finding 13). |
|
||||
| A6 | spell upkeep on win and loss | caster char with `active_spell` pointing at spell with `spell_points > 0` | both paths: `spell_points == (before − sp).max(0)`; `active_spell` unchanged |
|
||||
| A7 | loot existence filter | drop rolls true for id **not** in items catalog | drop skipped (no inventory entry, no name) |
|
||||
| A8 | determinism | two runs, `seed_from_u64(42)`, identical inputs | identical `format!("{:?}")` of outcome fields + char (Character has no PartialEq — WI-1 D5 pattern) |
|
||||
|
||||
Golden-value pinning (optional but recommended): run A1 once, record the full `CombatLog` JSON as a fixture string, assert `serde_json::to_string(&outcome.combat_log) == fixture` — pins RNG draw order permanently. Values are recorded by the coder when the test first runs; the assertion structure is fixed here.
|
||||
|
||||
**New tests — wire identity W1–W6 (1B review finding 1; sb-api `#[cfg(test)]` in routes):**
|
||||
|
||||
| # | Vector | Method |
|
||||
|---|---|---|
|
||||
| W1 | `AdventureRunResponse` | Build the DTO with fixed representative values; `assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN)` where GOLDEN is recorded **before the refactor** by serializing the current `json!` expression (routes/mod.rs:561–566) with the same values. Golden keys must come out in lexicographic order (`combat_log`, `enemy`, `loot`, `zone`) — proving the BTreeMap-order reproduction, not the literal order. The golden's `combat_log` must additionally show **nested** sorted keys (`gold_gained`, `loser`, `rounds`, `winner`, `xp_gained`; each round/crit entry sorted likewise) — proving the §4.3 Value bridge (defect #14). |
|
||||
| W2 | `HealResponse` | Same protocol vs routes/mod.rs:891–896; expected key order `gold, healed, hp, max_hp`. |
|
||||
| W3 | `SpellEntryResponse` | Same protocol vs routes/mod.rs:1015–1029; expected sorted keys `code … spell_points` (13 keys). |
|
||||
| W4 | `SpellbookResponse` | Same protocol vs routes/mod.rs:1031–1045; expected key order `caster_class, learn_costs, max_spell_points, spell_level, spell_points, spells`; `learn_costs` keys "2".."7" sorted. |
|
||||
| W5 | `ActivateSpellResponse` | Same protocol vs routes/mod.rs:1128–1131 (already lexicographic — the regression guard is the point). |
|
||||
| W6 | `LearnSpellsResponse` (via `LearnSpellResponse::Learn`) | Same protocol vs routes/mod.rs:1183–1188; expected key order `gold, learned_count, max_spell_points, spell_points`. |
|
||||
|
||||
Recording procedure (coder): before touching the handler, add a temporary test that evaluates the exact current `json!` expression with the fixed values and `to_string`s it; paste the output as the golden constant; delete the temporary test. The comparison is **byte-for-byte**, covering names, order, and null/number formatting.
|
||||
|
||||
**New tests — constants/helpers:** `as_str` parity for `ItemType`/`Quality` (10 variants), `gym_display_order_covers_all_training_grounds`, `race/class` table exhaustiveness, 5 loader-parse pins, `caster_spell_level` spot values (1→1, 2→1, 3→2, 6→3, 100→34), `spell_learn_cost` guard spot checks (level 0, −1, 8 → 999_999; levels 1..=7 → table values).
|
||||
|
||||
**Not covered (gaps that remain — documented, out of scope):** heal and spell-learn/activate business logic remains handler-resident and unit-untested (§10 Q3); no HTTP-level integration tests exist (unchanged by WI-2).
|
||||
|
||||
---
|
||||
|
||||
## 8. fmt / rollout order
|
||||
|
||||
**Recommendation: fmt FIRST, as a standalone commit.**
|
||||
|
||||
Justification:
|
||||
- Every later WI-2 commit (constants, extraction, responses) touches the same lines fmt would touch. fmt-last mixes ~120 lines of whitespace churn into reviewable diffs and corrupts PROBE size accounting (M-churn inflated into B/D categories).
|
||||
- The acceptance gate `cargo fmt --check` applies at every future commit anyway; establishing the baseline first means each subsequent commit is born compliant.
|
||||
- git blame noise is bounded to one revertable commit; optionally record the fmt commit hash in `.git-blame-ignore-revs` (coder's choice).
|
||||
|
||||
**Commit sequence (each leaves check/fmt/clippy/test green):**
|
||||
|
||||
1. `fmt: repo-wide baseline` (pure churn, no semantic diff — verify with `git diff -w` empty).
|
||||
2. `sb-core: constants module` — add `constants.rs`, replace literals (combat, character, spells). No signature changes.
|
||||
3. `sb-core: DomainError + signature conversions` — `error.rs`, convert 5 signatures, update 8 test assertions, convert `pick_enemy` to `Result`.
|
||||
4. `sb-core: adventure service` — new module + tests A1–A8; no handler change yet (dead_code allow already present at crate level; remove nothing).
|
||||
5. `sb-api: responses module + handler rewiring` — `responses.rs`, `domain_status`, `now_unix_ms`, run_adventure delegates to service, 4 handlers typed.
|
||||
6. `sb-api: label helpers wiring + gym/stat_value + policy constants` (may fold into 5).
|
||||
7. Full gate: `cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test`.
|
||||
8. EA model + generator pipeline commits (scope added at 1B rework) — see §11 for the plan; these live in the **skara-brae-common repo** (`scripts/openup/`, `docs/openup/model/`) and never touch skara-brae-server at all.
|
||||
|
||||
---
|
||||
|
||||
## 9. Size planning inputs (for psp-coder PLN)
|
||||
|
||||
Deleted/added estimates derive from: routes/mod.rs 1356 lines (run_adventure body ~150 → handler ~35; 6 json! sites → ~90 lines of DTO structs); new sb-core modules ~140 lines + ~200 test lines. PROBE by the coder; this WI's plan (660 LOC, PI 607–1295) already carries the churn.
|
||||
|
||||
---
|
||||
|
||||
## 10. Findings & decisions — 1B design review disposition
|
||||
|
||||
**Resolved at the question gate (PM/user decisions, 2026-10-03):**
|
||||
|
||||
- **Q1 — RESOLVED (keep verbatim):** `learned_count` (routes/mod.rs:1184) carries the **total** known-spell count after learning, not the newly-learned count. Fenced as a wire wart in §4.3; no semantic change permitted.
|
||||
- **Q2 — RESOLVED (confirmed):** new response DTOs stay **sb-api-local** (`routes::responses`); migration to a shared/OpenAPI home is deferred to the utoipa work item.
|
||||
- **Q3 — RESOLVED (roadmap):** extraction of heal and spell learn/activate business logic out of handlers becomes a **roadmap.md candidate**; not part of WI-2 (out-of-scope note in §1).
|
||||
|
||||
**1B review findings 1–13 disposition:** all fixed in this revision — F1 wire-order rule inverted (§4.3 rewritten; W1–W6 added §7), F2 gym stat order pinned (§4.5), F3 sb-db/sb-auth ledger (§6), F4 test-count reconciliation to 9 (§4.1/§5/test-cases.md), F5 lock-consolidation delta recorded (§4.2), F6 regen call sites corrected (use-case-model.md), F7 spell-level cap claim removed (use-case-model.md, §4.4), F8 constants ledger completed (§4.4), F9 spell-learn-cost guard semantics (§4.4), F10 crit-severity citations corrected (§4.4), F11 items/data wording (§2), F12 coder protocol restated (§4.6), F13 A5 de-flaked (§7).
|
||||
|
||||
**Defect #14 (type: Interface, inject: DES, remove: Code — found by psp-coder during implementation prep):** §4.3's "nested structs emit declaration order today" claim was wrong; `json!` sorts keys at **every nesting level**. Corrected per PM decision (coder's option A): `AdventureRunResponse.combat_log` is a `serde_json::Value` bridge populated via `serde_json::to_value(&outcome.combat_log)` in the `From<AdventureOutcome>` impl — byte-identical to today's sorted wire; `CombatLog` remains a typed struct in the domain/outcome. W1 golden as specified passes and now also pins the nested ordering. Applied in this revision; W2–W6 confirmed unaffected by the coder.
|
||||
|
||||
**Open QUESTION (new, from the rework scope): none.** The `.eapx`-in-a-versioned-repo recommendation was affirmed by the 2026-10-03 decision reversal with its home transposed to **skara-brae-common** (§11.a); keyfob-station precedent supports committing it.
|
||||
|
||||
Non-blocking notes (no question needed): nerve is spent before enemy pick, so the (data-impossible) `UnknownEncounterEnemy` 500 leaves nerve deducted — today's panic had the same side effect in memory; `now_unix_ms` returning 0 on clock-before-epoch differs from a panic only on hardware that cannot exist in deployment.
|
||||
|
||||
---
|
||||
|
||||
## 11. EA model & generator pipeline (scope added at 1B rework)
|
||||
|
||||
The full EA model plus generator pipeline (keyfob-station style) is **in WI-2 scope**. The interactive `.eapx` editing session is a **human gate**; everything around it — skeleton, scripts, checklist derivation, exports — is agent work assigned to **COD** (commits 8–9 in §8). **Everything in this section lives in the skara-brae-common repo** (per the 2026-10-03 decision-reversal record in the header); nothing here touches skara-brae-server or any `src/`.
|
||||
|
||||
### 11.a Model file home — decision: commit the `.eapx` into the skara-brae-common repo
|
||||
|
||||
- **Home:** version the binary model at `skara-brae-common/docs/openup/model/skara-brae.eapx`, alongside generated exports at `skara-brae-common/docs/openup/model/exports/` (JSON, later PDF/PNG diagram renders); generator scripts at `skara-brae-common/scripts/openup/`.
|
||||
- Rationale (post-reversal): skara-brae-common is the one canonical, versioned project-docs home for all OpenUP artifacts (header decision-reversal record) — the `.eapx` and its exports belong beside the markdown they render. The earlier "project-top folder is not versioned" argument is obsolete and superseded. keyfob-station precedent still applies: it commits **both** model and exports inside a repo (`docs/openup/model/keyfob-station.eapx` + `exports/`). The markdown artifacts + builder scripts remain the content source of truth; the `.eapx` is the human-gated rendering whose change history we keep (one binary per gate session bounds diff noise).
|
||||
- Alternative rejected: model outside any repo, only exports committed — loses model history and splits the canonical home; binary-diff unfriendliness is inherited either way.
|
||||
- Revisit trigger: if the `.eapx` grows beyond a few MB (diagram renders accumulate), switch that path to git-lfs; not needed now.
|
||||
|
||||
### 11.b Agent preparation steps (COD)
|
||||
|
||||
1. Clone the reference read-only: `git clone http://192.168.1.10:3000/jan/keyfob-station.git` (token `~/.config/forgejo/moria-token` if prompted). Never modify or push it.
|
||||
2. Study two scripts as the adaptation source: `scripts/add-wi19-uc11.ps1` (scenario-embedding pattern: `Name/Type/Notes/Steps` arrays with per-step `Actor`) and `scripts/export-model-json.ps1` (COM walk of Actors/UseCases/scenarios → JSON).
|
||||
3. Create the skeleton: copy the **EA installation's standard empty-model template `EABase.eapx`** to `skara-brae-common/docs/openup/model/skara-brae.eapx`. Note: keyfob carries **no** `EABase.eapx` in-repo (verified — only its derived `keyfob-station.eapx`); do **not** derive the skeleton from keyfob's model, it carries keyfob content.
|
||||
4. Derive the build checklist from the mapping content: package tree per `uc-realization-mapping.md` §1; 3 actors + 12 use cases with their Basic Path / Alternate / Exception scenario lists from `requirements/use-case-model.md`; 12 `UC-N Realization` packages with elements from the mapping table in `uc-realization-mapping.md` §2.
|
||||
|
||||
### 11.c Generator pipeline (COD; scripts in the skara-brae-common repo)
|
||||
|
||||
- `skara-brae-common/scripts/openup/build-model.ps1` (adapted from keyfob's builder pattern): EA COM automation (`New-Object -ComObject EA.App`); creates/rebuilds the `Requirements` and `Analysis-Design` package trees; scenario content is read from a machine-readable intermediate `docs/openup/model/exports/use-case-model.json` (common-repo-relative) so the hand-authored markdown stays the human-facing source and scripts consume JSON (keyfob embeds the content directly in PowerShell arrays — we keep that as fallback if the JSON step proves friction).
|
||||
- `skara-brae-common/scripts/openup/export-model.ps1` (adapted from keyfob's `export-model-json.ps1`): walks Actors/UseCases/scenarios/realization packages and regenerates `exports/use-case-model.json` + `exports/uc-realizations.json`; additionally triggers EA's native **XMI/XML export** as a lossless, diffable backup of the model.
|
||||
- Drift rule (standards): markdown and model must agree; exports are generated, never hand-edited; a mismatch is a defect fixed in the same cycle (stale docs are defects).
|
||||
- Environment: Windows + PowerShell + installed EA (as keyfob); scripts are committed to the skara-brae-common repo so the human gate is reproducible.
|
||||
|
||||
### 11.d Human-gate checklist (user, in EA on Windows; all paths in skara-brae-common)
|
||||
|
||||
1. Run `scripts/openup/build-model.ps1` against `docs/openup/model/skara-brae.eapx` (agent prepared it; user executes — EA must be installed/licensed).
|
||||
2. In the EA UI verify: model opens clean; `Requirements → Use Case Model` shows 3 actors + 12 use cases; every UC carries its Basic Path plus the alternates/exceptions with per-step actors; 12 `UC-N Realization` packages exist under `Analysis-Design` containing the mapping elements.
|
||||
3. Spot-check two scenarios against `requirements/use-case-model.md` and one realization table against `uc-realization-mapping.md` §2.
|
||||
4. Run `scripts/openup/export-model.ps1`; confirm exports regenerate without unexpected diff (or review the diff).
|
||||
5. Save the model; in the **skara-brae-common repo** commit `.eapx` + `exports/` + `scripts/openup/` together as one commit ("openup: EA model WI-2 baseline").
|
||||
|
||||
Phase assignment summary: 11.b steps 1–4 and 11.c scripts = **COD** (after the code commits in §8, in the skara-brae-common repo); 11.d = **HUMAN GATE** inside COD (`.eapx` editing/verification is never agent-performed); no PD interaction, no changes to skara-brae-server.
|
||||
80
docs/openup/analysis-design/uc-realization-mapping.md
Normal file
80
docs/openup/analysis-design/uc-realization-mapping.md
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
# Skara Brae — Analysis & Design Model (text form)
|
||||
|
||||
Version 1.0 (WI-2, 2026-10-03) · OpenUP artifact: `analysis-design/uc-realization-mapping.md`
|
||||
This is the **content the EA model will carry** once the modeling pipeline exists. The EA repository itself (`.eapx`, keyfob-station style) is planned for a later work item — EA is a human-gated interactive tool; this document is the authoritative source any future EA builder script generates from.
|
||||
|
||||
## 1. Model structure (planned EA packages)
|
||||
|
||||
```
|
||||
Model: Skara Brae
|
||||
├── Requirements
|
||||
│ ├── Vision (from requirements/vision.md)
|
||||
│ ├── Use Case Model
|
||||
│ │ ├── Actors: Player, Administrator, Guest
|
||||
│ │ └── Use Cases UC-01 … UC-12 (from requirements/use-case-model.md;
|
||||
│ │ scenarios as Basic Path / Alternate / Exception with per-step Actor)
|
||||
│ └── Supplementary Specifications (from requirements/supplementary-specifications.md)
|
||||
└── Analysis-Design
|
||||
├── Architecture Overview (crate/layer diagram, dependency rule)
|
||||
└── UC-01 Realization … UC-12 Realization (one package per UC: analysis classes,
|
||||
design elements, realization table below)
|
||||
```
|
||||
|
||||
## 2. UC realization mapping (analysis classes → design elements)
|
||||
|
||||
Legend — **Analysis class** (OpenUP stereotype): `«controller»`, `«entity»`, `«boundary»`. **Design element**: crate::module::item (Rust). "WI-2" marks elements introduced or re-homed by this work item; everything else exists at 899fde8.
|
||||
|
||||
| UC | Analysis class | Stereotype | Design element | Notes |
|
||||
|---|---|---|---|---|
|
||||
| UC-01 Register | AccountRegistry | controller | `sb-api::routes::{register, seed_users}` | argon2 hash via `sb-auth::hash`; JWT via `sb-auth::jwt` |
|
||||
| | UserAccount | entity | `sb-shared::user::User`, `Role` | persisted via `sb-db::Database` |
|
||||
| | AuthBoundary | boundary | `POST /api/auth/register`, `AuthResponse` | |
|
||||
| UC-02 Authenticate | Authenticator | controller | `sb-api::routes::login`, `sb-auth::{hash::verify_password, jwt::create_token}` | |
|
||||
| | TokenService | boundary | `POST /api/auth/login`, `AuthResponse`, `middleware::auth::auth_middleware` | middleware guards all UC-03…12 |
|
||||
| UC-03 Manage Characters | CharacterFactory | controller | `sb-core::character::create_character` | takes injected rng (WI-1) |
|
||||
| | Character | entity | `sb-shared::character::Character` (+ `Race`, `Class`, `CharacterStats`) | |
|
||||
| | CharacterCatalog | boundary | `GET/POST /api/users/{id}/characters`, `GET /api/characters/{id}` | store: `AppState.characters` |
|
||||
| | ResourceRegenerator | entity | `sb-core::character::apply_regen` (+ regen constants — WI-2 names them) | included sub-flow |
|
||||
| UC-04 Browse Catalogue | CatalogueBrowser | boundary | `GET /api/{races,classes,items,enemies,adventures}`; response types `sb-shared::responses` | data from `sb-common` JSON via `sb-core::{character::RACES/CLASSES, data, items}` |
|
||||
| | GameData | entity | `sb-core::data::{EnemyDef, ZoneDef, EncounterEntry, LootDrop}`, `items::load_all_items` | |
|
||||
| UC-05 Train Stat | TrainingService | controller | `sb-core::services::train_stat` | error type `DomainError` (WI-2) |
|
||||
| | TrainingGrounds | entity | `sb-core::character::TRAINING_GROUNDS`, `training_cost` (+ cost constants WI-2) | |
|
||||
| | GymBoundary | boundary | `GET/POST /api/characters/{id}/gym`, `GymResponse`, `TrainStatResponse` | |
|
||||
| UC-06 Manage Equipment | EquipmentService | controller | `sb-core::services::{equip_item, unequip_item}` | `DomainError` (WI-2) |
|
||||
| | Equipment/Inventory | entity | `Character.equipment` / `.inventory`, `sb-shared::item::Item` | |
|
||||
| | EquipmentBoundary | boundary | `POST /api/characters/{id}/{equip,unequip}` | |
|
||||
| UC-07 Trade at Shop | ShopService | controller | `sb-api::routes::{shop_action, buy_item, sell_item}` (handler-side; extraction candidate, design note Q3) | prices: `sb-core::items::{item_price, item_sell_price}` |
|
||||
| | ShopStock | entity | `AppState.shop_stock` | persisted via `sb-db` |
|
||||
| | ShopBoundary | boundary | `GET/POST /api/characters/{id}/shop`, `ShopResponse`, `ShopActionResponse` | |
|
||||
| UC-08 Run Adventure | **AdventureService** | controller | `sb-core::adventure::run_adventure` (**new, WI-2**) + `services::pick_enemy` → `Result` (WI-2) | takes injected rng; `DomainError` (WI-2) |
|
||||
| | CombatEngine | entity | `sb-core::combat::{resolve_combat_full, resolve_attack, crit tables, open_ended_roll}` + combat constants (WI-2) | |
|
||||
| | Combatant | entity | `sb-core::combat::CombatStats` | built from Character/EnemyDef |
|
||||
| | SpellEffect / BardSong | entity | `sb-core::combat::SpellEffect`, `sb-core::spells`, `sb-core::songs` | |
|
||||
| | AdventureBoundary | boundary | `GET /api/characters/{id}/adventure`; `AdventureRunResponse` (**new, WI-2**), `sb-shared::responses::CombatLog` | |
|
||||
| UC-09 Heal at Temple | HealingService | controller | `sb-api::routes::heal_character` (handler-side; extraction candidate, Q3) | |
|
||||
| | TempleBoundary | boundary | `POST /api/characters/{id}/heal`; `HealResponse` (**new, WI-2**) | |
|
||||
| UC-10 Practice Bard Song | SongService | controller | `sb-api::routes::set_song`; `sb-core::songs::available_songs` | |
|
||||
| | BardSong | entity | `sb-core::songs::BARD_SONGS`, `Character.active_song` | |
|
||||
| | SongBoundary | boundary | `GET/POST /api/characters/{id}/songs`, `SongsResponse`, `SetSongResponse` | |
|
||||
| UC-11 Study Magic | MagicService | controller | `sb-api::routes::{get_spells, learn_spell_level}`; `sb-core::spells::{spells_for_class, spell_learn_cost, max_spell_points, caster_spell_level (WI-2)}` | |
|
||||
| | Spell | entity | `sb-core::spells::SpellDef`, `Character.{known_spells, active_spell, spell_points}` | |
|
||||
| | MagicBoundary | boundary | `GET/POST /api/characters/{id}/spells`; `SpellbookResponse`, `LearnSpellResponse` (**new, WI-2**) | |
|
||||
| UC-12 Level Up | ReviewBoard | controller | `sb-core::character::level_up` | `DomainError` (WI-2) |
|
||||
| | Progression | entity | `sb-core::character::{xp_for_level, level_up_cost, MAX_LEVEL}` (+ curve constants WI-2) | |
|
||||
| | ReviewBoundary | boundary | `POST /api/characters/{id}/level-up`, `LevelUpResponse` | |
|
||||
|
||||
Cross-cutting design elements (WI-2): `sb-core::error::DomainError` + `sb-api::routes::domain_status` (status mapping table, design note §4.1) realize the error paths of UC-05/06/08/12; `sb-core::constants` realizes NFR "no magic numbers"; `sb-api::routes::responses` realizes NFR-19 (wire-format freeze).
|
||||
|
||||
Dependency rule check (Clean Architecture): all controllers/entities above live in `sb-core`/`sb-shared` (domain side, no HTTP imports); all boundaries live in `sb-api`; the single current violations (ShopService, HealingService, parts of MagicService in sb-api) are recorded as debt with a follow-up question — the model must not paper over them (stale docs are defects).
|
||||
|
||||
## 3. Research summary — keyfob-station EA pipeline (reference)
|
||||
|
||||
Source: read-only clone `http://192.168.1.10:3000/jan/keyfob-station.git` (inspected 2026-10-03; no modifications).
|
||||
|
||||
- **Model file:** `docs/openup/model/keyfob-station.eapx` (MS Access database — the EA native format; EABase-derived). Never hand-edited.
|
||||
- **Structure:** Model → `Requirements` package → `Use Case Model` package containing `Actor` and `UseCase` elements; scenario lists are stored on each use case as named scenarios of type **Basic Path / Alternate / Exception**, each with ordered steps carrying a per-step **Actor** tag and notes. An `Analysis-Design` package holds **one `UC-N Realization` package per use case** (realization diagrams + design elements).
|
||||
- **Builders are surgical scripts:** `scripts/add-*.ps1` (PowerShell driving the EA COM object `New-Object -ComObject EA.App`, `$repo.OpenFile($modelPath)`). Each script opens the model, adds/rebuilds exactly one package's contents, and closes it — because EA repository IDs and package rebuilds clobber manual edits, hand edits do not survive; all content lives in the scripts (scenario text is embedded as PowerShell arrays of `Name/Type/Notes/Steps`). This matches our standards note: *model additions are surgical; builders rebuild whole packages*.
|
||||
- **Exports:** `scripts/export-model-json.ps1` walks the Use Case Model package and emits `exports/use-case-model.json` + `supplementary-requirements.json`; realization diagrams are exported to PDF/PNG into `docs/openup/analysis-design/`. The JSON export is the doc-machine readable form of the same content.
|
||||
- **Windows-only:** the pipeline runs on Windows (EA is a Win32 app; scripts use `.ps1` + COM). For Skara Brae, the same pattern applies when the modeling WI lands: keep `docs/openup/*.md` (this file set) as the source the builder scripts embed, never the reverse.
|
||||
|
||||
Adoption decision for Skara Brae (updated at 1B rework): the EA model + generator pipeline is **in WI-2 scope**. This document (§1–2) plus `requirements/use-case-model.md` are the content source; the model-home recommendation, agent preparation steps, generator scripts (`scripts/openup/build-model.ps1`, `export-model.ps1`), and the human-gate checklist are specified in `design-wi-2-architecture-cleanup.md` §11. The `.eapx` interactive editing session remains human-gated; everything around it is COD-phase agent work.
|
||||
51
docs/openup/requirements/supplementary-specifications.md
Normal file
51
docs/openup/requirements/supplementary-specifications.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# Skara Brae — Supplementary Specifications
|
||||
|
||||
Version 1.0 (WI-2, 2026-10-03) · OpenUP artifact: `requirements/supplementary-specifications.md`
|
||||
Non-functional requirements and constraints for the server MVP. Facts cite code where they are implemented; assumptions are marked.
|
||||
|
||||
## 1. Performance / Scalability
|
||||
|
||||
- **NFR-01 (in-memory state):** all game state (users, characters, shop stock) is held in process memory behind `tokio::sync::RwLock` (`crates/sb-api/src/routes/mod.rs:33–42`). Single-process deployment; persistence is write-through to SQLite, reads never hit the DB.
|
||||
- **NFR-02 (lazy regeneration):** resource regeneration is computed on every character access from elapsed wall-clock time (`apply_regen`, character.rs:105–122) — no timers, no background ticks. Intervals: energy 180 s, nerve 300 s, HP 300 s (5% max HP per tick).
|
||||
- **NFR-03 (client polling):** clients poll character state to observe regeneration; the assumed UI refresh cadence is ~15 s *(assumption carried from project planning; server-side behavior is poll-frequency-independent by design NFR-02)*.
|
||||
- **NFR-04 (static catalogue):** items, enemies, zones, spells, and crit tables are compiled in via `include_str!` from `skara-brae-common` JSON and parsed once behind `LazyLock`; catalogue reads are lock-free after first access.
|
||||
|
||||
## 2. Reliability / Correctness
|
||||
|
||||
- **NFR-05 (deterministic domain logic):** all domain randomness is injected `StdRng` (WI-1); identical seeds produce identical outcomes. Request paths construct RNG only at `request_rng()`.
|
||||
- **NFR-06 (persistence best-effort):** persistence failures are logged (`tracing::error`) and do not fail the request; in-memory state remains authoritative for the process lifetime.
|
||||
- **NFR-07 (referential data integrity):** zone encounter tables must reference existing enemy ids and loot drops must reference existing item ids; violations are detected at first use (WI-2: `DomainError::UnknownEncounterEnemy` → 500 instead of a panic).
|
||||
|
||||
## 3. Quality Gates (per-commit acceptance)
|
||||
|
||||
- **NFR-08:** `cargo fmt --check` clean (fmt baseline established by WI-2).
|
||||
- **NFR-09:** `cargo clippy --all-targets -- -D warnings` clean — zero-warning policy.
|
||||
- **NFR-10:** `cargo test --workspace` fully green (45 sb-core tests at WI-2 start; suite only grows).
|
||||
- **NFR-11 (WI-2-specific):** behavior-identical refactor — JSON field names/order, HTTP status codes, game semantics, and RNG draw order unchanged.
|
||||
- **NFR-12:** no `unwrap()`/`expect()` on request paths outside the documented keep-with-reason invariants in the design-note §6 ledger (covers sb-api, sb-core, sb-db mutex poisoning, sb-auth time/entropy invariants).
|
||||
|
||||
## 4. Data
|
||||
|
||||
- **NFR-13 (shared data source):** all game content lives in the `skara-brae-common` repo as language-neutral JSON under `data/` — single source of truth consumed at build time by server (`include_str!` + serde) and client. Server must not duplicate or transform these definitions in code.
|
||||
- **NFR-14 (persistence format):** optional SQLite via `SB_DB_PATH` (rusqlite; users, characters, shop stock with `next_character_id`). Absent the variable the server runs purely in-memory with seeded `admin`/`player` accounts.
|
||||
- **NFR-15 (numeric headroom):** stats are `i64` (BIGINT) — “exponential growth, tiered display” is a product commitment; no narrowing conversions to smaller integers in new code.
|
||||
|
||||
## 5. Security
|
||||
|
||||
- **NFR-16:** passwords hashed with argon2; verification constant-time via the crate.
|
||||
- **NFR-17:** JWT (HS256, `jsonwebtoken`), 24 h expiry; all game routes behind auth middleware; cross-user access requires the `admin` role (403 otherwise).
|
||||
- **NFR-18:** no secrets in code; JWT secret and admin/player seed passwords come from environment/configuration.
|
||||
|
||||
## 6. Constraints
|
||||
|
||||
- **CON-01:** Rust, edition **2024**; workspace crates `sb-api` / `sb-core` / `sb-db` / `sb-auth` / `sb-shared` + external sibling `sb-common` (absolute path dependency; submodule migration planned).
|
||||
- **CON-02:** HTTP framework **Axum 0.8** (ws + macros features), tokio, tower stack (CORS/trace/compression-gzip).
|
||||
- **CON-03:** `rand 0.9`; domain functions take `rng: &mut impl Rng`; handlers obtain RNG only from `request_rng()` (WI-1 rule).
|
||||
- **CON-04:** Error handling: `thiserror 2` in-domain; **no new error/framework dependencies**; one domain error enum (`sb_core::error::DomainError`) with a single central HTTP status mapping in sb-api.
|
||||
- **CON-05:** Clean Architecture dependency rule: sb-core has zero knowledge of HTTP/axum/serde_json/tokio/tracing/sb-db; ports (domain traits) would live in sb-core, adapters outward.
|
||||
- **CON-06:** OpenUP work products are versioned in the **skara-brae-common repo** under `docs/openup/` — one canonical project-docs home (decision reversed 2026-10-03 from the earlier skara-brae-server `docs/openup/` plan). The drafted WI-2 artifacts move there verbatim (coder, session 2); WI-1 artifacts at the old project-top folder migrate there as well. `roadmap.md` stays at the project-top folder per the earlier PM decision.
|
||||
|
||||
## 7. Usability / API
|
||||
|
||||
- **NFR-19:** REST JSON API; field names and order are a wire contract — refactors must preserve them byte-for-byte. The current API emits JSON object keys in **lexicographic order** (serde_json resolves without `preserve_order`; `json!` uses BTreeMap-backed maps), and the typed-response structs of WI-2 declare fields in that order to stay byte-identical.
|
||||
- **NFR-20:** meaningful HTTP status codes: 400 malformed input, 401 auth failure, 403 foreign/non-permitted resource, 404 unknown resource, 409 name conflict, 410 shop stock exhausted, 422 game-rule violation (resources/requirements), 500 internal.
|
||||
170
docs/openup/requirements/use-case-model.md
Normal file
170
docs/openup/requirements/use-case-model.md
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
# Skara Brae — Use Case Model
|
||||
|
||||
Version 1.0 (WI-2, 2026-10-03) · OpenUP artifact: `requirements/use-case-model.md`
|
||||
Scope: the **existing implemented system** (server MVP as of `wi-2-architecture-cleanup` @ 899fde8). Every flow below is derived from actual handler code (`crates/sb-api/src/routes/mod.rs`) — status codes cited are the implemented behavior and are normative.
|
||||
|
||||
## Actors
|
||||
|
||||
| Actor | Description |
|
||||
|---|---|
|
||||
| **Player** | Authenticated user owning one or more characters (JWT role `player`). |
|
||||
| **Administrator** | Player with elevated rights (`role = admin`); may view any user's and any character's resources read-only. |
|
||||
| **Guest** | Unauthenticated caller; only register/login are open. |
|
||||
|
||||
## Use Case Overview
|
||||
|
||||
| ID | Use Case | Primary Actor | Includes / Extends |
|
||||
|---|---|---|---|
|
||||
| UC-01 | Register Account | Guest | — |
|
||||
| UC-02 | Authenticate | Guest | — |
|
||||
| UC-03 | Manage Characters | Player (Admin read) | — |
|
||||
| UC-04 | Browse Game Catalogue | Player | — |
|
||||
| UC-05 | Train Stat | Player | includes Regenerate Resources |
|
||||
| UC-06 | Manage Equipment | Player | — |
|
||||
| UC-07 | Trade at Shop | Player | — |
|
||||
| UC-08 | Run Adventure | Player | includes Regenerate Resources, Resolve Combat |
|
||||
| UC-09 | Heal at Temple | Player | — |
|
||||
| UC-10 | Practice Bard Song | Player | — |
|
||||
| UC-11 | Study Magic | Player | — |
|
||||
| UC-12 | Level Up at Review Board | Player | — |
|
||||
|
||||
Shared sub-flow **Regenerate Resources**: the system recomputes time-based regeneration **at specific call sites only** — list characters, view character, run adventure, and train stat (routes/mod.rs:264–266, 331, 442, 642). Energy +1 per 180 s, nerve +1 per 300 s, HP +5% of max per 300 s (only if alive and injured), capped at maxima; `last_regen` advances to now. It is **not** applied by equip/unequip, gym view, shop, heal, songs, spells, or level-up.
|
||||
|
||||
---
|
||||
|
||||
## UC-01 Register Account
|
||||
|
||||
**Precondition:** none. **Main success scenario:**
|
||||
1. Guest submits name (3–32 chars) and password (≥ 6 chars).
|
||||
2. System verifies the name is unused → 409 CONFLICT if taken.
|
||||
3. System hashes the password (argon2), assigns the `player` role and the next id, persists the user.
|
||||
4. System issues a JWT (HS256, `player` role) and returns `{token, user_id, name, role}`.
|
||||
|
||||
**Alternate flows:** name length invalid → 400; password too short → 400; name taken → 409; hashing/token failure → 500.
|
||||
|
||||
## UC-02 Authenticate
|
||||
|
||||
**Main success scenario:**
|
||||
1. Guest submits name and password.
|
||||
2. System finds the user (401 if unknown) and verifies the password hash (401 on mismatch).
|
||||
3. System issues a JWT carrying the user's real role (`admin`/`player`) and returns `{token, user_id, name, role}`.
|
||||
|
||||
**Alternate flows:** unknown name or wrong password → 401 UNAUTHORIZED; token creation failure → 500.
|
||||
|
||||
## UC-03 Manage Characters
|
||||
|
||||
**Main success scenario (create):**
|
||||
1. Player submits character name, race, class for their user id (Admin may act for any user).
|
||||
2. System validates the target user exists (404) and the trimmed name is 1–32 chars (400).
|
||||
3. System rolls stats (d6+5 base 6–16 per stat ± race modifiers), computes max HP/energy/nerve from class, grants starting gold (50) and level-1 class spells to casters.
|
||||
4. System assigns the next character id, stores and persists the character; returns it fully.
|
||||
|
||||
**Scenario (list/view):** Player lists their characters (Admin: any user's); the system applies Regenerate Resources to each returned character and returns them. Viewing one character applies regen then returns it.
|
||||
|
||||
**Alternate flows:** foreign user/character without admin role → 403 FORBIDDEN; user/character not found → 404; invalid name → 400.
|
||||
|
||||
## UC-04 Browse Game Catalogue
|
||||
|
||||
**Main success scenario:**
|
||||
1. Player requests the catalogue: races, classes, items (optional type/quality/slot filters), enemies, or adventure zones.
|
||||
2. System returns static game data loaded from `skara-brae-common` JSON (class info is enriched with `is_caster`).
|
||||
|
||||
**Alternate flows:** single item by unknown id → 404; filters may return an empty list (not an error).
|
||||
|
||||
## UC-05 Train Stat
|
||||
|
||||
**Precondition:** character exists and caller may act on it. **Main success scenario:**
|
||||
1. Player selects a training ground (strength / dexterity / constitution / intelligence / luck) and submits the stat name.
|
||||
2. System applies Regenerate Resources.
|
||||
3. System checks energy ≥ 1 and gold ≥ training cost (cost grows ~1.3× per 10 stat points).
|
||||
4. System deducts 1 energy and the gold cost, increments the stat; constitution training recomputes max HP (current HP clamped).
|
||||
5. System persists and returns `{success, message, character}`.
|
||||
|
||||
**Alternate flows:** foreign character → 403; character not found → 404; not enough energy → 422; not enough gold → 422; unknown stat name → 400.
|
||||
|
||||
## UC-06 Manage Equipment
|
||||
|
||||
**Main success scenario (equip):**
|
||||
1. Player submits an item id from their inventory.
|
||||
2. System moves the item from inventory to its slot; a previously equipped item in that slot returns to inventory.
|
||||
3. System persists and returns the updated character.
|
||||
|
||||
**Scenario (unequip):** Player submits a slot name; the system moves the equipped item back to inventory.
|
||||
|
||||
**Alternate flows:** foreign character → 403; character/item not found → 404; item not in inventory → 400; nothing equipped in slot → 400.
|
||||
|
||||
## UC-07 Trade at Shop
|
||||
|
||||
**Main success scenario (buy):**
|
||||
1. Player views the shop: always-stocked items plus rotating stock (with quantities) alongside their inventory.
|
||||
2. Player submits `buy` + item id.
|
||||
3. System checks gold ≥ price (422), and for non-always-in-stock items decrements the limited stock (410 GONE if 0).
|
||||
4. System deducts gold, adds the item to inventory, persists character and stock; returns `{success, message, gold}`.
|
||||
|
||||
**Scenario (sell):** Player submits `sell` + item id; system removes the first matching inventory item, pays half the item value, increments shop stock for non-permanent items.
|
||||
|
||||
**Alternate flows:** unknown action or item not owned (sell) → 400; item not found → 404; insufficient gold → 422; stock exhausted → 410.
|
||||
|
||||
## UC-08 Run Adventure
|
||||
|
||||
**Precondition:** character exists and caller may act on it. **Main success scenario:**
|
||||
1. Player requests an adventure for a zone (default `skara_brae_streets`).
|
||||
2. System validates the zone (404) and applies Regenerate Resources.
|
||||
3. System refuses incapacitated characters (HP ≤ 0 → 400) and characters with nerve below the zone cost (422); otherwise deducts the nerve cost.
|
||||
4. System draws one enemy from the zone's weighted encounter table.
|
||||
5. System resolves turn-based combat: open-ended d100 attack rolls, OB vs DB, RoleMaster crit tables with level-gap damage caps, active bard song effects, active spell damage/heal per round.
|
||||
6. On player victory: XP and gold per the enemy definition, character HP set to the last round's defender HP, and loot dropped per-item chance rolls (only items existing in the catalogue).
|
||||
7. On defeat: character HP set to 0; no rewards.
|
||||
8. Spell-point upkeep is charged whenever a spell was active, win or lose.
|
||||
9. System persists the character and returns `{zone, enemy, combat_log, loot}` (loot = item display names).
|
||||
|
||||
**Alternate flows:** foreign character → 403; character/zone not found → 404; incapacitated → 400; insufficient nerve → 422. **Exception:** encounter references an unknown enemy (data error) → 500 (post WI-2; previously a panic).
|
||||
|
||||
## UC-09 Heal at Temple
|
||||
|
||||
**Main success scenario:**
|
||||
1. Player requests healing, optionally `partial`.
|
||||
2. System computes missing HP; full heals all, partial heals half (rounded up); cost = amount healed, minimum 1 gold.
|
||||
3. System checks gold (422), deducts it, restores HP, persists; returns `{hp, max_hp, gold, healed: true}`.
|
||||
|
||||
**Alternate flows:** foreign character → 403; not found → 404; already at full HP → 400; insufficient gold → 422.
|
||||
|
||||
## UC-10 Practice Bard Song
|
||||
|
||||
**Precondition:** character class is Bard. **Main success scenario:**
|
||||
1. Player views available songs (level-gated, with combat effect descriptions) and the active song.
|
||||
2. Player submits a song id; system validates level requirement (422) and sets it active; returns `{success, message, active_song}`.
|
||||
|
||||
**Scenario (clear):** submitting no song id clears the active song. **Alternate flows:** non-Bard → 403; unknown song → 404; level too low → 422; foreign character → 403; not found → 404.
|
||||
|
||||
## UC-11 Study Magic
|
||||
|
||||
**Precondition:** character belongs to a caster class (Mage→magician, Wizard, Conjurer, Sorcerer). Spell level = (character level / 3) + 1 — **uncapped by code** (level 100 → spell level 34); learn *requests* are validated to 1–7, and the practical ceiling is emergent from the data (spells are defined only through level 7, so higher computed levels surface no new spells).
|
||||
|
||||
**Scenario (view spellbook):**
|
||||
1. Player requests their spellbook: caster class, current spell level, spell points, spells up to the achievable level (each marked known), and the learn-cost table for levels 2–7.
|
||||
|
||||
**Scenario (learn spell level):**
|
||||
1. Player submits a spell level (1–7).
|
||||
2. System validates caster class (403), level range (400), level not above the character's current spell level (422), gold ≥ learn cost (422), and that new spells exist (400).
|
||||
3. System deducts gold, adds all unknown spell ids of that level, recomputes and refills max spell points; returns `{learned_count, spell_points, max_spell_points, gold}`.
|
||||
|
||||
**Scenario (activate/clear active spell):**
|
||||
1. Player submits a spell id (or empty to clear).
|
||||
2. System validates the spell is known (403), exists (404), and spell points suffice (422); deducts the spell's points and sets it active; returns `{active_spell, spell_points}`.
|
||||
|
||||
## UC-12 Level Up at Review Board
|
||||
|
||||
**Main success scenario:**
|
||||
1. Player requests a level-up.
|
||||
2. System validates: below max level 100, XP ≥ 18·100·(level)² for the next level (i.e. `xp_for_level(next)`), gold ≥ 500·next level.
|
||||
3. System deducts the gold cost, increments the level, recomputes and fully restores HP/energy/nerve and max spell points; persists; returns `{success, message, character}`.
|
||||
|
||||
**Alternate flows (all 422):** already at max level; not enough XP; not enough gold. Foreign character → 403; not found → 404.
|
||||
|
||||
---
|
||||
|
||||
## Traceability Notes
|
||||
|
||||
- UC-08 is the design driver for WI-2's service extraction (`sb_core::adventure`); its status mapping feeds `DomainError` (see `analysis-design/design-wi-2-architecture-cleanup.md` §4.1–4.2).
|
||||
- Catalogue data backing UC-04 lives in `skara-brae-common/data/` (items, enemies, zones, spells, crit tables).
|
||||
41
docs/openup/requirements/vision.md
Normal file
41
docs/openup/requirements/vision.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# Skara Brae — Vision
|
||||
|
||||
Version 1.0 (WI-2, 2026-10-03) · OpenUP artifact: `requirements/vision.md` · Canonical home: **skara-brae-common repo**, `docs/openup/` (decision reversed 2026-10-03; drafts move verbatim from skara-brae-server/docs/)
|
||||
|
||||
## Problem / Opportunity
|
||||
|
||||
Text-based browser MMORPGs have proven retention and monetization for two decades (Torn City, since 2004), but incumbents carry 2000s-era engines. There is room for a modern, API-first, type-safe implementation of the formula: deep numeric progression, player-driven economy, idle-friendly pacing, real-time feedback.
|
||||
|
||||
## Product Vision
|
||||
|
||||
**Skara Brae** is a fantasy text-based/idle MMORPG set in an ancient, labyrinthine city of stone and sorcery (The Bard's Tale I inspiration, dark-fantasy tone). It transplants **Torn City's proven loop** — energy-gated activities, training, economy, social systems — into that setting, adds **idle-RPG elements** (offline progression, hireable workers, pets; Idle Fantasy inspiration), and grounds combat in a **RoleMaster-inspired critical-hit system** (open-ended d100, crit tables with bleed/stun effects, level-gap damage caps).
|
||||
|
||||
**Tagline:** *Set your hero to work. Close the app. Come back to loot.*
|
||||
|
||||
## Stakeholders
|
||||
|
||||
- **Owner/developer (solo)** — builds and operates the game on self-hosted infrastructure (Forgejo/Jenkins pipeline).
|
||||
- **Players** — web/PWA users; the API-first contract also serves future community tools.
|
||||
|
||||
## Key Features (committed MVP scope, Phase 1)
|
||||
|
||||
1. Accounts (register/login, JWT) and character creation (5 races, 10 classes).
|
||||
2. Stat training with escalating gold costs; energy/nerve/HP resources with lazy timed regeneration.
|
||||
3. Adventures (energy/nerve-gated zone runs) with turn-based combat, crit tables, XP/gold/loot.
|
||||
4. Economy: shop (buy/sell, stock), equipment slots, inventory.
|
||||
5. Magic (class-gated spell levels, spell points) and bard songs (combat buffs).
|
||||
6. Level-up review board; temple healing.
|
||||
7. REST API (Rust/Axum) + Vue 3 client; WebSocket notifications.
|
||||
|
||||
Later phases (not committed): PvP, guilds, properties, crafting, pets, blessings, offline workers, tower endgame.
|
||||
|
||||
## Constraints & Quality Bars
|
||||
|
||||
- Behavior continuity: refactors must be wire-format and game-semantics identical (WI-2 governs).
|
||||
- Quality gates: `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, full test suite — zero-warning policy.
|
||||
- Game data lives in the shared `skara-brae-common` repo as language-neutral JSON (single source of truth for server, client, and a future generated wiki).
|
||||
- BIGINT (`i64`) stat growth; stats only ever go up.
|
||||
|
||||
## Success Criteria (current iteration)
|
||||
|
||||
WI-1 delivered injectable RNG (deterministic domain tests). This iteration (WI-2) delivers an architecture cleanup that makes the codebase safe to grow on: typed responses, domain-extracted adventure service, named game-balance constants, one domain error taxonomy, fmt baseline — with all 45 existing tests green and zero behavior change. Longer term: a playable MVP per the feature list above.
|
||||
60
docs/openup/test/test-cases.md
Normal file
60
docs/openup/test/test-cases.md
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# Skara Brae — Test Cases (existing coverage mapped to use cases)
|
||||
|
||||
Version 1.0 (WI-2, 2026-10-03) · OpenUP artifact: `test/test-cases.md`
|
||||
Baseline: 45 unit tests, all green on `wi-2-architecture-cleanup` @ 899fde8 (`cargo test --workspace`).
|
||||
|
||||
## 1. Existing tests → use-case mapping
|
||||
|
||||
| Test (file::name) | Verifies | UC |
|
||||
|---|---|---|
|
||||
| character::`stat_bonus_at_20_is_zero` / `_below_20_is_negative` / `_above_20_is_positive` / `_handles_large_values` | OB/DB stat-bonus curve incl. BIGINT headroom | UC-08, UC-05 |
|
||||
| character::`xp_for_level_1_is_zero` / `_2_is_1800` / `_scales_subexponentially` / `_does_not_overflow` | XP curve 18·100·(level−1)² | UC-12 |
|
||||
| character::`level_up_fails_at_max_level` / `_succeeds_with_enough_xp_and_gold` / `_fails_without_enough_xp` / `_fails_without_enough_gold` | review-board rules | UC-12 |
|
||||
| character::`level_up_cost_scales` | 500·level cost | UC-12 |
|
||||
| character::`calc_max_hp_depends_on_class_and_con` / `calc_max_energy_and_nerve_vary_by_class` | class tables | UC-03 |
|
||||
| character::`training_cost_scales_with_stat` | 5·1.3^(stat/10) cost curve | UC-05 |
|
||||
| character::`apply_regen_restores_resources` / `_no_change_if_no_time_passed` | lazy regen (180 s / 300 s / 300 s) | shared sub-flow Regenerate Resources |
|
||||
| character::`roll_stats_returns_all_five` / `race_modifiers_apply` | character creation rolls ± race mods | UC-03 |
|
||||
| character::`create_character_is_deterministic_under_seeded_rng` | WI-1 injectable RNG at creation | UC-03, NFR-05 |
|
||||
| combat::`open_ended_roll_stays_in_range` | d100 open-ended re-rolls (≥96 / ≤5) | UC-08 |
|
||||
| combat::`offensive_bonus_includes_stat_and_base` / `defensive_bonus_includes_stat_and_base` | OB/DB composition | UC-08 |
|
||||
| combat::`crit_damage_cap_equal_level_is_none` / `_higher_defender_reduces` / `_scaling` | level-gap crit caps (0.60/0.40/0.15/0.05) | UC-08 |
|
||||
| combat::`resolve_attack_can_hit_or_miss` | round structure and HP clamping | UC-08 |
|
||||
| combat::`resolve_combat_produces_winner` | fight termination, winner/loser | UC-08 |
|
||||
| combat::`parse_attack_type_maps_known_types` | slash/crush/puncture + default crush | UC-08 |
|
||||
| services::`pick_enemy_respects_weights` | weighted encounter draw | UC-08 |
|
||||
| services::`train_stat_increases_stat` / `_fails_without_energy` / `_fails_without_gold` / `_fails_invalid_stat` / `train_constitution_recals_hp` | gym rules incl. max-HP recompute | UC-05 |
|
||||
| services::`equip_item_moves_from_inventory_to_slot` / `_swaps_previous` / `_fails_if_not_in_inventory` | equip semantics | UC-06 |
|
||||
| services::`unequip_item_moves_to_inventory` / `_fails_empty_slot` | unequip semantics | UC-06 |
|
||||
| services::`equipped_damage_sums_weapon_slots` / `total_defense_sums_all_slots` / `equipped_attack_type_returns_weapon_type` / `_defaults_to_crush` | equipment → combat stats | UC-08 |
|
||||
| (WI-1) seeded determinism pattern across the above | RNG injection | NFR-05 |
|
||||
|
||||
## 2. Coverage gaps the WI-2 refactor must not lose (and currently uncovered areas)
|
||||
|
||||
**Covered code being moved** — the run_adventure handler body (routes/mod.rs:409–567) is currently **not directly tested**; its ingredients (regen, pick_enemy, equipped stats, combat resolution) are covered per §1. WI-2 therefore **adds** direct service-level tests while moving the code:
|
||||
|
||||
| New test | Vector (design note §7) | UC |
|
||||
|---|---|---|
|
||||
| adventure::`run_adventure_win_applies_rewards_and_loot` | A1: seeded win → xp/gold/nerve/loot/inventory/exact outcome fields | UC-08 main |
|
||||
| adventure::`run_adventure_defeat_sets_hp_to_zero` | A2: overwhelming enemy → no rewards | UC-08 alt |
|
||||
| adventure::`run_adventure_rejects_incapacitated` | A3: hp = 0 → `CharacterIncapacitated`, nerve intact | UC-08 exception |
|
||||
| adventure::`run_adventure_rejects_insufficient_nerve` | A4 → `NotEnoughNerve` | UC-08 exception |
|
||||
| adventure::`run_adventure_applies_regen_before_guards` | A5: regen visible in combat HP | shared sub-flow |
|
||||
| adventure::`run_adventure_charges_spell_upkeep_on_win_and_loss` | A6 both paths | UC-08 step 8 / UC-11 interplay |
|
||||
| adventure::`run_adventure_filters_loot_to_catalog_items` | A7 drop for unknown item id skipped | UC-08 step 6 / NFR-07 |
|
||||
| adventure::`run_adventure_is_deterministic_under_seeded_rng` (+ optional golden CombatLog fixture) | A8 two seeds-42 runs identical | NFR-05 |
|
||||
| routes::`wire_identity_adventure_response` … `wire_identity_learn_response` (W1–W6) | byte-for-byte `serde_json::to_string` equality of each new DTO against golden strings recorded from the current `json!` expressions **before** the refactor; golden keys in lexicographic order **at every nesting level** (serde_json has no `preserve_order` — json! objects emit sorted keys; the adventure DTO's `combat_log` is the §4.3 Value bridge, defect #14) | UC-08/09/11, NFR-19 |
|
||||
|
||||
**New helper/constant pins** (design note §4.4–4.5): `as_str` parity for `ItemType`/`Quality` (wire-format guarantee NFR-19), `gym_display_order_covers_all_training_grounds` (pins GymResponse.stats order to routes/mod.rs:588–594, which differs from TRAINING_GROUNDS order), `race/class` table exhaustiveness, embedded-JSON loader parses (5), `caster_spell_level` spot values, `spell_learn_cost` guard spot checks (0, −1, 8 → 999_999).
|
||||
|
||||
**Known remaining gaps (documented, out of WI-2 scope — see design note Q3):**
|
||||
- Heal cost logic (UC-09) and spell learn/activate rules (UC-11) live in handlers and have no unit tests; WI-2 only types their responses.
|
||||
- Shop buy/sell stock transitions (UC-07), auth flows (UC-01/02), and level-up handler status mapping (UC-12 → all 422) have no HTTP-level integration tests. The exhaustive `domain_status` match (compile-time totality) is the WI-2 mitigation for the mapping itself.
|
||||
- No test asserts JSON field **order**; the response structs declare fields in wire order and the golden CombatLog fixture pins the largest payload.
|
||||
|
||||
## 3. Regression protocol for WI-2 (coder must run per commit)
|
||||
|
||||
1. `cargo fmt --check`
|
||||
2. `cargo clippy --all-targets -- -D warnings`
|
||||
3. `cargo test --workspace` — 45 baseline tests green at every step; the only permitted test edits are the **9** mechanical assertion updates listed in design note §4.1 (3 `level_up`, 3 `train_stat`, 1 `equip_item`, 1 `unequip_item`, 1 `pick_enemy_respects_weights` — the last gains an Ok-path `.unwrap()` because `pick_enemy` becomes `Result`-returning) plus newly added tests.
|
||||
4. Manual wire spot-check (recommended once, after commit 5): run the server, exercise `GET /api/characters/{id}/adventure`, `POST …/heal`, `GET …/spells`, `POST …/spells` (both modes) and diff responses **byte-for-byte** against pre-refactor output — field names, key **order** (lexicographic, since serde_json runs without `preserve_order`), null-ness, number formatting.
|
||||
Loading…
Add table
Add a link
Reference in a new issue