diff --git a/docs/openup/model/BUILD-CHECKLIST.md b/docs/openup/model/BUILD-CHECKLIST.md new file mode 100644 index 0000000..a5bb195 --- /dev/null +++ b/docs/openup/model/BUILD-CHECKLIST.md @@ -0,0 +1,203 @@ +# Skara Brae — EA Model Build Checklist (WI-2) + +Version 1.0 (2026-10-03) · Prepared by COD per `design-wi-2-architecture-cleanup.md` §11.b.4. +Sources (authoritative, do not deviate): `docs/openup/requirements/use-case-model.md`, +`docs/openup/analysis-design/uc-realization-mapping.md` (§1 tree, §2 realization table), +`docs/openup/requirements/supplementary-specifications.md`, design note §3 (boundary rules). +Drift rule: markdown and model must agree; a mismatch is a defect fixed in the same cycle. + +This checklist is written to be clicked through in EA **or** executed via +`scripts/openup/build-model.ps1` (then verified below). The per-discipline element lists +are complete: nothing outside them belongs in the WI-2 model. + +--- + +## 0. Prerequisites (Windows machine — see model/README.md for why not Linux) + +1. skara-brae-common checked out (branch `wi-2-openup-docs` or later). +2. EA installed and licensed. Copy the EA installation's empty-model template to the + model home: + `C:\Program Files\Sparx Systems\EA\EABase.eapx` → `docs/openup/model/skara-brae.eapx` + (exact install path may differ; the file is the standard empty `.eapx` template. + Do **not** derive the skeleton from keyfob-station's model — it carries keyfob content). +3. PowerShell 5.1+ (built into Windows) for the script path. + +## 1. Model root + +- Rename the default model root to **`Skara Brae`** (keyfob equivalent: single model root). + +--- + +## 2. Requirements discipline + +### 2.1 Package `Requirements` + +Create package **`Requirements`** directly under the model root. + +### 2.2 Package `Requirements / Vision` + +- Notes: purpose statement source = `docs/openup/requirements/vision.md` (the markdown is + the human-facing source; the model carries the product purpose in the package notes and + links, not a full copy). + +### 2.3 Package `Requirements / Use Case Model` + +**Actors (exactly 3)** — type `Actor`, notes verbatim from the Actors table: + +| Actor | Notes | +|---|---| +| `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 cases (exactly 12)** — type `UseCase`, names verbatim: + +`UC-01 Register Account`, `UC-02 Authenticate`, `UC-03 Manage Characters`, +`UC-04 Browse Game Catalogue`, `UC-05 Train Stat`, `UC-06 Manage Equipment`, +`UC-07 Trade at Shop`, `UC-08 Run Adventure`, `UC-09 Heal at Temple`, +`UC-10 Practice Bard Song`, `UC-11 Study Magic`, `UC-12 Level Up at Review Board`. + +Element notes: the UC's precondition line(s) verbatim from `use-case-model.md`; plus: +- UC-05 note appends: *includes Regenerate Resources.* +- UC-08 note appends: *includes Regenerate Resources, Resolve Combat. Exception: encounter + references an unknown enemy → 500 (post WI-2; previously a panic). WI-2 design driver + (`sb_core::adventure` extraction).* +- Include targets are **sub-flows, not use case elements** (only 12 UCs exist) — model the + includes in the notes, **not** as `«include»` connectors. + +**Associations (exactly 13)** — type `Association`, Actor → UseCase: + +| Actor | Use cases | +|---|---| +| `Guest` | UC-01, UC-02 | +| `Player` | UC-03, UC-04, UC-05, UC-06, UC-07, UC-08, UC-09, UC-10, UC-11, UC-12 | +| `Administrator` | UC-03 (read-only) | + +**Scenarios** — stored on each use case (`Scenarios` collection), types are EA's +`Basic Path` / `Alternate` / `Exception`. Naming and structuring rules used below +(also implemented in `build-model.ps1`): +- Markdown "Main success scenario" → scenario **Main Success Scenario** (`Basic Path`); + its numbered list items become ordered **steps**, each step's **Actor** = the leading + grammatical subject of the step text (`Player`/`Guest` → that actor; otherwise `System`). +- Markdown "**Scenario (x):**" block → scenario named **x** (`Alternate`); prose text goes + into the scenario notes verbatim. +- Markdown "**Alternate flows:**" prose → one scenario **Alternate flows** (`Alternate`), + prose verbatim in notes. +- Markdown "**Exception:**" prose → scenario **Exception** (`Exception`), prose verbatim. +- Precondition text rides on the scenario notes of the Main Success Scenario. +- UC-11 has no labeled main scenario in the markdown; its first listed scenario + (view spellbook) serves as the Basic Path, the remaining "Scenario (x)" blocks as + `Alternate`. +- Parenthetical actor remarks on a step text (e.g. UC-03 step 1 "(Admin may act for any + user)") move to the scenario notes to keep the step actionable; the wording is kept. + +| UC | Scenario (type) | Steps (Actor: text) / Notes | +|---|---|---| +| UC-01 | Main Success Scenario (Basic Path) | Guest: Guest submits name (3–32 chars) and password (≥ 6 chars). · System: System verifies the name is unused → 409 CONFLICT if taken. · System: System hashes the password (argon2), assigns the `player` role and the next id, persists the user. · System: System issues a JWT (HS256, `player` role) and returns `{token, user_id, name, role}`. | +| UC-01 | Alternate flows (Alternate) | Notes: name length invalid → 400; password too short → 400; name taken → 409; hashing/token failure → 500. | +| UC-02 | Main Success Scenario (Basic Path) | Guest: Guest submits name and password. · System: System finds the user (401 if unknown) and verifies the password hash (401 on mismatch). · System: System issues a JWT carrying the user's real role (`admin`/`player`) and returns `{token, user_id, name, role}`. | +| UC-02 | Alternate flows (Alternate) | Notes: unknown name or wrong password → 401 UNAUTHORIZED; token creation failure → 500. | +| UC-03 | Main Success Scenario (create) (Basic Path) | Notes: Admin may act for any user. · Player: Player submits character name, race, class for their user id. · System: System validates the target user exists (404) and the trimmed name is 1–32 chars (400). · System: 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. · System: System assigns the next character id, stores and persists the character; returns it fully. | +| UC-03 | list/view (Alternate) | Notes: 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. | +| UC-03 | Alternate flows (Alternate) | Notes: foreign user/character without admin role → 403 FORBIDDEN; user/character not found → 404; invalid name → 400. | +| UC-04 | Main Success Scenario (Basic Path) | Player: Player requests the catalogue: races, classes, items (optional type/quality/slot filters), enemies, or adventure zones. · System: System returns static game data loaded from `skara-brae-common` JSON (class info is enriched with `is_caster`). | +| UC-04 | Alternate flows (Alternate) | Notes: single item by unknown id → 404; filters may return an empty list (not an error). | +| UC-05 | Main Success Scenario (Basic Path) | Notes: Precondition: character exists and caller may act on it. · Player: Player selects a training ground (strength / dexterity / constitution / intelligence / luck) and submits the stat name. · System: System applies Regenerate Resources. · System: System checks energy ≥ 1 and gold ≥ training cost (cost grows ~1.3× per 10 stat points). · System: System deducts 1 energy and the gold cost, increments the stat; constitution training recomputes max HP (current HP clamped). · System: System persists and returns `{success, message, character}`. | +| UC-05 | Alternate flows (Alternate) | Notes: foreign character → 403; character not found → 404; not enough energy → 422; not enough gold → 422; unknown stat name → 400. | +| UC-06 | Main Success Scenario (equip) (Basic Path) | Player: Player submits an item id from their inventory. · System: System moves the item from inventory to its slot; a previously equipped item in that slot returns to inventory. · System: System persists and returns the updated character. | +| UC-06 | unequip (Alternate) | Notes: Player submits a slot name; the system moves the equipped item back to inventory. | +| UC-06 | Alternate flows (Alternate) | Notes: foreign character → 403; character/item not found → 404; item not in inventory → 400; nothing equipped in slot → 400. | +| UC-07 | Main Success Scenario (buy) (Basic Path) | Player: Player views the shop: always-stocked items plus rotating stock (with quantities) alongside their inventory. · Player: Player submits `buy` + item id. · System: System checks gold ≥ price (422), and for non-always-in-stock items decrements the limited stock (410 GONE if 0). · System: System deducts gold, adds the item to inventory, persists character and stock; returns `{success, message, gold}`. | +| UC-07 | sell (Alternate) | Notes: Player submits `sell` + item id; system removes the first matching inventory item, pays half the item value, increments shop stock for non-permanent items. | +| UC-07 | Alternate flows (Alternate) | Notes: unknown action or item not owned (sell) → 400; item not found → 404; insufficient gold → 422; stock exhausted → 410. | +| UC-08 | Main Success Scenario (Basic Path) | Notes: Precondition: character exists and caller may act on it. · Player: Player requests an adventure for a zone (default `skara_brae_streets`). · System: System validates the zone (404) and applies Regenerate Resources. · System: System refuses incapacitated characters (HP ≤ 0 → 400) and characters with nerve below the zone cost (422); otherwise deducts the nerve cost. · System: System draws one enemy from the zone's weighted encounter table. · System: 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. · System: 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). · System: On defeat: character HP set to 0; no rewards. · System: Spell-point upkeep is charged whenever a spell was active, win or lose. · System: System persists the character and returns `{zone, enemy, combat_log, loot}` (loot = item display names). | +| UC-08 | Alternate flows (Alternate) | Notes: foreign character → 403; character/zone not found → 404; incapacitated → 400; insufficient nerve → 422. | +| UC-08 | Exception (Exception) | Notes: encounter references an unknown enemy (data error) → 500 (post WI-2; previously a panic). | +| UC-09 | Main Success Scenario (Basic Path) | Player: Player requests healing, optionally `partial`. · System: System computes missing HP; full heals all, partial heals half (rounded up); cost = amount healed, minimum 1 gold. · System: System checks gold (422), deducts it, restores HP, persists; returns `{hp, max_hp, gold, healed: true}`. | +| UC-09 | Alternate flows (Alternate) | Notes: foreign character → 403; not found → 404; already at full HP → 400; insufficient gold → 422. | +| UC-10 | Main Success Scenario (Basic Path) | Notes: Precondition: character class is Bard. · Player: Player views available songs (level-gated, with combat effect descriptions) and the active song. · Player: Player submits a song id; system validates level requirement (422) and sets it active; returns `{success, message, active_song}`. | +| UC-10 | clear (Alternate) | Notes: submitting no song id clears the active song. | +| UC-10 | Alternate flows (Alternate) | Notes: non-Bard → 403; unknown song → 404; level too low → 422; foreign character → 403; not found → 404. | +| UC-11 | Main Success Scenario (view spellbook) (Basic Path) | Notes: 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. · Player: 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. | +| UC-11 | learn spell level (Alternate) | Player: Player submits a spell level (1–7). · System: 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). · System: 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}`. | +| UC-11 | activate/clear active spell (Alternate) | Player: Player submits a spell id (or empty to clear). · System: 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 | Main Success Scenario (Basic Path) | Player: Player requests a level-up. · System: System validates: below max level 100, XP ≥ 18·100·(level)² for the next level (i.e. `xp_for_level(next)`), gold ≥ 500·next level. · System: System deducts the gold cost, increments the level, recomputes and fully restores HP/energy/nerve and max spell points; persists; returns `{success, message, character}`. | +| UC-12 | Alternate flows (Alternate) | Notes: all of already at max level; not enough XP; not enough gold → 422. Foreign character → 403; not found → 404. | + +**Diagram** `Skara Brae Use Case Overview` (type `UseCase`): place all 3 actors (left +column) and all 12 use cases (right grid). + +### 2.4 Package `Requirements / Supplementary Specifications` + +- One child element per top-level section of `requirements/supplementary-specifications.md`, + type `Requirement`, name = `SS-
` with the section's content in notes: + `1. Performance / Scalability`, `2. Reliability / Correctness`, + `3. Quality Gates (per-commit acceptance)`, `4. Data`, `5. Security`, + `6. Constraints`, `7. Usability / API`. +- `Trace` connectors from each `SS-n` element to the use cases the section names + (the spec text cites the UCs; when it names none, leave untraced rather than guessing). + +--- + +## 3. Analysis-Design discipline + +### 3.1 Package `Analysis-Design` + +Create package **`Analysis-Design`** under the model root (mapping §1 name — note: +keyfob uses `Analysis & Design`; here the mapping document says `Analysis-Design` — +follow the mapping). + +### 3.2 Package `Analysis-Design / Architecture Overview` + +- Notes: the dependency rule verbatim from `uc-realization-mapping.md` §2 + ("all controllers/entities above live in `sb-core`/`sb-shared` …; the single current + violations (ShopService, HealingService, parts of MagicService in sb-api) are recorded + as debt … stale docs are defects") + design note §3 boundary rules. +- Three cross-cutting elements (type `Class`, notes from the mapping's cross-cutting + paragraph): + 1. `DomainError` — notes: `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. + 2. `Constants` — notes: `sb-core::constants` realizes NFR "no magic numbers". + 3. `Responses` — notes: `sb-api::routes::responses` realizes NFR-19 (wire-format freeze). + +### 3.3 Twelve realization packages + +For **each** entry in `docs/openup/model/exports/uc-realizations.json` +(generated from mapping §2 — 12 packages, 36 elements): + +1. Package `UC- Realization` under `Analysis-Design`. +2. Inside it, one `Collaboration` element named like the package, with a `Realization` + connector (Collaboration → the use case element in `Requirements / Use Case Model`). +3. Inside it, one `Class` element per mapping row: + - Name = analysis class (e.g. `AdventureService`). + - Stereotype = `controller` / `entity` / `boundary` (mapping §2 column). + - Notes = the mapping's *Design element* cell verbatim, plus the *Notes* cell + (e.g. `` `sb-core::adventure::run_adventure` (**new, WI-2**) + `services::pick_enemy` + → `Result` (WI-2) — takes injected rng; `DomainError` (WI-2)``). +4. One class diagram per realization package (name = package name), all its classes placed. + +Element counts for verification: UC-01:3, UC-02:2, UC-03:4, UC-04:2, UC-05:3, UC-06:3, +UC-07:3, UC-08:5, UC-09:2, UC-10:3, UC-11:3, UC-12:3 — total 36. + +--- + +## 4. Verification (design §11.d) + +1. Model opens clean (no missing-package errors). +2. `Requirements → Use Case Model` shows exactly 3 actors + 12 use cases; every UC carries + its Main Success Scenario plus the alternates/exceptions from the table above, with + per-step actors. +3. Spot-check two scenarios against `requirements/use-case-model.md` (recommended: UC-08 + MSS — 9 steps, and UC-11 learn spell level — 3 steps). +4. 12 `UC-N Realization` packages exist under `Analysis-Design` with the element counts + above; spot-check the UC-08 realization table against `uc-realization-mapping.md` §2. +5. Association count is 13; there are no `«include»` connectors (includes live in UC notes). + +## 5. Export + commit (design §11.d.4–5) + +1. Run `scripts/openup/export-model.ps1` → regenerates + `docs/openup/model/exports/use-case-model.json`, `uc-realizations.json` and an XMI + backup; confirm the diff is expected (`uc-realizations.json` must be unchanged in + content — it was generated from the mapping markdown). +2. Save the model; commit `.eapx` + `exports/` + `scripts/openup/` together as one commit + `openup: EA model WI-2 baseline` in **skara-brae-common**. diff --git a/docs/openup/model/README.md b/docs/openup/model/README.md new file mode 100644 index 0000000..79dfc36 --- /dev/null +++ b/docs/openup/model/README.md @@ -0,0 +1,37 @@ +# docs/openup/model/ — EA Model Home (WI-2 status) + +Per `design-wi-2-architecture-cleanup.md` §11 (decision reversal 2026-10-03): the binary +EA model lives here at `skara-brae.eapx` with generated exports under `exports/`; generator +scripts live in `scripts/openup/` (repo root). Markdown stays the content source of truth; +exports are generated, never hand-edited. + +## Status after COD session 2 (2026-10-03, Linux agent) + +| Design item (§11.b/c) | Status | +|---|---| +| §11.b.1 clone keyfob-station read-only | done — studied `add-wi19-uc11.ps1` (scenario embedding: `Name/Type/Notes/Steps` arrays with per-step `Actor`) and `export-model-json.ps1` (COM walk → JSON); patterns reused in the scripts below | +| §11.b.3 `.eapx` skeleton from EA's `EABase.eapx` | **not done — platform gap.** No `EABase.eapx` exists on this Linux machine (searched `~`, `~/.wine`, `/mnt`, `/media`, `/opt`, `/usr/local`); EA is a Win32 application. The binary is deliberately NOT committed and NOT derived from keyfob's model (it carries keyfob content). Copy it during the Windows session (see BUILD-CHECKLIST §0). | +| §11.b.4 build checklist | done — `BUILD-CHECKLIST.md` (packages, actors, use cases, scenarios, associations, realization packages — complete and clickable) | +| §11.c `build-model.ps1` / `export-model.ps1` | committed, adapted from the keyfob patterns; **Windows-gated** — this machine has no EA COM automation (and no `pwsh`), so they could not be executed here. They are prepared, not proven. | +| §11.c machine-readable intermediates | `exports/uc-realizations.json` generated (by `scripts/openup/markdown-to-json.py`, tested locally) from mapping §2 — 12 realizations, 36 elements. `exports/use-case-model.json` is **not** generated here: the markdown carries no per-step actor tags, so a faithful JSON intermediate cannot be produced mechanically. Per §11.c's recorded fallback, `build-model.ps1` embeds the scenario content as PowerShell arrays (keyfob pattern); the human gate verifies it against `use-case-model.md` via the checklist. | + +## Linux constraint and the two options + +The EA pipeline is PowerShell + COM automation (`New-Object -ComObject EA.App`) against a +`.eapx` (MS Access/Jet) file — Windows-only. This session ran on Linux. Options: + +1. **Run the pipeline on the Windows/EA machine (planned path).** `BUILD-CHECKLIST.md` + §0–§5 is the session script: copy `EABase.eapx` → `skara-brae.eapx`, run + `build-model.ps1`, verify per checklist §4, run `export-model.ps1`, commit + `.eapx` + `exports/` + `scripts/openup/` as `openup: EA model WI-2 baseline`. +2. **EA manual XML export → agent-side transform.** If the COM scripts hit friction in EA, + export the package manually from the EA UI (`Publish → XML / XMI`), drop the file into + `exports/`, and the agent side can transform/validate it into the JSON intermediates + with a small local script (same role as `markdown-to-json.py`). The committed scripts + remain the reproducible path of record. + +## Drift rule + +Markdown (`use-case-model.md`, `uc-realization-mapping.md`) and the model must agree; +exports are generated; a mismatch is a defect fixed in the same cycle (stale docs are +defects). The human gate (design §11.d) owns `.eapx` editing/verification — never the agent. diff --git a/docs/openup/model/exports/uc-realizations.json b/docs/openup/model/exports/uc-realizations.json new file mode 100644 index 0000000..2fef88f --- /dev/null +++ b/docs/openup/model/exports/uc-realizations.json @@ -0,0 +1,295 @@ +{ + "exportedFrom": "docs/openup/analysis-design/uc-realization-mapping.md §2, generated by scripts/openup/markdown-to-json.py — generated file, never hand-edit", + "realizations": [ + { + "id": "UC-01 Realization", + "elements": [ + { + "name": "AccountRegistry", + "stereotype": "controller", + "designElement": "`sb-api::routes::{register, seed_users}`", + "notes": "argon2 hash via `sb-auth::hash`; JWT via `sb-auth::jwt`" + }, + { + "name": "UserAccount", + "stereotype": "entity", + "designElement": "`sb-shared::user::User`, `Role`", + "notes": "persisted via `sb-db::Database`" + }, + { + "name": "AuthBoundary", + "stereotype": "boundary", + "designElement": "`POST /api/auth/register`, `AuthResponse`", + "notes": "" + } + ], + "useCase": "Register Account" + }, + { + "id": "UC-02 Realization", + "elements": [ + { + "name": "Authenticator", + "stereotype": "controller", + "designElement": "`sb-api::routes::login`, `sb-auth::{hash::verify_password, jwt::create_token}`", + "notes": "" + }, + { + "name": "TokenService", + "stereotype": "boundary", + "designElement": "`POST /api/auth/login`, `AuthResponse`, `middleware::auth::auth_middleware`", + "notes": "middleware guards all UC-03…12" + } + ], + "useCase": "Authenticate" + }, + { + "id": "UC-03 Realization", + "elements": [ + { + "name": "CharacterFactory", + "stereotype": "controller", + "designElement": "`sb-core::character::create_character`", + "notes": "takes injected rng (WI-1)" + }, + { + "name": "Character", + "stereotype": "entity", + "designElement": "`sb-shared::character::Character` (+ `Race`, `Class`, `CharacterStats`)", + "notes": "" + }, + { + "name": "CharacterCatalog", + "stereotype": "boundary", + "designElement": "`GET/POST /api/users/{id}/characters`, `GET /api/characters/{id}`", + "notes": "store: `AppState.characters`" + }, + { + "name": "ResourceRegenerator", + "stereotype": "entity", + "designElement": "`sb-core::character::apply_regen` (+ regen constants — WI-2 names them)", + "notes": "included sub-flow" + } + ], + "useCase": "Manage Characters" + }, + { + "id": "UC-04 Realization", + "elements": [ + { + "name": "CatalogueBrowser", + "stereotype": "boundary", + "designElement": "`GET /api/{races,classes,items,enemies,adventures}`; response types `sb-shared::responses`", + "notes": "data from `sb-common` JSON via `sb-core::{character::RACES/CLASSES, data, items}`" + }, + { + "name": "GameData", + "stereotype": "entity", + "designElement": "`sb-core::data::{EnemyDef, ZoneDef, EncounterEntry, LootDrop}`, `items::load_all_items`", + "notes": "" + } + ], + "useCase": "Browse Game Catalogue" + }, + { + "id": "UC-05 Realization", + "elements": [ + { + "name": "TrainingService", + "stereotype": "controller", + "designElement": "`sb-core::services::train_stat`", + "notes": "error type `DomainError` (WI-2)" + }, + { + "name": "TrainingGrounds", + "stereotype": "entity", + "designElement": "`sb-core::character::TRAINING_GROUNDS`, `training_cost` (+ cost constants WI-2)", + "notes": "" + }, + { + "name": "GymBoundary", + "stereotype": "boundary", + "designElement": "`GET/POST /api/characters/{id}/gym`, `GymResponse`, `TrainStatResponse`", + "notes": "" + } + ], + "useCase": "Train Stat" + }, + { + "id": "UC-06 Realization", + "elements": [ + { + "name": "EquipmentService", + "stereotype": "controller", + "designElement": "`sb-core::services::{equip_item, unequip_item}`", + "notes": "`DomainError` (WI-2)" + }, + { + "name": "Equipment/Inventory", + "stereotype": "entity", + "designElement": "`Character.equipment` / `.inventory`, `sb-shared::item::Item`", + "notes": "" + }, + { + "name": "EquipmentBoundary", + "stereotype": "boundary", + "designElement": "`POST /api/characters/{id}/{equip,unequip}`", + "notes": "" + } + ], + "useCase": "Manage Equipment" + }, + { + "id": "UC-07 Realization", + "elements": [ + { + "name": "ShopService", + "stereotype": "controller", + "designElement": "`sb-api::routes::{shop_action, buy_item, sell_item}` (handler-side; extraction candidate, design note Q3)", + "notes": "prices: `sb-core::items::{item_price, item_sell_price}`" + }, + { + "name": "ShopStock", + "stereotype": "entity", + "designElement": "`AppState.shop_stock`", + "notes": "persisted via `sb-db`" + }, + { + "name": "ShopBoundary", + "stereotype": "boundary", + "designElement": "`GET/POST /api/characters/{id}/shop`, `ShopResponse`, `ShopActionResponse`", + "notes": "" + } + ], + "useCase": "Trade at Shop" + }, + { + "id": "UC-08 Realization", + "elements": [ + { + "name": "AdventureService", + "stereotype": "controller", + "designElement": "`sb-core::adventure::run_adventure` (**new, WI-2**) + `services::pick_enemy` → `Result` (WI-2)", + "notes": "takes injected rng; `DomainError` (WI-2)" + }, + { + "name": "CombatEngine", + "stereotype": "entity", + "designElement": "`sb-core::combat::{resolve_combat_full, resolve_attack, crit tables, open_ended_roll}` + combat constants (WI-2)", + "notes": "" + }, + { + "name": "Combatant", + "stereotype": "entity", + "designElement": "`sb-core::combat::CombatStats`", + "notes": "built from Character/EnemyDef" + }, + { + "name": "SpellEffect / BardSong", + "stereotype": "entity", + "designElement": "`sb-core::combat::SpellEffect`, `sb-core::spells`, `sb-core::songs`", + "notes": "" + }, + { + "name": "AdventureBoundary", + "stereotype": "boundary", + "designElement": "`GET /api/characters/{id}/adventure`; `AdventureRunResponse` (**new, WI-2**), `sb-shared::responses::CombatLog`", + "notes": "" + } + ], + "useCase": "Run Adventure" + }, + { + "id": "UC-09 Realization", + "elements": [ + { + "name": "HealingService", + "stereotype": "controller", + "designElement": "`sb-api::routes::heal_character` (handler-side; extraction candidate, Q3)", + "notes": "" + }, + { + "name": "TempleBoundary", + "stereotype": "boundary", + "designElement": "`POST /api/characters/{id}/heal`; `HealResponse` (**new, WI-2**)", + "notes": "" + } + ], + "useCase": "Heal at Temple" + }, + { + "id": "UC-10 Realization", + "elements": [ + { + "name": "SongService", + "stereotype": "controller", + "designElement": "`sb-api::routes::set_song`; `sb-core::songs::available_songs`", + "notes": "" + }, + { + "name": "BardSong", + "stereotype": "entity", + "designElement": "`sb-core::songs::BARD_SONGS`, `Character.active_song`", + "notes": "" + }, + { + "name": "SongBoundary", + "stereotype": "boundary", + "designElement": "`GET/POST /api/characters/{id}/songs`, `SongsResponse`, `SetSongResponse`", + "notes": "" + } + ], + "useCase": "Practice Bard Song" + }, + { + "id": "UC-11 Realization", + "elements": [ + { + "name": "MagicService", + "stereotype": "controller", + "designElement": "`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)}`", + "notes": "" + }, + { + "name": "Spell", + "stereotype": "entity", + "designElement": "`sb-core::spells::SpellDef`, `Character.{known_spells, active_spell, spell_points}`", + "notes": "" + }, + { + "name": "MagicBoundary", + "stereotype": "boundary", + "designElement": "`GET/POST /api/characters/{id}/spells`; `SpellbookResponse`, `LearnSpellResponse` (**new, WI-2**)", + "notes": "" + } + ], + "useCase": "Study Magic" + }, + { + "id": "UC-12 Realization", + "elements": [ + { + "name": "ReviewBoard", + "stereotype": "controller", + "designElement": "`sb-core::character::level_up`", + "notes": "`DomainError` (WI-2)" + }, + { + "name": "Progression", + "stereotype": "entity", + "designElement": "`sb-core::character::{xp_for_level, level_up_cost, MAX_LEVEL}` (+ curve constants WI-2)", + "notes": "" + }, + { + "name": "ReviewBoundary", + "stereotype": "boundary", + "designElement": "`POST /api/characters/{id}/level-up`, `LevelUpResponse`", + "notes": "" + } + ], + "useCase": "Level Up at Review Board" + } + ], + "crossCutting": "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).", + "dependencyRule": "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)." +} diff --git a/scripts/openup/build-model.ps1 b/scripts/openup/build-model.ps1 new file mode 100644 index 0000000..e3b09bf --- /dev/null +++ b/scripts/openup/build-model.ps1 @@ -0,0 +1,456 @@ +# Skara Brae — EA model builder (WI-2), adapted from keyfob-station's +# generate-usecase-model.ps1 / add-wi19-uc11.ps1 patterns (COM automation). +# +# Design: skara-brae-common docs/openup/analysis-design/design-wi-2-architecture-cleanup.md §11. +# Content sources (authoritative): docs/openup/requirements/use-case-model.md, +# docs/openup/analysis-design/uc-realization-mapping.md, docs/openup/model/BUILD-CHECKLIST.md. +# +# Platform: Windows + installed EA. The .eapx skeleton must exist first — copy the EA +# installation's EABase.eapx to docs/openup/model/skara-brae.eapx (see model/README.md; +# never derive it from keyfob-station's model). +# +# Scenario content is embedded below as arrays (keyfob fallback pattern, §11.c) because +# the markdown does not carry machine-readable per-step actor tags; the human gate +# verifies it against use-case-model.md via BUILD-CHECKLIST.md §4. +# Realization packages are built from the generated intermediate +# docs/openup/model/exports/uc-realizations.json (scripts/openup/markdown-to-json.py). + +$ErrorActionPreference = 'Stop' + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$modelPath = Join-Path $repoRoot 'docs\openup\model\skara-brae.eapx' +if (-not (Test-Path $modelPath)) { + Write-Output "MISSING $modelPath - copy your EA installation's EABase.eapx there first (see docs/openup/model/README.md)" + exit 1 +} +$realizationsPath = Join-Path $repoRoot 'docs\openup\model\exports\uc-realizations.json' +$realizations = Get-Content $realizationsPath -Raw -Encoding UTF8 | ConvertFrom-Json + +$step = 'start' +$app = $null +$eaBefore = @(Get-Process -Name EA -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id) + +function Set-ScenarioSteps($scenario, $stepsData) { + foreach ($sd in $stepsData) { + $scenarioStep = $scenario.Steps.AddNew($sd.Text, $sd.Actor) + $null = $scenarioStep.Update() + } + $scenario.Steps.Refresh() + $null = $scenario.Update() + $null = $scenario.Update() +} + +try { + $app = New-Object -ComObject EA.App + $app.Visible = $false + $repo = $app.Repository + $null = $repo.OpenFile($modelPath) + + $step = 'rename model root' + $root = $repo.Models.GetAt([int]0) + if ($root.Name -ne 'Skara Brae') { $root.Name = 'Skara Brae'; $null = $root.Update() } + + # ---------- Requirements tree ---------- + $step = 'remove and recreate Requirements' + for ($i = 0; $i -lt $root.Packages.Count; $i++) { + if (($root.Packages.GetAt([int]$i)).Name -eq 'Requirements') { + $null = $root.Packages.DeleteAt([int]$i, $false) + $root.Packages.Refresh() + break + } + } + $requirements = $root.Packages.AddNew('Requirements', 'Package') + $null = $requirements.Update() + $root.Packages.Refresh() + + $step = 'Vision package' + $vision = $requirements.Packages.AddNew('Vision', 'Package') + $vision.Notes = 'Product purpose and segments. Human-facing source: docs/openup/requirements/vision.md (markdown is the source of truth; this package carries purpose notes and links, not a full copy).' + $null = $vision.Update() + $requirements.Packages.Refresh() + + $step = 'Supplementary Specifications package' + $supp = $requirements.Packages.AddNew('Supplementary Specifications', 'Package') + $supp.Notes = 'Non-functional requirements. Source: docs/openup/requirements/supplementary-specifications.md; one Requirement element per top-level section, Trace connectors only where the section names a use case.' + $null = $supp.Update() + $requirements.Packages.Refresh() + $suppSections = @( + @{ Name = 'SS-1 Performance / Scalability' }, + @{ Name = 'SS-2 Reliability / Correctness' }, + @{ Name = 'SS-3 Quality Gates (per-commit acceptance)' }, + @{ Name = 'SS-4 Data' }, + @{ Name = 'SS-5 Security' }, + @{ Name = 'SS-6 Constraints' }, + @{ Name = 'SS-7 Usability / API' } + ) + foreach ($section in $suppSections) { + $element = $supp.Elements.AddNew($section.Name, 'Requirement') + $null = $element.Update() + } + $supp.Elements.Refresh() + + # ---------- Use Case Model ---------- + $step = 'remove and recreate Use Case Model' + for ($i = 0; $i -lt $requirements.Packages.Count; $i++) { + if (($requirements.Packages.GetAt([int]$i)).Name -eq 'Use Case Model') { + $null = $requirements.Packages.DeleteAt([int]$i, $false) + $requirements.Packages.Refresh() + break + } + } + $ucModel = $requirements.Packages.AddNew('Use Case Model', 'Package') + $ucModel.Notes = 'Actors, use cases and scenarios of the implemented server MVP. Source: docs/openup/requirements/use-case-model.md (normative for status codes and flows).' + $null = $ucModel.Update() + $requirements.Packages.Refresh() + + $step = 'create actors' + $actors = @( + @{ Name = 'Player'; Notes = 'Authenticated user owning one or more characters (JWT role player).' }, + @{ Name = 'Administrator'; Notes = 'Player with elevated rights (role = admin); may view any user''s and any character''s resources read-only.' }, + @{ Name = 'Guest'; Notes = 'Unauthenticated caller; only register/login are open.' } + ) + $elements = @{} + foreach ($actor in $actors) { + $element = $ucModel.Elements.AddNew($actor.Name, 'Actor') + $element.Notes = $actor.Notes + $null = $element.Update() + $elements[$actor.Name] = $element + } + $ucModel.Elements.Refresh() + + $step = 'create use cases' + $useCases = @( + @{ Name = 'UC-01 Register Account'; Notes = 'Open registration. No precondition.' }, + @{ Name = 'UC-02 Authenticate'; Notes = 'Login with name and password; issues a JWT with the user''s real role.' }, + @{ Name = 'UC-03 Manage Characters'; Notes = 'Create, list and view characters. Admin may act for any user (read-only view).' }, + @{ Name = 'UC-04 Browse Game Catalogue'; Notes = 'Races, classes, items (type/quality/slot filters), enemies, adventure zones from static game data.' }, + @{ Name = 'UC-05 Train Stat'; Notes = 'Train a stat at a training ground; costs 1 energy + gold. Includes Regenerate Resources.' }, + @{ Name = 'UC-06 Manage Equipment'; Notes = 'Equip from inventory / unequip back to inventory.' }, + @{ Name = 'UC-07 Trade at Shop'; Notes = 'Buy from (always-stocked + rotating) shop stock or sell inventory items.' }, + @{ Name = 'UC-08 Run Adventure'; Notes = 'Zone adventure with weighted enemy pick and turn-based combat. Includes Regenerate Resources, Resolve Combat. Exception: encounter references an unknown enemy -> 500 (post WI-2; previously a panic). WI-2 design driver (sb_core::adventure extraction).' }, + @{ Name = 'UC-09 Heal at Temple'; Notes = 'Full or partial healing, cost = amount healed, minimum 1 gold.' }, + @{ Name = 'UC-10 Practice Bard Song'; Notes = 'Set or clear the active bard song (Bard only, level-gated).' }, + @{ Name = 'UC-11 Study Magic'; Notes = 'View spellbook, learn spell levels (1-7, gold cost), activate/clear the active spell. Caster classes only; spell level = (character level / 3) + 1, uncapped by code.' }, + @{ Name = 'UC-12 Level Up at Review Board'; Notes = 'Manual level-up: XP threshold + gold cost (500 per next level), full resource restore on level-up.' } + ) + foreach ($useCase in $useCases) { + $element = $ucModel.Elements.AddNew($useCase.Name, 'UseCase') + $element.Notes = $useCase.Notes + $null = $element.Update() + $elements[$useCase.Name] = $element + } + $ucModel.Elements.Refresh() + + $step = 'create associations' + function Add-EaConnector([object]$clientName, [object]$supplierName, [string]$type, [string]$stereotype) { + $connector = ($elements[$clientName]).Connectors.AddNew('', $type) + $connector.ClientID = ($elements[$clientName]).ElementID + $connector.SupplierID = ($elements[$supplierName]).ElementID + if ($stereotype) { $connector.Stereotype = $stereotype } + $null = $connector.Update() + ($elements[$clientName]).Connectors.Refresh() + } + Add-EaConnector 'Guest' 'UC-01 Register Account' 'Association' '' + Add-EaConnector 'Guest' 'UC-02 Authenticate' 'Association' '' + foreach ($ucName in @('UC-03 Manage Characters', 'UC-04 Browse Game Catalogue', 'UC-05 Train Stat', 'UC-06 Manage Equipment', 'UC-07 Trade at Shop', 'UC-08 Run Adventure', 'UC-09 Heal at Temple', 'UC-10 Practice Bard Song', 'UC-11 Study Magic', 'UC-12 Level Up at Review Board')) { + Add-EaConnector 'Player' $ucName 'Association' '' + } + Add-EaConnector 'Administrator' 'UC-03 Manage Characters' 'Association' '' + + $step = 'create scenarios' + $scenariosByUc = @{ + 'UC-01 Register Account' = @( + @{ Name = 'Main Success Scenario'; Type = 'Basic Path'; + Notes = ''; + Steps = @( + @{ Text = 'Guest submits name (3-32 chars) and password (>= 6 chars).'; Actor = 'Guest' }, + @{ Text = 'System verifies the name is unused -> 409 CONFLICT if taken.'; Actor = 'System' }, + @{ Text = 'System hashes the password (argon2), assigns the player role and the next id, persists the user.'; Actor = 'System' }, + @{ Text = 'System issues a JWT (HS256, player role) and returns {token, user_id, name, role}.'; Actor = 'System' } ) }, + @{ Name = 'Alternate flows'; Type = 'Alternate'; + Notes = 'name length invalid -> 400; password too short -> 400; name taken -> 409; hashing/token failure -> 500.'; Steps = @() } + ) + 'UC-02 Authenticate' = @( + @{ Name = 'Main Success Scenario'; Type = 'Basic Path'; + Notes = ''; + Steps = @( + @{ Text = 'Guest submits name and password.'; Actor = 'Guest' }, + @{ Text = 'System finds the user (401 if unknown) and verifies the password hash (401 on mismatch).'; Actor = 'System' }, + @{ Text = 'System issues a JWT carrying the user''s real role (admin/player) and returns {token, user_id, name, role}.'; Actor = 'System' } ) }, + @{ Name = 'Alternate flows'; Type = 'Alternate'; + Notes = 'unknown name or wrong password -> 401 UNAUTHORIZED; token creation failure -> 500.'; Steps = @() } + ) + 'UC-03 Manage Characters' = @( + @{ Name = 'Main Success Scenario (create)'; Type = 'Basic Path'; + Notes = 'Admin may act for any user.'; + Steps = @( + @{ Text = 'Player submits character name, race, class for their user id.'; Actor = 'Player' }, + @{ Text = 'System validates the target user exists (404) and the trimmed name is 1-32 chars (400).'; Actor = 'System' }, + @{ Text = '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.'; Actor = 'System' }, + @{ Text = 'System assigns the next character id, stores and persists the character; returns it fully.'; Actor = 'System' } ) }, + @{ Name = 'list/view'; Type = 'Alternate'; + Notes = '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.'; Steps = @() }, + @{ Name = 'Alternate flows'; Type = 'Alternate'; + Notes = 'foreign user/character without admin role -> 403 FORBIDDEN; user/character not found -> 404; invalid name -> 400.'; Steps = @() } + ) + 'UC-04 Browse Game Catalogue' = @( + @{ Name = 'Main Success Scenario'; Type = 'Basic Path'; + Notes = ''; + Steps = @( + @{ Text = 'Player requests the catalogue: races, classes, items (optional type/quality/slot filters), enemies, or adventure zones.'; Actor = 'Player' }, + @{ Text = 'System returns static game data loaded from skara-brae-common JSON (class info is enriched with is_caster).'; Actor = 'System' } ) }, + @{ Name = 'Alternate flows'; Type = 'Alternate'; + Notes = 'single item by unknown id -> 404; filters may return an empty list (not an error).'; Steps = @() } + ) + 'UC-05 Train Stat' = @( + @{ Name = 'Main Success Scenario'; Type = 'Basic Path'; + Notes = 'Precondition: character exists and caller may act on it.'; + Steps = @( + @{ Text = 'Player selects a training ground (strength / dexterity / constitution / intelligence / luck) and submits the stat name.'; Actor = 'Player' }, + @{ Text = 'System applies Regenerate Resources.'; Actor = 'System' }, + @{ Text = 'System checks energy >= 1 and gold >= training cost (cost grows ~1.3x per 10 stat points).'; Actor = 'System' }, + @{ Text = 'System deducts 1 energy and the gold cost, increments the stat; constitution training recomputes max HP (current HP clamped).'; Actor = 'System' }, + @{ Text = 'System persists and returns {success, message, character}.'; Actor = 'System' } ) }, + @{ Name = 'Alternate flows'; Type = 'Alternate'; + Notes = 'foreign character -> 403; character not found -> 404; not enough energy -> 422; not enough gold -> 422; unknown stat name -> 400.'; Steps = @() } + ) + 'UC-06 Manage Equipment' = @( + @{ Name = 'Main Success Scenario (equip)'; Type = 'Basic Path'; + Notes = ''; + Steps = @( + @{ Text = 'Player submits an item id from their inventory.'; Actor = 'Player' }, + @{ Text = 'System moves the item from inventory to its slot; a previously equipped item in that slot returns to inventory.'; Actor = 'System' }, + @{ Text = 'System persists and returns the updated character.'; Actor = 'System' } ) }, + @{ Name = 'unequip'; Type = 'Alternate'; + Notes = 'Player submits a slot name; the system moves the equipped item back to inventory.'; Steps = @() }, + @{ Name = 'Alternate flows'; Type = 'Alternate'; + Notes = 'foreign character -> 403; character/item not found -> 404; item not in inventory -> 400; nothing equipped in slot -> 400.'; Steps = @() } + ) + 'UC-07 Trade at Shop' = @( + @{ Name = 'Main Success Scenario (buy)'; Type = 'Basic Path'; + Notes = ''; + Steps = @( + @{ Text = 'Player views the shop: always-stocked items plus rotating stock (with quantities) alongside their inventory.'; Actor = 'Player' }, + @{ Text = 'Player submits buy + item id.'; Actor = 'Player' }, + @{ Text = 'System checks gold >= price (422), and for non-always-in-stock items decrements the limited stock (410 GONE if 0).'; Actor = 'System' }, + @{ Text = 'System deducts gold, adds the item to inventory, persists character and stock; returns {success, message, gold}.'; Actor = 'System' } ) }, + @{ Name = 'sell'; Type = 'Alternate'; + Notes = 'Player submits sell + item id; system removes the first matching inventory item, pays half the item value, increments shop stock for non-permanent items.'; Steps = @() }, + @{ Name = 'Alternate flows'; Type = 'Alternate'; + Notes = 'unknown action or item not owned (sell) -> 400; item not found -> 404; insufficient gold -> 422; stock exhausted -> 410.'; Steps = @() } + ) + 'UC-08 Run Adventure' = @( + @{ Name = 'Main Success Scenario'; Type = 'Basic Path'; + Notes = 'Precondition: character exists and caller may act on it.'; + Steps = @( + @{ Text = 'Player requests an adventure for a zone (default skara_brae_streets).'; Actor = 'Player' }, + @{ Text = 'System validates the zone (404) and applies Regenerate Resources.'; Actor = 'System' }, + @{ Text = 'System refuses incapacitated characters (HP <= 0 -> 400) and characters with nerve below the zone cost (422); otherwise deducts the nerve cost.'; Actor = 'System' }, + @{ Text = 'System draws one enemy from the zone''s weighted encounter table.'; Actor = 'System' }, + @{ Text = '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.'; Actor = 'System' }, + @{ Text = '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).'; Actor = 'System' }, + @{ Text = 'On defeat: character HP set to 0; no rewards.'; Actor = 'System' }, + @{ Text = 'Spell-point upkeep is charged whenever a spell was active, win or lose.'; Actor = 'System' }, + @{ Text = 'System persists the character and returns {zone, enemy, combat_log, loot} (loot = item display names).'; Actor = 'System' } ) }, + @{ Name = 'Alternate flows'; Type = 'Alternate'; + Notes = 'foreign character -> 403; character/zone not found -> 404; incapacitated -> 400; insufficient nerve -> 422.'; Steps = @() }, + @{ Name = 'Exception'; Type = 'Exception'; + Notes = 'encounter references an unknown enemy (data error) -> 500 (post WI-2; previously a panic).'; Steps = @() } + ) + 'UC-09 Heal at Temple' = @( + @{ Name = 'Main Success Scenario'; Type = 'Basic Path'; + Notes = ''; + Steps = @( + @{ Text = 'Player requests healing, optionally partial.'; Actor = 'Player' }, + @{ Text = 'System computes missing HP; full heals all, partial heals half (rounded up); cost = amount healed, minimum 1 gold.'; Actor = 'System' }, + @{ Text = 'System checks gold (422), deducts it, restores HP, persists; returns {hp, max_hp, gold, healed: true}.'; Actor = 'System' } ) }, + @{ Name = 'Alternate flows'; Type = 'Alternate'; + Notes = 'foreign character -> 403; not found -> 404; already at full HP -> 400; insufficient gold -> 422.'; Steps = @() } + ) + 'UC-10 Practice Bard Song' = @( + @{ Name = 'Main Success Scenario'; Type = 'Basic Path'; + Notes = 'Precondition: character class is Bard.'; + Steps = @( + @{ Text = 'Player views available songs (level-gated, with combat effect descriptions) and the active song.'; Actor = 'Player' }, + @{ Text = 'Player submits a song id; system validates level requirement (422) and sets it active; returns {success, message, active_song}.'; Actor = 'Player' } ) }, + @{ Name = 'clear'; Type = 'Alternate'; + Notes = 'submitting no song id clears the active song.'; Steps = @() }, + @{ Name = 'Alternate flows'; Type = 'Alternate'; + Notes = 'non-Bard -> 403; unknown song -> 404; level too low -> 422; foreign character -> 403; not found -> 404.'; Steps = @() } + ) + 'UC-11 Study Magic' = @( + @{ Name = 'Main Success Scenario (view spellbook)'; Type = 'Basic Path'; + Notes = '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.'; + Steps = @( + @{ Text = '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.'; Actor = 'Player' } ) }, + @{ Name = 'learn spell level'; Type = 'Alternate'; + Notes = ''; + Steps = @( + @{ Text = 'Player submits a spell level (1-7).'; Actor = 'Player' }, + @{ Text = '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).'; Actor = 'System' }, + @{ Text = '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}.'; Actor = 'System' } ) }, + @{ Name = 'activate/clear active spell'; Type = 'Alternate'; + Notes = ''; + Steps = @( + @{ Text = 'Player submits a spell id (or empty to clear).'; Actor = 'Player' }, + @{ Text = '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}.'; Actor = 'System' } ) } + ) + 'UC-12 Level Up at Review Board' = @( + @{ Name = 'Main Success Scenario'; Type = 'Basic Path'; + Notes = ''; + Steps = @( + @{ Text = 'Player requests a level-up.'; Actor = 'Player' }, + @{ Text = 'System validates: below max level 100, XP >= 18*100*(level)^2 for the next level (i.e. xp_for_level(next)), gold >= 500*next level.'; Actor = 'System' }, + @{ Text = 'System deducts the gold cost, increments the level, recomputes and fully restores HP/energy/nerve and max spell points; persists; returns {success, message, character}.'; Actor = 'System' } ) }, + @{ Name = 'Alternate flows'; Type = 'Alternate'; + Notes = 'already at max level; not enough XP; not enough gold -> all 422. Foreign character -> 403; not found -> 404.'; Steps = @() } + ) + ) + foreach ($useCase in $useCases) { + $ucElement = $elements[$useCase.Name] + foreach ($scenarioData in $scenariosByUc[$useCase.Name]) { + $scenario = $ucElement.Scenarios.AddNew($scenarioData.Name, $scenarioData.Type) + $scenario.Notes = $scenarioData.Notes + $null = $scenario.Update() + Set-ScenarioSteps $scenario $scenarioData.Steps + } + $ucElement.Scenarios.Refresh() + $null = $ucElement.Update() + } + + $step = 'create use case overview diagram' + $diagram = $ucModel.Diagrams.AddNew('Skara Brae Use Case Overview', 'UseCase') + $diagram.Notes = 'Actor-to-use-case overview of the implemented server MVP.' + $null = $diagram.Update() + $ucModel.Diagrams.Refresh() + $diagram = $ucModel.Diagrams.GetByName('Skara Brae Use Case Overview') + $actorY = 40 + foreach ($actor in $actors) { + $diagramObject = $diagram.DiagramObjects.AddNew('', '') + $diagramObject.ElementID = ($elements[$actor.Name]).ElementID + $diagramObject.left = 40; $diagramObject.right = 130 + $diagramObject.top = $actorY; $diagramObject.bottom = $actorY + 60 + $null = $diagramObject.Update() + $actorY += 200 + } + $ucX = 400; $ucY = 40 + foreach ($useCase in $useCases) { + $diagramObject = $diagram.DiagramObjects.AddNew('', '') + $diagramObject.ElementID = ($elements[$useCase.Name]).ElementID + $diagramObject.left = $ucX; $diagramObject.right = $ucX + 300 + $diagramObject.top = $ucY; $diagramObject.bottom = $ucY + 60 + $null = $diagramObject.Update() + $ucY += 100 + } + $null = $diagram.Update() + + # ---------- Analysis-Design tree ---------- + $step = 'remove and recreate Analysis-Design' + for ($i = 0; $i -lt $root.Packages.Count; $i++) { + if (($root.Packages.GetAt([int]$i)).Name -eq 'Analysis-Design') { + $null = $root.Packages.DeleteAt([int]$i, $false) + $root.Packages.Refresh() + break + } + } + $analysisDesign = $root.Packages.AddNew('Analysis-Design', 'Package') + $null = $analysisDesign.Update() + $root.Packages.Refresh() + + $step = 'Architecture Overview package' + $architecture = $analysisDesign.Packages.AddNew('Architecture Overview', 'Package') + $architecture.Notes = 'Dependency rule (Clean Architecture): all controllers/entities 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). Boundary rules: design note §3.' + $null = $architecture.Update() + $analysisDesign.Packages.Refresh() + $crossCutting = @( + @{ Name = 'DomainError'; Notes = '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.' }, + @{ Name = 'Constants'; Notes = 'sb-core::constants realizes NFR "no magic numbers".' }, + @{ Name = 'Responses'; Notes = 'sb-api::routes::responses realizes NFR-19 (wire-format freeze).' } + ) + foreach ($crossElement in $crossCutting) { + $element = $architecture.Elements.AddNew($crossElement.Name, 'Class') + $element.Notes = $crossElement.Notes + $null = $element.Update() + } + $architecture.Elements.Refresh() + + $step = 'UC realization packages (from exports/uc-realizations.json)' + foreach ($realization in $realizations.realizations) { + $realizationPackage = $analysisDesign.Packages.AddNew($realization.id, 'Package') + $null = $realizationPackage.Update() + $analysisDesign.Packages.Refresh() + + $collaboration = $realizationPackage.Elements.AddNew($realization.id, 'Collaboration') + $null = $collaboration.Update() + $realizationPackage.Elements.Refresh() + $connector = $collaboration.Connectors.AddNew('', 'Realization') + $connector.ClientID = $collaboration.ElementID + $connector.SupplierID = ($elements[$realization.useCase]).ElementID + $null = $connector.Update() + $collaboration.Connectors.Refresh() + + foreach ($mappingElement in $realization.elements) { + $classElement = $realizationPackage.Elements.AddNew($mappingElement.name, 'Class') + $classElement.Stereotype = $mappingElement.stereotype + $classElement.Notes = "Design element: $($mappingElement.designElement). $($mappingElement.notes)" + $null = $classElement.Update() + } + $realizationPackage.Elements.Refresh() + + $realizationDiagram = $realizationPackage.Diagrams.AddNew($realization.id, 'Class') + $null = $realizationDiagram.Update() + $realizationPackage.Diagrams.Refresh() + $diagramObj = $realizationPackage.Diagrams.GetByName($realization.id) + $classY = 40 + foreach ($mappingElement in $realization.elements) { + $placed = $false + for ($e = 0; $e -lt $realizationPackage.Elements.Count; $e++) { + $candidate = $realizationPackage.Elements.GetAt([int]$e) + if ($candidate.Name -eq $mappingElement.name -and $candidate.Type -eq 'Class') { + $do = $diagramObj.DiagramObjects.AddNew('', '') + $do.ElementID = $candidate.ElementID + $do.left = 40; $do.right = 300 + $do.top = $classY; $do.bottom = $classY + 60 + $null = $do.Update() + $classY += 100 + $placed = $true + break + } + } + if (-not $placed) { throw "class not found in $($realization.id): $($mappingElement.name)" } + } + $null = $diagramObj.Update() + } + + # ---------- verify ---------- + $step = 'verify' + $null = $repo.CloseFile() + $null = $repo.OpenFile($modelPath) + $checkUc = $repo.Models.GetAt([int]0).Packages.GetByName('Requirements').Packages.GetByName('Use Case Model') + $checkUc.Elements.Refresh() + $actorCount = 0; $ucCount = 0 + for ($i = 0; $i -lt $checkUc.Elements.Count; $i++) { + $element = $checkUc.Elements.GetAt([int]$i) + if ($element.Type -eq 'Actor') { $actorCount++ } + if ($element.Type -eq 'UseCase') { $ucCount++ } + } + $checkAd = $repo.Models.GetAt([int]0).Packages.GetByName('Analysis-Design') + $checkAd.Packages.Refresh() + $realizationCount = 0 + for ($i = 0; $i -lt $checkAd.Packages.Count; $i++) { + if (($checkAd.Packages.GetAt([int]$i)).Name -like 'UC-?? Realization') { $realizationCount++ } + } + Write-Output "VERIFY actors=$actorCount (expect 3), useCases=$ucCount (expect 12), realizationPackages=$realizationCount (expect 12)" + if ($actorCount -ne 3 -or $ucCount -ne 12 -or $realizationCount -ne 12) { Write-Output 'VERIFY FAILED'; exit 1 } + $null = $repo.CloseFile() + Write-Output 'ALL STEPS OK - save the model in EA if prompted, then run export-model.ps1 and BUILD-CHECKLIST.md §4' +} +catch { + Write-Output "FAILED at [$step]: $($_.Exception.Message)" + exit 1 +} +finally { + Get-Process -Name EA -ErrorAction SilentlyContinue | + Where-Object { $eaBefore -notcontains $_.Id } | + ForEach-Object { Stop-Process -Id $_.Id -Force } +} diff --git a/scripts/openup/export-model.ps1 b/scripts/openup/export-model.ps1 new file mode 100644 index 0000000..b99c953 --- /dev/null +++ b/scripts/openup/export-model.ps1 @@ -0,0 +1,171 @@ +# Skara Brae — EA model exporter (WI-2), adapted from keyfob-station's +# export-model-json.ps1 (COM walk of the model -> machine-readable JSON). +# +# Regenerates (generated files — never hand-edit): +# docs/openup/model/exports/use-case-model.json (actors, use cases, scenarios, steps) +# docs/openup/model/exports/uc-realizations.json (realization packages + elements) +# docs/openup/model/exports/xmi-backup.xml (lossless EA XMI 2.1 export of the root) +# +# Drift rule: markdown (use-case-model.md, uc-realization-mapping.md) and the model must +# agree; a mismatch is a defect fixed in the same cycle. uc-realizations.json content must +# match what scripts/openup/markdown-to-json.py generated from mapping §2. +# +# Platform: Windows + installed EA (design §11.c). If COM automation hits friction, the +# fallback is EA's manual Publish -> XML export into exports/ (see model/README.md). + +$ErrorActionPreference = 'Stop' + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$modelPath = Join-Path $repoRoot 'docs\openup\model\skara-brae.eapx' +if (-not (Test-Path $modelPath)) { + Write-Output "MISSING $modelPath - nothing to export" + exit 1 +} +$exportsDir = Join-Path $repoRoot 'docs\openup\model\exports' +New-Item -ItemType Directory -Force -Path $exportsDir | Out-Null +$ucOutPath = Join-Path $exportsDir 'use-case-model.json' +$realOutPath = Join-Path $exportsDir 'uc-realizations.json' +$xmiOutPath = Join-Path $exportsDir 'xmi-backup.xml' + +$app = $null +$eaBefore = @(Get-Process -Name EA -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id) +try { + $app = New-Object -ComObject EA.App + $app.Visible = $false + $repo = $app.Repository + $null = $repo.OpenFile($modelPath) + $root = $repo.Models.GetAt([int]0) + + # ---------- Requirements / Use Case Model ---------- + $ucModel = $root.Packages.GetByName('Requirements').Packages.GetByName('Use Case Model') + + $actors = @() + $useCases = @() + for ($i = 0; $i -lt $ucModel.Elements.Count; $i++) { + $element = $ucModel.Elements.GetAt([int]$i) + if ($element.Type -eq 'Actor') { + $actors += [ordered]@{ name = $element.Name; notes = $element.Notes } + } + } + + for ($i = 0; $i -lt $ucModel.Elements.Count; $i++) { + $element = $ucModel.Elements.GetAt([int]$i) + if ($element.Type -ne 'UseCase') { continue } + + $associatedActors = @() + for ($c = 0; $c -lt $element.Connectors.Count; $c++) { + $connector = $element.Connectors.GetAt([int]$c) + if ($connector.Type -ne 'Association') { continue } + $otherId = if ($connector.ClientID -eq $element.ElementID) { $connector.SupplierID } else { $connector.ClientID } + $other = $repo.GetElementByID($otherId) + if ($other.Type -eq 'Actor') { $associatedActors += $other.Name } + } + + $scenarios = @() + $element.Scenarios.Refresh() + for ($s = 0; $s -lt $element.Scenarios.Count; $s++) { + $scenario = $element.Scenarios.GetAt([int]$s) + $steps = @() + $scenario.Steps.Refresh() + for ($t = 0; $t -lt $scenario.Steps.Count; $t++) { + $scenarioStep = $scenario.Steps.GetAt([int]$t) + $steps += [ordered]@{ actor = $scenarioStep.StepType; text = $scenarioStep.Name } + } + $scenarios += [ordered]@{ + name = $scenario.Name + type = $scenario.Type + notes = $scenario.Notes + steps = $steps + } + } + + $useCases += [ordered]@{ + name = $element.Name + notes = $element.Notes + actors = $associatedActors + scenarios = $scenarios + } + } + + $ucPayload = [ordered]@{ + exportedFrom = 'docs/openup/model/skara-brae.eapx (Requirements/Use Case Model)' + actors = $actors + useCases = $useCases + } + [System.IO.File]::WriteAllText($ucOutPath, ($ucPayload | ConvertTo-Json -Depth 8), [System.Text.Encoding]::UTF8) + Write-Output "exported: $ucOutPath ($($useCases.Count) use cases, $($actors.Count) actors)" + + # ---------- Analysis-Design realizations ---------- + $analysisDesign = $root.Packages.GetByName('Analysis-Design') + $realizations = @() + for ($p = 0; $p -lt $analysisDesign.Packages.Count; $p++) { + $realizationPackage = $analysisDesign.Packages.GetAt([int]$p) + if ($realizationPackage.Name -notlike 'UC-?? Realization') { continue } + $realizationPackage.Elements.Refresh() + + $useCaseName = '' + $classes = @() + for ($e = 0; $e -lt $realizationPackage.Elements.Count; $e++) { + $element = $realizationPackage.Elements.GetAt([int]$e) + if ($element.Type -eq 'Collaboration') { + $element.Connectors.Refresh() + for ($c = 0; $c -lt $element.Connectors.Count; $c++) { + $connector = $element.Connectors.GetAt([int]$c) + if (($connector.Type -eq 'Realization' -or $connector.Type -eq 'Realisation') -and $connector.ClientID -eq $element.ElementID) { + $useCaseName = $repo.GetElementByID($connector.SupplierID).Name + } + } + } + if ($element.Type -eq 'Class') { + $classes += [ordered]@{ + name = $element.Name + stereotype = $element.Stereotype + notes = $element.Notes + } + } + } + $realizations += [ordered]@{ + id = $realizationPackage.Name + useCase = $useCaseName + elements = $classes + } + } + + $architecture = $analysisDesign.Packages.GetByName('Architecture Overview') + $architecture.Elements.Refresh() + $crossCutting = @() + for ($e = 0; $e -lt $architecture.Elements.Count; $e++) { + $element = $architecture.Elements.GetAt([int]$e) + $crossCutting += [ordered]@{ name = $element.Name; notes = $element.Notes } + } + + $realPayload = [ordered]@{ + exportedFrom = 'docs/openup/model/skara-brae.eapx (Analysis-Design realization packages)' + realizations = $realizations + crossCutting = $crossCutting + } + [System.IO.File]::WriteAllText($realOutPath, ($realPayload | ConvertTo-Json -Depth 8), [System.Text.Encoding]::UTF8) + Write-Output "exported: $realOutPath ($($realizations.Count) realizations)" + + # ---------- lossless XMI backup ---------- + try { + $project = $repo.GetProjectInterface() + $null = $project.ExportPackageXMI($root.PackageGUID, $xmiOutPath, 1, 0) + Write-Output "exported: $xmiOutPath (XMI 2.1 backup of model root '$($root.Name)')" + } + catch { + Write-Output "WARNING: XMI export failed ($($_.Exception.Message)); use EA Publish -> XML manually per model/README.md option 2" + } + + $null = $repo.CloseFile() + Write-Output 'EXPORT OK - review the exports diff, then commit .eapx + exports/ + scripts/openup/ as one commit' +} +catch { + Write-Output "FAILED: $($_.Exception.ToString())" + exit 1 +} +finally { + Get-Process -Name EA -ErrorAction SilentlyContinue | + Where-Object { $eaBefore -notcontains $_.Id } | + ForEach-Object { Stop-Process -Id $_.Id -Force } +} diff --git a/scripts/openup/markdown-to-json.py b/scripts/openup/markdown-to-json.py new file mode 100644 index 0000000..8a9df99 --- /dev/null +++ b/scripts/openup/markdown-to-json.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Generate the machine-readable OpenUP model intermediates from the markdown sources. + +Transforms the approved OpenUP artifacts (never hand-edited exports): + docs/openup/analysis-design/uc-realization-mapping.md (§2 table) + docs/openup/requirements/use-case-model.md (canonical UC names) +into: + docs/openup/model/exports/uc-realizations.json + +The use-case-model.json intermediate is NOT produced here: the markdown does not +carry per-step actor tags, so a faithful transformation requires the EA session +(export-model.ps1) or staged decisions. See docs/openup/model/README.md. + +Runs on the standard library only (Linux, no EA/COM required). +""" + +import json +import re +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +MAPPING_MD = REPO_ROOT / "docs/openup/analysis-design/uc-realization-mapping.md" +UC_MODEL_MD = REPO_ROOT / "docs/openup/requirements/use-case-model.md" +OUTPUT_JSON = REPO_ROOT / "docs/openup/model/exports/uc-realizations.json" + +UC_ID_PATTERN = re.compile(r"^(UC-\d{2})\s*(.*)$") + + +def canonical_use_case_names() -> dict[str, str]: + """UC id -> full use case name, from the use-case-model.md overview table.""" + names: dict[str, str] = {} + for line in UC_MODEL_MD.read_text(encoding="utf-8").splitlines(): + cells = [cell.strip() for cell in line.strip().strip("|").split("|")] + if len(cells) < 2: + continue + match = UC_ID_PATTERN.match(cells[0]) + if match: + names[match.group(1)] = cells[1] + if len(names) != 12: + raise SystemExit( + f"expected 12 use cases in {UC_MODEL_MD}, found {len(names)}" + ) + return names + + +def split_table_row(line: str) -> list[str]: + return [cell.strip() for cell in line.strip().strip("|").split("|")] + + +def is_table_row(line: str) -> bool: + return line.strip().startswith("|") and line.strip().endswith("|") + + +def parse_realization_table(mapping_text: str) -> tuple[list[dict], dict[str, str]]: + """Parse the §2 mapping table into per-UC realization entries. + + Returns (realizations, paragraphs) where paragraphs carries the verbatim + cross-cutting and dependency-rule paragraphs that follow the table. + """ + lines = mapping_text.splitlines() + section_started = False + realizations: list[dict] = [] + current: dict | None = None + + for index, line in enumerate(lines): + if line.startswith("## 2. UC realization mapping"): + section_started = True + continue + if not section_started: + continue + if line.startswith("## 3."): + break + if not is_table_row(line): + continue + cells = split_table_row(line) + if cells[0] and set(cells[0]) <= {"-", " ", ":"}: + continue + if cells[0] in ("UC", "Analysis class"): + continue + + uc_cell = cells[0] + element = { + # Emphasis markers are formatting, not content; element names are + # identifiers (e.g. **AdventureService** -> AdventureService). + # All other cells stay verbatim. + "name": cells[1].replace("**", ""), + "stereotype": cells[2], + "designElement": cells[3], + "notes": cells[4] if len(cells) > 4 else "", + } + id_match = UC_ID_PATTERN.match(uc_cell) + if id_match: + current = {"id": f"{id_match.group(1)} Realization", "useCaseId": id_match.group(1), "elements": [element]} + realizations.append(current) + elif uc_cell == "" and current is not None: + current["elements"].append(element) + else: + raise SystemExit(f"unparseable mapping row {index + 1}: {line!r}") + + paragraphs = {} + for paragraph_key, paragraph_prefix in ( + ("crossCutting", "Cross-cutting design elements"), + ("dependencyRule", "Dependency rule check"), + ): + paragraphs[paragraph_key] = next( + (line for line in lines if line.startswith(paragraph_prefix)), "" + ) + return realizations, paragraphs + + +def build_payload() -> dict: + canonical_names = canonical_use_case_names() + realizations, paragraphs = parse_realization_table( + MAPPING_MD.read_text(encoding="utf-8") + ) + if len(realizations) != 12: + raise SystemExit( + f"expected 12 realizations in {MAPPING_MD}, found {len(realizations)}" + ) + for realization in realizations: + realization["useCase"] = canonical_names[realization.pop("useCaseId")] + return { + "exportedFrom": ( + "docs/openup/analysis-design/uc-realization-mapping.md §2, generated by " + "scripts/openup/markdown-to-json.py — generated file, never hand-edit" + ), + "realizations": realizations, + "crossCutting": paragraphs["crossCutting"], + "dependencyRule": paragraphs["dependencyRule"], + } + + +def main() -> int: + payload = build_payload() + OUTPUT_JSON.parent.mkdir(parents=True, exist_ok=True) + OUTPUT_JSON.write_text( + json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + ) + element_count = sum(len(r["elements"]) for r in payload["realizations"]) + print( + f"wrote {OUTPUT_JSON.relative_to(REPO_ROOT)}: " + f"{len(payload['realizations'])} realizations, {element_count} elements" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main())