Compare commits

...

9 commits

Author SHA1 Message Date
0e61d7cd02 Merge WI-2 OpenUP work set + EA model (PSP1, issue #2) into main
All checks were successful
build / build-test (push) Successful in 46s
2026-10-05 10:01:01 +02:00
6e5d3fb45a openup: architecture notebook + EA diagram renders
All checks were successful
build / build-test (push) Successful in 44s
- analysis-design/architecture-notebook.md: Architecture Notebook work
  product in the keyfob docx template structure (10 sections), grounded
  in mapping / design note / supplementary specs; embeds the component
  model and all 12 realization figures
- scripts/openup/export-diagram-images.ps1: read-only EA COM renderer
  (EA-15 findings: image API on ProjectInterface, OpenDiagram before
  export or blank stub, type 0 = PNG); renders reflect manual layouts
- renders: requirements/use-case-overview.png, analysis-design/
  uc-01..12-realization.png + component-architecture.png; embedded as
  figures in use-case-model.md and uc-realization-mapping.md
- BUILD-CHECKLIST 6.4 documents the exporter
2026-10-05 10:00:45 +02:00
c0226df62d openup: EA model built + enriched - baseline, associations, joint design package
All checks were successful
build / build-test (push) Successful in 16s
Windows/EA session per BUILD-CHECKLIST 0-6 (the planned human-gate path,
executed on the EA machine). Baseline built from EABase.eapx via
build-model.ps1 and verified per checklist 4: 3 actors, 12 UCs with
scenarios, 13 associations, 0 include connectors, 7 SS requirements,
12 realization packages / 36 elements; UC-08 MSS (9 steps) and UC-11
learn spell level (3 steps) match use-case-model.md verbatim.

Script fixes (defects 1-3 of the session log): PS 5.1 hashtable parse
error; realization UC lookup now accepts short and full use-case names.

Enrichment after a manual layout pass - model changes are surgical from
now on, re-running build-model.ps1 is forbidden (checklist 6):
- add-analysis-associations.ps1: 24 analysis associations per mapping
  2a (boundary-controller-entity; UC-04 boundary-entity) rendered via
  DiagramLinks without touching manual layout; 4 uses-dependencies to
  DomainError (UC-05/06/08/12 error paths, model-level)
- add-design-package.ps1: Analysis-Design/Design - joint component
  model (skara-brae-client, sb-api, sb-auth, sb-db, sb-core, sb-shared,
  sb-common; 10 dependencies, rule inward). Joint-model decision
  recorded in mapping 1: one model for server+client+common; client-
  side UC realizations are a future work item.

Markdown sources updated first (drift rule): mapping 1 tree + 2a
interactions + 3 design component model; checklist 6. Exports
regenerated from the model: use-case-model.json new,
uc-realizations.json re-exported (useCase full names, notes merged,
crossCutting added; element content verified equal to the
markdown-to-json generator output).

Open: XMI backup - ExportPackageXMI fails via COM (DISP_E_TYPEMISMATCH,
PS 5.1 + cscript); manual EA UI export per model/README.md option 2.
2026-10-03 19:47:59 +02:00
Jan-Peter von Hunnius
201f8f251c docs(openup): WI-2 postmortem actuals in Work Items List (issue #2)
All checks were successful
build / build-test (push) Successful in 23s
2026-10-03 17:56:23 +02:00
Jan-Peter von Hunnius
161dcf1270 docs(openup): WI-2 test report — architecture cleanup CMP+TST (issue #2) 2026-10-03 17:50:58 +02:00
Jan-Peter von Hunnius
41f1ec2887 openup: EA model preparation (COD) - checklist, scripts, realizations intermediate
Per design-wi-2-architecture-cleanup.md §11.b/c: keyfob-station pipeline
studied (add-wi19-uc11.ps1, export-model-json.ps1). Platform gap: EA COM
pipeline is Windows-only; this session ran on Linux (no EABase.eapx found,
no pwsh) - .eapx skeleton NOT committed, documented in model/README.md
with the two path options. Landed:
- docs/openup/model/BUILD-CHECKLIST.md: full EA-session checklist
  (packages, 3 actors, 12 UCs with scenarios, 13 associations, 12
  realization packages / 36 elements) derived from use-case-model.md +
  uc-realization-mapping.md
- docs/openup/model/README.md: platform note, status, drift rule
- docs/openup/model/exports/uc-realizations.json: generated from mapping
  §2 via scripts/openup/markdown-to-json.py (tested locally)
- scripts/openup/build-model.ps1, export-model.ps1: adapted from keyfob
  patterns, Windows-gated (human gate per design §11.d)
- scripts/openup/markdown-to-json.py: local markdown -> JSON transform
2026-10-03 17:38:06 +02:00
Jan-Peter von Hunnius
7a9a5a3e7b docs(game): migrate game-design canon notes from Joplin into docs/game/
Eight canon notes (Game Concept Overview, Mechanics Source Mapping,
Combat System & Stat Scaling Design, Skill & Magic System, Medals &
Honours System, Lore & World Building, Idle Fantasy Inspirations,
Tech Stack & Architecture) as markdown, note title as H1, with source
line citing the Joplin note ID. Joplin keeps status notes.
2026-10-03 17:27:43 +02:00
Jan-Peter von Hunnius
2343d5e154 docs(openup): migrate WI-1 artifacts from unversioned project-top docs/
work-items.md (project-management/), design-wi-1-injectable-rng.md
(analysis-design/), test-report-wi-1-injectable-rng.md (test/) move into
the canonical OpenUP home per the 2026-10-03 decision reversal. Verbatim,
no content changes; project-top docs/ husk removed.
2026-10-03 17:18:04 +02:00
Jan-Peter von Hunnius
4798f55660 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).
2026-10-03 17:17:44 +02:00
44 changed files with 6740 additions and 0 deletions

View file

@ -0,0 +1,172 @@
# Skara Brae — Combat System & Stat Scaling Design
Source: Joplin note 0ef630db4e3e4d6dab18ccaefccaaa5a, migrated 2026-10-03
## The Core Tension
**RoleMaster approach**: Open-ended d100 roll + critical tables. A level 1 can theoretically kill a level 60 with a lucky crit (roll 01-00 on the crit table → "Instant Death"). Thrilling unpredictability, terrible long-term retention — months of work undone by RNG.
**Torn City approach**: Stats grow into hundreds of millions over years. Combat outcomes are overwhelmingly deterministic — a level 60 will always beat a level 1. Safe long-term progression, no surprise.
**Decision**: Hybrid "Scaled Brutality" — Torn-scale numbers with RoleMaster-inspired crits that are capped by level difference but never reach zero.
---
## Exponential Stat Growth (Torn-Style)
- Stats use `BIGINT` (up to 9.2 quintillion) — room for years of growth
- Training, equipment, education, and crafting all feed into stat progression
- Stats grow exponentially, not linearly — early levels feel meaningful, later levels require dedication
- **No rebirth/prestige system for now** — stats only ever go up. Can revisit with player base later.
### Stat Display
| Raw Value | Display |
|---|---|
| 1,247 | 1.2K |
| 12,432 | 12.4K |
| 4,721,000 | 4.7M |
| 1,234,567,890 | 1.2B |
| 3,800,000,000,000 | 3.8T |
- Show **effective power rating** (composite of all stats) as a single number for quick comparison
- Show **stat growth rate** (e.g., "+2.3K/day") so players feel momentum
- Historical stat graphs (like Torn) for long-term engagement
---
## RoleMaster-Inspired Critical System
Every attack rolls on a **critical table** (d100 open-ended: roll 96-100, roll again and add). But crit damage is **capped by level difference**.
### Crit Damage Caps
| Level Difference (target above attacker) | Crit Damage Cap |
|---|---|
| Equal level | Full crit range (01-100) — no cap |
| +10 levels | 60% of target's max HP |
| +20 levels | 40% of target's max HP |
| +50 levels | 15% of target's max HP |
| +100 levels | 5% of target's max HP |
The cap never reaches zero — a level 1 can always chip away at a level 60.
### Critical Effects
Crits don't just do damage. They apply **status effects** based on the crit roll and weapon type:
| Effect | Duration | Description |
|---|---|---|
| **Bleed** | 3-5 ticks | Damage over time (scaling with weapon) |
| **Stun** | 1 turn | Target skips next action |
| **Disarm** | 1 turn | Target loses weapon bonuses |
| **Cripple** | 3-5 turns | Target agility/strength reduced |
| **Blind** | 2-3 turns | Target accuracy reduced |
**Key insight**: Status effects work **regardless of level difference**. A level 1 landing a lucky crit can *disrupt* a level 60 (stun them for a turn) even if the damage is negligible. This creates the RoleMaster feel of "anyone can affect anyone" without the frustration of instant death.
### Armor and Crit Mitigation
- **Heavy armor** reduces crit roll by a flat amount (e.g., plate armor: -15 to crit roll)
- **Crit Shield enchantments**: High-end enchantments absorb X crit damage per day
- **Constitution** reduces bleed/cripple duration
---
## Why This Works
- **Long-term players** keep their billions-scale progression and feel powerful — they will win 99% of encounters
- **New players** feel the thrill of landing a lucky crit (15% HP off a whale is still exciting)
- **Guild wars become strategic**: A swarm of low-level players can chip away at a high-level defender through coordinated crit effects
- **Dueling has drama**: Crits are always possible, but rarely fight-ending between equals
- **No instant death**: No months of work lost to a single bad roll
---
## Combat Formula (Draft)
```
base_damage = weapon_damage * (1 + strength / 100)
mitigation = armor_rating * (1 + constitution / 200)
net_damage = max(1, base_damage - mitigation)
crit_roll = d100_open_ended() // 96-100: roll again and add
if crit_roll >= crit_threshold(attacker_agility, target_level_diff):
crit_multiplier = crit_table(crit_roll, weapon_type)
crit_damage = net_damage * crit_multiplier
crit_damage = min(crit_damage, target_max_hp * crit_cap(target_level_diff))
apply_critical_effect(crit_roll, target)
else:
apply_damage(net_damage, target)
```
### Open-Ended d100
```
fn d100_open_ended() -> u32:
total = d100()
while total >= 96: // 96-100: roll again and add
total += d100()
return total
```
This means most rolls are 1-95, but occasionally you get 96+100+3 = 199, or even rarer chains. The open-ended mechanic creates the "anything can happen" feel without instant death.
---
## Critical Tables (Structure)
Crit tables are weapon-type specific. Each entry maps a roll range to an effect:
```toml
# crit_tables/slash_unarmed.toml
[entries.01_20]
name = "Glancing blow"
damage_multiplier = 1.5
effect = "none"
[entries.21_50]
name = "Deep slash"
damage_multiplier = 2.0
effect = "bleed"
effect_duration = 3
[entries.51_75]
name = "Arterial strike"
damage_multiplier = 2.5
effect = "bleed"
effect_duration = 5
[entries.76_90]
name = "Debilitating wound"
damage_multiplier = 3.0
effect = "cripple"
effect_duration = 4
[entries.91_100]
name = "Devastating cleave"
damage_multiplier = 4.0
effect = "bleed"
effect_duration = 5
effect2 = "stun"
effect2_duration = 1
[entries.101_plus]
name = "Legendary strike"
damage_multiplier = 5.0
effect = "bleed"
effect_duration = 5
effect2 = "stun"
effect2_duration = 2
```
These tables live in `skara-brae-common/crit-tables/` and are loaded by both server (for combat resolution) and client (for display).
---
## Related Notes
- [Game Concept Overview](joplin://9cf8a2e701654ffbbd6512cc837c7e2f)
- [Idle Fantasy Inspirations](joplin://) *(to be created)*
- [Tech Stack & Architecture](joplin://cecd2ef2690d4d1fba3450342aa63953)
- [Project Plan](joplin://d243ae57778343fca997d46b73d0ee3b)

View file

@ -0,0 +1,569 @@
# Skara Brae — Game Concept Overview
Source: Joplin note 9cf8a2e701654ffbbd6512cc837c7e2f, migrated 2026-10-03
# Skara Brae — Fantasy Text-Based MMORPG
## Inspiration
**Torn City** is a text-based, crime-themed MMORPG running since 2004 with over a million users. Core mechanics include:
- Crime system (mugging, drug trafficking, organized crime)
- PvP combat with experience/leveling
- Factions (clan system with wars and organized crimes)
- Companies (player-run businesses)
- Properties and housing
- Stock market and banking
- Item market / bazaar / auctions
- Education system
- Bounty system
- Rich REST API for community tools
**Idle Fantasy** ([source](https://github.com/tristinbaker/IdleFantasy)) is an open-source offline idle RPG for Android with 23 skills, 20 dungeons, 170+ quests, guild rank progression, pets, and inn workers. Key inspirations: offline sessions, hireable NPC workers, pet buff system, blessing system, slayer tasks. See [Idle Fantasy Inspirations](joplin://48b3a3c76f734c8dafbbf9e8b3b5f80a) for full analysis.
**Skara Brae** transplants Torn's proven mechanics into a dark fantasy setting inspired by **The Bard's Tale I** and the real neolithic settlement on Orkney, with idle RPG elements from Idle Fantasy and a **RoleMaster-inspired critical hit system** (see [Combat System & Stat Scaling Design](joplin://0ef630db4e3e4d6dab18ccaefccaaa5a)).
---
## Setting
The city of **Skara Brae** — an ancient, labyrinthine city of stone and sorcery. Once a thriving hub of the Old Kingdom, now a place of shadow and opportunity. Adventurers arrive seeking fortune, power, and answers to mysteries buried beneath the cobblestones.
**Theme**: Low-to-dark fantasy. Grimy taverns, corrupt city watch, competing guilds, ancient dungeons beneath the city, magical artifacts, political intrigue. Not high fantasy — think more *The Witcher* meets *Thief* meets *Bard's Tale*.
---
## Core Game Loops
### 1. Adventures (→ Torn's "Crimes")
The primary PvE activity. Players embark on adventures throughout the city and surrounding wilderness.
| Torn Mechanic | Skara Brae Equivalent |
|---|---|
| Search for cash | Pick pockets in the marketplace |
| Sell illegal items | Fence stolen goods at the Thieves' Guild |
| Mug someone | Waylay travelers on the road |
| Burglary | Break into manors and vaults |
| Organized crime | Dungeon raids (guild activity) |
| Drug trafficking | Brew and sell potions (alchemy) |
Adventures have:
- **Risk/reward tiers** (safe but low yield → dangerous but high yield)
- **Cooldown timers** (energy system)
- **Skill-based success chances** (your thievery skill affects pickpocketing)
- **Time-of-day modifiers** (nighttime gives bonuses to stealth activities)
- **Day-of-week events** (market day, full moon effects, festival bonuses)
- **Offline continuation**: Adventures continue for up to 1 hour while logged out. On return, collect accumulated rewards. Server-side tick calculates elapsed rewards.
### 2. Combat (PvP & PvE)
- **Arena**: Structured PvP duels with ranked matchmaking
- **Ambush**: Open-world PvP with risk/reward (infamy system)
- **Dungeon bosses**: PvE endgame encounters
- **Combat is turn-based/text-based** with strategic depth (choose actions, manage stamina/mana)
- **RoleMaster-inspired critical hits**: Open-ended d100 crit tables with status effects (bleed, stun, disarm, cripple). Crit damage capped by level difference but never zero. See [Combat System & Stat Scaling Design](joplin://0ef630db4e3e4d6dab18ccaefccaaa5a).
- **Exponential stat growth**: BIGINT stats, tiered display (K/M/B/T). Stats only ever go up — no rebirth/prestige for now.
### 3. Economy
- **Bazaar**: Player-driven item marketplace
- **Auction House**: Rare items, timed auctions
- **Bank of Skara Brae**: Deposits, interest, loans
- **Merchant Ventures**: Invest in trading caravans (→ Torn's stocks) — returns depend on random events
- **Crafting**: Alchemy, blacksmithing, enchanting — create items to sell or use (see Crafting deep dive below)
- **Player shops/stalls**: Set up a stall in the Market Square (→ Torn's bazaar model)
### 4. Guilds (→ Torn's "Factions")
See Guilds deep dive below for full details.
- Player organizations with hierarchy (Guildmaster → Officers → Members → Initiates)
- **Guild Hall**: Upgradable base providing bonuses to members
- **Guild Wars**: Territory control, raids on rival guild halls
- **Guild Adventures**: Cooperative dungeon raids (→ organized crimes)
- **Guild treasury**: Shared resources, taxation of members
### 5. Properties (→ Torn's "Properties")
- **Dwellings**: From a straw bed at an inn to a tower in the city walls
- Properties provide: rest bonuses (energy recovery), storage, crafting stations
- Rentable, sellable, upgradable
### 6. Education (→ Torn's "Education")
- **Schools**: Fighters' Academy, College of Magic, Thieves' Den, Merchants' Guild School
- Courses take real time to complete, provide permanent stat/skill bonuses
- Some adventures require specific education prerequisites
### 7. Quests & Storylines
- **Main storyline**: Uncover the mystery beneath Skara Brae (Bard's Tale-inspired Mangar threat)
- **Side quests**: NPC-given missions with rewards
- **Dynamic events**: City-wide events (plague, invasion, festival)
- **Daily quests**: 3-5 per day from NPCs (Madame Sylara, Captain Rennick, Guildmasters). "Complete 3 adventures" / "Craft 2 potions" / "Win 1 arena duel". Rewards: gold, XP, skill XP, occasional items.
- **Weekly quests**: Larger goals, better rewards
- **Cumulative achievements**: "Mine 10,000 ore total" → permanent bonus
---
## Crafting System (Deep Dive)
### Gathering Professions (5)
| Profession | Materials | Locations |
|---|---|---|
| **Mining** | Ore, gems, stone | Mines, Undercity, mountains |
| **Herbalism** | Herbs, fungi, reagents | Forests, swamps, gardens |
| **Woodcutting** | Timber, resin, bark | Forests, groves |
| **Fishing** | Fish, pearls, salvage | Harbor, rivers, underground lakes |
| **Skinning** | Leather, bone, sinew | Wilds, dungeons (from kills) |
### Crafting Professions (7)
| Profession | Creates | Key Materials |
|---|---|---|
| **Blacksmithing** | Weapons, heavy armor, tools | Ore, coal, flux |
| **Alchemy** | Potions, poisons, elixirs | Herbs, reagents, solvents |
| **Enchanting** | Magical enhancements, scrolls | Rare reagents, essence |
| **Tailoring** | Cloth armor, bags, banners | Cloth, silk, magical thread |
| **Jewelcrafting** | Rings, amulets, gem cuts | Gems, precious metals |
| **Fletching** | Bows, crossbows, arrows | Wood, sinew, feathers |
| **Cooking** | Food (healing, buffs), beverages | Fish, meat, herbs, grain |
### Profession Mastery Levels
Novice → Apprentice → Journeyman → Expert → Master → Grandmaster
Each tier unlocks new recipes and improves crafting quality.
### Crafting Quality Rolls
Every crafted item rolls for quality:
| Quality | Chance (base) | Effect |
|---|---|---|
| Common | 50% | Base stats |
| Fine | 25% | +10% stats |
| Superior | 15% | +25% stats |
| Exceptional | 7% | +50% stats |
| Masterwork | 2.5% | +100% stats |
| Legendary | 0.5% | +200% stats + unique affix |
### Crafting Crits (RoleMaster-inspired)
- Small chance of exceptional result regardless of skill level (mirrors combat crit system)
- d100 roll on craft: 96+ triggers a crafting crit → quality tier upgraded by 1-2 levels
- High skill level improves base quality but crits can happen to anyone
### Recipe Discovery
- **Taught recipes**: From education courses, guild rank rewards
- **Adventure drops**: Recipe scrolls found in dungeons
- **Experimentation**: Combine materials with a chance to discover a new recipe (消耗 materials on failure)
- **Seasonal events**: Limited-time recipes
---
## The Inn — Social Hub (Deep Dive)
**The Singing Tavern** is the central social hub of Skara Brae, inspired by Idle Fantasy's Inn system.
### Social Features
- **Chat rooms**: General, trade, guild recruitment, district-specific, roleplay
- **Rumors board**: NPC-posted hints about adventures, dungeon openings, rare spawns, upcoming events
- **Bounty board**: Player-posted and NPC-posted bounties on other players
- **Barter table**: Direct player-to-player trading (avoid market fees) — face-to-face exchange
### Minigames & Gambling
- **Dice games**: Roll dice against other players or the house
- **Card games**: Fantasy-themed card game (simplified poker variant)
- **Arm wrestling**: Strength-based contest (simple stat check with randomness)
- **Drinking contest**: Constitution-based, winner gets bragging rights + temporary charisma bonus
### Rest & Recovery
- **Energy/HP/Mana recovery** at a rate determined by inn quality (tied to property system)
- Basic room: 1× recovery rate
- Quality room: 1.5× recovery rate
- Luxury suite: 2× recovery rate
- Members with properties can rest at home for variable rates
### Carousing
- Spend gold to temporarily boost charisma (affects next N trades, conversations, or persuasion attempts)
- Duration: Until you complete N social interactions or time expires
- Risk: Overdoing it can result in "hangover" debuff (-agility for 1 hour)
### Innkeeper Daily Quests
- **Madame Sylara** offers 3 daily quests from the inn
- Quest types: "Collect information from X NPCs", "Deliver a message", "Win X gold gambling"
- Rewards scale with difficulty
### Hire Workers (Idle Fantasy-inspired)
The most distinctive feature from Idle Fantasy — **hire NPCs to automate tasks**:
| Worker | Task | Efficiency | Cost |
|---|---|---|---|
| **Mercenary** | Runs adventures while offline | 60% of normal rewards | Gold/hour |
| **Apprentice** | Trains a skill while offline | 50% of active rate | Gold/hour |
| **Gatherer** | Collects basic materials | Low-tier materials only | Gold/hour |
- Worker quality tiers: Novice → Journeyman → Expert (better rates, higher cost)
- Workers consume your energy while active
- Creates a meaningful **gold sink** for veteran players
- Buy **provisions** in bulk (food, potions, torches) for dungeon expeditions
---
## Pets (Deep Dive)
### Acquisition
- **Taming**: Wild encounters in the Wilds (skill check, rare success)
- **Purchase**: Pet trader in the Market Square
- **Breeding**: Combine two pets for a chance at rare offspring with combined bonuses
- **Quest rewards**: Specific pets from storyline/side quests
- **Rare enemy drops**: Dungeon bosses and rare spawns can drop pet eggs/whistles
- **Seasonal events**: Limited-time pets
### Pet Rarity Tiers
Common → Uncommon → Rare → Epic → Legendary → Mythic
### Pet Types & Bonuses
| Pet | Bonus |
|---|---|
| Wolf | +tracking, +combat XP |
| Raven | +scouting, +perception |
| Cat | +stealth, +thievery XP |
| Owl | +magic XP, +wisdom |
| Hound | +combat damage, +slayer XP |
| Toad | +poison resistance, +alchemy XP |
| Hawk | +ranged accuracy, +gathering yield |
### Pet Mechanics
- **Active pet**: One pet follows you (visible in combat), providing its passive bonus
- **Pet stable**: Store up to N pets (expandable via property upgrades)
- **Pet aging**: Pets grow over time, increasing their bonus at milestones (1 week, 1 month, 6 months)
- **Pet death**: Pets can be killed in PvP ambush (creates real stakes) — resurrected at Temple Row for gold
- **Pet equipment**: Collars, armor, trinkets — crafted or found. Enhance pet bonuses.
- **Pet breeding**: Combine two pets → offspring has a chance of inheriting combined bonuses or rare mutation
---
## Guilds (Deep Dive)
### Guild Hall Upgrades
| Upgrade | Effect | Build Cost |
|---|---|---|
| **Training Grounds** | +X% stat training speed for members | Gold + materials |
| **Library** | +X% education course speed | Gold + enchanted books |
| **Vault** | Shared storage for members | Gold + crafted locks |
| **War Room** | Plan raids, view territory map | Gold + cartography tools |
| **Brewery** | Member happiness → reduced quit rate, social bonuses | Gold + cooking supplies |
| **Infirmary** | Faster HP/energy recovery for members | Gold + alchemy supplies |
| **Enchanting Lab** | +X% enchanting quality for members | Gold + rare reagents |
### Guild Taxation
- Configurable tax rate on member earnings: **0-25%**
- Tax gold flows into guild treasury
- Guildmaster and officers set the rate
- High tax → more treasury for upgrades, but member dissatisfaction
### Guild Alliances
- Formal treaties between guilds: shared chat, coordinated raids, non-aggression pacts
- Alliance leaderboard
- Alliance vs. alliance wars for territory control
### Guild Territories
- Control of city districts provides bonuses to all guild members in that district
- Territory gained through guild wars and maintained through activity
- District bonuses: Market Square (reduced listing fees), Harbor (trade route bonuses), etc.
### Guild Rank Progression (Idle Fantasy-inspired)
- **10 rank levels** per guild: Initiate → Apprentice → Journeyman → Expert → Veteran → Elite → Champion → Master → Grandmaster → Legend
- **Daily guild tasks**: Complete for reputation points
- **Rank-up quests**: Progression quests to advance in guild rank
- **Cross-skill rewards**: Higher ranks unlock resources from skills you don't personally train
- **Guild reputation**: Decayed by inactivity, increased by events, raids, member achievements
### Guild Bank
- Shared treasury with withdrawal permissions by rank
- Deposit logs visible to officers+
- Withdrawal limits configurable by rank
---
## Blessings / Church (Idle Fantasy-inspired)
- **Temple Row** offers blessings from the Old Gods
- **Blessing types**: XP boost (up to 1.5×), Damage reduction, Gold multiplier, Crit chance increase
- **Duration**: Based on Wisdom stat and offering quality
- **One blessing active** at a time
- **Offerings**: Gold, rare items, bones from undead enemies
- Higher Wisdom = longer duration, stronger effect
- Adds "buff management" layer without being tedious
---
## Slayer Tasks (Idle Fantasy-inspired)
- **Captain Rennick** at City Watch HQ gives slayer tasks (lawful targets)
- **The Beggar King** gives underground slayer tasks (criminal targets)
- "Kill 15 skeletal warriors in the Undercity" → bonus Slayer XP + gold reward
- Slayer level unlocks tougher targets with better rewards
- Ties into the bounty system naturally
---
## Character System
### Races
| Race | Bonus | Penalty |
|---|---|---|
| Human | Balanced, bonus to charisma | None (jack of all trades) |
| Dwarf | Constitution +, Smithing + | Agility - |
| Elf | Magic +, Perception + | Strength - |
| Halfling | Agility +, Thievery + | Strength - |
| Orc | Strength +, Intimidation + | Intelligence - |
| Half-Elf | Charisma +, Magic + | Constitution - |
### Classes (determine starting skills and education paths)
- **Warrior** — Melee combat, tanking
- **Rogue** — Stealth, thievery, poison
- **Mage** — Offensive magic, enchanting
- **Bard** — Buffs, social skills, lore
- **Cleric** — Healing, protection, undead turning
- **Ranger** — Ranged combat, tracking, survival
### Stats (BIGINT — exponential growth)
- **Strength** — Physical damage, carrying capacity
- **Agility** — Dodge, stealth, thievery
- **Constitution** — HP, endurance, poison resistance
- **Intelligence** — Magic power, crafting quality
- **Wisdom** — Mana, perception, healing power, blessing duration
- **Charisma** — Trading prices, faction relations, bardic abilities
Stats only ever go up. Display tiered: `1.2K`, `4.7M`, `1.2B`, `3.8T`. See [Combat System & Stat Scaling Design](joplin://0ef630db4e3e4d6dab18ccaefccaaa5a).
### Skills (leveled through use)
- Weapon skills (sword, axe, bow, staff, etc.)
- Thievery (lockpicking, pickpocketing, sneaking)
- Magic schools (fire, ice, illusion, necromancy, etc.)
- Crafting (alchemy, blacksmithing, enchanting, tailoring, jewelcrafting, fletching, cooking)
- Gathering (mining, herbalism, woodcutting, fishing, skinning)
- Social (persuasion, intimidation, bartering)
- Support (mercantile/trading, slayer/bounty hunting)
---
## Technology Stack
### Backend — Rust
| Component | Technology | Rationale |
|---|---|---|
| HTTP Framework | **Axum** | Modern, ergonomic, excellent ecosystem, tower middleware |
| Real-time | **WebSocket** via axum + tokio-tungstenite | Live combat, chat, notifications |
| Database | **PostgreSQL** + **SQLx** | ACID compliance, relational data fits game entities well |
| Cache/Sessions | **Redis** | Session tokens, leaderboards, rate limiting, real-time pub/sub |
| Auth | **JWT** (jsonwebtoken crate) | Stateless auth, API-friendly |
| Background Jobs | **tokio** tasks + custom scheduler | Timers for cooldowns, education courses, guild wars |
| Migrations | **sqlx-cli** or **refinery** | Database schema versioning |
| Serialization | **serde** + **serde_json** | JSON API, message passing |
| Game Tick Engine | Custom event loop on **tokio** | Time-based game events (day/night cycle, weather, offline rewards) |
### Frontend — Vue.js
| Component | Technology | Rationale |
|---|---|---|
| Framework | **Vue 3** + **Composition API** | Modern, reactive, good for complex UIs |
| Build Tool | **Vite** | Fast dev server, optimized builds |
| State Management | **Pinia** | Type-safe, modular stores for game state |
| Routing | **Vue Router** | SPA navigation |
| UI Framework | **PrimeVue** or custom dark fantasy theme | Rich component library |
| Real-time | **WebSocket client** (native) | Live updates, combat, chat |
| HTTP Client | **Axios** or **fetch API** | REST API calls |
| Mobile | **PWA** or **Capacitor** | Native-like mobile experience |
### Infrastructure
| Component | Technology |
|---|---|
| Containerization | Docker + docker-compose |
| Reverse Proxy | Caddy or nginx |
| CI/CD | GitHub Actions |
| Monitoring | Prometheus + Grafana |
| Logging | tracing crate (Rust) → structured JSON logs |
---
## Architecture Overview
```
┌──────────────────────────────────────────────────┐
│ Frontend (Vue 3) │
│ ┌─────────┐ ┌─────────┐ ┌──────────┐ ┌───────┐ │
│ │ REST API │ │WebSocket│ │ State │ │ PWA │ │
│ │ Client │ │ Client │ │ (Pinia) │ │ Shell │ │
│ └────┬─────┘ └────┬────┘ └──────────┘ └───────┘ │
└───────┼────────────┼──────────────────────────────┘
│ HTTP/REST │ WS
▼ ▼
┌──────────────────────────────────────────────────┐
│ API Gateway (Axum) │
│ ┌──────────┐ ┌──────────────┐ ┌───────────────┐ │
│ │ Auth │ │ Rate Limiter │ │ WS Handler │ │
│ │ Middleware│ │ │ │ │ │
│ └──────────┘ └──────────────┘ └───────────────┘ │
├──────────────────────────────────────────────────┤
│ Game Services (Rust) │
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌───────────┐ │
│ │ Player │ │ Combat │ │ Economy│ │ Guild │ │
│ │Service │ │Service │ │Service │ │ Service │ │
│ ├────────┤ ├────────┤ ├────────┤ ├───────────┤ │
│ │Adventure│ │ World │ │ Market │ │ Chat & │ │
│ │Service │ │Service │ │Service │ │ Social │ │
│ ├────────┤ ├────────┤ ├────────┤ ├───────────┤ │
│ │Crafting│ │ Pet │ │Blessing│ │ Worker │ │
│ │Service │ │Service │ │Service │ │ Service │ │
│ └────────┘ └────────┘ └────────┘ └───────────┘ │
├──────────────────────────────────────────────────┤
│ Game Engine Core │
│ ┌────────────┐ ┌───────────┐ ┌───────────────┐ │
│ │ Tick/Sched │ │ Event Bus │ │ Rule Engine │ │
│ │ (timers) │ │ (pub/sub) │ │ (formulas) │ │
│ └────────────┘ └───────────┘ └───────────────┘ │
├──────────────────────────────────────────────────┤
│ Data Layer │
│ ┌──────────────┐ ┌──────────┐ ┌───────────┐ │
│ │ PostgreSQL │ │ Redis │ │ S3/Minio │ │
│ │ (persistent)│ │ (cache) │ │ (assets) │ │
│ └──────────────┘ └──────────┘ └───────────┘ │
└──────────────────────────────────────────────────┘
```
---
## API Design (RESTful + WebSocket)
### REST Endpoints (examples)
```
# Authentication
POST /api/auth/register
POST /api/auth/login
POST /api/auth/refresh
# Character
GET /api/character
PUT /api/character
GET /api/character/stats
GET /api/character/skills
PUT /api/character/train/:skill
# Adventures
GET /api/adventures/available
POST /api/adventures/embark/:id
GET /api/adventures/history
GET /api/adventures/offline-rewards # Collect offline session rewards
# Combat
POST /api/combat/arena/challenge/:target_id
POST /api/combat/ambush/:target_id
GET /api/combat/arena/leaderboard
# Economy
GET /api/market/items
POST /api/market/list
POST /api/market/buy/:listing_id
GET /api/bank/balance
POST /api/bank/deposit
POST /api/bank/withdraw
# Crafting
GET /api/crafting/recipes
POST /api/crafting/craft/:recipe_id
GET /api/crafting/professions
# Pets
GET /api/pets
POST /api/pets/activate/:pet_id
POST /api/pets/breed
GET /api/pets/stable
# Guilds
GET /api/guild
POST /api/guild/create
POST /api/guild/:id/join
GET /api/guild/:id/members
POST /api/guild/:id/raid
GET /api/guild/:id/rank-quests
# Properties
GET /api/properties
POST /api/properties/:id/buy
PUT /api/properties/:id/upgrade
# Education
GET /api/education/available
POST /api/education/enroll/:course_id
GET /api/education/progress
# Blessings
GET /api/blessings/available
POST /api/blessings/activate/:blessing_id
GET /api/blessings/active
# Inn / Workers
GET /api/inn/workers
POST /api/inn/hire/:worker_type
POST /api/inn/dismiss/:worker_id
GET /api/inn/quests # Daily innkeeper quests
# Slayer
GET /api/slayer/task
POST /api/slayer/claim-reward
```
### WebSocket Channels
```
ws://server/ws?token=JWT
# Client → Server
{ "type": "combat:action", "data": { "action": "attack", "target": "enemy_1" } }
{ "type": "chat:message", "data": { "channel": "guild", "message": "..." } }
# Server → Client
{ "type": "combat:result", "data": { "damage": 42, "enemy_hp": 158, "crit": false } }
{ "type": "combat:crit", "data": { "roll": 147, "effect": "bleed", "duration": 5 } }
{ "type": "notification", "data": { "text": "Your adventure has completed!" } }
{ "type": "market:update", "data": { "item": "...", "price": 500 } }
{ "type": "adventure:offline_rewards", "data": { "gold": 1247, "xp": 5300, "items": [...] } }
```
---
## Monetization (Ethical F2P)
- **Patron Subscription** (→ Torn's donator packs): Monthly sub providing QoL benefits (extra energy, custom themes, expanded log, etc.) — no pay-to-win
- **Cosmetics**: Character portraits, UI themes, custom titles
- **Convenience**: Extra character slots, extended history logs, extra pet stable slots
- **No**: Pay-to-win items, selling power, loot boxes
---
## MVP Scope (Phase 1)
1. User registration, authentication, character creation (race + class)
2. Basic stat/skill system with training (BIGINT stats)
3. Adventure system (5-10 adventures across difficulty tiers)
4. Turn-based combat engine with crit tables
5. Basic economy: inventory, bazaar (buy/sell), bank
6. Guild creation and membership
7. REST API + basic Vue.js frontend
8. Real-time notifications via WebSocket
9. Offline adventure rewards collection
10. Basic crafting (2-3 professions)
---
## Future Phases
- **Phase 2**: PvP arena, guild wars, properties, education system, pets, blessings
- **Phase 3**: Full storyline/quests, dynamic world events, day/night cycle, inn workers, slayer tasks
- **Phase 4**: Mobile app (Capacitor), merchant ventures (stock market), advanced crafting, pet breeding
- **Phase 5**: Community API, mod support, tournament system
---
## Related Design Notes
| Note | ID | Content |
|---|---|---|
| [Combat System & Stat Scaling Design](joplin://0ef630db4e3e4d6dab18ccaefccaaa5a) | `0ef630db4e3e4d6dab18ccaefccaaa5a` | RoleMaster + Torn hybrid combat, crit tables, stat scaling |
| [Idle Fantasy Inspirations](joplin://48b3a3c76f734c8dafbbf9e8b3b5f80a) | `48b3a3c76f734c8dafbbf9e8b3b5f80a` | Idle RPG mechanics, offline sessions, workers, pets |
| [Lore & World Building](joplin://867bdc9845624908a90928887a71c4c9) | `867bdc9845624908a90928887a71c4c9` | 10 city districts, 8 key NPCs, 5-act storyline |
| [Tech Stack & Architecture](joplin://cecd2ef2690d4d1fba3450342aa63953) | `cecd2ef2690d4d1fba3450342aa63953` | Rust workspace, Vue frontend, database schema |
| [Project Plan](joplin://d243ae57778343fca997d46b73d0ee3b) | `d243ae57778343fca997d46b73d0ee3b` | Roadmap, MVP scope, repository structure |

View file

@ -0,0 +1,243 @@
# Skara Brae — Idle Fantasy Inspirations
Source: Joplin note 48b3a3c76f734c8dafbbf9e8b3b5f80a, migrated 2026-10-03
Source: [Idle Fantasy](https://github.com/tristinbaker/IdleFantasy) — an open-source offline idle RPG for Android (Kotlin, Jetpack Compose, GPL-3.0).
> **Status update (2026-07):** Repo re-reviewed at **v1.12.11, 366 stars, 810 commits** (was ~206 stars when this note was first written). Several new systems shipped after the original capture — they are documented in **[New Mechanics (post-2025 evolution)](#new-mechanics-post-2025-evolution)** below. The original sections (offline sessions, workers, pets, guild ranks, blessings, slayer, daily/long-term quests, mercantile) remain valid and are not repeated in the new section.
*"Set your hero to work. Close the app. Come back to loot."*
Key mechanics from Idle Fantasy to adapt for Skara Brae:
---
## Offline / Idle Sessions
**Idle Fantasy**: Pick a skill or dungeon, start a session, close the app. Hero trains for up to 1 hour while closed. Come back to collect XP and loot. No stamina bar, no energy system.
**Skara Brae adaptation**: Skara Brae keeps Torn-style energy (differentiates our game), but adds offline adventure progression:
- Adventures continue for up to **1 hour** while logged out (or up to your energy cap)
- On return, collect accumulated gold, XP, and loot
- **Server-side tick** calculates elapsed time rewards on login
- Training skills offline at **50% rate** (active play is always better)
- This keeps casual players engaged without requiring constant attention
---
## 23 Skills Across 4 Categories
**Idle Fantasy** has 23 skills organized into:
| Category | Skills |
|---|---|
| **Gathering (7)** | Mining, Fishing, Woodcutting, Farming, Firemaking, Agility, Thieving |
| **Crafting (7)** | Smithing, Cooking, Fletching, Crafting, Runecrafting, Herblore, Construction |
| **Combat (7)** | Attack, Strength, Defense, Ranged, Magic, Hitpoints, Prayer |
| **Support (2)** | Mercantile, Slayer |
**Skara Brae mapping** (merging with existing design):
| Idle Fantasy | Skara Brae Equivalent |
|---|---|
| Mining, Woodcutting | Mining, Woodcutting (gathering) |
| Fishing, Farming | Fishing, Herbalism (gathering) |
| Firemaking | — (drop, not fantasy-appropriate) |
| Agility | Agility (thievery skill tree) |
| Thieving | Thievery (existing) |
| Smithing | Blacksmithing |
| Cooking | Cooking (alchemy sub-skill) |
| Fletching | Fletching (ranged weapon crafting) |
| Crafting | Tailoring, Jewelcrafting |
| Runecrafting | Enchanting |
| Herblore | Alchemy |
| Construction | — (~~defer to property system~~ → **RE-EVALUATED, see below**: make it a real skill) |
| Attack, Strength, Defense | Melee combat skills |
| Ranged | Ranged combat skill |
| Magic | Magic skills (fire, ice, illusion, necromancy) |
| Hitpoints | Constitution |
| Prayer | Wisdom / Cleric skills |
| Mercantile | Bartering / Trading skill |
| Slayer | Slayer / Bounty Hunting |
---
## Pets — Permanent Passive Buffs
**Idle Fantasy**: Rare enemies drop **collectible pets** that grant permanent passive XP bonuses.
**Skara Brae adaptation**:
- Pets drop from rare enemies in dungeons and adventures
- Each pet provides a **permanent passive bonus** (e.g., +2% mining XP, +5% gold from adventures)
- Pets have **rarity tiers**: Common → Uncommon → Rare → Epic → Legendary → Mythic
- **Pet stable**: Multiple pets owned, one active at a time (or multiple for patrons)
- **Pet aging**: Pets grow over time, increasing their bonus at milestones (1 week, 1 month, 6 months)
- **Pet death**: Can be killed in PvP ambush — resurrected at Temple Row for gold (creates stakes)
- **Pet breeding**: Combine two pets for a chance at rare offspring with combined bonuses
- Pet types: wolf (tracking bonus), raven (scouting), cat (stealth), owl (magic XP), hound (combat XP), etc.
---
## Inn Workers — Hire NPCs to Automate Tasks
**Idle Fantasy**: The Inn lets you **hire workers** who queue up sessions on your behalf, and buy food in bulk.
**Skara Brae adaptation**:
- **The Singing Tavern** offers hireable NPCs:
- **Mercenary**: Runs adventures while you're offline (reduced rewards: 60% of normal)
- **Apprentice**: Trains a skill while you're offline (50% rate)
- **Gatherer**: Collects basic materials while you're offline (low-tier only)
- Workers cost **gold per hour** — creates a gold sink for veteran players
- Worker quality tiers: Novice → Journeyman → Expert (better rates, higher cost)
- Buy **provisions** in bulk (food for dungeon expeditions, potions, torches)
- This is Idle Fantasy's most distinctive mechanic — automating the grind for gold
---
## Guild Rank Progression
**Idle Fantasy**: 17 guilds with **10 rank levels** each. Advance by completing progression quests and earning reputation through daily requests. Higher ranks unlock harder dailies and better cross-skill rewards.
**Skara Brae adaptation** (merging with existing guild design):
- Guilds have **rank tiers** within the guild (Initiate → Apprentice → Journeyman → Expert → Master → Grandmaster)
- **Daily guild tasks**: Complete for reputation points
- **Rank-up quests**: Progression quests to advance in guild rank
- **Cross-skill rewards**: Higher ranks unlock access to resources from skills you don't personally train
- Existing guild hall upgrades (training grounds, library, vault, war room, brewery) remain
---
## Church / Blessings
**Idle Fantasy**: The Church offers **timed blessings** powered by Prayer levels and bones. Three types: XP boosts (up to 1.5×), Defense bonuses, Coin multipliers. One active at a time.
**Skara Brae adaptation**:
- **Temple Row** offers blessings from the Old Gods
- Blessing types: XP boost (up to 1.5×), Damage reduction, Gold multiplier, Crit chance increase
- Duration based on Wisdom stat and offering quality
- One blessing active at a time
- Offerings: Gold, rare items, bones from undead enemies
- Higher Wisdom = longer duration, stronger effect
- This adds a "buff management" layer without being tedious
---
## Slayer Tasks
**Idle Fantasy**: Visit the Slayer Master for kill-specific-enemy assignments. Each kill earns bonus XP. Complete tasks to level Slayer skill and unlock tougher assignments.
**Skara Brae adaptation**:
- **Captain Rennick** at City Watch HQ (or **The Beggar King** for underground targets) gives slayer tasks
- "Kill 15 skeletal warriors in the Undercity" → bonus Slayer XP + gold reward
- Slayer level unlocks tougher targets with better rewards
- Ties into the bounty system naturally
---
## Quest Diversity
**Idle Fantasy**: 170+ quests — **daily quests** (reset every morning) + **long-term cumulative quests** (track progress over many sessions).
**Skara Brae adaptation**:
- **Daily quests**: 3-5 per day from NPCs (Madame Sylara, Captain Rennick, Guildmasters)
- "Complete 3 adventures" / "Craft 2 potions" / "Win 1 arena duel"
- Rewards: gold, XP, skill XP, occasional items
- **Weekly quests**: Larger goals, better rewards
- **Storyline quests**: Multi-step, unlock new areas/NPCs/mechanics
- **Cumulative achievements**: "Mine 10,000 ore total" → permanent bonus
- Quest board in the Inn + NPC-specific quest givers
---
## New Mechanics (post-2025 evolution)
These systems appeared in Idle Fantasy **after** this note was first captured (now v1.12.11) and are strong candidates to integrate into Skara Brae.
### Infinite Tower ⭐
Endless floor-by-floor combat gauntlet; enemies scale forever; **every 10 floors = a milestone** rewarding unique gear + permanent XP/coin bonuses.
**Why it fits:** Gives the BIGINT exponential stat system a natural home — infinite content matches infinite stats. We currently have dungeons + arena but **no repeatable-scaling endgame PvE**.
**Adaptation:** Reskin as **Mangar's Tower** / the Infinite Labyrinth beneath the city. Floor milestones grant small, bounded permanent bonuses (consistent with "stats only go up"). Per-floor leaderboard. → **Phase 3**.
### Expeditions ⭐⭐ (highest-value pickup)
Non-combat *skilling* dungeons tied to a gathering skill (Agility, Fishing, Mining, Thieving, Woodcutting). Completing sessions gradually uncovers **5 hidden lore notes**; collecting all 5 **unlocks a secret combat dungeon**. Advanced expeditions are gated by having unlocked an earlier dungeon — a discovery tree.
**Why it fits:** Bridges gathering skills to content gating *and* delivers lore organically — exactly the "mystery beneath Skara Brae" theme. Our notes currently silo gathering from dungeons; Expeditions unify them into one progression tree.
**Adaptation:** Each gathering skill gets 2 expedition tiers (lvl 1, lvl 40) → reveals a themed dungeon (e.g., Mining "Copper Caverns" → "Collapsed Mine"; Fishing "Sunken Grotto" → "Drowned Temple"; Thieving "Thieves' Den" → "Rogue Sanctum"). Lore notes double as world-building collectibles. → **Phase 2–3**.
### Buildings / Builder's Workshop ⭐
Upgrade town buildings (Inn, Guild Hall, Church, Fairgrounds, Garden, Queue Master, Cape Rack) with coins + **Construction** materials. Each building has tiers (0 → 3), gating a passive bonus; higher tiers require a higher Construction level.
**Why it fits:** Reframes our scattered "guild hall upgrades" into a unified **town-meta progression** the player personally drives, and gives the crafting economy a permanent gold/material sink.
**Adaptation:** Make **Construction a real skill** (see re-evaluation below). Upgradable buildings: The Singing Tavern (+worker XP), Temple Row (+blessing duration), Adventurers' Guild (−quest requirements), Market Square / Fairgrounds (carnival), Harbor, plus queue-slot and cape-rack buildings. → **Phase 2**.
### Carnival
Traveling minigame hub. **Idle minigames** convert a skill level → tickets over time (15% → 35% chance per minute, scaling to lvl 99; Archery Range=Ranged, Strongman=Strength, Wizard's Duel=Magic, Fishing Derby=Fishing). **Active minigames** (Ring Toss, Hammer Strike, Potion Sequence, Item Appraisal, Pick-a-Cup, Higher/Lower) reward more tickets on Hard, each on a 10-minute cooldown. Ticket shop sells XP lamps, gear, pets, capes.
**Why it fits:** Richer than our "inn gambling (dice/cards)"; the skill → ticket idle conversion is a clever loop. Drops onto our existing **seasonal/festival calendar** as a traveling faire.
**Adaptation:** A traveling Carnival/Faire that appears during festivals; tickets redeemed at its shop. Active minigames map onto our inn minigame table. → **Phase 3–4**.
### Pity System ("Fortune's Favor")
Escalating bad-luck protection on the rarest gear drops (IF's v1.12.10 "Divine gear pity system").
**Adaptation:** Given our 0.5% Legendary crafting + rare drops, add an escalating counter that guarantees a high-rarity result after N dry attempts. Reskin as **Fortune's Favor**. → **Phase 2**.
### Gear Scoring
Pre-dungeon, IF tells you how your current gear stacks up against the dungeon's rating (v1.12.10 gear scoring).
**Adaptation:** A recommended-power / gear-score indicator on our risk/reward adventure tiers so players self-select difficulty. → **Phase 1–2**.
### Titles (UX reinforcement)
Permanent cosmetic titles displayed as "*Name* the *Title*"; crucially, **locked titles are visible with their unlock condition** (skill-mastery = complete all quests in a skill chain; guild-max = reach lvl 10 in a guild; boss clears; seasonal events).
**Adaptation:** Our Medals & Honours note already mentions titles — **adopt IF's locked-title-with-requirement UX** as a motivator (e.g., "Master Smith," "Archmage," "Godslayer," seasonal titles kept permanently). → **Phase 2**.
### XP Lamps
Consumable that grants XP to a chosen skill (10k / 50k variants); obtained from quests, events, the Carnival shop.
**Adaptation:** Flexible catch-up / reward currency we currently lack. → **Phase 2**.
### Queue Slots (parallel sessions)
The "Queue Master" building grants additional concurrent session/queue slots.
**Adaptation:** An idle-progression lever — patrons / upgrades can run multiple offline workers/sessions in parallel. → **Phase 3**.
### Raid Bosses (confirmed)
IF's "Godslayer" title = defeat every raid boss at least once, confirming a raid-boss roster exists.
**Adaptation:** Validates designing a raid-boss roster for our guild raids (6–12 players). → **Phase 5**.
---
## Re-evaluation: Construction Skill
Original mapping (above) said Construction → "defer to property system." **Reversed.** IF now uses Construction as the backbone of the Buildings / Builder's Workshop meta. Add **Construction** as a real crafting skill in Skara Brae, feeding town-building upgrades.
---
## Process Ideas Worth Copying
- **Auto-generated wiki from game data.** IF's entire community wiki is generated from the game's data files. This validates keeping all content (items, adventures, crit tables, spells, recipes, medals, dungeons, tower floors, expeditions) as structured JSON/TOML in `skara-brae-common` — we can auto-generate both client tooltips *and* a community game wiki from a single source.
- **Layered PvE ladder.** IF matured into: expeditions → dungeons → raids → infinite tower, with buildings + carnival as parallel progression. Adopt this as our PvE spine.
---
## What NOT to Take from Idle Fantasy
- **No energy/stamina removal**: Skara Brae keeps Torn-style energy — it's a core differentiator
- **No fully offline game**: Skara Brae is multiplayer — offline is supplementary, not primary
- **No single-player focus**: Guild interaction, PvP, and economy are central to our game
---
## Related Notes
- [Game Concept Overview](joplin://9cf8a2e701654ffbbd6512cc837c7e2f) — Full game design with all features
- [Combat System & Stat Scaling Design](joplin://0ef630db4e3e4d6dab18ccaefccaaa5a) — Crit tables, BIGINT stats
- [Lore & World Building](joplin://867bdc9845624908a90928887a71c4c9) — Districts, NPCs, storyline
- [Tech Stack & Architecture](joplin://cecd2ef2690d4d1fba3450342aa63953) — Rust workspace, DB schema
- [Project Plan](joplin://d243ae57778343fca997d46b73d0ee3b) — Roadmap, MVP, repo structure

View file

@ -0,0 +1,129 @@
# Skara Brae — Lore & World Building
Source: Joplin note 867bdc9845624908a90928887a71c4c9, migrated 2026-10-03
## The City of Skara Brae
Skara Brae sits on a rocky wind-swept coast, where the Old Kingdom once held sway. The city is ancient — its lowest foundations predate recorded history, carved from living stone by hands unknown. Above, generations have built layer upon layer: crooked timber houses, cobblestone squares, towering keeps, and the ever-present stench of the harbor.
### Districts
| District | Description | Game Function |
|---|---|---|
| **The Harbor** | Fish markets, smuggler coves, foreign traders, ship berths | Black market, rare imports, sea adventures, **travel to offshore locations** |
| **The Lower City** | Slums, street markets, thieves' dens | Pickpocketing, fencing, cheap lodging |
| **The Market Square** | Bazaar, auctions, money changers, Garth's Equipment Shoppe | Main marketplace, bank, player shops/stalls |
| **The Crafters' Quarter** | Smithies, alchemy labs, tailor shops | Crafting, education (trades) |
| **Temple Row** | Shrines to the Old Gods, healing houses | Cleric education, healing, divine quests, **blessings**, **pet resurrection** |
| **The Academy** | Mage towers, libraries, lecture halls | Magic education, enchanting, spell learning |
| **The Arena** | Blood-stained sand, cheering crowds | PvP combat, tournaments, betting |
| **The Noble Quarter** | Manors, gardens, the City Watch HQ | Properties, burglary targets, politics, high-stakes gambling |
| **The Undercity** | Ancient catacombs, sewers, forgotten vaults | High-level dungeons, rare loot, slayer tasks |
| **The Wilds** | Forests, roads, ruins beyond the walls | Adventures, ambushes, gathering |
| **The Adventurers' Guild** | Unmarked building — must be discovered | **Review Board**: Level up, learn spells, switch spell schools, LFG board |
### Key Locations
| Location | District | Function |
|---|---|---|
| **The Singing Tavern** | Lower City | Social hub, gambling (dice, cards, arm wrestling), hire workers, daily quests from Madame Sylara, LFG board, rumors |
| **Garth's Equipment Shoppe** | Market Square | NPC shop: buy/sell basic equipment. Always open. Buys at 40% value. |
| **The Adventurers' Guild** | (Hidden — discover via quest) | Review Board: level up, learn spells, switch magic schools. Looking for Group board. Party formation. |
| **The Bank of Skara Brae** | Market Square | Deposits, interest (0.5%/day), loans (collateral required) |
| **Temple of the Dawn** | Temple Row | Healing, cleric training, blessings, pet resurrection |
| **The Arena** | Arena | PvP duels, tournaments (Arena Day), ranked matchmaking, spectating |
| **City Watch HQ** | Noble Quarter | Bounty board (Captain Rennick), slayer tasks, wanted posters |
| **The Beggar King's Lair** | Lower City sewers | Thieves' Guild contact, rogue training, underground slayer tasks |
### Travel Destinations (Offshore)
Ships depart from the Harbor. Each trip costs gold + takes real time. Travel time = risk of pirate/sea monster encounter.
| Destination | Travel Time | Description | Function |
|---|---|---|---|
| **The Iron Islands** | 1 hour | Mining colony, volcanic forges | Rare ores, high-level blacksmithing, mining adventures |
| **Stormwatch Keep** | 2 hours | Military outpost on contested coast | PvP zone, mercenary contracts, rare weapon drops |
| **The Lost Tombs** | 3 hours | Ancient ruins on a jungle island | High-level dungeon, rare loot, puzzle content |
| **The Free Ports** | 4 hours | Neutral trade hub, no PvP | Cross-faction trading, rare imports, auction house access |
| **Seasonal locations** | Varies | Event-specific destinations | Festival of Lights → Frozen Lake, Mangar's Eve → Shadow Realm, etc. |
---
## Key NPCs
| NPC | Location | Function |
|---|---|---|
| **Mangar the Dark** | (Hidden — storyline) | Main antagonist. Ancient sorcerer seeking to unseal the Old Kingdom's power beneath the city. |
| **Guildmaster Bron** | Fighters' Academy | Combat training, warrior education |
| **Madame Sylara** | The Singing Tavern | Quest giver, information broker, daily quest board |
| **Old Grist** | Harbor | Fence (buys stolen goods, no questions asked), black market dealer |
| **High Priestess Vaela** | Temple of the Dawn | Healing, cleric training, divine quests, blessings, pet resurrection |
| **Archmage Therion** | The Academy | Magic education, enchanting instructor |
| **Captain Rennick** | City Watch HQ | Bounty board, slayer tasks, wanted posters |
| **The Beggar King** | Lower City sewers | Thieves' Guild contact, rogue training, underground slayer tasks |
| **Garth** | Garth's Equipment Shoppe | NPC shopkeeper. Buys and sells equipment. |
| **The Review Board Examiner** | Adventurers' Guild | Levels up characters, administers spell exams, allows school switching |
| **Harbormaster Kael** | Harbor | Travel booking, ship departures, sea event rumors |
---
## The Main Storyline
Mirrors Bard's Tale I's dungeon progression structure:
**Act I — Arrival**: The player arrives in Skara Brae as a nobody. Tutorials, basic adventures, introduction to guilds and the city's districts. Discover the Adventurers' Guild (hidden building). Clear the Wine Cellar beneath The Singing Tavern.
**Act II — Whispers**: Strange occurrences — undead sightings in the Undercity, magical anomalies near the Academy. Players investigate the Sewers. Introduction of Mangar as a shadowy threat. Find hints about the Mad God.
**Act III — The Catacombs**: Players venture into the Catacombs beneath the Temple of the Mad God (must learn the god's name first). Defeat the Spectre, obtain the Eye. Deeper mysteries of the Old Kingdom emerge.
**Act IV — Harkyn's Castle**: Using the Eye, players access the northeastern city via Baron Harkyn's Castle. Guilds must choose sides — the city fractures. PvP becomes narratively meaningful. Alliance warfare over territory.
**Act V — The Deep**: Kylearan's Tower (archmage ally) provides the key to Mangar's Tower. Final dungeon: 5 levels beneath the city. Requires coordinated guild effort. Rotating challenges. Defeat Mangar — or discover he was merely a servant of something worse.
---
## Day/Night Cycle
| Time | Effects |
|---|---|
| **Dawn (06:00-08:00)** | Markets open, energy refresh bonus, temple blessings available |
| **Day (08:00-18:00)** | Normal gameplay, crafting bonuses, education available, safe street travel |
| **Dusk (18:00-20:00)** | Arena tournaments, market closing deals, reduced listing fees |
| **Night (20:00-06:00)** | Stealth adventures bonus, certain NPCs appear, danger increases, **random encounters when traveling between districts** |
The cycle runs on a compressed game clock (e.g., 1 game day = 4 real hours) to ensure all time zones experience all phases.
**Navigation model**: District-based for the city (click to navigate). Room-by-room exploration for dungeons (with minimap). Night travel between districts triggers random encounter chance.
---
## Calendar & Events
### Weekly
- **Moonday** — Magic bonuses (+10% spell effectiveness)
- **Tidesday** — Harbor events, rare imports, travel discounts
- **Market Day** — Reduced listing fees, special vendors at Market Square
- **Arena Day** — Tournament with prizes, betting
- **Rest Day** — Energy recovery doubled
### Monthly
- **Full Moon** — Undercity opens new passages, were-creatures appear
- **New Moon** — Thieves' Guild special contracts
### Seasonal (with temporary locations)
- **Festival of Lights** (Winter) — **Frozen Lake**: Ice fishing, ice dungeon, cosmetic rewards
- **The Hunt** (Spring) — **The Dark Forest**: World boss hunt, rare pet drops
- **Mangar's Eve** (Autumn) — **Shadow Realm**: Special dungeon, exclusive loot
- **The Great Market** (Summer) — **Bazaar of Wonders**: Rare vendors, limited-time recipes
---
## Related Notes
- [Mechanics Source Mapping](joplin://87a0f70b45c4453caae0fa2b5b8a96f9) — How BT1, RoleMaster, Idle Fantasy, and Torn City map to our mechanics
- [Game Concept Overview](joplin://9cf8a2e701654ffbbd6512cc837c7e2f) — Full game design
- [Combat System & Stat Scaling Design](joplin://0ef630db4e3e4d6dab18ccaefccaaa5a) — Crit tables, BIGINT stats
- [Idle Fantasy Inspirations](joplin://48b3a3c76f734c8dafbbf9e8b3b5f80a) — Idle mechanics
- [Tech Stack & Architecture](joplin://cecd2ef2690d4d1fba3450342aa63953) — Rust workspace, DB schema
- [Project Plan](joplin://d243ae57778343fca997d46b73d0ee3b) — Roadmap, MVP, repo structure

View file

@ -0,0 +1,298 @@
# Skara Brae — Mechanics Source Mapping
Source: Joplin note 87a0f70b45c4453caae0fa2b5b8a96f9, migrated 2026-10-03
How elements from each inspiration source map to Skara Brae mechanics.
---
## Sources
| Source | Type | Key Contribution |
|---|---|---|
| **The Bard's Tale I** (1985) | Dungeon crawl RPG | Setting, city structure, review board, party system, spell learning, dungeon progression |
| **RoleMaster / MERP** (1980+) | Tabletop RPG | Combat engine (d100 open-ended, crit tables), 10-stats, skill ranks, spell realms, brutality |
| **Idle Fantasy** (2024) | Mobile idle RPG | Offline sessions, hireable workers, pets, guild ranks, blessings, slayer tasks, quest diversity |
| **Torn City** (2004) | Text-based MMORPG | Crime→adventure loop, economy, factions, properties, education, PvP, billions-scale stats, casino, travel |
---
## The Mapping
### Leveling & Progression
| Mechanic | Source | Skara Brae Adaptation |
|---|---|---|
| **Review Board** | Bard's Tale I | The **Adventurers' Guild** in Skara Brae. Unmarked building (must be discovered). Only place to level up, learn new spells, and change spell schools. Pay gold + meet XP threshold. Magical exam for spellcasters (lore check). |
| **Experience points** | All | BIGINT XP. Earned from adventures, combat, crafting, quests. Level thresholds scale exponentially. |
| **Stat growth to billions** | Torn City | BIGINT stats, tiered display (K/M/B/T). Stats only go up. Training, equipment, education, and crafting all feed into stats. |
| **Skill ranks via development points** | RoleMaster | Skills leveled through use (not point-buy). Each skill has its own XP bar. Higher ranks = logarithmic cost increase. |
| **Education courses** | Torn City | Schools (Fighters' Academy, College of Magic, Thieves' Den, Merchants' Guild). Real-time courses, permanent bonuses. Some adventures require prerequisites. |
### Spell System
| Mechanic | Source | Skara Brae Adaptation |
|---|---|---|
| **Spell schools as classes** | Bard's Tale I | Magician (offensive), Conjurer (summoning), Sorcerer (debilitating), Wizard (utility). Mages can switch schools at the Review Board. Master all four = Archmage title (cosmetic + achievement). |
| **Spell learning at Review Board** | Bard's Tale I | Must visit the Adventurers' Guild to learn new spells after leveling. Each spell costs gold + requires minimum skill level. Spell codes replaced by meaningful names. |
| **Three realms of magic** | RoleMaster | **Essence** (wizards — raw magical energy), **Channeling** (clerics — divine power from Old Gods), **Mentalism** (bards/psionics — mind power). Each realm has open lists (available to all) and closed lists (class-restricted). |
| **Bard songs as spells** | Bard's Tale I | Bard songs function as long-duration buffs. Strengthen armor, increase attack speed, heal over time, boost skill XP. Certain puzzles/adventures require specific songs. |
| **85 spells** | Bard's Tale I | Target: 60-80 spells at launch across all realms and schools. Expandable via updates. |
### Combat
| Mechanic | Source | Skara Brae Adaptation |
|---|---|---|
| **d100 open-ended roll** | RoleMaster | All attacks roll d100 open-ended (96-100: roll again and add). Creates rare but exciting high rolls. |
| **Critical tables by weapon type** | RoleMaster | Slash, crush, puncture, unarmed crit tables. Each maps roll range to damage multiplier + status effect (bleed, stun, disarm, cripple, blind). Tables stored in `skara-brae-common/crit-tables/`. |
| **Crit damage capped by level difference** | Original design | Equal level = full crit. +50 levels = 15% cap. +100 = 5% cap. Status effects always apply. Low levels can disrupt, not kill. |
| **Armor type vs. weapon cross-reference** | RoleMaster | Attack tables cross-index weapon type against armor type (unarmored, leather, chain, plate). Heavy armor reduces crit severity. |
| **Concussion hits** | RoleMaster | Normal hits (non-crit) = concussion damage. Accumulates, can knock unconscious. Natural recovery over time. |
| **Body part damage** | RoleMaster | Crit rolls determine hit location (head, torso, arms, legs). Location affects which status effects apply. Head crits = stun. Arm crits = disarm. Leg crits = cripple. |
| **Resistance rolls (RR)** | RoleMaster | Magic attacks require resistance roll: d100 + Will stat vs. spell difficulty. Failed RR = full effect. Partial success = reduced effect. Success = resisted. |
| **Turn-based combat** | Bard's Tale I | Combat is turn-based/text-based. Choose actions each round. Manage stamina, mana, positioning (front/back rank). |
| **Party combat (6 characters)** | Bard's Tale I | See Party System below. |
### Adventures & Dungeons
| Mechanic | Source | Skara Brae Adaptation |
|---|---|---|
| **Crime tiers** | Torn City | Adventures in risk/reward tiers: Safe (pick pockets) → Moderate (burglary) → Dangerous (dungeon raid) → Extreme (Undercity deep). Higher tiers = better rewards, more energy cost, higher failure chance. |
| **Dungeon progression** | Bard's Tale I | Sequential dungeons with prerequisites: Wine Cellar → Sewers → Catacombs → Harkyn's Castle → Kylearan's Tower → Mangar's Tower. Each unlocks the next. Later dungeons require items/keys from earlier ones. |
| **Random encounters on city streets** | Bard's Tale I | Moving between districts at night triggers random encounters. Higher danger districts = harder encounters. Daytime = mostly safe. |
| **Dungeon puzzles and riddles** | Bard's Tale I | Dungeons include locked doors (lockpicking check), riddles (lore check), magical barriers (spell requirement), and boss mechanics. |
| **Racing → Idle adventuring** | Torn City + Idle Fantasy | **Expeditions**: Send your character (or a hired worker) on a timed expedition to a dungeon or adventure zone. Takes 15 min to 1 hour. Returns with loot. Reduced rewards (60%) compared to active play. Energy consumed while on expedition. Comparable to Torn's racing (timed event with wager → payout). |
| **Organized crime → Guild raids** | Torn City | **Guild raids**: Cooperative dungeon runs requiring 4-6 guild members. Each member contributes. Rewards split. Guild treasury gets a cut. Weekly cooldown. |
### City & Navigation
| Mechanic | Source | Skara Brae Adaptation |
|---|---|---|
| **30×30 city grid** | Bard's Tale I | **Decision needed**: Full MUD-style room-by-room map, or district-based navigation? See Map Question below. |
| **Named streets and buildings** | Bard's Tale I | Key locations are named (Rakhir Street, The Singing Tavern, Garth's Equipment Shoppe). Even with district navigation, these names persist as flavor. |
| **Locked gates / guardian statues** | Bard's Tale I | Certain districts require keys, quest completion, or level thresholds to access. The Undercity requires a password from a quest. The Noble Quarter requires property ownership or guild rank. |
| **District-based navigation** | Torn City | Click on a district, see available actions. Fast, mobile-friendly, no spatial navigation needed. |
| **Christmas Town** | Torn City | **Seasonal locations**: Temporary areas that appear during seasonal events. Festival of Lights → Frozen Lake (ice fishing, ice dungeon). Mangar's Eve → Shadow Realm (special dungeon). The Great Market → Bazaar of Wonders (rare vendors). These locations disappear after the event. |
| **Travel (off-shore)** | Torn City | **Travel system**: Take a ship from the Harbor to offshore locations. Each trip costs gold + takes real time (1-4 hours). Destinations: The Iron Islands (mining colony, rare ores), Stormwatch Keep (military outpost, PvP zone), The Lost Tombs (ancient ruins, high-level dungeon), The Free Ports (neutral trade hub, no PvP). Travel time = opportunity for ambush by pirates or sea monsters. |
#### Map Question: MUD-Style vs. District Navigation
**Option A: MUD-Style Room Map**
- 30×30 grid (like BT1), each tile is a "room" with description, exits, and possible encounters
- Players navigate with cardinal directions (N/S/E/W)
- Feels immersive, authentic to BT1 roots
- Problem: Tedious on mobile. Hard to make feel good in a web UI. Most players would find it annoying.
- Works if we want a true MUD feel
**Option B: District-Based Navigation (Recommended)**
- City has 10 districts (already defined). Click a district → see available locations and actions.
- Within districts, specific locations (The Singing Tavern, Garth's Shoppe) are clickable destinations.
- Dungeons are separate instances (enter → turn-based combat + exploration).
- Problem: Less immersive. Loses the BT1 city-as-maze feel.
- Works best for web/mobile, fast navigation, Torn-like UX
**Option C: Hybrid**
- District navigation for the main city (fast, mobile-friendly)
- Dungeons use room-by-room exploration with a minimap (MUD/MUD2 style)
- City streets at night can trigger random encounters when traveling between districts (without full room navigation)
- Best of both worlds: fast city navigation, immersive dungeon crawling
- The BT1 city-as-maze can be a special dungeon instance ("Skara Brae: The Eternal Winter" — a flashback to Mangar's original attack)
**Recommendation**: Option C (Hybrid). District nav for city, room-by-room for dungeons. Night travel has encounter chance.
### Economy
| Mechanic | Source | Skara Brae Adaptation |
|---|---|---|
| **Bazaar / Item market** | Torn City | Player-driven marketplace. List items for sale. Search, filter, buy. Transaction fee (gold sink). |
| **Garth's Equipment Shoppe** | Bard's Tale I | NPC shop with basic equipment. Always available. Sells low-to-mid tier gear. Buys items at 40% value. |
| **Auction House** | Torn City | Rare items, timed auctions. 24-72 hour listings. Buyout option. |
| **Bank of Skara Brae** | Torn City | Deposit gold, earn interest (0.5%/day). Loans available (collateral required). |
| **Casino → Inn games** | Torn City | **The Singing Tavern** offers gambling: Dice (craps variant), Cards (fantasy poker), Arm Wrestling (strength check), Shell Game (perception check). Wager gold, win or lose. House edge ensures long-term gold sink. High-stakes table in the back room (Noble Quarter access). |
| **Stock market → Merchant ventures** | Torn City | Invest in trading caravans departing from the Harbor. Returns depend on random events (pirates, storms, bandits, rare finds). Check results after 24 hours. |
| **Player shops/stalls** | Torn City | Set up a stall in the Market Square. List items at custom prices. Stall rent = gold sink. Higher rent = better location (more traffic). |
| **Fencing stolen goods** | Torn City | **Old Grist** at the Harbor buys stolen goods. Lower prices than market but no questions asked. Thievery skill affects quality of goods you can fence. |
### Guilds & Clans
| Mechanic | Source | Skara Brae Adaptation |
|---|---|---|
| **Factions** | Torn City | **Guilds** (already designed). Player organizations with hierarchy, guild hall, territory, treasury, taxation, wars. Guild rank progression (10 ranks). |
| **Companies** | Torn City | **Clans** — a separate system from guilds. Clans are player-run businesses/cooperatives. |
| **Clan types** | Original design | Clan types: Trading Company (market bonuses), Mercenary Band (PvP bonuses), Crafting Cooperative (crafting bonuses), Exploration Society (adventure bonuses). A player can be in one guild AND one clan simultaneously. Guilds are about power/territory. Clans are about economic specialization. |
| **Guild wars** | Torn City | Territory control through raids. Formal war declaration. Surrender terms. Winner takes territory + treasury portion. |
| **Alliances** | Torn City | Guild-to-guild treaties. Non-aggression pacts, trade agreements, mutual defense. Alliance chat channel. |
| **Guild Adventures** | Torn City | Cooperative activities requiring multiple members (→ Torn's organized crimes). Weekly cooldown. |
### Party System & Grouping
| Mechanic | Source | Skara Brae Adaptation |
|---|---|---|
| **Party of 6 characters** | Bard's Tale I | **Parties of 2-4 players** (not 6 characters per player). Group up for adventures and dungeons. Party leader chooses destination. Loot distributed by configured rules (need/greed, round-robin, leader assigns). |
| **Front/back rank** | Bard's Tale I | Combat positions: Front rank (melee, takes hits first) and back rank (ranged/magic, protected). Party leader assigns positions. |
| **Adventurer's Guild party board** | Bard's Tale I | **Looking for Group board** at the Adventurers' Guild. Post: "Looking for healer for Catacombs run" or "Need tank for guild raid." Players apply, leader accepts. |
| **Group bonuses** | MMORPGs | Party of 2: +10% XP. Party of 3: +20% XP. Party of 4: +30% XP. Encourages grouping without making it mandatory. |
| **NPC party members** | Bard's Tale I | Hire **mercenaries** from the inn to fill empty party slots. Cost gold per adventure. Lower stats than players, no special abilities. Better than nothing for solo players attempting hard content. |
| **Raid content** | MMORPGs | **Guild raids**: 6-12 players in an instanced dungeon. Boss mechanics require coordination. Weekly lockout. Best loot in the game. |
### Races (Conservative)
Drawing from all fantasy settings but keeping it traditional:
| Race | Bonus | Penalty | Lore |
|---|---|---|---|
| **Human** | +Charisma, adaptable | None (jack of all trades) | Most numerous. Run the City Watch, merchant houses, and government. |
| **Dwarf** | +Constitution, +Smithing | -Agility | Clan-based hill-dwellers. Run the best forges in the Crafters' Quarter. |
| **Elf** | +Intelligence, +Perception | -Strength | Ancient, declining race. Tied to the Old Kingdom's magic. Run the Academy. |
| **Halfling** | +Agility, +Thievery | -Strength | Nimble, underestimated. Run the smuggling routes at the Harbor. |
| **Half-Orc** | +Strength, +Intimidation | -Intelligence | Marginalized. Often found in the Arena or as mercenaries. |
| **Half-Elf** | +Charisma, +Magic | -Constitution | Bridge between human and elven societies. Often diplomats or bards. |
**Decision**: Keep the 6 races from the current design. Conservative, well-understood, no weird ones.
### Classes (Conservative)
| Class | Role | Key Abilities | Source Inspiration |
|---|---|---|---|
| **Warrior** | Tank / Melee DPS | Heavy armor, weapon mastery, shield skills | BT1 Warrior, RM Fighter |
| **Rogue** | DPS / Utility | Stealth, lockpicking, poison, backstab (high crit chance) | BT1 Rogue, RM Thief |
| **Mage** | Ranged DPS | 4 spell schools (Magician, Conjurer, Sorcerer, Wizard). Switch schools at Review Board. | BT1 Magician/Conjurer/Sorcerer/Wizard, RM Essence user |
| **Bard** | Support / Buffs | Bard songs (long-duration buffs), lore skills, social bonuses, minor healing | BT1 Bard, RM Bard |
| **Cleric** | Healer / Support | Channeling spells (healing, protection, undead turning), blessings | RM Channeling user, D&D Cleric |
| **Ranger** | Ranged DPS / Survival | Bow mastery, tracking, wilderness survival, minor nature magic | BT1 Hunter, RM Ranger |
| **Paladin** | Tank / Healer hybrid | Heavy armor + minor healing, undead bonuses, aura buffs | BT1 Paladin, D&D Paladin |
| **Monk** | Melee DPS / Evasion | Unarmed combat, high dodge, stunning blows, meditation (mana recovery) | BT1 Monk, RM Monk |
**Decision**: 8 classes. The original 6 + Paladin and Monk (both in BT1). Conservative, recognizable archetypes. Each class has a clear role.
### Properties & Housing
| Mechanic | Source | Skara Brae Adaptation |
|---|---|---|
| **Properties** | Torn City | Buy/rent dwellings: Straw bed at inn → Room at boarding house → Apartment → House → Tower. Properties provide: energy recovery rate, storage capacity, crafting station quality, pet stable size. |
| **Upgrades** | Torn City | Upgrade properties: Add a forge (blacksmithing bonus), library (education speed bonus), garden (herbalism yield), wine cellar (entertaining bonus). |
| **Rent** | Torn City | Properties have weekly rent. Fail to pay = locked out (items in storage). Pay back rent to regain access. Permanent ownership available for high gold cost. |
### Pets & Companions
(Fully designed in Idle Fantasy Inspirations note)
| Mechanic | Source | Skara Brae Adaptation |
|---|---|---|
| **Collectible pets** | Idle Fantasy | Rare drops from enemies. Provide passive bonuses. |
| **Pet breeding** | Idle Fantasy | Combine pets for rare offspring. |
| **Pet death in PvP** | Original design | Pets can be killed, resurrected at temple. Creates stakes. |
### Crafting
(Fully designed in Game Concept Overview note)
| Mechanic | Source | Skara Brae Adaptation |
|---|---|---|
| **23 skills** | Idle Fantasy | Mapped to 5 gathering + 7 crafting + combat + support skills. |
| **Quality rolls** | Original design | Common → Legendary quality tiers with crafting crits (RM-inspired). |
| **Crafting professions** | Idle Fantasy / RM | 12 professions with mastery levels (Novice → Grandmaster). |
### Quest System
| Mechanic | Source | Skara Brae Adaptation |
|---|---|---|
| **Main storyline (5 acts)** | Bard's Tale I | Mangar the Dark as antagonist. 5-act progression mirroring BT1's dungeon sequence. |
| **Daily quests** | Idle Fantasy | 3-5 daily quests from NPCs. Quick goals, moderate rewards. |
| **Weekly quests** | Torn City | Larger goals, better rewards. |
| **Cumulative achievements** | Idle Fantasy | "Mine 10,000 ore total" → permanent bonus. Long-term engagement. |
| **Slayer tasks** | Idle Fantasy | Kill-specific-enemy assignments from Captain Rennick or The Beggar King. |
| **Bounty board** | Torn City | Player-posted and NPC-posted bounties on other players. |
| **Riddles and puzzles** | Bard's Tale I | Dungeon-specific puzzles requiring lore knowledge, bard songs, or specific items. |
### World & Events
| Mechanic | Source | Skara Brae Adaptation |
|---|---|---|
| **Day/night cycle** | Original design | Compressed clock (1 game day = 4 real hours). Night = stealth bonuses, danger increases. |
| **Calendar events** | Original design | Moonday (magic bonus), Market Day (reduced fees), Arena Day (tournaments). |
| **Seasonal events** | Torn City | Festival of Lights, The Hunt, Mangar's Eve, The Great Market. Each with temporary locations (like Torn's Christmas Town). |
| **Dynamic world events** | Original design | Plague, invasion, festival. City-wide events affecting all players. |
| **Weather** | RoleMaster | Affects combat (rain = ranged penalty, fog = stealth bonus) and gathering (rain = herbalism bonus). |
---
## Summary: What Each Source Primarily Contributes
### Bard's Tale I — Setting & Structure
- The city of Skara Brae (setting, districts, key locations)
- Review Board (leveling, spell learning)
- Adventurer's Guild (character management, LFG board)
- Party-based combat with front/back rank
- Dungeon progression with prerequisites
- Mangar as antagonist
- Named locations (Garth's Shoppe, Rakhir Street)
- Spell schools (Magician, Conjurer, Sorcerer, Wizard → Archmage)
- Bard songs as buffs
- Paladin and Monk classes
### RoleMaster / MERP — Combat Engine
- d100 open-ended roll system
- Critical tables by weapon type (slash, crush, puncture, unarmed)
- Attack vs. armor cross-reference tables
- Body part damage with status effects
- Resistance rolls for magic
- Concussion hits vs. critical hits
- Three realms of magic (Essence, Channeling, Mentalism)
- 10 stats (expanded to fit our 6-stat system)
- Skill rank progression
- Brutality: any level can critically affect any level (adapted with caps)
### Idle Fantasy — Idle & Progression Mechanics
- Offline sessions (1-hour cap)
- Hireable NPC workers (inn system)
- Pet collection and passive bonuses
- Guild rank progression (10 ranks with daily tasks)
- Blessings/church system
- Slayer tasks
- Daily + cumulative quest system
- 23-skill framework (adapted to our profession system)
- Quality tiers in crafting
- Provision buying in bulk
### Torn City — Economy & Long-Term Engagement
- Crime → adventure loop with energy system
- Billions-scale stat growth (BIGINT)
- Player-driven economy (bazaar, auctions, bank, market)
- Properties and housing with rent
- Education system (real-time courses)
- Casino → inn gambling games
- Racing → expeditions (timed adventures)
- Travel to offshore locations
- Factions → guilds (with territory wars)
- Companies → clans (economic specialization)
- Stock market → merchant ventures
- Bounty system
- PvP (arena + ambush)
- Patron subscription (ethical F2P)
- Community API from day one
- Seasonal events with temporary locations (Christmas Town)
---
## Open Design Questions
- [ ] **Map system**: Hybrid recommended (district nav for city, room-by-room for dungeons). Confirm?
- [ ] **Clan system**: Separate from guilds? One guild + one clan per player?
- [ ] **Party size**: 2-4 players? Up to 4+2 (4 players + 2 NPCs)?
- [ ] **Raid size**: 6? 12? For guild raid content.
- [ ] **Travel time**: 1-4 hours real time for offshore locations? Too long? Too short?
- [ ] **Spell school switching**: Allow mages to switch schools freely at Review Board, or restrict (cooldown, cost)?
- [ ] **Archmage**: Cosmetic title + achievement for mastering all spell schools? Or gameplay bonus?
- [ ] **Mercenary NPCs**: How strong? Flat % of player stats? Fixed stat range by hire cost?
---
## Related Notes
- [Game Concept Overview](joplin://9cf8a2e701654ffbbd6512cc837c7e2f) — Full game design
- [Combat System & Stat Scaling Design](joplin://0ef630db4e3e4d6dab18ccaefccaaa5a) — Crit tables, BIGINT stats
- [Idle Fantasy Inspirations](joplin://48b3a3c76f734c8dafbbf9e8b3b5f80a) — Idle mechanics
- [Lore & World Building](joplin://867bdc9845624908a90928887a71c4c9) — Districts, NPCs, storyline
- [Tech Stack & Architecture](joplin://cecd2ef2690d4d1fba3450342aa63953) — Rust workspace, DB schema
- [Project Plan](joplin://d243ae57778343fca997d46b73d0ee3b) — Roadmap, MVP, repo structure

View file

@ -0,0 +1,358 @@
# Skara Brae — Medals & Honours System (Torn City-Inspired)
Source: Joplin note 4877acc671ed4bfcb0d6bf2c5323c94a, migrated 2026-10-03
> **Goal**: A deep, profile-defining achievement system in the spirit of **Torn City's Honours/Awards** — long-term goals, bragging rights, rare prestige, and a *few* meaningful permanent bonuses — layered onto every existing Skara Brae system (combat, adventure, crafting, magic, guild, economy, etc.).
Torn City's honours are a major long-term retention driver: hundreds of awards across every activity, displayed on the profile, some granting small permanent stat boosts. Skara Brae adapts this into a fantasy-flavoured, **three-tier** structure: **Medals** (tiered, category-based) → **Honours** (rare, prestigious, single awards) → **Titles** (displayed name prefixes/suffixes).
---
## 1. System Overview
| Layer | What It Is | How Earned | Reward Style |
|---|---|---|---|
| **Medals** | Tiered achievement badges, organized by category | Cumulative thresholds across gameplay | Mostly cosmetic; some grant small permanent bonus per tier |
| **Honours** | Rare, prestigious single awards for exceptional feats | Specific hard/rare accomplishments | Display + a few grant permanent bonus |
| **Titles** | Name prefixes/suffixes shown with character name | Unlocked by medals/honours | Cosmetic only |
| **Hall of Fame** | Leaderboards / rankings | Top N per category, season & all-time | Bragging rights + seasonal cosmetic |
**Design guardrail**: Permanent gameplay bonuses from honours/medals are **small, %-based, and collectively capped** (max ~+10% total across all sources) so this rewards dedication without creating unbridgeable gaps or pay-to-win pressure. The bulk of the value is **cosmetic prestige + long-term goals**.
---
## 2. Medals — Tiered Badges by Category
Each medal has **5 tiers**. Cross a threshold → earn that tier (and upgrade the badge on your profile). Tiers are cumulative counters tracked per character.
### Tier Progression (Fantasy-Flavoured)
| Tier | Name | Relative Rarity |
|---|---|---|
| T1 | **Copper** | Common — early game |
| T2 | **Silver** | Uncommon — mid game |
| T3 | **Gold** | Rare — dedicated players |
| T4 | **Platinum** | Very rare — long-term veterans |
| T5 | **Dragon** | Legendary — top-tier devotees |
### 2.1 Medal Categories & Examples
> Thresholds are illustrative; final values set in tuning. A medal marked ⚡ grants a small permanent bonus at one or more tiers (see §4 for the bonus catalogue).
#### ⚔️ Combat (PvP & PvE)
| Medal | Copper | Silver | Gold | Platinum | Dragon | Bonus? |
|---|---|---|---|---|---|---|
| Pit Fighter (arena wins) | 10 | 100 | 1,000 | 10,000 | 50,000 | ⚡ +arena XP |
| Ambusher (successful ambushes) | 5 | 50 | 500 | 2,500 | 10,000 | — |
| Survivor (survive lethal attacks <10% HP) | 1 | 10 | 50 | 200 | 1,000 | — |
| Crit Master ⚡ (critical hits landed) | 100 | 1,000 | 10,000 | 100,000 | 500,000 | +crit chance |
| Flawless (win combat without taking damage) | 5 | 50 | 250 | 1,000 | 5,000 | — |
| Duelist (1v1 ranked wins) | 10 | 100 | 500 | 2,000 | 10,000 | ⚡ +duel rewards |
| Dungeon Clearer (dungeons completed) | 1 | 10 | 50 | 200 | 1,000 | — |
#### 🗡️ Adventure (→ Torn's Crimes)
| Medal | Copper | Silver | Gold | Platinum | Dragon | Bonus? |
|---|---|---|---|---|---|---|
| Pickpocket (successful pickpockets) | 10 | 100 | 1,000 | 10,000 | 50,000 | — |
| Burglar (manors/vaults broken into) | 1 | 25 | 100 | 500 | 2,000 | — |
| Fencer (stolen goods fenced) | 10 | 100 | 1,000 | 10,000 | 50,000 | ⚡ +fence value |
| Waylayer (travelers waylaid) | 5 | 50 | 500 | 2,500 | 10,000 | — |
| Dungeon Raider (guild raids completed) | 1 | 5 | 25 | 100 | 500 | — |
| Adventurer (total adventures completed) | 100 | 1,000 | 10,000 | 50,000 | 250,000 | ⚡ +adventure gold |
| Night Owl ⚡ (nighttime adventures) | 50 | 500 | 5,000 | 25,000 | 100,000 | +stealth at night |
#### 🔨 Crafting & Gathering
| Medal | Copper | Silver | Gold | Platinum | Dragon | Bonus? |
|---|---|---|---|---|---|---|
| Smith (items crafted) | 10 | 100 | 1,000 | 10,000 | 50,000 | — |
| Artisan (Superior+ quality crafted) | 5 | 50 | 500 | 2,500 | 10,000 | — |
| Mastercraft (Masterwork crafted) | 1 | 10 | 50 | 200 | 1,000 | ⚡ +crafting crit |
| Legendary Forger (Legendary crafted) | 1 | 5 | 25 | 100 | 500 | — |
| Miner's Grit ⚡ (ore mined) | 1K | 10K | 100K | 1M | 10M | +gathering yield |
| Herbalist (herbs gathered) | 1K | 10K | 100K | 1M | 10M | — |
| Polymath (all 12 professions to Journeyman) | — | 1 | — | all to Expert | all to Master | ⚡ +crafting XP |
#### ✨ Magic & Skills (feeds the Skill/Magic System)
| Medal | Copper | Silver | Gold | Platinum | Dragon | Bonus? |
|---|---|---|---|---|---|---|
| Apprentice (any skill to rank 10) | 1 | 5 | 12 | 25 | 50 skills | — |
| Spellweaver (spells learned) | 5 | 25 | 75 | 150 | 250 | — |
| List Master ⚡ (spell list maxed) | 1 | 3 | 6 | 10 | 20 lists | +PP regen |
| Archmage (all 4 Essence schools mastered) | — | — | — | 1 | — | ⚡ +casting bonus |
| Loremaster (all Lore skills rank 20) | — | — | 1 | — | — | — |
| Jack of All Trades ⚡ (skills at rank 20+) | 5 | 15 | 30 | 50 | 80 | +DP gain |
#### 💰 Economy
| Medal | Copper | Silver | Gold | Platinum | Dragon | Bonus? |
|---|---|---|---|---|---|---|
| Coinpurse (total gold earned) | 10K | 1M | 100M | 10B | 1T | — |
| Tycoon ⚡ (net worth milestone) | 100K | 10M | 1B | 100B | 10T | +bank interest |
| Merchant (market trades completed) | 10 | 100 | 1,000 | 10,000 | 50,000 | ⚡ -listing fees |
| Gambler (inn games won) | 10 | 100 | 1,000 | 5,000 | 25,000 | — |
| Speculator (merchant ventures profited) | 1 | 10 | 50 | 200 | 1,000 | — |
#### 🛡️ Guild & Social
| Medal | Copper | Silver | Gold | Platinum | Dragon | Bonus? |
|---|---|---|---|---|---|---|
| Loyalist (guild tenure) | 1mo | 6mo | 1yr | 2yr | 5yr | — |
| War Hero (guild wars won) | 1 | 5 | 25 | 100 | 500 | ⚡ +guild rep gain |
| Benefactor (gold donated to guild) | 10K | 1M | 100M | 10B | 1T | — |
| Reputation (social reputation points) | 100 | 1K | 10K | 100K | 1M | — |
| Carouser ⚡ (inn carousing sessions) | 10 | 100 | 1,000 | 5,000 | 25,000 | +charisma temp |
#### 🏹 Slayer & Hunting
| Medal | Copper | Silver | Gold | Platinum | Dragon | Bonus? |
|---|---|---|---|---|---|---|
| Slayer (tasks completed) | 1 | 10 | 50 | 250 | 1,000 | ⚡ +slayer XP |
| Bounty Hunter (bounties claimed) | 1 | 10 | 50 | 250 | 1,000 | — |
| Exterminator (total enemies slain) | 100 | 1K | 10K | 100K | 1M | — |
| Rare Game (rare/elite enemies killed) | 1 | 10 | 50 | 200 | 1,000 | — |
#### 🗺️ Exploration
| Medal | Copper | Silver | Gold | Platinum | Dragon | Bonus? |
|---|---|---|---|---|---|---|
| Pathfinder (districts discovered) | 3 | 6 | all 11 | — | — | — |
| Voyager (offshore trips) | 1 | 5 | 25 | 100 | 500 | — |
| Tomb Raider (Lost Tombs cleared) | 1 | 5 | 25 | 100 | 500 | — |
| World Walker (all travel destinations visited) | — | — | — | — | 1 | — |
#### 🎪 Events & Seasonal
| Medal | Copper | Silver | Gold | Platinum | Dragon | Bonus? |
|---|---|---|---|---|---|---|
| Festival Goer (seasonal events joined) | 1 | 3 | 6 | 12 | 24 | — |
| The Hunt Champion (world boss dealt final blow) | — | — | 1 | 3 | 10 | — |
| Mangar's Eve survivor | — | 1 | 3 | 5 | 10 | — |
### 2.2 Hidden Medals
A subset of medals are **hidden** (like Torn's secret honours) — the name/description is `???` until earned. Examples: discover the Adventurers' Guild, find a hidden room in a dungeon, fence goods to Old Grist 100 times. Creates exploration-driven surprise and community wiki culture.
---
## 3. Honours — Rare Prestige Awards
Honours are **single, non-tiered** awards for exceptional, hard, or one-off accomplishments. Far rarer than medals. Displayed in a dedicated "Honours" ribbon on the profile. Each has a unique fantasy name and flavour text.
| Honour | Requirement | Bonus? | Notes |
|---|---|---|---|
| **First Light** | Be among the first 100 characters to reach level 50 | ⚡ +1% all XP | World-first cohort |
| **Dragonslayer** | Deal the final blow to a Dragon-tier world boss | ⚡ +2% damage to bosses | Coordination feat |
| **Archmage of Skara Brae** | Master all four Essence schools (Archmage) | ⚡ +5% spell effectiveness | Magic capstone |
| **Grandmaster Artisan** | Reach Grandmaster in all 12 professions | ⚡ +crafting crit chance | Ultimate crafter |
| **Unbroken** | Win 100 arena duels without a single loss streak | ⚡ +arena prestige | Combat excellence |
| **The Untouchable** | Reach level 50 without ever being ambushed/defeated | ⚡ +1% dodge | Pure PvE run |
| **Mangar's Bane** | Complete Act V (defeat Mangar) | ⚡ +title "Bane of Mangar" | Storyline capstone |
| **Citizen of the Year** | Win a seasonal Hall of Fame #1 | Cosmetic title | Seasonal |
| **Lucky Devil** | Roll an upward open-ended 200+ (any maneuver) | — | RNG flex |
| **Iron Will** | Resist 100 spells via Resistance Roll | ⚡ +5 RR | Anti-magic |
| **The Beggar King's Shadow** | Max all underground slayer tasks | ⚡ +stealth | Rogue capstone |
| **Champion of the Dawn** | Complete all of High Priestess Vaela's divine quests | ⚡ +blessing duration | Cleric capstone |
| **Patron of the Arts** | Hire 1,000 gold worth of inn workers | — | Gold-sink flex |
| **Survivor of the Deep** | Clear the Undercity without a party member falling | ⚡ +Undercity rewards | Solo/coordination |
Honours are intentionally **curated and limited** (target: ~40 at launch, expandable). They're the "wow" awards that make a profile stand out.
---
## 4. Permanent Bonuses — Bounded & Capped
The ⚡ bonuses are the main gameplay incentive. To prevent power creep:
- Each ⚡ bonus is **small**: typically +1% to +5% in its niche.
- **Collective cap**: total bonus from all honours + medals combined is capped at **+10%** per category of effect (e.g., max +10% combat-related, +10% crafting-related, etc.). Beyond the cap, additional ⚡ awards are cosmetic-only.
- **Diminishing stacking**: multiple sources of the same effect stack *multiplicatively with diminishing returns* (e.g., two +5% crit bonuses = 1 - (0.95)(0.95) = +9.75%, not +10%).
- **Cosmetic is king**: most medals/honours are display-only. The bonuses are seasoning, not the meal.
This mirrors Torn, where honours give tiny permanent stat bumps — enough to matter to min-maxers, not enough to invalidate progression.
---
## 5. Titles
Earned from medals/honours. One **active title** displayed with the character name (e.g., in chat, combat, profile). Players can switch their active title freely from earned titles.
| Title | Source |
|---|---|
| the Pit Fighter | Pit Fighter medal (Gold) |
| Slayer of the Deep | Survivor of the Deep honour |
| Bane of Mangar | Mangar's Bane honour |
| Archmage | Archmage honour |
| the Coin-King / Coin-Queen | Tycoon medal (Platinum) |
| Dragonslayer | Dragonslayer honour |
| Grandmaster | Grandmaster Artisan honour |
| Loremaster | Loremaster medal |
| Shadow / the Unseen | Beggar King's Shadow honour |
| of the Dawn | Champion of the Dawn honour |
Titles are **purely cosmetic** — no mechanical effect. They're the social currency of prestige.
---
## 6. Hall of Fame / Leaderboards
Redis-backed leaderboards (already in the tech stack) power the Hall of Fame:
| Board | Scope | Reset |
|---|---|---|
| Combat ranking | PvP rating | Seasonal (monthly) + all-time |
| Wealth ranking | Net worth | All-time |
| Adventure ranking | Adventures completed (season) | Seasonal |
| Crafting ranking | Items crafted (season) | Seasonal |
| Guild ranking | Guild reputation | Seasonal + all-time |
| Slayer ranking | Slayer tasks (season) | Seasonal |
Top 3 / Top 10 / Top 100 each season earn a **seasonal honour + cosmetic**. All-time leaders are enshrined permanently.
---
## 7. Profile Display
A character's profile (viewable by others, like Torn's profile page) shows:
- **Honours ribbon** — row of rare honour icons (the flex zone)
- **Medal case** — categorized medal badges with earned tiers; unearned show greyed silhouettes (hidden medals show `???`)
- **Active title** — prefix/suffix on the name
- **Hall of Fame badges** — seasonal & all-time rankings
- **Featured medal** — player picks one medal to feature prominently
This creates the Torn-style "let me see how impressive this player is at a glance" profile.
---
## 8. Database Schema (Draft)
```sql
-- Medal definitions (static game data, in skara-brae-common)
-- (could be JSON/TOML in common repo; shown as SQL for schema context)
CREATE TABLE medal_definitions (
id VARCHAR(64) PRIMARY KEY, -- 'pit_fighter'
category VARCHAR(32) NOT NULL,
name VARCHAR(64) NOT NULL,
description TEXT NOT NULL,
is_hidden BOOLEAN NOT NULL DEFAULT FALSE,
-- tier thresholds as JSON array: [10,100,1000,10000,50000]
thresholds JSONB NOT NULL
);
-- Character medal progress (highest tier earned + current count)
CREATE TABLE character_medals (
character_id UUID NOT NULL REFERENCES characters(id),
medal_id VARCHAR(64) NOT NULL REFERENCES medal_definitions(id),
count BIGINT NOT NULL DEFAULT 0,
highest_tier SMALLINT NOT NULL DEFAULT 0, -- 0..5
PRIMARY KEY (character_id, medal_id)
);
-- Honour definitions
CREATE TABLE honour_definitions (
id VARCHAR(64) PRIMARY KEY,
name VARCHAR(64) NOT NULL,
description TEXT NOT NULL,
bonus_json JSONB, -- null if cosmetic
title VARCHAR(64) -- granted title, if any
);
-- Character honours earned
CREATE TABLE character_honours (
character_id UUID NOT NULL REFERENCES characters(id),
honour_id VARCHAR(64) NOT NULL REFERENCES honour_definitions(id),
earned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (character_id, honour_id)
);
-- Earned titles
CREATE TABLE character_titles (
character_id UUID NOT NULL REFERENCES characters(id),
title VARCHAR(64) NOT NULL,
source VARCHAR(64) NOT NULL, -- honour_id or medal_id
earned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (character_id, title)
);
ALTER TABLE characters ADD COLUMN active_title VARCHAR(64);
ALTER TABLE characters ADD COLUMN featured_medal VARCHAR(64);
```
Counters that feed medals (arena wins, crits, gold earned, etc.) are best stored as **existing gameplay columns** or a lightweight `character_counters` key-value table to avoid schema bloat:
```sql
CREATE TABLE character_counters (
character_id UUID NOT NULL REFERENCES characters(id),
counter_key VARCHAR(48) NOT NULL, -- 'arena_wins', 'crits_landed', ...
value BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (character_id, counter_key)
);
```
---
## 9. API Additions (Draft)
```
# Profile / medals / honours (public-ish)
GET /api/profile/:character_id # overview incl. medals, honours, title
GET /api/character/medals # own medals + progress + hidden (revealed only)
GET /api/character/honours # own honours
PUT /api/character/active-title # set active title
PUT /api/character/featured-medal # set featured medal
# Hall of Fame
GET /api/leaderboard/:board # seasonal or all-time rankings
```
A new WebSocket notification type for medal/honour unlocks:
```json
{ "type": "achievement:unlock", "data": { "kind": "medal", "id": "crit_master", "tier": 3, "name": "Crit Master", "bonus": "+1% crit chance" } }
{ "type": "achievement:unlock", "data": { "kind": "honour", "id": "dragonslayer", "name": "Dragonslayer" } }
```
---
## 10. Integration With Existing Systems
| Existing System | Integration |
|---|---|
| **Cumulative achievements** (existing design) | *Replaced/augmented* by medals. "Mine 10,000 ore" → Miner's Grit medal. |
| **Skill & Magic System** (new) | Skill ranks, spell lists mastered, Archmage, Jack of All Trades all feed medals/honours. |
| **Combat crit tables** | Crit Master medal tracks crits landed. |
| **Crafting quality tiers** | Artisan / Mastercraft / Legendary Forger medals track quality outcomes. |
| **Adventures / crimes** | Pickpocket, Burglar, Fencer, Adventurer medals mirror Torn crime milestones. |
| **Guilds / Clans** | War Hero, Benefactor, Loyalist medals; seasonal guild HOF. |
| **Slayer / Bounty** | Slayer, Bounty Hunter medals. |
| **Economy** | Coinpurse, Tycoon, Merchant medals; wealth HOF. |
| **Seasonal events** | Festival Goer, seasonal HOF "Citizen of the Year" honour. |
| **Redis** (tech stack) | Powers leaderboards + counter aggregation for medal thresholds. |
---
## 11. MVP / Phasing
- **Phase 1 (MVP)**: Medal *counters* + a handful of combat/adventure medals (cosmetic only). Profile shows medal case. Lays the tracking infrastructure.
- **Phase 2**: Full medal categories, ⚡ bonuses (capped), Honours, Titles, profile ribbon.
- **Phase 3**: Hall of Fame leaderboards (seasonal + all-time), hidden medals, seasonal honours.
- **Phase 4**: Community medal wiki integration, medal-of-the-week, special event honours.
---
## 12. Open Design Questions
- [ ] **Bonus cap**: confirm +10% per-effect-category collective cap? Adjust per category?
- [ ] **Tier count**: 5 tiers (Copper→Dragon) or simplify to 4 (drop Dragon)?
- [ ] **World-first honours** (First Light): feasible to award fairly at launch? Use a registration-cohort instead?
- [ ] **Hidden medals**: how many at launch? Risk of players missing them entirely.
- [ ] **Seasonal HOF reset cadence**: monthly? Quarterly?
- [ ] **Bonus stacking math**: confirm multiplicative-diminishing vs. additive-capped.
- [ ] **Counter storage**: dedicated `character_counters` table vs. adding columns — choose before Phase 1.
---
## Related Notes
- [Game Concept Overview](joplin://9cf8a2e701654ffbbd6512cc837c7e2f) — Core loops, cumulative achievements (now medals)
- [Skill & Magic System](joplin://4c295d18c9b04aea947b534834ffaced) — Skills/magic feed medals & honours
- [Mechanics Source Mapping](joplin://87a0f70b45c4453caae0fa2b5b8a96f9) — Torn City source mapping (honours expanded here)
- [Combat System & Stat Scaling Design](joplin://0ef630db4e3e4d6dab18ccaefccaaa5a) — Crit Master medal tracks crits
- [Tech Stack & Architecture](joplin://cecd2ef2690d4d1fba3450342aa63953) — Redis leaderboards, DB schema
- [Lore & World Building](joplin://867bdc9845624908a90928887a71c4c9) — NPCs/districts referenced in honours
- [Project Plan](joplin://d243ae57778343fca997d46b73d0ee3b) — Roadmap, phasing

View file

@ -0,0 +1,390 @@
# Skara Brae — Skill & Magic System (MERP/Rolemaster-Inspired)
Source: Joplin note 4c295d18c9b04aea947b534834ffaced, migrated 2026-10-03
> **Goal**: Port the depth of the MERP/Rolemaster skill and magic engine into Skara Brae's existing use-based, BIGINT-stat, idle/MMO framework — *without* abandoning the Torn-style "stats only go up, train by doing" feel.
---
## 1. Design Philosophy — Reconciling Two Worlds
| Concern | RoleMaster/MERP | Skara Brae Existing | Resolution |
|---|---|---|---|
| **How skills grow** | Development Points spent per level (point-buy) | Use-based XP per skill (like RuneScape/Idle Fantasy) | **Hybrid**: practice-ranks by use **+** development-ranks by spend. See §3. |
| **Skill depth** | ~60 skills in 18+ categories, stat-linked, diminishing returns | Flat list of ~25 skills, each on its own XP bar | Adopt RM **categories**, **rank-bonus formula**, **stat integration**, **maneuver difficulties**. |
| **Magic** | Spell *lists* (open/closed/base), realm-based, learned in order, power-point cost | 4 BT1 schools + 3 realms mentioned but undefined | Full **spell-list system** (§5): curated lists per realm, ordered progression, PP cost, bolt/ball/utility classification. |
| **Resolution** | d100 open-ended + skill bonus vs. maneuver difficulty | d100 open-ended for combat only | Generalize d100 open-ended **maneuver roll** to *all* skills (§4). |
| **Stats** | 10 stats | 6 BIGINT stats (Str/Agi/Con/Int/Wis/Cha) | Map each skill to 1–3 of our 6 stats (§2.2). |
**Key principle**: Use-based training is the *primary* advancement loop (consistent with Torn + Idle Fantasy + the idle/offline features). Development Points are a *secondary* layer that lets players cross-train skills they don't actively grind — exactly Rolemaster's flexibility, but earned through play rather than per-level allocation alone.
---
## 2. The Skill Catalogue
### 2.1 Skill Categories
All skills are organized into Rolemaster-style **categories**. A character's **profession (class)** grants a **profession modifier** per category — a discount on Development Point cost and/or a small bonus to the skill bonus. This is the Rolemaster "class feel" mechanism.
| # | Category | Example Skills | Primary Use |
|---|---|---|---|
| 1 | **Armor** | Light Armor, Medium Armor, Heavy Armor, Shield | Mitigation, agility penalties |
| 2 | **Weapon — Melee** | 1H Edged, 1H Concussion, 2-Handed, Polearms, Unarmed, Martial Arts | Attack rolls, OB |
| 3 | **Weapon — Ranged** | Bow, Crossbow, Thrown, Sling | Ranged attack rolls |
| 4 | **Athletic / Brawn** | Climbing, Swimming, Gymnastics (dodge), Lifting | Movement, escape, evasion |
| 5 | **Awareness / Search** | Perception, Alertness, Tracking, Trap Sense, Ambush Awareness | Detection, scouting |
| 6 | **Body Development** | Constitution (HP pool growth), Stamina | Survivability |
| 7 | **Combat Maneuvers** | Mounted Combat, Adrenal Defense, Multiple Parry, Feint | Advanced combat options |
| 8 | **Craft / Trade** | Blacksmithing, Alchemy, Enchanting, Tailoring, Jewelcrafting, Fletching, Cooking | Crafting professions |
| 9 | **Gathering** | Mining, Herbalism, Woodcutting, Fishing, Skinning | Material acquisition |
| 10 | **Directed Spells** | Runes, Staves & Wands, Scrolls | Using magic items |
| 11 | **Influence** | Leadership, Persuasion, Intimidation, Disguise | Social, guild, trade |
| 12 | **Lore** | General Lore, Magical Lore, Religious Lore, Skara Brae Lore | Quests, puzzles, dungeon riddles |
| 13 | **Magic Lore** | Runereading, Spell Mastery, Mana Weaving | Spellcasting support |
| 14 | **Mental** *(Mentalism)* | Telepathy, Mental Defense, Mind Control, Enhancement | Mentalism spell channeling |
| 15 | **Outdoor / Environment** | Foraging, Survival, Weather Reading, Navigation | Wilderness, Wilds, offshore |
| 16 | **Self Control** | Meditation (mana regen), Frenzy (berserk), Willpower (RR bonus) | Buffs, recovery |
| 17 | **Special Attacks** | Backstab, Ambush, Poisons, Stunning Blows | Rogue/Monk techniques |
| 18 | **Spells** *(per realm)* | Essence Lists, Channeling Lists, Mentalism Lists | Spellcasting — see §5 |
| 19 | **Subterfuge / Stealth** | Lockpicking, Pickpocketing, Sneaking, Hiding, Trap Setting | Thievery, adventures |
| 20 | **Technical / Trade** | Bartering, Appraisal, Engineering (siege/traps) | Economy, guild siege |
| 21 | **Urban** | Streetwise, Gambling, Carousing, Black Market (fencing) | Inn, Lower City, Harbor |
> Mappers to existing design: categories 8 & 9 = the 5 gathering + 7 crafting professions already defined. Category 19 = the Thievery tree. Category 21 = social/inn activities. Category 18 = the magic system in §5.
### 2.2 Stat Linkage
Each skill rolls **1–3 relevant stats**. Stat bonus is derived from the BIGINT stat via a **logarithmic bonus function** (so billions-scale stats still produce a bounded, meaningful bonus):
```
stat_bonus(value) = floor( 10 * log2( value / 10 ) ) // bounded, grows slowly
// Example: 10 → 0, 20 → +10, 80 → +30, 10K → +100, 1B → ~+200
```
(Exact curve TBD in tuning — the point is **slow, diminishing** growth so that a character with 1B Intelligence isn't auto-winning every lore check, but is meaningfully better than one with 10K.)
| Sample skills | Linked Stats |
|---|---|
| 1H Edged | Str, Agi |
| Bow | Agi |
| Stealth / Sneaking | Agi |
| Lockpicking | Agi, Int |
| Blacksmithing | Str, Int |
| Alchemy | Int, Wis |
| Persuasion | Cha |
| Tracking | Wis, Int |
| Meditation | Wis, Con |
| Unarmed / Martial Arts | Str, Con, Agi |
| Runereading | Int |
---
## 3. Skill Ranks — Practice + Development (Hybrid)
### 3.1 Two Tracks, One Bonus
A skill's effectiveness is driven by its **total ranks**, which come from two sources:
1. **Practice Ranks** — earned automatically by using the skill (use-based XP → rank thresholds). *This is the core loop.* Each skill has its own XP bar. Offline apprentice workers grant practice XP at 50% (existing design).
2. **Development Ranks** — purchased with **Development Points (DP)**. Lets a player boost a skill they aren't actively grinding, or push past a practice plateau.
```
total_ranks(skill) = practice_ranks + development_ranks
skill_bonus(skill) = rank_bonus(total_ranks)
+ Σ stat_bonus(linked_stats)
+ profession_bonus(class, category)
+ equipment_bonus
+ situational_modifiers
```
### 3.2 Rank Bonus — Diminishing Returns (Rolemaster Formula, Adapted)
| Rank Band | Bonus per Rank | Cumulative at Band End |
|---|---|---|
| Ranks 1–10 | +5 each | +50 |
| Ranks 11–20 | +3 each | +80 |
| Ranks 21–30 | +2 each | +100 |
| Ranks 31–50 | +1 each | +120 |
| Ranks 51+ | +0.5 each (rounded) | slow creep |
This mirrors Rolemaster's "first rank is worth a lot, then diminishing." It keeps use-based grinding rewarding early and makes Development Points meaningful for pushing high-end skills. **Caps** per skill can be set per tuning (e.g., max 50 ranks for non-combat, higher for weapons/spells).
### 3.3 Development Points
- **DP per character level** = `5 + stat_bonus(Intelligence)` (Intelligence = learning aptitude). Patrons may get +2.
- **DP cost to buy a rank** depends on the skill's category and the character's profession:
| Profession Modifier | DP per Rank | Meaning |
|---|---|---|
| Favored | 1 | Class's core competencies |
| Normal | 2 | Secondary skills |
| Unfavored | 4 | Outside the class's wheelhouse |
| Restricted | 8 (or disallowed) | E.g., heavy spellcasting for a Warrior |
| Class | Favored Categories (examples) | Unfavored |
|---|---|---|
| **Warrior** | Armor, Weapon-Melee, Weapon-Ranged, Body Dev, Combat Maneuvers | Spells, Mental |
| **Rogue** | Subterfuge/Stealth, Special Attacks, Urban, Awareness, Weapon-Melee | Spells (closed), Self Control |
| **Mage** | Spells (Essence), Lore, Magic Lore, Directed Spells, Craft (Enchant/Alchemy) | Armor, Weapon-Melee, Subterfuge |
| **Bard** | Spells (Mentalism), Influence, Lore, Urban, Self Control | Armor (heavy), Body Dev |
| **Cleric** | Spells (Channeling), Influence, Self Control, Lore | Weapon-Ranged, Subterfuge |
| **Ranger** | Weapon-Ranged, Outdoor/Envir, Awareness, Gathering, minor Channeling | Urban, Influence |
| **Paladin** | Armor, Weapon-Melee, Spells (minor Channeling), Self Control, Influence | Subterfuge, Mental |
| **Monk** | Unarmed/Martial Arts, Athletic, Self Control, Body Dev | Armor (heavy), Spells |
### 3.4 Classless Cross-Training
Because any class *can* buy any (non-restricted) skill at higher DP cost, a Warrior *can* learn a basic Fire Bolt — but it costs 8 DP/rank and caps low. This is pure Rolemaster flexibility and rewards hybrid builds, while keeping class identity strong.
---
## 4. Skill Resolution — The Maneuver Roll (Generalized d100)
Every non-trivial skill action resolves with the same engine as combat: an **open-ended d100 maneuver**.
### 4.1 Difficulty Modifiers
| Difficulty | Modifier | Example in Skara Brae |
|---|---|---|
| Routine | +30 | Pick an easy lock (tavern cellar) |
| Easy | +20 | Track a fresh trail in snow |
| Light | +10 | Brew a basic healing potion |
| Medium | 0 | Pickpocket a sober merchant |
| Hard | −10 | Pickpocket a guard; scale a city wall |
| Very Hard | −20 | Defuse a dungeon trap; fence rare goods |
| Extremely Hard | −30 | Pick a vault lock in the Noble Quarter |
| Sheer Folly | −50 | Climb a sheer cliff in a storm |
| Absurd | −70 | Pickpocket Mangar's lieutenant |
### 4.2 Resolution
```
maneuver_total = d100_open_ended() + skill_bonus(skill) + difficulty_modifier
```
Open-ended: 96–100 rolls again and adds; 01–05 rolls again and subtracts (downward open-ended, per Rolemaster).
### 4.3 Result Table
| maneuver_total | Outcome | Effect |
|---|---|---|
| ≥ +80 | **Absolute Success** | Best result + bonus (e.g., +50% yield, bonus loot) |
| +21 to +79 | **Complete Success** | Full intended effect |
| 0 to +20 | **Partial Success** | Effect at reduced value / longer time |
| −20 to −1 | **Failure** | No effect; small chance of minor mishap |
| −80 to −21 | **Absolute Failure** | Failure + complication (broken pick, alarm, wasted materials) |
| ≤ −81 | **Spectacular Failure** | Severe consequence (injury, aggro, lost materials, trap sprung) |
Downward open-ended rolls (01–05) feed the lower bands → the "fumble" tension that makes Rolemaster feel alive.
### 4.4 Why This Matters
- **Adventures** (pickpocketing, burglary, fencing) now use real skill checks with difficulty tiers — replacing a flat success chance.
- **Crafting** quality rolls (Common→Legendary) can be driven off the maneuver table instead of a separate d100.
- **Dungeon puzzles** (lore checks, riddles, lockpicking, trap disarming) become skill encounters.
- **Social** actions (persuade the Beggar King, bluff the City Watch) use Influence skills.
- Consistent with the combat crit table already designed — **one unified resolution engine**.
---
## 5. The Magic System — Spell Lists per Realm
### 5.1 Realms & Primary Stat (Power Points)
| Realm | Casters | Primary Stat (PP) | Flavor |
|---|---|---|---|
| **Essence** | Mages | Intelligence | Raw magical energy woven from the world |
| **Channeling** | Clerics, Paladins (minor) | Wisdom | Power granted by the Old Gods |
| **Mentalism** | Bards | Charisma | Power drawn from mind and will |
**Power Points (PP)** = the spell-casting resource (replaces/generates the `mana` BIGINT pool). PP-per-level scales with the realm stat bonus:
```
max_pp = base_pp(class) + realm_stat_bonus * pp_multiplier(class)
```
PP regenerates over time, boosted by **Meditation** skill (Self Control) and blessings. Spells cost PP to cast; higher-tier spells on a list cost more.
### 5.2 Spell Lists — Open, Closed, Base
A **spell list** is an ordered progression: spell #1 is the weakest, spell #N is the strongest. You must learn a list **in order** — you can't learn spell #10 without #1–#9. This is core Rolemaster.
| List Class | Availability | Example |
|---|---|---|
| **Open** | Available to *all* casters of that realm (favored DP cost) | Essence Hand (utility) for all Essence users |
| **Closed** | Restricted to specific professions of that realm (normal/normal+ cost) | Life Mastery (healing) closed to Clerics |
| **Base** | The profession's signature lists (cheapest, defines the class) | Fire Law as a Mage base list |
You **rank up a spell list** like a skill (practice by casting + DP). Your **list rank** determines the highest spell number you can cast from that list. E.g., Fire Law rank 12 lets you cast Fire Law spells 1–12.
### 5.3 Curated Spell Lists per Realm
*(Target: ~20 lists per realm, ~10–15 spells each. Below is the launch set — expandable.)*
#### Essence (Mages)
| List | Class | Highlights |
|---|---|---|
| **Fire Law** | Base (Mage) | Spark → Fire Bolt → Wall of Fire → Fire Ball → Inferno |
| **Ice Law** | Base (Mage) | Frost → Ice Bolt → Freeze → Blizzard |
| **Light Law** | Base (Mage) | Glow → Light Bolt → Blinding Flash → Sun Bolt |
| **Earth Law** | Closed | Stone Bolt → Entangle → Earthquake |
| **Water Law** | Closed | Water Bolt → Wave → Tidal Surge |
| **Illusions** | Closed | Phantasmal Force → Blur → Invisibility |
| **Living Change** | Closed | Enhance Stat → Shrink → Polymorph |
| **Lofty Bridge** | Closed | Leap → Long Step → Gate → Teleport |
| **Essence Hand** | Open | Light → Unseen Hand → Lock Lore → Dispel |
| **Essence Ways** | Open | Detect Magic → Aura Reading → Sense Essence |
| **Spell Wall** | Open | Minor Shield → Barrier → Wall of Force |
| **Essence Perceptions** | Open | Detect Essence → Analyze Magic |
> **BT1 School Mapping**: The four Bard's Tale schools are *focuses within Essence*:
> - **Magician** (offensive) → Fire/Ice/Light Law base lists
> - **Conjurer** (summoning) → summoning sub-lists + Earth Law
> - **Sorcerer** (debilitating) → Illusions + Living Change
> - **Wizard** (utility) → Lofty Bridge + Essence Hand + Spell Wall
>
> Switching "school" at the Review Board = reprioritizing which base lists you rank. **Master all four = Archmage** (all Essence lists learned).
#### Channeling (Clerics)
| List | Class | Highlights |
|---|---|---|
| **Life Mastery** | Base (Cleric) | Minor Heal → Major Heal → Regeneration → Resurrection |
| **Repulse / Undead Mastery** | Base (Cleric) | Turn Undead → Hold Undead → Control Undead |
| **Protections** | Base (Cleric) | Sanctuary → Holy Shield → Consecration |
| **Detections** | Closed | Detect Evil → Detect Undead → Detect Trap |
| **Creations** | Closed | Create Light → Create Food → Create Water |
| **Weather Ways** | Closed | Calm Weather → Fair Winds → Storm Call |
| **Communal Ways** | Open | Sense Deity → Commune → Divine Guidance |
| **Channeling Perceptions** | Open | Detect Aura → Sense Danger |
#### Mentalism (Bards)
| List | Class | Highlights |
|---|---|---|
| **Mind Mastery** | Base (Bard) | Daze → Confusion → Mental Stun → Dominate |
| **Song / Sound** | Base (Bard) | Valor (atk buff) → Hope (morale) → Fear (debuff) → Charm → Lullaby (sleep). *Long-duration bard songs.* |
| **Self Healing** | Closed | Mend → Recover → Revitalize |
| **Mind's Door** | Closed | Telepathy → Mind Speech → Mind Store |
| **Body Control** | Closed | Adrenal Speed → Adrenal Strength → Prowess |
| **Mental Illusions** | Closed | Mirage → Phantasm → Mirage Illusion |
| **Mind's Eye** | Open | Sense Thoughts → Detect Motive |
| **Mental Defenses** | Open | Mind Barrier → Mental Shield → Iron Will |
### 5.4 Spell Classification (Combat Integration)
| Type | Resolution | Notes |
|---|---|---|
| **Bolt** (directed) | Attack roll = d100 open-ended + Directed Spells skill vs. target's defense (like a missile) | Fire Bolt, Ice Bolt. Can trigger crit tables (elemental crit). |
| **Ball / Area** (non-directed) | Target makes **Resistance Roll**: d100 + Will bonus + level vs. spell level | Fire Ball, Blizzard. Failed RR = full effect; partial = reduced. |
| **Defensive / Utility** | Auto-cast (costs PP); resisted only where noted | Shields, teleports, detections, healing. |
This slots directly into the existing **Resistance Roll** design (d100 + Will vs. spell difficulty) and the **d100 open-ended crit tables**. Elemental crit tables (burn, frost, shock) extend the slash/crush/puncture/unarmed tables already defined.
### 5.5 Spell Acquisition — The Review Board
Per existing design, the **Adventurers' Guild (Review Board)** is where you learn magic:
- Increase a spell-list rank by paying gold + meeting the level/practice prerequisite.
- Casters must visit to gain access to new spell numbers on a list.
- **Magical exam** (lore check) for spellcasters (existing design) — now a real *Magical Lore* maneuver.
- Mages switch Essence-school focus here (reprioritize base lists).
- **Archmage**: a Mage who has maxed all four BT1 schools' base lists → cosmetic title + achievement + small global casting bonus.
### 5.6 Spell Scrolls & Items
- **Scrolls** (Directed Spells → Scrolls skill): one-use spell from any list; lets non-casters cast minor spells (Torn-like consumable). Found in dungeons, crafted by Enchanting.
- **Staves & Wands** (Directed Spells → Staves & Wands): rechargeable PP-cost items casting a fixed spell. Crafted/bound via Enchanting.
- **Runes** (Magic Lore → Runereading): permanent magical inscriptions on gear; activated by Runereading skill.
---
## 6. Integration With Existing Systems
| Existing System | Change / Enhancement |
|---|---|
| **Combat (crit tables)** | Elemental crit tables added (burn, frost, shock). Bolts roll like missiles; balls use RR. Maneuver roll unified with combat. |
| **BIGINT stats** | `stat_bonus()` log function feeds skills. Stats keep growing to billions; bonuses grow slowly (bounded). |
| **Adventures** | Success now computed via maneuver roll + skill bonus + difficulty tier. Time-of-day & skill synergies become modifiers. |
| **Crafting (5 gather + 7 craft)** | These are skills in categories 8 & 9. Quality roll driven by maneuver table; crafting crits = upward open-ended success. |
| **Thievery** | Lives in Subterfuge/Stealth category (Lockpicking, Pickpocketing, Sneaking, Hiding). Adventures use these directly. |
| **Education courses** | Completing a course grants **DP** (development points) — a second DP source beyond leveling. Makes education feel impactful. |
| **Blessings** | Could grant temporary +skill bonus or +DP regen instead of flat multipliers. |
| **Workers (apprentice)** | Apprentice grants *practice XP* to a chosen skill at 50% — unchanged, now feeds practice ranks. |
| **Database** | New tables needed: `skill_categories`, `character_skill_ranks` (split practice/dev), `spell_lists`, `character_spell_list_ranks`, `spell_definitions`. See §7. |
---
## 7. Database Additions (Draft)
```sql
-- Skill ranks (practice + development split)
CREATE TABLE character_skills (
character_id UUID NOT NULL REFERENCES characters(id),
skill_name VARCHAR(48) NOT NULL,
category VARCHAR(32) NOT NULL,
practice_ranks INT NOT NULL DEFAULT 0,
development_ranks INT NOT NULL DEFAULT 0,
experience BIGINT NOT NULL DEFAULT 0, -- feeds practice_ranks
PRIMARY KEY (character_id, skill_name)
);
-- Development point pool per character
ALTER TABLE characters ADD COLUMN unspent_dp INT NOT NULL DEFAULT 0;
-- Spell lists
CREATE TABLE spell_lists (
id VARCHAR(64) PRIMARY KEY, -- e.g. 'fire_law'
realm VARCHAR(16) NOT NULL, -- essence | channeling | mentalism
list_class VARCHAR(16) NOT NULL, -- open | closed | base
name VARCHAR(64) NOT NULL,
description TEXT
);
-- Spell definitions (ordered within a list)
CREATE TABLE spells (
id VARCHAR(64) PRIMARY KEY, -- e.g. 'fire_law_3'
list_id VARCHAR(64) NOT NULL REFERENCES spell_lists(id),
spell_number INT NOT NULL, -- position in list (1..N)
name VARCHAR(64) NOT NULL,
spell_type VARCHAR(16) NOT NULL, -- bolt | ball | defensive | utility
pp_cost INT NOT NULL,
effect_json JSONB NOT NULL, -- damage, duration, modifiers
description TEXT,
UNIQUE (list_id, spell_number)
);
-- Character spell-list ranks
CREATE TABLE character_spell_lists (
character_id UUID NOT NULL REFERENCES characters(id),
list_id VARCHAR(64) NOT NULL REFERENCES spell_lists(id),
rank INT NOT NULL DEFAULT 0, -- highest spell_number accessible
experience BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (character_id, list_id)
);
```
---
## 8. Open Design Questions
- [ ] **Stat bonus curve**: exact `stat_bonus()` function shape — log2 base? Per-stat caps?
- [ ] **Rank caps**: hard cap per skill (50? 100?) or soft cap via DP scarcity?
- [ ] **PP vs. existing `mana` BIGINT column**: unify into PP, or keep mana as the raw pool and PP as derived? Recommend: rename `mana`→PP conceptually, derive max from formula.
- [ ] **Elemental crit tables**: how many (burn/frost/shock/sonic/poison)? Where stored (`skara-brae-common/crit-tables/`)?
- [ ] **Scroll/wand crafting**: which Enchanting recipes produce scrolls? List-restricted or universal?
- [ ] **DP from education**: how many DP per completed course? Balance against per-level DP.
- [ ] **Multi-realm casters**: should any class get a *second* realm at reduced effectiveness (e.g., Ranger = Essence + Channeling minor)? Rolemaster has semi/hybrid casters.
- [ ] **Archmage bonus**: cosmetic only, or small global casting bonus?
---
## Related Notes
- [Game Concept Overview](joplin://9cf8a2e701654ffbbd6512cc837c7e2f) — Skills, classes, stats, spell schools, education
- [Combat System & Stat Scaling Design](joplin://0ef630db4e3e4d6dab18ccaefccaaa5a) — d100 open-ended crit tables (this system generalizes them)
- [Mechanics Source Mapping](joplin://87a0f70b45c4453caae0fa2b5b8a96f9) — RoleMaster source mapping (expanded here)
- [Lore & World Building](joplin://867bdc9845624908a90928887a71c4c9) — Adventurers' Guild / Review Board, districts for learning
- [Tech Stack & Architecture](joplin://cecd2ef2690d4d1fba3450342aa63953) — DB schema, crate structure (skills.rs, spell engine)
- [Project Plan](joplin://d243ae57778343fca997d46b73d0ee3b) — Roadmap, MVP scope
- [Medals & Honours System](joplin://4877acc671ed4bfcb0d6bf2c5323c94a) — Skill/magic mastery feeds medals

View file

@ -0,0 +1,651 @@
# Skara Brae — Tech Stack & Architecture
Source: Joplin note cecd2ef2690d4d1fba3450342aa63953, migrated 2026-10-03
## Why This Stack in 2026
Torn launched in 2004 as a PHP/HTML text game. It received an engine overhaul in 2014 and a major Crimes 2.0 update in 2023. Modern Torn has real-time features, mobile apps, and a rich API.
For Skara Brae, we build for **2026+ expectations**:
- Instant real-time feedback (WebSocket everywhere)
- Mobile-first (PWA, not just responsive)
- API-first design (community tools from day one)
- Type-safe full stack
- Idle/offline progression (server-side tick engine)
---
## Deployment Topology (self-hosted, no Docker)
> Resolved 2026-08-02. Canonical reference: [Development Environment & Server Topology](joplin://58b6a5c37ba1424684e2e70eb839f1e7).
| Tier | Machine (OS) | Runtime |
|---|---|---|
| **Develop** | workhorse (Ubuntu 26.04, Ryzen 9 5950X 16C/32T, 128 GiB, RTX 5060 Ti) | Kilo + RustRover/WebStorm; local PostgreSQL (native package); `cargo run` + Vite. 32 threads → fast incremental builds. |
| **Test / Stage** | basement server (FreeBSD, LAN-only) | **Jenkins** CI (lint → test → cross-compile Linux x86_64 → transfer binary). A **bhyve Debian VM** mirrors prod: bare systemd units (PG + app binary + Caddy). Also file server + artifact store. |
| **Produce / Git** | Hetzner box (Debian, public) | **Forgejo** (primary git host — public + private repos), bare **systemd** game services (PG + app binary), **Caddy** (TLS). Planned **Codeberg** mirror for redundancy. |
**Flow:** commit on workhorse → push Forgejo (Hetzner, internet SSH) → Jenkins (basement) builds & cross-compiles via webhook → deploys to bhyve Debian staging → on green, SCPs binary to Hetzner prod → `systemctl restart`.
**Redis — deferrable for MVP.** Redis provides caching, leaderboards (sorted sets), and WebSocket pub/sub. At low player counts, the Rust process can hold session/leaderboard data in memory. Add Redis to prod when you need multi-process scaling or persistence across restarts. PostgreSQL handles all persistent data regardless.
**Sizing caveat:** the Hetzner box is "decent, not a powerhouse" and now also runs Forgejo (+ planned Codeberg mirror). A Rust binary + Forgejo is light, but watch combined RAM (Forgejo + Codeberg + PG + app). Set a threshold for upsizing or splitting.
---
## Art Direction (decided 2026-08-02)
**Torn-style graphical browser UI.** The game mechanics are text/numbers; the presentation is a fully graphical web application.
### UI characteristics
- **Location panels**: illustrated backgrounds per district (tavern, market, dungeon entrance, etc.)
- **Item/equipment icons**: sprite-style icons with quality-tier border glow (Common→Legendary)
- **Stat bars & HUD**: graphical HP/energy/PP bars, XP progress bars, skill meters
- **Themed color scheme**: dark fantasy palette, per-district tinting
- **Tab-based navigation**: Torn-style tab bar for core pages (City, Adventures, Combat, Inventory, Guild, etc.)
- **CSS animations**: transitions for page changes, stat updates, combat results
### Rendering approach
- All rendering via **HTML/CSS/SVG within Vue 3 SPA** — no canvas, no WebGL.
- Keeps it lightweight, accessible, and PWA-compatible.
- Vue component library with dark-fantasy theming.
### Art pipeline
- **Location art**: digital illustrations — ~11 district panels + dungeon room tiles
- **Item icons**: sprite sheet or SVG icon set
- **UI chrome**: Vue component library (stat bars, progress rings, modals, tooltips, tab bar)
---
## Multi-Repo Structure
The project is split into three repositories. See [Project Plan](joplin://d243ae57778343fca997d46b73d0ee3b) for full rationale.
```
git@hetzner/skara-brae/ # Forgejo on Hetzner (public + private)
├── skara-brae-server/ # Rust backend (opened in RustRover)
├── skara-brae-client/ # Vue 3 frontend (opened in WebStorm)
└── skara-brae-common/ # Shared game data, API contract, constants
```
> Git source of truth: **Forgejo** on the Hetzner box (public-facing). Jenkins on the basement box connects via deploy key + webhook. See [Deployment Topology](#deployment-topology-self-hosted-no-docker).
### Local Checkout Layout (IDE Wiring)
The two consumer repos live in different JetBrains IDEs (RustRover, WebStorm). Keep all three as **siblings under one parent folder** — the parent is a plain folder, **not** a git repo:
```
~/Development/skara-brae/ # workspace root (plain folder, NOT a git repo)
├── skara-brae-server/ # RustRover opens THIS
├── skara-brae-client/ # WebStorm opens THIS
└── skara-brae-common/ # shared — 3rd window, or "Attach" in either IDE
```
> This follows the cross-project workspace convention `~/Development/<project>/`. On workhorse, legacy repos still live in per-IDE default folders (`~/RustroverProjects`, `~/WebstormProjects`, `~/IdeaProjects`); relocate on first touch, or clone into `~/Development/skara-brae/` and symlink the repo back into the IDE folder for discoverability — but always open the **real path** in the IDE so path/file deps resolve. Full convention in [Development Environment & Server Topology § Workspace & Repository Layout](joplin://58b6a5c37ba1424684e2e70eb839f1e7).
- **RustRover (server)** — path dependency in `skara-brae-server/Cargo.toml`:
```toml
sb-common = { path = "../skara-brae-common/rust" }
```
Sibling path deps resolve fine. To edit common from inside RustRover: **Settings → Project Structure → Attach** (or add `../skara-brae-common` as a content root).
- **WebStorm (client)** — local file dependency in `skara-brae-client/package.json`:
```json
"@skara-brae/common": "file:../skara-brae-common/ts"
```
plus a Vite alias (`@common` → that path) so HMR picks up data edits. Attach the module likewise to edit it in-window.
### Shared-Repo Consumption Strategy (Bilingual Package)
`skara-brae-common` is a **bilingual package** whose source of truth is **language-neutral data + schema**, consumed at build time by both projects via codegen (never hand-duplicated types):
```
skara-brae-common/
├── data/ # language-neutral source of truth
│ ├── items/ adventures/ crit-tables/ spells/ recipes/
│ ├── medals/ dungeons/ tower/ expeditions/ (*.json / *.toml)
│ └── schema/ # JSON Schema (entities) + openapi.yaml (API contract)
├── rust/ # thin crate the server depends on
│ ├── Cargo.toml # name = "sb-common"; includes data via include_str! + serde
│ └── src/lib.rs
├── ts/ # thin package the client depends on
│ ├── package.json # name = "@skara-brae/common"; generated types + data re-exports
│ └── src/index.ts
└── codegen/ # data/ + schema → rust/ and ts/ bindings
```
**How sharing actually works:**
- **Static game data** (`data/*.json`) is the single source of truth. Both sides consume at build time: Rust via `include_str!` + `serde`; Vue via Vite JSON import.
- **API contract — server is authoritative.** Expose OpenAPI via [`utoipa`](https://docs.rs/utoipa); generate the client's TS types with `openapi-typescript` (or `orval`).
- **Entity types** — define once as JSON Schema in `data/schema/`, generate both Rust structs (`typify` / `schemars`) and TS interfaces (`json-schema-to-typescript`) from `codegen/`.
**Two clarifications:**
1. Do **not** confuse `skara-brae-common` (cross-language, this third repo) with the `sb-shared` crate in the server workspace below — `sb-shared` is a **server-internal** Rust crate (types/errors/constants) and stays inside `skara-brae-server/crates/`.
2. **Alternative for self-contained CI**: add `skara-brae-common` as a **git submodule** inside both repos. Start with sibling + path; switch to submodules only if contributors/CI demand it.
### Cross-language dependency path workaround
Cargo workspaces **cannot** resolve dependency paths that point above the workspace root. When `sb-common` is declared in `[workspace.dependencies]` with `path = "../skara-brae-common/rust"`, Cargo resolves the path relative to the **consuming crate** (e.g., `crates/sb-api/`), not the workspace root — producing the error `failed to read .../skara-brae-server/skara-brae-common/rust/Cargo.toml`.
**Current workaround (local solo dev):** use an **absolute path** in each crate's `[dependencies]`:
```toml
# In crates/sb-api/Cargo.toml and crates/sb-core/Cargo.toml:
[dependencies]
sb-common = { path = "/home/jan/Development/skara-brae/skara-brae-common/rust" }
```
Do **not** declare `sb-common` in `[workspace.dependencies]` — only in each consuming crate.
**For CI / contributors:** switch to **git submodules** (`server/common/`, `client/common/`) so each repo is self-contained. This is the planned long-term solution. For local solo work, the absolute path is functional.
---
## Server Project Structure
```
skara-brae-server/
├── Cargo.toml # Workspace root (DO NOT put sb-common in [workspace.dependencies])
├── crates/
│ ├── sb-api/ # Axum HTTP API server
│ │ ├── src/
│ │ │ ├── main.rs
│ │ │ ├── config.rs
│ │ │ ├── routes/
│ │ │ │ ├── mod.rs
│ │ │ │ ├── auth.rs
│ │ │ │ ├── character.rs
│ │ │ │ ├── adventure.rs
│ │ │ │ ├── combat.rs
│ │ │ │ ├── market.rs
│ │ │ │ ├── guild.rs
│ │ │ │ ├── property.rs
│ │ │ │ ├── education.rs
│ │ │ │ ├── crafting.rs
│ │ │ │ ├── pets.rs
│ │ │ │ ├── blessings.rs
│ │ │ │ ├── inn.rs
│ │ │ │ └── slayer.rs
│ │ │ ├── middleware/
│ │ │ │ ├── auth.rs
│ │ │ │ └── rate_limit.rs
│ │ │ └── ws/
│ │ │ ├── handler.rs
│ │ │ └── channels.rs
│ │ └── Cargo.toml # sb-common via absolute path
│ │
│ ├── sb-core/ # Game engine core
│ │ ├── src/
│ │ │ ├── lib.rs
│ │ │ ├── items.rs
│ │ │ ├── character/
│ │ │ │ ├── mod.rs
│ │ │ │ ├── stats.rs # BIGINT stat system, tiered display
│ │ │ │ ├── skills.rs
│ │ │ │ └── races.rs
│ │ │ ├── combat/
│ │ │ │ ├── mod.rs
│ │ │ │ ├── engine.rs
│ │ │ │ ├── actions.rs
│ │ │ │ ├── formulas.rs
│ │ │ │ └── crit_tables.rs # RoleMaster-inspired open-ended d100
│ │ │ ├── adventure/
│ │ │ │ ├── mod.rs
│ │ │ │ ├── templates.rs
│ │ │ │ ├── rewards.rs
│ │ │ │ └── offline.rs # Offline session reward calculation
│ │ │ ├── economy/
│ │ │ │ ├── mod.rs
│ │ │ │ ├── market.rs
│ │ │ │ └── bank.rs
│ │ │ ├── guild/
│ │ │ │ ├── mod.rs
│ │ │ │ ├── wars.rs
│ │ │ │ └── ranks.rs # 10-rank progression system
│ │ │ ├── crafting/
│ │ │ │ ├── mod.rs
│ │ │ │ ├── professions.rs # 12 professions (5 gathering + 7 crafting)
│ │ │ │ ├── recipes.rs
│ │ │ │ └── quality.rs # Quality roll system (Common→Legendary)
│ │ │ ├── pets/
│ │ │ │ ├── mod.rs
│ │ │ │ ├── breeding.rs
│ │ │ │ └── bonuses.rs
│ │ │ ├── blessings/
│ │ │ │ ├── mod.rs
│ │ │ │ └── effects.rs
│ │ │ ├── workers/
│ │ │ │ ├── mod.rs # Inn workers (NPC automation)
│ │ │ │ └── tasks.rs
│ │ │ ├── slayer/
│ │ │ │ ├── mod.rs
│ │ │ │ └── tasks.rs
│ │ │ ├── world/
│ │ │ │ ├── mod.rs
│ │ │ │ ├── time.rs # Day/night cycle
│ │ │ │ ├── weather.rs
│ │ │ │ └── events.rs # Dynamic world events
│ │ │ └── rules/
│ │ │ ├── mod.rs
│ │ │ └── formulas.rs # All game formulas in one place
│ │ └── Cargo.toml # sb-common via absolute path
│ │
│ ├── sb-db/ # Database layer
│ │ ├── src/
│ │ │ ├── lib.rs
│ │ │ ├── models/
│ │ │ │ ├── user.rs
│ │ │ │ ├── character.rs
│ │ │ │ ├── item.rs
│ │ │ │ ├── guild.rs
│ │ │ │ ├── property.rs
│ │ │ │ ├── pet.rs
│ │ │ │ ├── crafting.rs
│ │ │ │ └── blessing.rs
│ │ │ ├── queries/
│ │ │ └── migrations/
│ │ └── Cargo.toml
│ │
│ ├── sb-auth/ # Authentication & authorization
│ │ ├── src/
│ │ │ ├── lib.rs
│ │ │ ├── jwt.rs
│ │ │ ├── hash.rs # argon2 password hashing
│ │ │ └── session.rs
│ │ └── Cargo.toml
│ │
│ └── sb-shared/ # Server-internal shared types, errors, constants
│ ├── src/
│ │ ├── lib.rs
│ │ ├── types.rs
│ │ ├── errors.rs
│ │ └── constants.rs
│ └── Cargo.toml
│
├── migrations/ # SQL migrations
│ ├── 001_initial.sql
│ ├── 002_character.sql
│ ├── 003_guilds.sql
│ ├── 004_pets.sql
│ ├── 005_crafting.sql
│ └── 006_blessings.sql
│
├── systemd/ # systemd .service unit files for deployment
│ ├── skara-brae.service
│ ├── skara-brae.env # environment variables (DB URL, Redis URL, etc.)
│ └── install.sh # install script: copy binary + unit → /etc/systemd/system/
│
└── README.md
```
> Note: `sb-shared` above is the **server-internal** Rust crate. The **cross-language** shared repo is `skara-brae-common` (consumed via `sb-common` with an absolute path). See [Cross-language dependency path workaround](#cross-language-dependency-path-workaround) above.
---
## systemd Deployment (prod & staging)
### skara-brae.service
```ini
[Unit]
Description=Skara Brae Game Server
After=network.target postgresql.service
Wants=postgresql.service
[Service]
Type=simple
User=skara-brae
Group=skara-brae
ExecStart=/opt/skara-brae/bin/skara-brae-server
EnvironmentFile=/opt/skara-brae/skara-brae.env
Restart=always
RestartSec=5
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
```
### skara-brae.env
```env
DATABASE_URL=postgres://skara-brae:password@127.0.0.1:5432/skara_brae
REDIS_URL=redis://127.0.0.1:6379 # only when Redis is deployed
LISTEN_ADDR=127.0.0.1:8080
JWT_SECRET=changeme
RUST_LOG=info
```
### Caddy (reverse proxy + TLS)
```
git.yourdomain.tld {
reverse_proxy 127.0.0.1:3000
}
api.skara-brae.tld {
reverse_proxy 127.0.0.1:8080
}
play.skara-brae.tld {
root * /srv/skara-brae-web
file_server
try_files {path} /index.html
}
```
### Jenkins deploy step (SSH to Hetzner)
```bash
scp target/release/skara-brae-server hetzner:/opt/skara-brae/bin/
ssh hetzner "systemctl restart skara-brae"
```
### Install script (first-time setup)
```bash
#!/bin/bash
set -euo pipefail
sudo useradd -r -s /bin/false skara-brae
sudo mkdir -p /opt/skara-brae/{bin,config}
sudo cp skara-brae-server /opt/skara-brae/bin/
sudo cp systemd/skara-brae.service /etc/systemd/system/
sudo cp systemd/skara-brae.env /opt/skara-brae/
sudo systemctl daemon-reload
sudo systemctl enable --now skara-brae
```
---
## Key Crate Dependencies
### sb-api (Axum server)
```toml
[dependencies]
axum = { version = "0.8", features = ["ws", "macros"] }
tokio = { version = "1", features = ["full"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sb-core = { path = "../sb-core" }
sb-db = { path = "../sb-db" }
sb-auth = { path = "../sb-auth" }
sb-shared = { path = "../sb-shared" }
sb-common = { path = "/home/jan/Development/skara-brae/skara-brae-common/rust" }
utoipa = { version = "5", features = ["axum_extras"] }
tracing = "0.1"
tracing-subscriber = "0.3"
```
### sb-core (Game engine)
```toml
[dependencies]
serde = { version = "1", features = ["derive"] }
rand = "0.9"
chrono = { version = "0.4", features = ["serde"] }
thiserror = "2"
sb-shared = { path = "../sb-shared" }
sb-common = { path = "/home/jan/Development/skara-brae/skara-brae-common/rust" }
```
### sb-db (Database)
```toml
[dependencies]
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "chrono", "uuid", "migrate"] }
sb-shared = { path = "../sb-shared" }
serde = { version = "1", features = ["derive"] }
```
### sb-auth (Auth)
```toml
[dependencies]
jsonwebtoken = "9"
argon2 = "0.5"
serde = { version = "1", features = ["derive"] }
chrono = { version = "0.4", features = ["serde"] }
sb-shared = { path = "../sb-shared" }
```
---
## Database Schema (Core Tables)
```sql
-- Users
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(32) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL, -- argon2 hash
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_login TIMESTAMPTZ,
is_banned BOOLEAN NOT NULL DEFAULT FALSE,
is_patron BOOLEAN NOT NULL DEFAULT FALSE
);
-- Characters (one per user, or multiple for patrons)
CREATE TABLE characters (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
name VARCHAR(32) UNIQUE NOT NULL,
race VARCHAR(16) NOT NULL,
class VARCHAR(16) NOT NULL,
level INT NOT NULL DEFAULT 1,
experience BIGINT NOT NULL DEFAULT 0,
gold BIGINT NOT NULL DEFAULT 100,
energy INT NOT NULL DEFAULT 100,
max_energy INT NOT NULL DEFAULT 100,
hp BIGINT NOT NULL DEFAULT 100,
max_hp BIGINT NOT NULL DEFAULT 100,
pp BIGINT NOT NULL DEFAULT 50,
max_pp BIGINT NOT NULL DEFAULT 50,
strength BIGINT NOT NULL DEFAULT 10,
agility BIGINT NOT NULL DEFAULT 10,
constitution BIGINT NOT NULL DEFAULT 10,
intelligence BIGINT NOT NULL DEFAULT 10,
wisdom BIGINT NOT NULL DEFAULT 10,
charisma BIGINT NOT NULL DEFAULT 10,
location VARCHAR(32) NOT NULL DEFAULT 'tavern',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Skills
CREATE TABLE character_skills (
character_id UUID NOT NULL REFERENCES characters(id),
skill_name VARCHAR(32) NOT NULL,
level INT NOT NULL DEFAULT 0,
experience BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (character_id, skill_name)
);
-- Inventory
CREATE TABLE inventory (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
character_id UUID NOT NULL REFERENCES characters(id),
item_id UUID NOT NULL,
quantity INT NOT NULL DEFAULT 1,
equipped_slot VARCHAR(16),
quality VARCHAR(16) NOT NULL DEFAULT 'common',
acquired_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Guilds
CREATE TABLE guilds (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(64) UNIQUE NOT NULL,
description TEXT,
leader_id UUID NOT NULL REFERENCES characters(id),
treasury BIGINT NOT NULL DEFAULT 0,
tax_rate SMALLINT NOT NULL DEFAULT 10 CHECK (tax_rate BETWEEN 0 AND 25),
level INT NOT NULL DEFAULT 1,
reputation BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE guild_members (
guild_id UUID NOT NULL REFERENCES guilds(id),
character_id UUID NOT NULL REFERENCES characters(id),
rank VARCHAR(16) NOT NULL DEFAULT 'initiate',
reputation BIGINT NOT NULL DEFAULT 0,
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (guild_id, character_id)
);
-- Properties
CREATE TABLE properties (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
property_type VARCHAR(32) NOT NULL,
owner_id UUID REFERENCES characters(id),
guild_id UUID REFERENCES guilds(id),
level INT NOT NULL DEFAULT 1,
rent_price BIGINT,
sale_price BIGINT,
is_listed BOOLEAN NOT NULL DEFAULT FALSE
);
-- Market listings
CREATE TABLE market_listings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
seller_id UUID NOT NULL REFERENCES characters(id),
item_id UUID NOT NULL,
quantity INT NOT NULL DEFAULT 1,
price BIGINT NOT NULL,
listed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);
-- Adventures log
CREATE TABLE adventure_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
character_id UUID NOT NULL REFERENCES characters(id),
adventure_id VARCHAR(64) NOT NULL,
result VARCHAR(16) NOT NULL,
rewards_gold BIGINT NOT NULL DEFAULT 0,
rewards_xp BIGINT NOT NULL DEFAULT 0,
started_at TIMESTAMPTZ NOT NULL,
completed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Education enrollment
CREATE TABLE education_enrollments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
character_id UUID NOT NULL REFERENCES characters(id),
course_id VARCHAR(64) NOT NULL,
enrolled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completes_at TIMESTAMPTZ NOT NULL,
completed BOOLEAN NOT NULL DEFAULT FALSE
);
-- Pets
CREATE TABLE pets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
character_id UUID NOT NULL REFERENCES characters(id),
pet_type VARCHAR(32) NOT NULL,
name VARCHAR(32),
rarity VARCHAR(16) NOT NULL DEFAULT 'common',
level INT NOT NULL DEFAULT 1,
experience BIGINT NOT NULL DEFAULT 0,
bonus_type VARCHAR(32) NOT NULL,
bonus_value DECIMAL(5,2) NOT NULL DEFAULT 1.00,
is_active BOOLEAN NOT NULL DEFAULT FALSE,
age_days INT NOT NULL DEFAULT 0,
is_dead BOOLEAN NOT NULL DEFAULT FALSE,
acquired_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE pet_equipment (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
pet_id UUID NOT NULL REFERENCES pets(id) ON DELETE CASCADE,
item_id UUID NOT NULL,
slot VARCHAR(16) NOT NULL
);
-- Crafting professions
CREATE TABLE character_professions (
character_id UUID NOT NULL REFERENCES characters(id),
profession VARCHAR(32) NOT NULL,
mastery_level VARCHAR(16) NOT NULL DEFAULT 'novice',
experience BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (character_id, profession)
);
-- Crafting recipes (discovered)
CREATE TABLE character_recipes (
character_id UUID NOT NULL REFERENCES characters(id),
recipe_id VARCHAR(64) NOT NULL,
discovered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (character_id, recipe_id)
);
-- Blessings (active)
CREATE TABLE active_blessings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
character_id UUID NOT NULL REFERENCES characters(id),
blessing_type VARCHAR(32) NOT NULL,
effect_value DECIMAL(5,2) NOT NULL,
activated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);
-- Inn workers (hired NPCs)
CREATE TABLE hired_workers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
character_id UUID NOT NULL REFERENCES characters(id),
worker_type VARCHAR(32) NOT NULL,
quality VARCHAR(16) NOT NULL DEFAULT 'novice',
task VARCHAR(64),
hired_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
cost_per_hour BIGINT NOT NULL
);
-- Slayer tasks
CREATE TABLE slayer_tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
character_id UUID NOT NULL REFERENCES characters(id),
enemy_type VARCHAR(64) NOT NULL,
target_count INT NOT NULL,
current_count INT NOT NULL DEFAULT 0,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed BOOLEAN NOT NULL DEFAULT FALSE
);
-- Offline session state
CREATE TABLE offline_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
character_id UUID NOT NULL REFERENCES characters(id),
session_type VARCHAR(32) NOT NULL,
started_at TIMESTAMPTZ NOT NULL,
max_duration INTERVAL NOT NULL DEFAULT INTERVAL '4 hours',
claimed BOOLEAN NOT NULL DEFAULT FALSE
);
```
---
## Why Rust for a Game Like This
1. **Performance**: Torn serves millions of requests. Rust handles this with minimal resources.
2. **Type safety**: Complex game rules caught at compile time. `sb-core` becomes a verified game engine.
3. **Fearless concurrency**: Tokio + ownership model makes the tick engine correct by construction.
4. **No GC pauses**: Deterministic performance for real-time combat.
5. **Workspace architecture**: Clean crate separation — fast independent compilation.
6. **Ecosystem**: Axum, SQLx, Tokio, Serde — mature, production-grade in 2026.
7. **Single static binary**: Cross-compile, deploy, run. No Docker, no runtime deps.
---
## Why Vue.js (Not React/Svelte)
- **Composition API** maps to game UI patterns (reactive stat bars, inventory grids, combat logs)
- **Pinia** stores model game state naturally
- **Single-file components** co-locate template + logic + styles
- User preference (stated)
- **PWA support** via Vite plugin
- **SPA only** — no Nuxt/SSR. Game is behind auth; public pages generated separately.
---
## Related Notes
- [Development Environment & Server Topology](joplin://58b6a5c37ba1424684e2e70eb839f1e7) — Canonical dev/test/prod machine roles, toolchain, workspace layout, services glossary
- [Game Concept Overview](joplin://9cf8a2e701654ffbbd6512cc837c7e2f) — Full game design with crafting, pets, inn, guilds
- [Combat System & Stat Scaling Design](joplin://0ef630db4e3e4d6dab18ccaefccaaa5a) — Crit tables, BIGINT stats, combat formula
- [Idle Fantasy Inspirations](joplin://48b3a3c76f734c8dafbbf9e8b3b5f80a) — Offline sessions, workers, pets, blessings
- [Lore & World Building](joplin://867bdc9845624908a90928887a71c4c9) — Districts, NPCs, storyline
- [Project Plan](joplin://d243ae57778343fca997d46b73d0ee3b) — Roadmap, MVP scope, repo structure, decisions log

View file

@ -0,0 +1,244 @@
# Skara Brae — Architecture Notebook
Version 1.0 (2026-10-05) · OpenUP Analysis & Design artifact: `analysis-design/architecture-notebook.md`
Summary work product in the keyfob-station template structure. Sources (all in-repo, normative):
`analysis-design/uc-realization-mapping.md` (§1–§3), `analysis-design/design-wi-2-architecture-cleanup.md`
(§2–§4, §11), `requirements/supplementary-specifications.md`, `requirements/vision.md`, and the EA model
`docs/openup/model/skara-brae.eapx` (exports under `docs/openup/model/exports/`). Detailed per-WI design
remains in the WI design notes; this notebook is the cross-WI architecture view. Drift rule applies:
a mismatch between this notebook and its sources is a defect fixed in the same cycle.
## 1. Purpose
This notebook describes the goals, decisions, constraints and significant elements that shape the design of
Skara Brae across work items. The analysis and design models live in the EA model (Requirements / Use Case
Model, Analysis-Design realizations and Design component model); this notebook summarizes them for review
and onboarding. One model covers the whole product — server, client and common — because the use cases are
player-facing and Requirement→realization traceability spans tiers (joint-model decision, 2026-10-03).
## 2. Architectural Goals and Philosophy
- **Dependency rule inward** (Clean Architecture): domain in `sb-core`/`sb-shared` with zero HTTP/framework
knowledge; adapters (`sb-api`, `sb-db`, `sb-auth`) and the client at the edge (D1, CON-05).
- **Behavior continuity**: refactors are wire-format and game-semantics identical (NFR-11, NFR-19).
- **Determinism by construction**: domain randomness is injected (WI-1); identical seeds produce identical
outcomes (NFR-05, D5).
- **One taxonomy of failure**: a single domain error enum with one central HTTP status mapping (D2).
- **Data as source of truth**: game content is language-neutral JSON in `skara-brae-common`, compiled into
both consumers (NFR-13, D6).
- **Zero-warning quality gate**: fmt + clippy + full test suite on every commit (NFR-08..NFR-10).
- **Docs are architecture**: OpenUP markdown is the content source; the EA model renders it; drift is a
defect (D7).
## 3. Assumptions and Dependencies
- Single-process Rust server (Axum 0.8, edition 2024); all game state in memory behind `tokio::sync::RwLock`,
optional write-through persistence to SQLite via `SB_DB_PATH` (NFR-01, NFR-14).
- Static game data ships with the binary via `include_str!` from `skara-brae-common` (NFR-04, NFR-13).
- Clients are web/PWA (Vue 3 SPA) polling the REST API; server behavior is poll-frequency-independent
(NFR-02, NFR-03).
- `sb-common` is consumed as a sibling absolute-path dependency locally; git-submodule migration planned
(CON-01).
- Self-hosted pipeline: Forgejo (git), Jenkins CI, systemd deployment (vision.md; topology canon in Joplin
"Tech Stack & Architecture" note).
## 4. Architecturally Significant Requirements
The full set is `requirements/supplementary-specifications.md`; the categories that shape the architecture:
| Category | IDs | Architectural consequence |
|---|---|---|
| Performance / Scalability | NFR-01..NFR-04 | in-memory state, lazy regen (no timers), lock-free catalogue reads |
| Reliability / Correctness | NFR-05..NFR-07 | injected RNG, best-effort persistence, referential data integrity → `DomainError` |
| Quality Gates | NFR-08..NFR-12 | fmt/clippy/test gates; unwrap ledger; behavior-identical refactors |
| Data | NFR-13..NFR-15 | bilingual common package, SQLite format, i64 stat growth |
| Security | NFR-16..NFR-18 | argon2, JWT HS256 + auth middleware, role model (player/admin) |
| Constraints | CON-01..CON-06 | crate layout, Axum 0.8, rand 0.9 rule, one error enum, dependency rule, docs home |
| Usability / API | NFR-19..NFR-20 | wire-format freeze (lexicographic key order), status-code contract |
## 5. Decisions, Constraints, and Justifications
Full records in the WI design notes; the architecture-level decisions:
- **D1 — Layered crate architecture with inward dependencies.** `sb-api` (boundary/adapter) may import
`sb-core`, `sb-shared`, `sb-common`, `sb-db`, `sb-auth`; `sb-core` must not reference axum/http/
serde_json-json!/tokio/sb-db/sb-api (design note §3). Known debt: ShopService, HealingService, parts of
MagicService remain handler-side in sb-api — recorded, follow-up WI question Q3; the model must not paper
over them.
- **D2 — One domain error taxonomy.** `sb_core::error::DomainError` (thiserror 2) + single status mapping
`sb-api::routes::domain_status` realize the error paths of UC-05/06/08/12 (design note §4.1).
- **D3 — Constants centralized.** `sb_core::constants` is the single audit home for game-balance scalars
(NFR "no magic numbers"); static data tables stay in their modules (design note §2).
- **D4 — Response DTOs in sb-api, not sb-shared.** HTTP wire types never leak into the domain crate;
`AdventureOutcome` derives nothing (design note §2 placement decisions).
- **D5 — Injectable RNG with a single construction point.** `request_rng()` (`StdRng::from_os_rng`, WI-1);
domain functions take `rng: &mut impl Rng` (CON-03, NFR-05).
- **D6 — Bilingual shared data package.** `skara-brae-common`: `data/` JSON single source, `rust/` + `ts/`
thin consumers, `codegen/` planned (NFR-13; tech-stack canon).
- **D7 — Joint EA model, markdown source of truth, surgical builders.** Model changes after the manual
layout session go through surgical scripts only; `build-model.ps1` rebuilds and is forbidden on the laid-
out model (mapping §1, BUILD-CHECKLIST §6).
## 6. Architectural Mechanisms
- **Lazy resource regeneration** — resources recompute from elapsed wall-clock time at specific call sites;
no timers or background ticks (NFR-02; UC-05/UC-08 "Regenerate Resources" sub-flow).
- **Static catalogue via LazyLock** — game data parsed once at first access; lock-free reads (NFR-04, UC-04).
- **DomainError → status mapping** — domain rule failures flow through one enum to one mapping; handlers own
store misses/authz (D2, NFR-20).
- **Wire-format freeze** — JSON keys serialize in lexicographic order; typed responses declare fields in that
order (NFR-19).
- **Injectable RNG determinism** — seeded domain tests, single entropy entry point (D5, NFR-05).
- **Best-effort write-through persistence** — SQLite behind an in-memory-authoritative process (NFR-06,
NFR-14).
## 7. Key Abstractions
The analysis vocabulary (boundary/controller/entity) of the use-case realizations — full mapping incl.
design elements in `uc-realization-mapping.md` §2; classes, stereotypes and interactions per UC in the EA
model (§2a lists the 24 associations + 4 «uses» dependencies).
| Class | Stereotype | Responsibility (design element) |
|---|---|---|
| AuthBoundary | «boundary» | `POST /api/auth/register`, `AuthResponse` (UC-01) |
| TokenService | «boundary» | `POST /api/auth/login`, `AuthResponse`, auth middleware (UC-02, guards UC-03…12) |
| CharacterCatalog | «boundary» | character routes (UC-03) |
| CatalogueBrowser | «boundary» | catalogue routes + `sb-shared::responses` (UC-04) |
| GymBoundary | «boundary» | gym routes + responses (UC-05) |
| EquipmentBoundary | «boundary» | equip/unequip routes (UC-06) |
| ShopBoundary | «boundary» | shop routes + responses (UC-07) |
| AdventureBoundary | «boundary» | adventure route + `AdventureRunResponse` (UC-08) |
| TempleBoundary | «boundary» | heal route + `HealResponse` (UC-09) |
| SongBoundary | «boundary» | song routes + responses (UC-10) |
| MagicBoundary | «boundary» | spell routes + responses (UC-11) |
| ReviewBoundary | «boundary» | level-up route + response (UC-12) |
| AccountRegistry | «controller» | `sb-api::routes::{register, seed_users}` (UC-01) |
| Authenticator | «controller» | `sb-api::routes::login` + sb-auth verify/create (UC-02) |
| CharacterFactory | «controller» | `sb-core::character::create_character` (UC-03) |
| TrainingService | «controller» | `sb-core::services::train_stat` (UC-05) |
| EquipmentService | «controller» | `sb-core::services::{equip_item, unequip_item}` (UC-06) |
| ShopService | «controller» | `sb-api::routes::{shop_action, buy_item, sell_item}` (UC-07; **debt**: handler-side, extraction candidate Q3) |
| AdventureService | «controller» | `sb-core::adventure::run_adventure` (UC-08; WI-2 extraction) |
| HealingService | «controller» | `sb-api::routes::heal_character` (UC-09; **debt**: handler-side, extraction candidate Q3) |
| SongService | «controller» | `sb-api::routes::set_song` + `available_songs` (UC-10) |
| MagicService | «controller» | spell routes + `sb-core::spells` helpers (UC-11; **debt**: parts handler-side) |
| ReviewBoard | «controller» | `sb-core::character::level_up` (UC-12) |
| UserAccount | «entity» | `sb-shared::user::{User, Role}` (UC-01/02) |
| Character | «entity» | `sb-shared::character::Character` + Race/Class/Stats (UC-03) |
| ResourceRegenerator | «entity» | `sb-core::character::apply_regen` + regen constants (UC-03/05/08 sub-flow) |
| GameData | «entity» | `sb-core::data::{EnemyDef, ZoneDef, EncounterEntry, LootDrop}` (UC-04) |
| TrainingGrounds | «entity» | `TRAINING_GROUNDS`, `training_cost` (UC-05) |
| Equipment/Inventory | «entity» | `Character.equipment/.inventory`, `sb-shared::item::Item` (UC-06) |
| ShopStock | «entity» | `AppState.shop_stock` (UC-07) |
| CombatEngine | «entity» | `sb-core::combat::{resolve_combat_full, resolve_attack, crit tables, open_ended_roll}` (UC-08) |
| Combatant | «entity» | `sb-core::combat::CombatStats` (UC-08) |
| SpellEffect / BardSong | «entity» | `SpellEffect`, `sb-core::spells`, `sb-core::songs` (UC-08/10/11) |
| BardSong | «entity» | `BARD_SONGS`, `Character.active_song` (UC-10) |
| Spell | «entity» | `SpellDef`, `Character.{known_spells, active_spell, spell_points}` (UC-11) |
| Progression | «entity» | `xp_for_level`, `level_up_cost`, `MAX_LEVEL` (UC-12) |
Cross-cutting: `DomainError` (error paths UC-05/06/08/12), `Constants` (no-magic-numbers NFR), `Responses`
(wire-format freeze NFR-19) — carried in the model's Architecture Overview package.
## 8. Layers
The joint component model (mapping §3); dependencies point inward only.
| Tier | Component | Role |
|---|---|---|
| Presentation | skara-brae-client | Vue 3.5 SPA (Pinia, Vite), JWT bearer, REST via proxy; player-facing boundary of UC-01…12 |
| Boundary / adapter | sb-api | Axum routes/handlers, `domain_status`, `responses`; boundaries of all UCs |
| Adapter | sb-auth | argon2 + JWT (UC-01/02; middleware guards UC-03…12) |
| Adapter | sb-db | persistence via `sb-db::Database` |
| Domain | sb-core | controllers/entities: adventure, services, character, combat, spells, songs, data/items |
| Domain types | sb-shared | `User`, `Character`, `Item`, response types; zero HTTP imports |
| Data | sb-common | bilingual data package (`data/` JSON, `rust/`, `ts/`, `codegen/`); UC-04 data source |
![Component Architecture](component-architecture.png)
*Figure 1: Joint component architecture — server crates, client, common (EA model render).*
## 9. Architectural Views
### 9.1 Use Case View
Architecturally significant use cases: UC-08 (Run Adventure — WI-2 design driver, combat + data integrity),
UC-03/UC-12 (character lifecycle), UC-04 (shared-data consumption), UC-05/06/07 (rule-driven economy
actions with the DomainError error paths), UC-01/02 (auth, middleware guard). Full model:
`requirements/use-case-model.md` + overview figure there.
| Realization | Use Case | Analysis classes |
|---|---|---|
| UC-01 Realization | Register Account | AuthBoundary, AccountRegistry, UserAccount |
| UC-02 Realization | Authenticate | TokenService, Authenticator |
| UC-03 Realization | Manage Characters | CharacterCatalog, CharacterFactory, Character, ResourceRegenerator |
| UC-04 Realization | Browse Game Catalogue | CatalogueBrowser, GameData |
| UC-05 Realization | Train Stat | GymBoundary, TrainingService, TrainingGrounds |
| UC-06 Realization | Manage Equipment | EquipmentBoundary, EquipmentService, Equipment/Inventory |
| UC-07 Realization | Trade at Shop | ShopBoundary, ShopService, ShopStock |
| UC-08 Realization | Run Adventure | AdventureBoundary, AdventureService, CombatEngine, Combatant, SpellEffect / BardSong |
| UC-09 Realization | Heal at Temple | TempleBoundary, HealingService |
| UC-10 Realization | Practice Bard Song | SongBoundary, SongService, BardSong |
| UC-11 Realization | Study Magic | MagicBoundary, MagicService, Spell |
| UC-12 Realization | Level Up at Review Board | ReviewBoundary, ReviewBoard, Progression |
### 9.2 Analysis View (Use-Case Realizations)
Each realization package in the EA model carries a class diagram (boundary/controller/entity with §2a
associations); renders in this folder, generated by `scripts/openup/export-diagram-images.ps1`:
![UC-01 Realization](uc-01-realization.png)
![UC-02 Realization](uc-02-realization.png)
![UC-03 Realization](uc-03-realization.png)
![UC-04 Realization](uc-04-realization.png)
![UC-05 Realization](uc-05-realization.png)
![UC-06 Realization](uc-06-realization.png)
![UC-07 Realization](uc-07-realization.png)
![UC-08 Realization](uc-08-realization.png)
![UC-09 Realization](uc-09-realization.png)
![UC-10 Realization](uc-10-realization.png)
![UC-11 Realization](uc-11-realization.png)
![UC-12 Realization](uc-12-realization.png)
### 9.3 Interaction View
Not yet modeled: no sequence diagrams exist for the Skara Brae MVP (the EA model carries scenarios with
per-step actors, not interactions). Candidate first sequences: UC-08 adventure request→combat→loot, and
UC-01/02 auth flows. Adding them is a future work item (PlantUML prototype → EA per the workflow canon).
### 9.4 Operational View
Single Rust process per deployment (systemd unit on the self-hosted boxes; Forgejo + Jenkins pipeline per
vision.md). All state in memory, optional write-through SQLite (NFR-01/06/14); no Redis for MVP. The Vue SPA
is served statically and talks to the API over HTTPS (Caddy, tech-stack canon). Clients poll; no push
channel in the MVP (WebSocket notifications are a later-phase feature, vision.md).
### 9.5 Design Diagram Index
| Diagram | File | Documents |
|---|---|---|
| Use Case Overview | `../requirements/use-case-overview.png` | actors, use cases, associations |
| UC-01…UC-12 Realization | `uc-01-realization.png` … `uc-12-realization.png` | analysis classes + associations per UC |
| Component Architecture | `component-architecture.png` | joint component model, dependency rule |
All three sets are renders of the EA model (`docs/openup/model/skara-brae.eapx`) produced by
`scripts/openup/export-diagram-images.ps1`; regenerate after model changes (read-only, layout-preserving).
## 10. References
- `requirements/vision.md`, `requirements/use-case-model.md`, `requirements/supplementary-specifications.md`
- `analysis-design/uc-realization-mapping.md` (§1 structure, §2 mapping, §2a interactions, §3 component model)
- `analysis-design/design-wi-1-injectable-rng.md`, `design-wi-2-architecture-cleanup.md` (detailed WI designs)
- EA model: `docs/openup/model/skara-brae.eapx` (+ `exports/` JSON, `BUILD-CHECKLIST.md`)
- Client-side UC realizations: future work item (joint-model decision, mapping §1)

Binary file not shown.

View file

@ -0,0 +1,94 @@
# WI-1 Design — Injectable RNG (skara-brae-server#1, PSP1)
Revision 2 — after 1B Design Review (defects D1–D5, D7; D1 blocker: ThreadRng !Send across .await).
## Requirements (source: Forgejo issue #1)
1. No direct `rand::rng()` in non-test code outside a single construction point.
2. `create_character()` and `resolve_combat_full()` accept an injectable RNG.
3. All existing tests green; `cargo fmt --check`, `clippy --all-targets -D warnings`, `cargo test` clean.
Out of scope: route-handler refactor, typed responses, magic numbers.
Note: `resolve_combat_full` already accepts `rng: &mut impl Rng` (combat.rs:174–183) —
only the call-site argument changes. The issue's phrasing overstates; its
`train_stat()` precedent reference is also inaccurate (train_stat takes no RNG).
Actual precedents: `pick_enemy`, `roll_stats`, `resolve_attack`, `open_ended_roll`.
## Design
Pattern: domain functions take `rng: &mut impl Rng`; request handlers obtain
their RNG from a **single shared constructor** in sb-api:
```rust
fn request_rng() -> StdRng { StdRng::from_os_rng() }
```
`StdRng` (not `rand::rng()`/ThreadRng) is required because the handler futures
must stay `Send` for axum: a ThreadRng binding held across the handlers'
`.await` points makes the future `!Send` and fails to compile (D1/D2).
`StdRng` is `Send + Sync` and matches the seeded `StdRng` used in domain tests.
Changes:
1. **`sb-core/src/character.rs` — `create_character`**
Signature: `create_character(rng: &mut impl Rng, user_id: u32, name: String, race: Race, class: Class) -> Character`.
Drops the internal `let mut rng = rand::rng();`, forwards `rng` to
`roll_stats` (which already takes `&mut impl Rng`). No other body change.
2. **`sb-api/src/routes/mod.rs` — `request_rng()` helper + create-character
handler (~line 288)**
Add `request_rng()` (single construction point in the codebase, satisfying
the acceptance criterion literally). Create handler:
`let mut rng = request_rng();` then `create_character(&mut rng, ...)`.
3. **`sb-api/src/routes/mod.rs` — `run_adventure` handler**
Replace the three `rand::rng()` constructions (lines 442, 497, 504) with
one `let mut rng = request_rng();` before the first guarded block; thread
`&mut rng` into `domain::pick_enemy`, `combat::resolve_combat_full`
(replacing the inline `&mut rand::rng()` argument), and the loot-roll
`rng.random_bool(...)` loop. Hoist the block-scoped `use rand::Rng;` (line
503) so `random_bool` resolves at the consolidated call sites. Being
`Send`, the rng may cross `.await` points legally.
4. **`sb-core/src/character.rs` — new test (new object, 18 LOC planned)**
`create_character_is_deterministic_under_seeded_rng`: two
`StdRng::seed_from_u64(42)` runs produce identical characters compared via
`format!("{:?}")` (Character derives no PartialEq — D5); covers
stats/HP/energy/nerve/spells and id/gold. No different-seed inequality
assert (probabilistic flake, review nit — dropped).
## Coverage & exclusions (acceptance audit)
All four non-test `rand::rng()` sites covered (character.rs:130;
routes/mod.rs:442, 497, 504). Workspace audit found exactly one other direct
randomness construction: `sb-auth/src/hash.rs:8` uses `getrandom::fill` for
password-salt entropy — infrastructure entropy, not domain randomness;
deliberately excluded (out of scope).
## 2B Code Review record (rev 2.1)
Implementation deviations from the letter of rev 2 (both accepted, recorded per review nit D11):
- `use rand::Rng;` remains block-scoped in the loot section (its only use site; matches file style) instead of hoisted.
- The create-handler rng is wrapped in a scoping block (`let mut char = { let mut rng = request_rng(); ... }`) instead of a plain `let mut rng = request_rng();` — tighter drop scope before the following awaits.
Fmt findings D9/D10 fixed on the touched lines; repo-wide pre-existing fmt debt (HEAD fails `cargo fmt --check` unrelated to this WI) was NOT touched — no drive-by reformat.
## Alternatives considered
- `AppState`-held shared RNG: rejected — single-writer contention, no benefit.
- ThreadRng with tight scoping/drop before awaits: rejected — fragile
(lexical drop scopes + await regions), `StdRng` construction is equivalent
and robust (D1 correction).
- Generic `R: Rng` type parameter instead of `impl Trait`: equivalent;
`&mut impl Rng` matches the existing `services.rs` style.
## Size plan (from PROBE, method C)
| Part | B | D | M | A |
|---|---|---|---|---|
| character.rs | 316 | 0 | 3 | 0 |
| routes/mod.rs | 1049 | 2 | 12 | 2 + 4 (request_rng, review rework) |
| new test | — | — | — | 18 |
Plan N = 35 (original) → 39 after review rework; predicted N = 42 (70% PI 37–73) — rework keeps the plan inside the interval. Time 105 min (75–135).

View 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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,161 @@
# 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)
└── Design (component model of the joint architecture — server
crates + skara-brae-client + skara-brae-common; §3)
```
Joint-model decision (2026-10-03): one EA model covers the whole product — server, client
and common — because the use cases are player-facing and Requirement→realization
traceability spans tiers. Client-side UC realization packages (Vue boundary elements per
UC) are a future work item: no markdown source exists yet; they are NOT invented here.
Today the client enters the model via the §4 component model only.
## 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).
### 2a. Analysis interactions (associations + dependencies, added 2026-10-03)
Within each realization package the analysis classes collaborate per the robustness rule
boundary ↔ controller ↔ entity (boundary never touches entity directly — exception: UC-04
has no controller, its boundary reads the static catalogue entity). Each association serves
the UC's Basic Path steps (boundary receives the actor request, controller applies the
rules, entities hold the state). Cross-cutting: the four `«uses»` dependencies realize the
error paths (Alternate/Exception flows) named in the cross-cutting paragraph above.
| UC | From | To | Type | Serves |
|---|---|---|---|---|
| UC-01 | AuthBoundary | AccountRegistry | Association | MSS steps 1–4 |
| UC-01 | AccountRegistry | UserAccount | Association | MSS steps 3–4 |
| UC-02 | TokenService | Authenticator | Association | MSS steps 1–3 |
| UC-03 | CharacterCatalog | CharacterFactory | Association | MSS (create) steps 1–4 |
| UC-03 | CharacterFactory | Character | Association | MSS steps 3–4 |
| UC-03 | CharacterFactory | ResourceRegenerator | Association | list/view (regen on return) |
| UC-04 | CatalogueBrowser | GameData | Association (no controller in this realization) | MSS steps 1–2 |
| UC-05 | GymBoundary | TrainingService | Association | MSS steps 1–5 |
| UC-05 | TrainingService | TrainingGrounds | Association | MSS steps 3–4 |
| UC-06 | EquipmentBoundary | EquipmentService | Association | MSS (equip) + unequip |
| UC-06 | EquipmentService | Equipment/Inventory | Association | MSS steps 2–3 |
| UC-07 | ShopBoundary | ShopService | Association | MSS (buy) + sell |
| UC-07 | ShopService | ShopStock | Association | MSS steps 3–4 |
| UC-08 | AdventureBoundary | AdventureService | Association | MSS steps 1–9 |
| UC-08 | AdventureService | CombatEngine | Association | MSS step 5 |
| UC-08 | AdventureService | Combatant | Association | MSS step 5 |
| UC-08 | AdventureService | SpellEffect / BardSong | Association | MSS steps 5, 8 |
| UC-09 | TempleBoundary | HealingService | Association | MSS steps 1–3 |
| UC-10 | SongBoundary | SongService | Association | MSS steps 1–2 + clear |
| UC-10 | SongService | BardSong | Association | MSS steps 1–2 |
| UC-11 | MagicBoundary | MagicService | Association | view + learn + activate |
| UC-11 | MagicService | Spell | Association | learn + activate steps |
| UC-12 | ReviewBoundary | ReviewBoard | Association | MSS steps 1–3 |
| UC-12 | ReviewBoard | Progression | Association | MSS step 2 |
The authoritative connector count is **24 associations** (UC-01:2, UC-02:1, UC-03:3,
UC-04:1, UC-05:2, UC-06:2, UC-07:2, UC-08:4 — its realization has one boundary, one
controller and three entities, UC-09:1, UC-10:2, UC-11:2, UC-12:2) plus **4 cross-cutting
dependencies** below — 28 connectors in total.
| UC | From (controller) | To | Type | Serves |
|---|---|---|---|---|
| UC-05 | TrainingService | DomainError (Architecture Overview) | Dependency «uses» | Alternate flows (error paths) |
| UC-06 | EquipmentService | DomainError (Architecture Overview) | Dependency «uses» | Alternate flows (error paths) |
| UC-08 | AdventureService | DomainError (Architecture Overview) | Dependency «uses» | Exception (unknown enemy → 500) |
| UC-12 | ReviewBoard | DomainError (Architecture Overview) | Dependency «uses» | Alternate flows (error paths) |
## 3. Design component model (joint architecture, added 2026-10-03)
Package `Analysis-Design / Design` — components of the three repos and their dependency
rule (sources: design note §2–3, mapping §2 dependency paragraph, client repo @ 34dc02f,
common repo @ wi-2-openup-docs). Every component note carries the UCs it serves.
| Component | Kind | Notes (traceability) |
|---|---|---|
| skara-brae-client | Component (presentation tier) | Vue 3.5 SPA (TypeScript, Pinia, Vite); monolithic App.vue @ 34dc02f; JWT bearer; REST via Vite proxy to :8080. Serves UC-01…12 as the player-facing boundary. |
| sb-api | Component (boundary/adapter) | Axum routes/handlers, `domain_status`, `responses` (wire-format freeze). May import sb-core, sb-shared, sb-common, sb-db, sb-auth (design §3). Serves all UC boundaries per §2. |
| sb-auth | Component (adapter) | argon2 hashing + JWT (UC-01/02). Imports sb-shared. |
| sb-db | Component (adapter) | persistence via `sb-db::Database`. Imports sb-shared. |
| sb-core | Component (domain) | controllers/entities: adventure (UC-08), services (UC-05/06), character (UC-03/12), combat, spells (UC-11), songs (UC-10), data/items (UC-04). Must not reference axum/http/serde_json::json!/tokio/sb-db/sb-api (design §3). |
| sb-shared | Component (domain types) | `User`, `Character`, `Item`, response types. Zero HTTP imports. Entities of all UCs. |
| sb-common | Component (data) | bilingual data package: data/ JSON (items, enemies, spells, zones), rust/ crate, ts/ package, codegen/. UC-04 data source; consumed by sb-core + sb-api; future client codegen. |
Dependencies (arrows = depends on): skara-brae-client → sb-api (REST/JSON);
sb-api → {sb-core, sb-shared, sb-db, sb-auth, sb-common}; sb-core → {sb-shared, sb-common};
sb-db → sb-shared; sb-auth → sb-shared. The dependency rule: inward toward
sb-core/sb-shared (domain); adapters (sb-api/sb-db/sb-auth) and the client at the edge.
![Component Architecture](component-architecture.png)
*Figure: joint component architecture (rendered from the EA model via `scripts/openup/export-diagram-images.ps1`). Per-UC realization figures: `uc-01-realization.png` … `uc-12-realization.png` in this folder; they are embedded in the Architecture Notebook (§9.2).*
## 4. 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.

View file

@ -0,0 +1,251 @@
# 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-<n> <section title>` 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-<NN> 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**.
---
## 6. Enrichment (2026-10-03, after manual layout session) — SURGICAL only
The user laid out diagrams by hand in EA after the WI-2 baseline build. From here on the
model may only be touched by the two surgical scripts below — **never** re-run
`build-model.ps1` (it deletes and rebuilds the Requirements / Analysis-Design packages and
would destroy manual layout). Sources: `uc-realization-mapping.md` §2a (interactions) and
§3 (design component model).
### 6.1 Analysis associations — `scripts/openup/add-analysis-associations.ps1`
- Inside each `UC-NN Realization` package: Association connectors per mapping §2a
(boundary→controller, controller→entity; UC-04 boundary→entity) — **24 associations**
(UC-01:2, UC-02:1, UC-03:3, UC-04:1, UC-05:2, UC-06:2, UC-07:2, UC-08:4, UC-09:1,
UC-10:2, UC-11:2, UC-12:2).
- 4 Dependencies `«uses»` from TrainingService, EquipmentService, AdventureService,
ReviewBoard to `DomainError` (Architecture Overview) — the error paths of UC-05/06/08/12.
- DiagramLinks added to each package's existing class diagram so the connectors render
WITHOUT touching any DiagramObject (manual layout preserved). Idempotent: existing
connectors are skipped.
### 6.2 Design package — `scripts/openup/add-design-package.ps1`
- Creates `Analysis-Design / Design` (rebuilds only this package — agent-owned, contains
no manual edits): 7 components (skara-brae-client, sb-api, sb-auth, sb-db, sb-core,
sb-shared, sb-common) with traceability notes, 10 Dependency connectors, one Component
diagram `Component Architecture` (layered layout, dependency rule inward).
### 6.3 Verification
1. 24 associations across realization packages (per-UC counts above); 4 `«uses»`
dependencies to DomainError; still 0 `«include»` connectors.
2. `Design` package: 7 components, 10 dependencies, 1 diagram.
3. Baseline intact: 3 actors, 12 use cases, 12 realization packages, 13 actor-UC
associations; Requirements tree untouched.
4. Manual layout preserved: the scripts add no/modify no DiagramObjects in existing
diagrams (only the new `Design` diagram carries its own object layout).
### 6.4 Diagram image export — `scripts/openup/export-diagram-images.ps1`
Read-only on the model: renders 14 diagrams to PNG for the markdown documents
(`requirements/use-case-overview.png`, `analysis-design/uc-01..uc-12-realization.png`,
`analysis-design/component-architecture.png`). EA-15 findings baked into the script:
image methods live on **ProjectInterface** (not Repository); `PutDiagramImageToFile(id,
path, 0)` = PNG; the diagram must be `OpenDiagram`-ed first or a blank 241×151 stub is
written. Regenerate after model changes; renders reflect the manual layout.

View file

@ -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.

View file

@ -0,0 +1,271 @@
{
"exportedFrom": "docs/openup/model/skara-brae.eapx (Analysis-Design realization packages)",
"realizations": [
{
"id": "UC-01 Realization",
"useCase": "UC-01 Register Account",
"elements": [
{
"name": "AccountRegistry",
"stereotype": "controller",
"notes": "Design element: . Design element: `sb-api::routes::{register, seed_users}`. argon2 hash via `sb-auth::hash`; JWT via `sb-auth::jwt`"
},
{
"name": "AuthBoundary",
"stereotype": "boundary",
"notes": "Design element: . Design element: `POST /api/auth/register`, `AuthResponse`. "
},
{
"name": "UserAccount",
"stereotype": "entity",
"notes": "Design element: . Design element: `sb-shared::user::User`, `Role`. persisted via `sb-db::Database`"
}
]
},
{
"id": "UC-02 Realization",
"useCase": "UC-02 Authenticate",
"elements": [
{
"name": "Authenticator",
"stereotype": "controller",
"notes": "Design element: . Design element: `sb-api::routes::login`, `sb-auth::{hash::verify_password, jwt::create_token}`. "
},
{
"name": "TokenService",
"stereotype": "boundary",
"notes": "Design element: . Design element: `POST /api/auth/login`, `AuthResponse`, `middleware::auth::auth_middleware`. middleware guards all UC-03…12"
}
]
},
{
"id": "UC-03 Realization",
"useCase": "UC-03 Manage Characters",
"elements": [
{
"name": "Character",
"stereotype": "entity",
"notes": "Design element: . Design element: `sb-shared::character::Character` (+ `Race`, `Class`, `CharacterStats`). "
},
{
"name": "CharacterCatalog",
"stereotype": "boundary",
"notes": "Design element: . Design element: `GET/POST /api/users/{id}/characters`, `GET /api/characters/{id}`. store: `AppState.characters`"
},
{
"name": "CharacterFactory",
"stereotype": "controller",
"notes": "Design element: . Design element: `sb-core::character::create_character`. takes injected rng (WI-1)"
},
{
"name": "ResourceRegenerator",
"stereotype": "entity",
"notes": "Design element: . Design element: `sb-core::character::apply_regen` (+ regen constants — WI-2 names them). included sub-flow"
}
]
},
{
"id": "UC-04 Realization",
"useCase": "UC-04 Browse Game Catalogue",
"elements": [
{
"name": "CatalogueBrowser",
"stereotype": "boundary",
"notes": "Design element: . Design element: `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}`"
},
{
"name": "GameData",
"stereotype": "entity",
"notes": "Design element: . Design element: `sb-core::data::{EnemyDef, ZoneDef, EncounterEntry, LootDrop}`, `items::load_all_items`. "
}
]
},
{
"id": "UC-05 Realization",
"useCase": "UC-05 Train Stat",
"elements": [
{
"name": "GymBoundary",
"stereotype": "boundary",
"notes": "Design element: . Design element: `GET/POST /api/characters/{id}/gym`, `GymResponse`, `TrainStatResponse`. "
},
{
"name": "TrainingGrounds",
"stereotype": "entity",
"notes": "Design element: . Design element: `sb-core::character::TRAINING_GROUNDS`, `training_cost` (+ cost constants WI-2). "
},
{
"name": "TrainingService",
"stereotype": "controller",
"notes": "Design element: . Design element: `sb-core::services::train_stat`. error type `DomainError` (WI-2)"
}
]
},
{
"id": "UC-06 Realization",
"useCase": "UC-06 Manage Equipment",
"elements": [
{
"name": "Equipment/Inventory",
"stereotype": "entity",
"notes": "Design element: . Design element: `Character.equipment` / `.inventory`, `sb-shared::item::Item`. "
},
{
"name": "EquipmentBoundary",
"stereotype": "boundary",
"notes": "Design element: . Design element: `POST /api/characters/{id}/{equip,unequip}`. "
},
{
"name": "EquipmentService",
"stereotype": "controller",
"notes": "Design element: . Design element: `sb-core::services::{equip_item, unequip_item}`. `DomainError` (WI-2)"
}
]
},
{
"id": "UC-07 Realization",
"useCase": "UC-07 Trade at Shop",
"elements": [
{
"name": "ShopBoundary",
"stereotype": "boundary",
"notes": "Design element: . Design element: `GET/POST /api/characters/{id}/shop`, `ShopResponse`, `ShopActionResponse`. "
},
{
"name": "ShopService",
"stereotype": "controller",
"notes": "Design element: . Design element: `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}`"
},
{
"name": "ShopStock",
"stereotype": "entity",
"notes": "Design element: . Design element: `AppState.shop_stock`. persisted via `sb-db`"
}
]
},
{
"id": "UC-08 Realization",
"useCase": "UC-08 Run Adventure",
"elements": [
{
"name": "AdventureBoundary",
"stereotype": "boundary",
"notes": "Design element: . Design element: `GET /api/characters/{id}/adventure`; `AdventureRunResponse` (**new, WI-2**), `sb-shared::responses::CombatLog`. "
},
{
"name": "AdventureService",
"stereotype": "controller",
"notes": "Design element: . Design element: `sb-core::adventure::run_adventure` (**new, WI-2**) + `services::pick_enemy` → `Result` (WI-2). takes injected rng; `DomainError` (WI-2)"
},
{
"name": "Combatant",
"stereotype": "entity",
"notes": "Design element: . Design element: `sb-core::combat::CombatStats`. built from Character/EnemyDef"
},
{
"name": "CombatEngine",
"stereotype": "entity",
"notes": "Design element: . Design element: `sb-core::combat::{resolve_combat_full, resolve_attack, crit tables, open_ended_roll}` + combat constants (WI-2). "
},
{
"name": "SpellEffect / BardSong",
"stereotype": "entity",
"notes": "Design element: . Design element: `sb-core::combat::SpellEffect`, `sb-core::spells`, `sb-core::songs`. "
}
]
},
{
"id": "UC-09 Realization",
"useCase": "UC-09 Heal at Temple",
"elements": [
{
"name": "HealingService",
"stereotype": "controller",
"notes": "Design element: . Design element: `sb-api::routes::heal_character` (handler-side; extraction candidate, Q3). "
},
{
"name": "TempleBoundary",
"stereotype": "boundary",
"notes": "Design element: . Design element: `POST /api/characters/{id}/heal`; `HealResponse` (**new, WI-2**). "
}
]
},
{
"id": "UC-10 Realization",
"useCase": "UC-10 Practice Bard Song",
"elements": [
{
"name": "BardSong",
"stereotype": "entity",
"notes": "Design element: . Design element: `sb-core::songs::BARD_SONGS`, `Character.active_song`. "
},
{
"name": "SongBoundary",
"stereotype": "boundary",
"notes": "Design element: . Design element: `GET/POST /api/characters/{id}/songs`, `SongsResponse`, `SetSongResponse`. "
},
{
"name": "SongService",
"stereotype": "controller",
"notes": "Design element: . Design element: `sb-api::routes::set_song`; `sb-core::songs::available_songs`. "
}
]
},
{
"id": "UC-11 Realization",
"useCase": "UC-11 Study Magic",
"elements": [
{
"name": "MagicBoundary",
"stereotype": "boundary",
"notes": "Design element: . Design element: `GET/POST /api/characters/{id}/spells`; `SpellbookResponse`, `LearnSpellResponse` (**new, WI-2**). "
},
{
"name": "MagicService",
"stereotype": "controller",
"notes": "Design element: . Design element: `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)}`. "
},
{
"name": "Spell",
"stereotype": "entity",
"notes": "Design element: . Design element: `sb-core::spells::SpellDef`, `Character.{known_spells, active_spell, spell_points}`. "
}
]
},
{
"id": "UC-12 Realization",
"useCase": "UC-12 Level Up at Review Board",
"elements": [
{
"name": "Progression",
"stereotype": "entity",
"notes": "Design element: . Design element: `sb-core::character::{xp_for_level, level_up_cost, MAX_LEVEL}` (+ curve constants WI-2). "
},
{
"name": "ReviewBoard",
"stereotype": "controller",
"notes": "Design element: . Design element: `sb-core::character::level_up`. `DomainError` (WI-2)"
},
{
"name": "ReviewBoundary",
"stereotype": "boundary",
"notes": "Design element: . Design element: `POST /api/characters/{id}/level-up`, `LevelUpResponse`. "
}
]
}
],
"crossCutting": [
{
"name": "Constants",
"notes": "sb-core::constants realizes NFR \"no magic numbers\"."
},
{
"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": "Responses",
"notes": "sb-api::routes::responses realizes NFR-19 (wire-format freeze)."
}
]
}

View file

@ -0,0 +1,546 @@
{
"exportedFrom": "docs/openup/model/skara-brae.eapx (Requirements/Use Case Model)",
"actors": [
{
"name": "Administrator",
"notes": "Player with elevated rights (role = admin); may view any user\u0027s and any character\u0027s resources read-only."
},
{
"name": "Guest",
"notes": "Unauthenticated caller; only register/login are open."
},
{
"name": "Player",
"notes": "Authenticated user owning one or more characters (JWT role player)."
}
],
"useCases": [
{
"name": "UC-01 Register Account",
"notes": "Open registration. No precondition.",
"actors": [
"Guest"
],
"scenarios": [
{
"name": "Main Success Scenario",
"type": "Basic Path",
"notes": "",
"steps": [
{
"actor": 0,
"text": "Guest submits name (3-32 chars) and password (\u003e= 6 chars)."
},
{
"actor": 0,
"text": "System verifies the name is unused -\u003e 409 CONFLICT if taken."
},
{
"actor": 0,
"text": "System hashes the password (argon2), assigns the player role and the next id, persists the user."
},
{
"actor": 0,
"text": "System issues a JWT (HS256, player role) and returns {token, user_id, name, role}."
}
]
},
{
"name": "Alternate flows",
"type": "Alternate",
"notes": "name length invalid -\u003e 400; password too short -\u003e 400; name taken -\u003e 409; hashing/token failure -\u003e 500.",
"steps": [
]
}
]
},
{
"name": "UC-02 Authenticate",
"notes": "Login with name and password; issues a JWT with the user\u0027s real role.",
"actors": [
"Guest"
],
"scenarios": [
{
"name": "Main Success Scenario",
"type": "Basic Path",
"notes": "",
"steps": [
{
"actor": 0,
"text": "Guest submits name and password."
},
{
"actor": 0,
"text": "System finds the user (401 if unknown) and verifies the password hash (401 on mismatch)."
},
{
"actor": 0,
"text": "System issues a JWT carrying the user\u0027s real role (admin/player) and returns {token, user_id, name, role}."
}
]
},
{
"name": "Alternate flows",
"type": "Alternate",
"notes": "unknown name or wrong password -\u003e 401 UNAUTHORIZED; token creation failure -\u003e 500.",
"steps": [
]
}
]
},
{
"name": "UC-03 Manage Characters",
"notes": "Create, list and view characters. Admin may act for any user (read-only view).",
"actors": [
"Player",
"Administrator"
],
"scenarios": [
{
"name": "Main Success Scenario (create)",
"type": "Basic Path",
"notes": "Admin may act for any user.",
"steps": [
{
"actor": 0,
"text": "Player submits character name, race, class for their user id."
},
{
"actor": 0,
"text": "System validates the target user exists (404) and the trimmed name is 1-32 chars (400)."
},
{
"actor": 0,
"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": 0,
"text": "System assigns the next character id, stores and persists the character; returns it fully."
}
]
},
{
"name": "list/view",
"type": "Alternate",
"notes": "Player lists their characters (Admin: any user\u0027s); 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 -\u003e 403 FORBIDDEN; user/character not found -\u003e 404; invalid name -\u003e 400.",
"steps": [
]
}
]
},
{
"name": "UC-04 Browse Game Catalogue",
"notes": "Races, classes, items (type/quality/slot filters), enemies, adventure zones from static game data.",
"actors": [
"Player"
],
"scenarios": [
{
"name": "Main Success Scenario",
"type": "Basic Path",
"notes": "",
"steps": [
{
"actor": 0,
"text": "Player requests the catalogue: races, classes, items (optional type/quality/slot filters), enemies, or adventure zones."
},
{
"actor": 0,
"text": "System returns static game data loaded from skara-brae-common JSON (class info is enriched with is_caster)."
}
]
},
{
"name": "Alternate flows",
"type": "Alternate",
"notes": "single item by unknown id -\u003e 404; filters may return an empty list (not an error).",
"steps": [
]
}
]
},
{
"name": "UC-05 Train Stat",
"notes": "Train a stat at a training ground; costs 1 energy + gold. Includes Regenerate Resources.",
"actors": [
"Player"
],
"scenarios": [
{
"name": "Main Success Scenario",
"type": "Basic Path",
"notes": "Precondition: character exists and caller may act on it.",
"steps": [
{
"actor": 0,
"text": "Player selects a training ground (strength / dexterity / constitution / intelligence / luck) and submits the stat name."
},
{
"actor": 0,
"text": "System applies Regenerate Resources."
},
{
"actor": 0,
"text": "System checks energy \u003e= 1 and gold \u003e= training cost (cost grows ~1.3x per 10 stat points)."
},
{
"actor": 0,
"text": "System deducts 1 energy and the gold cost, increments the stat; constitution training recomputes max HP (current HP clamped)."
},
{
"actor": 0,
"text": "System persists and returns {success, message, character}."
}
]
},
{
"name": "Alternate flows",
"type": "Alternate",
"notes": "foreign character -\u003e 403; character not found -\u003e 404; not enough energy -\u003e 422; not enough gold -\u003e 422; unknown stat name -\u003e 400.",
"steps": [
]
}
]
},
{
"name": "UC-06 Manage Equipment",
"notes": "Equip from inventory / unequip back to inventory.",
"actors": [
"Player"
],
"scenarios": [
{
"name": "Main Success Scenario (equip)",
"type": "Basic Path",
"notes": "",
"steps": [
{
"actor": 0,
"text": "Player submits an item id from their inventory."
},
{
"actor": 0,
"text": "System moves the item from inventory to its slot; a previously equipped item in that slot returns to inventory."
},
{
"actor": 0,
"text": "System persists and returns the updated character."
}
]
},
{
"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 -\u003e 403; character/item not found -\u003e 404; item not in inventory -\u003e 400; nothing equipped in slot -\u003e 400.",
"steps": [
]
}
]
},
{
"name": "UC-07 Trade at Shop",
"notes": "Buy from (always-stocked + rotating) shop stock or sell inventory items.",
"actors": [
"Player"
],
"scenarios": [
{
"name": "Main Success Scenario (buy)",
"type": "Basic Path",
"notes": "",
"steps": [
{
"actor": 0,
"text": "Player views the shop: always-stocked items plus rotating stock (with quantities) alongside their inventory."
},
{
"actor": 0,
"text": "Player submits buy + item id."
},
{
"actor": 0,
"text": "System checks gold \u003e= price (422), and for non-always-in-stock items decrements the limited stock (410 GONE if 0)."
},
{
"actor": 0,
"text": "System deducts gold, adds the item to inventory, persists character and stock; returns {success, message, gold}."
}
]
},
{
"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) -\u003e 400; item not found -\u003e 404; insufficient gold -\u003e 422; stock exhausted -\u003e 410.",
"steps": [
]
}
]
},
{
"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 -\u003e 500 (post WI-2; previously a panic). WI-2 design driver (sb_core::adventure extraction).",
"actors": [
"Player"
],
"scenarios": [
{
"name": "Main Success Scenario",
"type": "Basic Path",
"notes": "Precondition: character exists and caller may act on it.",
"steps": [
{
"actor": 0,
"text": "Player requests an adventure for a zone (default skara_brae_streets)."
},
{
"actor": 0,
"text": "System validates the zone (404) and applies Regenerate Resources."
},
{
"actor": 0,
"text": "System refuses incapacitated characters (HP \u003c= 0 -\u003e 400) and characters with nerve below the zone cost (422); otherwise deducts the nerve cost."
},
{
"actor": 0,
"text": "System draws one enemy from the zone\u0027s weighted encounter table."
},
{
"actor": 0,
"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": 0,
"text": "On player victory: XP and gold per the enemy definition, character HP set to the last round\u0027s defender HP, and loot dropped per-item chance rolls (only items existing in the catalogue)."
},
{
"actor": 0,
"text": "On defeat: character HP set to 0; no rewards."
},
{
"actor": 0,
"text": "Spell-point upkeep is charged whenever a spell was active, win or lose."
},
{
"actor": 0,
"text": "System persists the character and returns {zone, enemy, combat_log, loot} (loot = item display names)."
}
]
},
{
"name": "Alternate flows",
"type": "Alternate",
"notes": "foreign character -\u003e 403; character/zone not found -\u003e 404; incapacitated -\u003e 400; insufficient nerve -\u003e 422.",
"steps": [
]
},
{
"name": "Exception",
"type": "Exception",
"notes": "encounter references an unknown enemy (data error) -\u003e 500 (post WI-2; previously a panic).",
"steps": [
]
}
]
},
{
"name": "UC-09 Heal at Temple",
"notes": "Full or partial healing, cost = amount healed, minimum 1 gold.",
"actors": [
"Player"
],
"scenarios": [
{
"name": "Main Success Scenario",
"type": "Basic Path",
"notes": "",
"steps": [
{
"actor": 0,
"text": "Player requests healing, optionally partial."
},
{
"actor": 0,
"text": "System computes missing HP; full heals all, partial heals half (rounded up); cost = amount healed, minimum 1 gold."
},
{
"actor": 0,
"text": "System checks gold (422), deducts it, restores HP, persists; returns {hp, max_hp, gold, healed: true}."
}
]
},
{
"name": "Alternate flows",
"type": "Alternate",
"notes": "foreign character -\u003e 403; not found -\u003e 404; already at full HP -\u003e 400; insufficient gold -\u003e 422.",
"steps": [
]
}
]
},
{
"name": "UC-10 Practice Bard Song",
"notes": "Set or clear the active bard song (Bard only, level-gated).",
"actors": [
"Player"
],
"scenarios": [
{
"name": "Main Success Scenario",
"type": "Basic Path",
"notes": "Precondition: character class is Bard.",
"steps": [
{
"actor": 0,
"text": "Player views available songs (level-gated, with combat effect descriptions) and the active song."
},
{
"actor": 0,
"text": "Player submits a song id; system validates level requirement (422) and sets it active; returns {success, message, active_song}."
}
]
},
{
"name": "clear",
"type": "Alternate",
"notes": "submitting no song id clears the active song.",
"steps": [
]
},
{
"name": "Alternate flows",
"type": "Alternate",
"notes": "non-Bard -\u003e 403; unknown song -\u003e 404; level too low -\u003e 422; foreign character -\u003e 403; not found -\u003e 404.",
"steps": [
]
}
]
},
{
"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.",
"actors": [
"Player"
],
"scenarios": [
{
"name": "Main Success Scenario (view spellbook)",
"type": "Basic Path",
"notes": "Precondition: character belongs to a caster class (Mage-\u003emagician, Wizard, Conjurer, Sorcerer). Spell level = (character level / 3) + 1 - uncapped by code (level 100 -\u003e spell level 34); learn requests are validated to 1-7.",
"steps": [
{
"actor": 0,
"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."
}
]
},
{
"name": "learn spell level",
"type": "Alternate",
"notes": "",
"steps": [
{
"actor": 0,
"text": "Player submits a spell level (1-7)."
},
{
"actor": 0,
"text": "System validates caster class (403), level range (400), level not above the character\u0027s current spell level (422), gold \u003e= learn cost (422), and that new spells exist (400)."
},
{
"actor": 0,
"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}."
}
]
},
{
"name": "activate/clear active spell",
"type": "Alternate",
"notes": "",
"steps": [
{
"actor": 0,
"text": "Player submits a spell id (or empty to clear)."
},
{
"actor": 0,
"text": "System validates the spell is known (403), exists (404), and spell points suffice (422); deducts the spell\u0027s points and sets it active; returns {active_spell, spell_points}."
}
]
}
]
},
{
"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.",
"actors": [
"Player"
],
"scenarios": [
{
"name": "Main Success Scenario",
"type": "Basic Path",
"notes": "",
"steps": [
{
"actor": 0,
"text": "Player requests a level-up."
},
{
"actor": 0,
"text": "System validates: below max level 100, XP \u003e= 18*100*(level)^2 for the next level (i.e. xp_for_level(next)), gold \u003e= 500*next level."
},
{
"actor": 0,
"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}."
}
]
},
{
"name": "Alternate flows",
"type": "Alternate",
"notes": "already at max level; not enough XP; not enough gold -\u003e all 422. Foreign character -\u003e 403; not found -\u003e 404.",
"steps": [
]
}
]
}
]
}

Binary file not shown.

View file

@ -0,0 +1,11 @@
# Skara Brae — Work Items List
| # | Issue | Work Item | Iteration | PSP level | Est. N (LOC) | Act. N (LOC) | Est. time (min) | Act. time (min) | Status |
|---|-------|-----------|-----------|-----------|--------------|--------------|-----------------|-----------------|--------|
| 1 | skara-brae-server#1 | Injectable RNG (plan item #8) | I1 | PSP1.0 | 35 (pred. 42, PI 37–73) | **41** | 105 (PI 75–135) | **90** | **Done** — both actuals inside 70% PI (size −2.4%, time −14%) |
| 2 | skara-brae-server#2 | Architecture cleanup + OpenUP work set (plan item #7) | I1 | PSP1.0 | 660 (pred. 951, PI 607–1295) | **1833** | 620 (PI 455–785) | **~171** | **Done** (pending human gates) — size +42% OVER UPI, time 72% UNDER LPI; 22 defects, 100% pre-compile; gates green, 71/71 tests |
Notes:
- Estimates filled during PLN (PROBE); actuals during PM postmortem.
- Iteration I1 = first Skara Brae iteration (starts with this WI).
- Canonical OpenUP home since WI-2: this repo (`skara-brae-common/docs/openup/`); roadmap.md stays at the project top folder.

View 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.

View file

@ -0,0 +1,174 @@
# 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 | — |
![Skara Brae Use Case Overview](use-case-overview.png)
*Figure 1: Use case overview (rendered from the EA model, `docs/openup/model/skara-brae.eapx`, via `scripts/openup/export-diagram-images.ps1`).*
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).

Binary file not shown.

View 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.

View 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.

View file

@ -0,0 +1,36 @@
# WI-1 Test Report — Injectable RNG (skara-brae-server#1, PSP1)
Date: 2026-10-02 · Phase: TST · Executor: agent (PM session, Kilo/glm-5.3)
## Scope under test
Branch `wi-1-injectable-rng` (uncommitted working tree vs base `64e081a`):
`sb-core/src/character.rs` (create_character signature + new determinism test),
`sb-api/src/routes/mod.rs` (request_rng helper, create handler, run_adventure
consolidation).
## Test cases
| # | Test | Purpose | Result |
|---|---|---|---|
| 1 | `create_character_is_deterministic_under_seeded_rng` (NEW) | Two `StdRng::seed_from_u64(42)` runs yield identical stats (Debug-string) and equal hp/max_hp/energy/nerve — proves RNG injectability end-to-end | **pass** |
| 2 | Existing sb-core suite (44 tests: character 20, combat 8, services 17 -1 overlap after adding #1) | No regression from signature change / rng threading | **pass** |
| 3 | Acceptance audit: `grep -rn "rand::rng()" crates/` | Zero occurrences in the whole repo (incl. tests) — acceptance criterion | **pass** (only remaining entropy: `sb-auth/src/hash.rs:8` `getrandom::fill`, excluded by design) |
| 4 | `cargo clippy --all-targets -- -D warnings` | Zero-warning standard | **pass** (clean) |
| 5 | `cargo build --workspace` | Compile integrity | **pass** |
| 6 | `rustfmt --check` (touched lines) | Coding standard on new/rewritten code | **pass** (repo-wide pre-existing fmt debt documented, not touched — see CMP log) |
## Evidence
- `cargo test --workspace`: **45 passed, 0 failed** (sb-core lib target; remaining targets empty)
- Determinism test present and green: `test character::tests::create_character_is_deterministic_under_seeded_rng ... ok`
- Full output captured in session log (PSP time log, TST phase, 2026-10-02 22:17)
## Defects found in test phase
None. (All defects D1–D11 were found in Design Review / Code / Code Review phases; see defect log.)
## Notes
- The new test replaced a whole-struct `Debug` comparison after D8 (HashMap iteration order instability) — comparison now covers the random-dependent state exactly.
- No behavioral change expected from ThreadRng→StdRng: same distributions, one OS-entropy seeding per request (design rev 2, D1 correction).

View file

@ -0,0 +1,65 @@
# WI-2 Test Report — Architecture Cleanup + OpenUP Work Set (skara-brae-server#2, PSP1)
Date: 2026-10-03 · Phase: CMP + TST · Executor: agent (psp-tester session, Kilo/glm-5.3)
## Scope under verification
- **Code** — `skara-brae-server`, branch `wi-2-architecture-cleanup`, 7 commits over base `899fde8`: `337e9bc` fmt baseline, `093012a` constants module, `423997f` DomainError + signature conversions, `6d9f02b` adventure service, `5d5a594` responses module + handler rewiring, `f065942` label helpers + policy constants, `c613142` 2B findings rework. Working tree clean at verification start.
- **Docs** — `skara-brae-common`, branch `wi-2-openup-docs`, 4 commits: `4798f55` WI-2 OpenUP work set move (verbatim), `2343d5e` WI-1 artifact migration, `7a9a5a3` game-canon migration from Joplin, `41f1ec2` EA model preparation. Working tree clean at verification start.
## Verification gates (CMP)
| # | Command | Result | Evidence (summary line) |
|---|---|---|---|
| 1 | `cargo fmt --check` (server) | **pass**, exit 0 | no output — zero formatting diffs |
| 2 | `cargo clippy --all-targets -- -D warnings` (server) | **pass**, exit 0 | `Finished \`dev\` profile [unoptimized + debuginfo] target(s) in 0.11s` — zero warnings emitted |
| 3 | `cargo build --workspace` (server) | **pass**, exit 0 | `Finished \`dev\` profile [unoptimized + debuginfo] target(s) in 2.13s` |
| 4 | Docs-side sanity (common): `git status --porcelain` clean; `python3 scripts/openup/markdown-to-json.py` regenerates the export | **pass**, script exit 0 | `wrote docs/openup/model/exports/uc-realizations.json: 12 realizations, 36 elements`; sha256 before == after: `23c9617f34ae8e92882b49bfc48c990d9c79d975b9756a93b250391dc3aa6f41` — **byte-identical, no drift**; working tree still clean after regeneration |
## Test matrix (TST)
`cargo test --workspace`: **exit 0 — 71 passed, 0 failed, 0 ignored**.
| Target | Count | Summary line |
|---|---|---|
| sb-api (bin unittests) | 6 | `test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out` |
| sb-core (lib unittests) | 63 | `test result: ok. 63 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out` |
| sb-auth / sb-db | 0 | `test result: ok. 0 passed` (both) |
| sb-shared (lib unittests) | 2 | `test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out` |
| Doc-tests (4 targets) | 0 | all `test result: ok. 0 passed` |
Count reconciliation: 45 baseline (WI-1) + 26 new `#[test]` fns (diff vs `899fde8`: 26 added, 0 removed) = 71.
Coverage classes within the 71:
- **Adventure service vectors A1–A8** (8 new, sb-core `adventure.rs`; mapping per `test-cases.md`): A1 `run_adventure_win_applies_rewards_and_loot` (seeded win → xp/gold/nerve/loot/inventory), A2 `run_adventure_defeat_sets_hp_to_zero`, A3 `run_adventure_rejects_incapacitated`, A4 `run_adventure_rejects_insufficient_nerve`, A5 `run_adventure_applies_regen_before_guards`, A6 `run_adventure_charges_spell_upkeep_on_win_and_loss`, A7 `run_adventure_filters_loot_to_catalog_items`, A8 `run_adventure_is_deterministic_under_seeded_rng`. Targeted run: `cargo test -p sb-core run_adventure_ -- --nocapture` → exit 0, `8 passed; 0 failed … 55 filtered out`.
- **Wire-identity goldens W1–W6** (6 new, sb-api `routes/responses.rs`): all six pass under `cargo test -p sb-api wire_identity_ -- --nocapture` → exit 0, `6 passed; 0 failed`. Representative assertions (responses.rs:156/167/237):
- `assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W1)` — W1 keys in lexicographic order (`combat_log`, `enemy`, `loot`, `zone`) with nested sorted round/crit keys, pinning the §4.3 `Value` bridge (defect #14).
- `assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W2)` — `{"gold":123,"healed":true,"hp":45,"max_hp":80}`.
- `assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W6)` — `{"gold":19700,"learned_count":4,"max_spell_points":40,"spell_points":40}` (incl. the Q1 fenced `learned_count` wart, byte-preserved).
- **Baseline 45 + mechanical edits** — the only permitted test edits were the **9 mechanical assertion updates** from design §4.1 / `test-cases.md` §3. Verified in `git diff 899fde8..HEAD`: 3 × `level_up` (`Err("…")` → `Err(DomainError::AlreadyAtMaxLevel | NotEnoughXp | NotEnoughGold)`), 3 × `train_stat` (`NotEnoughEnergy | NotEnoughGold | InvalidStat`), 1 × `equip_item` (`ItemNotInInventory`), 1 × `unequip_item` (`NothingEquippedInSlot`), 1 × `pick_enemy_respects_weights` gains Ok-path `.unwrap()` (service now `Result`-returning). No test logic changes.
- **New table-coverage / data / spot-check tests** (12 new: 10 sb-core, 2 sb-shared): `gym_display_order_covers_all_training_grounds`, `race_table_covers_all_variants`, `class_table_covers_all_variants`, `crit_tables_parse`, `enemies_json_parses`, `zones_json_parses`, `all_items_json_parses`, `caster_spell_level_spot_values`, `spell_learn_cost_guard_spot_checks`, `spells_json_parses`, `item_type_as_str_matches_serde_wire`, `quality_as_str_matches_serde_wire`.
## Wire-invisible deltas — recorded, verified as documented
Both deliberate semantic deltas from design §4.2/§6 are present in the wired code exactly as recorded (1B review finding 5 dispositions), with no reachable wire-visible change:
1. **Lock consolidation** — the refactored adventure handler holds one write-lock span around the service call (`routes/mod.rs:488`); the dead "character vanished between acquisitions" branch is structurally impossible (no character-deletion path exists). Recorded, not claimed away.
2. **Enemy-pick failure** — data-impossible `DomainError::UnknownEncounterEnemy` maps to `StatusCode::INTERNAL_SERVER_ERROR` (`routes/mod.rs:61`) where the pre-refactor code panicked; same in-memory side effects (nerve already spent).
## Verification limits
- **2B finding #3 evidence limit (docs move):** the verbatim-ness of the OpenUP work-set move from untracked `skara-brae-server/docs/openup/` into `skara-brae-common/docs/openup/` is **not cryptographically verifiable** — the source files were never committed, so no git hash chain exists to diff against. Verification is therefore **content sanity only**: exports regenerate byte-identical from the moved markdown sources (CMP gate 4), the `markdown-to-json.py` invariants hold (12 use cases, 36 realization elements), and the tree is clean. W2–W6 goldens were recorded by the coder per the design protocol (§7: serialized from the pre-refactor `json!` expressions with fixed representative values); **W1 was independently reconstructed by the 2B reviewer** and both agree with the shipped bytes.
- **No manual wire spot-check** was executed (design regression-protocol step 4 is "recommended once", not mandatory); byte-identity of the live HTTP surface rests on the W1–W6 golden tests.
## Platform note
The EA pipeline (`scripts/openup/build-model.ps1`, `export-model.ps1`) is **Windows-gated** (PowerShell + EA COM automation); both scripts are committed but were **not executed on this Linux host** — only the pure-Python intermediate step (`markdown-to-json.py`, stdlib-only) ran. The human gate (design §11.d: EA session, `.eapx` commit) is **pending** and outside this verification's scope.
## Defects found in test phase
None. No defect candidates.
## Verdict
**PASS** — all four CMP gates green (fmt, clippy zero-warning, build, docs drift check byte-identical); all 71 tests green across the workspace; the two recorded wire-invisible deltas verified as documented; docs work set internally consistent. Human EA gate outstanding by design.

View file

@ -0,0 +1,173 @@
# Skara Brae — EA analysis-association adder (post-WI-2 enrichment).
# SURGICAL: adds connectors + diagram links to EXISTING realization packages.
# Never deletes or rebuilds anything - manual layout in the .eapx is preserved.
# Re-running build-model.ps1 after a manual layout session is FORBIDDEN (it rebuilds).
#
# Source of truth: docs/openup/analysis-design/uc-realization-mapping.md §2a
# (24 associations + 4 cross-cutting «uses» dependencies to DomainError).
$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"
exit 1
}
# (packageId, fromElement, toElement) per mapping §2a - 24 rows.
$associationRows = @(
@{ Pkg = 'UC-01 Realization'; From = 'AuthBoundary'; To = 'AccountRegistry' },
@{ Pkg = 'UC-01 Realization'; From = 'AccountRegistry'; To = 'UserAccount' },
@{ Pkg = 'UC-02 Realization'; From = 'TokenService'; To = 'Authenticator' },
@{ Pkg = 'UC-03 Realization'; From = 'CharacterCatalog'; To = 'CharacterFactory' },
@{ Pkg = 'UC-03 Realization'; From = 'CharacterFactory'; To = 'Character' },
@{ Pkg = 'UC-03 Realization'; From = 'CharacterFactory'; To = 'ResourceRegenerator' },
@{ Pkg = 'UC-04 Realization'; From = 'CatalogueBrowser'; To = 'GameData' },
@{ Pkg = 'UC-05 Realization'; From = 'GymBoundary'; To = 'TrainingService' },
@{ Pkg = 'UC-05 Realization'; From = 'TrainingService'; To = 'TrainingGrounds' },
@{ Pkg = 'UC-06 Realization'; From = 'EquipmentBoundary'; To = 'EquipmentService' },
@{ Pkg = 'UC-06 Realization'; From = 'EquipmentService'; To = 'Equipment/Inventory' },
@{ Pkg = 'UC-07 Realization'; From = 'ShopBoundary'; To = 'ShopService' },
@{ Pkg = 'UC-07 Realization'; From = 'ShopService'; To = 'ShopStock' },
@{ Pkg = 'UC-08 Realization'; From = 'AdventureBoundary'; To = 'AdventureService' },
@{ Pkg = 'UC-08 Realization'; From = 'AdventureService'; To = 'CombatEngine' },
@{ Pkg = 'UC-08 Realization'; From = 'AdventureService'; To = 'Combatant' },
@{ Pkg = 'UC-08 Realization'; From = 'AdventureService'; To = 'SpellEffect / BardSong' },
@{ Pkg = 'UC-09 Realization'; From = 'TempleBoundary'; To = 'HealingService' },
@{ Pkg = 'UC-10 Realization'; From = 'SongBoundary'; To = 'SongService' },
@{ Pkg = 'UC-10 Realization'; From = 'SongService'; To = 'BardSong' },
@{ Pkg = 'UC-11 Realization'; From = 'MagicBoundary'; To = 'MagicService' },
@{ Pkg = 'UC-11 Realization'; From = 'MagicService'; To = 'Spell' },
@{ Pkg = 'UC-12 Realization'; From = 'ReviewBoundary'; To = 'ReviewBoard' },
@{ Pkg = 'UC-12 Realization'; From = 'ReviewBoard'; To = 'Progression' }
)
# (packageId, controllerName) - 4 «uses» dependencies to DomainError.
$domainErrorRows = @(
@{ Pkg = 'UC-05 Realization'; From = 'TrainingService' },
@{ Pkg = 'UC-06 Realization'; From = 'EquipmentService' },
@{ Pkg = 'UC-08 Realization'; From = 'AdventureService' },
@{ Pkg = 'UC-12 Realization'; From = 'ReviewBoard' }
)
$step = 'start'
$app = $null
$eaBefore = @(Get-Process -Name EA -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id)
function Test-ConnectorExists([object]$clientElement, [int]$supplierId, [string]$type) {
$clientElement.Connectors.Refresh()
for ($c = 0; $c -lt $clientElement.Connectors.Count; $c++) {
$existing = $clientElement.Connectors.GetAt([int]$c)
if ($existing.Type -eq $type -and $existing.SupplierID -eq $supplierId) { return $true }
}
return $false
}
try {
$app = New-Object -ComObject EA.App
$app.Visible = $false
$repo = $app.Repository
$null = $repo.OpenFile($modelPath)
$root = $repo.Models.GetAt([int]0)
$step = 'resolve Analysis-Design + DomainError'
$analysisDesign = $root.Packages.GetByName('Analysis-Design')
$architecture = $analysisDesign.Packages.GetByName('Architecture Overview')
$architecture.Elements.Refresh()
$domainError = $null
for ($i = 0; $i -lt $architecture.Elements.Count; $i++) {
$candidate = $architecture.Elements.GetAt([int]$i)
if ($candidate.Name -eq 'DomainError') { $domainError = $candidate; break }
}
if (-not $domainError) { throw 'DomainError element not found in Architecture Overview' }
$step = 'collect realization package elements'
$elementsByPkg = @{}
for ($p = 0; $p -lt $analysisDesign.Packages.Count; $p++) {
$pkg = $analysisDesign.Packages.GetAt([int]$p)
if ($pkg.Name -notlike 'UC-?? Realization') { continue }
$pkg.Elements.Refresh()
$map = @{}
for ($e = 0; $e -lt $pkg.Elements.Count; $e++) {
$element = $pkg.Elements.GetAt([int]$e)
if ($element.Type -eq 'Class') { $map[$element.Name] = $element }
}
$elementsByPkg[$pkg.Name] = @{ Map = $map; Package = $pkg }
}
$step = 'add associations + diagram links'
$createdAssoc = 0; $skippedAssoc = 0
foreach ($row in $associationRows) {
$entry = $elementsByPkg[$row.Pkg]
if (-not $entry) { throw "package not found: $($row.Pkg)" }
$client = $entry.Map[$row.From]
$supplier = $entry.Map[$row.To]
if (-not $client) { throw "element not found in $($row.Pkg): $($row.From)" }
if (-not $supplier) { throw "element not found in $($row.Pkg): $($row.To)" }
if (Test-ConnectorExists $client $supplier.ElementID 'Association') { $skippedAssoc++; continue }
$connector = $client.Connectors.AddNew('', 'Association')
$connector.ClientID = $client.ElementID
$connector.SupplierID = $supplier.ElementID
$null = $connector.Update()
$client.Connectors.Refresh()
$diagram = $entry.Package.Diagrams.GetAt([int]0)
$diagram.DiagramLinks.Refresh()
$link = $diagram.DiagramLinks.AddNew('', '')
$link.ConnectorID = $connector.ConnectorID
$null = $link.Update()
$null = $diagram.Update()
$createdAssoc++
}
$step = 'add DomainError dependencies (model-level, no diagram links - DomainError is not placed on realization diagrams; manual layout stays untouched)'
$createdDeps = 0; $skippedDeps = 0
foreach ($row in $domainErrorRows) {
$entry = $elementsByPkg[$row.Pkg]
$client = $entry.Map[$row.From]
if (-not $client) { throw "element not found in $($row.Pkg): $($row.From)" }
if (Test-ConnectorExists $client $domainError.ElementID 'Dependency') { $skippedDeps++; continue }
$connector = $client.Connectors.AddNew('', 'Dependency')
$connector.ClientID = $client.ElementID
$connector.SupplierID = $domainError.ElementID
$connector.Stereotype = 'uses'
$null = $connector.Update()
$client.Connectors.Refresh()
$createdDeps++
}
$step = 'verify'
$assocTotal = 0; $depTotal = 0
foreach ($pkgName in $elementsByPkg.Keys) {
$entry = $elementsByPkg[$pkgName]
foreach ($className in $entry.Map.Keys) {
$element = $entry.Map[$className]
$element.Connectors.Refresh()
for ($c = 0; $c -lt $element.Connectors.Count; $c++) {
$connector = $element.Connectors.GetAt([int]$c)
if ($connector.Type -eq 'Association') { $assocTotal++ }
if ($connector.Type -eq 'Dependency' -and $connector.SupplierID -eq $domainError.ElementID) { $depTotal++ }
}
}
}
$assocTotal = $assocTotal / 2
Write-Output "ASSOCIATIONS created=$createdAssoc skipped(existing)=$skippedAssoc -> total=$assocTotal (expect 24)"
Write-Output "DOMAINERROR DEPS created=$createdDeps skipped(existing)=$skippedDeps -> total=$depTotal (expect 4)"
if ($assocTotal -ne 24 -or $depTotal -ne 4) { Write-Output 'VERIFY FAILED'; exit 1 }
$null = $repo.CloseFile()
Write-Output 'ALL STEPS OK - associations added surgically; manual layout untouched'
}
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 }
}

View file

@ -0,0 +1,180 @@
# Skara Brae — EA Design package builder (post-WI-2 enrichment).
# SURGICAL: creates/rebuilds ONLY Analysis-Design / Design (agent-owned, no manual
# edits). Requirements, realization packages and Architecture Overview are untouched;
# never re-run build-model.ps1 after a manual layout session.
#
# Source of truth: docs/openup/analysis-design/uc-realization-mapping.md §3
# (joint component model: server crates + skara-brae-client + skara-brae-common).
$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"
exit 1
}
$components = @(
@{ Name = 'skara-brae-client';
Notes = 'Presentation tier. Vue 3.5 SPA (TypeScript, Pinia, Vite); monolithic App.vue @ 34dc02f; JWT bearer auth; REST via Vite proxy to :8080. Serves UC-01..UC-12 as the player-facing boundary. Repo: skara-brae-client.' },
@{ Name = 'sb-api';
Notes = 'Boundary/adapter crate (Axum). Routes/handlers, domain_status status mapping, responses (wire-format freeze, NFR-19). May import sb-core, sb-shared, sb-common, sb-db, sb-auth (design note section 3). Carries the boundaries of all UCs per mapping section 2. Known debt: ShopService/HealingService/parts of MagicService still handler-side.' },
@{ Name = 'sb-auth';
Notes = 'Adapter crate. argon2 password hashing + JWT creation/verification. Serves UC-01 (register), UC-02 (authenticate); auth_middleware guards UC-03..UC-12. Imports sb-shared.' },
@{ Name = 'sb-db';
Notes = 'Adapter crate. Persistence via sb-db::Database. Serves persistence of UC-01..UC-03, UC-05..UC-12 state. Imports sb-shared.' },
@{ Name = 'sb-core';
Notes = 'Domain crate (game engine). Controllers/entities: adventure (UC-08), services train/equip/unequip (UC-05/06), character create/level-up/regen (UC-03/12), combat, spells (UC-11), songs (UC-10), data/items catalogue (UC-04). Must not reference axum/http/StatusCode/serde_json json!/tokio/sb-db/sb-api (design note section 3). DomainError error taxonomy (UC-05/06/08/12 error paths).' },
@{ Name = 'sb-shared';
Notes = 'Domain types crate. User, Character, Item, response types. Zero HTTP imports. Entities of all UCs (mapping section 2).' },
@{ Name = 'sb-common';
Notes = 'Bilingual data package (repo skara-brae-common). data/ JSON (items, enemies, spells, crit tables, zones), rust/ crate (include_str + serde), ts/ package, codegen/. UC-04 data source; consumed by sb-core and sb-api; future client type codegen.' }
)
$dependencyRows = @(
@{ From = 'skara-brae-client'; To = 'sb-api' },
@{ From = 'sb-api'; To = 'sb-core' },
@{ From = 'sb-api'; To = 'sb-shared' },
@{ From = 'sb-api'; To = 'sb-db' },
@{ From = 'sb-api'; To = 'sb-auth' },
@{ From = 'sb-api'; To = 'sb-common' },
@{ From = 'sb-core'; To = 'sb-shared' },
@{ From = 'sb-core'; To = 'sb-common' },
@{ From = 'sb-db'; To = 'sb-shared' },
@{ From = 'sb-auth'; To = 'sb-shared' }
)
$positions = @{
'skara-brae-client' = @(40, 340, 40, 140)
'sb-api' = @(440, 740, 40, 140)
'sb-core' = @(840, 1140, 40, 140)
'sb-shared' = @(1240, 1540, 40, 140)
'sb-auth' = @(440, 740, 260, 360)
'sb-db' = @(840, 1140, 260, 360)
'sb-common' = @(1240, 1540, 260, 360)
}
$step = 'start'
$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)
$step = 'resolve Analysis-Design'
$analysisDesign = $root.Packages.GetByName('Analysis-Design')
$step = 'rebuild Design package (agent-owned only)'
for ($i = 0; $i -lt $analysisDesign.Packages.Count; $i++) {
if (($analysisDesign.Packages.GetAt([int]$i)).Name -eq 'Design') {
$null = $analysisDesign.Packages.DeleteAt([int]$i, $false)
$analysisDesign.Packages.Refresh()
break
}
}
$design = $analysisDesign.Packages.AddNew('Design', 'Package')
$design.Notes = 'Joint component model of the Skara Brae architecture: server crates, skara-brae-client, skara-brae-common. Dependency rule: inward toward sb-core/sb-shared (domain); adapters (sb-api, sb-db, sb-auth) and the client at the edge. Source: uc-realization-mapping.md section 3. Added 2026-10-03; joint-model decision recorded there (client-side UC realization packages = future work item).'
$null = $design.Update()
$analysisDesign.Packages.Refresh()
$step = 'create components'
$elements = @{}
foreach ($component in $components) {
$element = $design.Elements.AddNew($component.Name, 'Component')
$element.Notes = $component.Notes
$null = $element.Update()
$elements[$component.Name] = $element
}
$design.Elements.Refresh()
$step = 'create dependencies'
foreach ($row in $dependencyRows) {
$client = $elements[$row.From]
$supplier = $elements[$row.To]
$connector = $client.Connectors.AddNew('', 'Dependency')
$connector.ClientID = $client.ElementID
$connector.SupplierID = $supplier.ElementID
$null = $connector.Update()
$client.Connectors.Refresh()
}
$step = 'create component diagram'
$diagram = $design.Diagrams.AddNew('Component Architecture', 'Component')
$diagram.Notes = 'Layered joint architecture: client (edge) -> sb-api (boundary) -> domain (sb-core, sb-shared) with sb-common data; adapters sb-auth, sb-db at the edge of the server.'
$null = $diagram.Update()
$design.Diagrams.Refresh()
$diagram = $design.Diagrams.GetByName('Component Architecture')
foreach ($component in $components) {
$coords = $positions[$component.Name]
$diagramObject = $diagram.DiagramObjects.AddNew('', '')
$diagramObject.ElementID = ($elements[$component.Name]).ElementID
$diagramObject.left = $coords[0]; $diagramObject.right = $coords[1]
$diagramObject.top = $coords[2]; $diagramObject.bottom = $coords[3]
$null = $diagramObject.Update()
}
$step = 'create diagram links'
$analysisDesign.Packages.Refresh()
$design = $analysisDesign.Packages.GetByName('Design')
$design.Elements.Refresh()
$elements = @{}
for ($e = 0; $e -lt $design.Elements.Count; $e++) {
$element = $design.Elements.GetAt([int]$e)
if ($element.Type -eq 'Component') { $elements[$element.Name] = $element }
}
$diagram = $design.Diagrams.GetByName('Component Architecture')
foreach ($row in $dependencyRows) {
$client = $elements[$row.From]
$client.Connectors.Refresh()
for ($c = 0; $c -lt $client.Connectors.Count; $c++) {
$connector = $client.Connectors.GetAt([int]$c)
if ($connector.Type -eq 'Dependency') {
$supplier = $elements[$row.To]
if ($connector.SupplierID -eq $supplier.ElementID) {
$diagram.DiagramLinks.Refresh()
$link = $diagram.DiagramLinks.AddNew('', '')
$link.ConnectorID = $connector.ConnectorID
$null = $link.Update()
}
}
}
}
$null = $diagram.Update()
$step = 'verify'
$componentCount = 0; $dependencyCount = 0
$design = $analysisDesign.Packages.GetByName('Design')
$design.Elements.Refresh()
for ($e = 0; $e -lt $design.Elements.Count; $e++) {
$element = $design.Elements.GetAt([int]$e)
if ($element.Type -eq 'Component') { $componentCount++ }
}
foreach ($name in $elements.Keys) {
$element = $elements[$name]
$element.Connectors.Refresh()
for ($c = 0; $c -lt $element.Connectors.Count; $c++) {
$connector = $element.Connectors.GetAt([int]$c)
if ($connector.Type -eq 'Dependency' -and $connector.ClientID -eq $element.ElementID) { $dependencyCount++ }
}
}
Write-Output "VERIFY components=$componentCount (expect 7), dependencies=$dependencyCount (expect 10)"
if ($componentCount -ne 7 -or $dependencyCount -ne 10) { Write-Output 'VERIFY FAILED'; exit 1 }
$null = $repo.CloseFile()
Write-Output 'ALL STEPS OK - Design package created; nothing else touched'
}
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 }
}

View file

@ -0,0 +1,461 @@
# 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)'
$elementsByShortName = @{}
foreach ($useCase in $useCases) { $elementsByShortName[$useCase.Name.Substring(6)] = $elements[$useCase.Name] }
foreach ($realization in $realizations.realizations) {
$supplierElement = $elements[$realization.useCase]
if (-not $supplierElement) { $supplierElement = $elementsByShortName[$realization.useCase] }
if (-not $supplierElement) { throw "use case not found for $($realization.id): $($realization.useCase)" }
$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 = $supplierElement.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 }
}

View file

@ -0,0 +1,90 @@
# Skara Brae — EA diagram image exporter.
# Renders the model's diagrams to PNG for embedding in the OpenUP markdown documents.
# READ-ONLY on the model (opens diagrams, renders, closes; no model mutation).
#
# EA-15 findings baked in (probe session 2026-10-05):
# - the image methods live on ProjectInterface, NOT on Repository
# (Repository.SaveImageToPath exists but always returns False);
# - PutDiagramImageToFile(id, path, type) with type 0 = PNG;
# - the diagram must be OPEN (repo.OpenDiagram) before export, else a blank
# 241x151 stub is written;
# - works with EA hidden (app.Visible = $false).
#
# Output (keyfob-station convention: requirements overview next to the UC docs,
# realization + design figures in analysis-design/):
# docs/openup/requirements/use-case-overview.png
# docs/openup/analysis-design/uc-01-realization.png .. uc-12-realization.png
# docs/openup/analysis-design/component-architecture.png
$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"
exit 1
}
$reqDir = Join-Path $repoRoot 'docs\openup\requirements'
$adDir = Join-Path $repoRoot 'docs\openup\analysis-design'
$null = New-Item -ItemType Directory -Force -Path $reqDir, $adDir
$step = 'start'
$app = $null
$eaBefore = @(Get-Process -Name EA -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id)
function Export-Diagram([object]$project, [object]$repo, [int]$diagramId, [string]$outPath) {
if (Test-Path $outPath) { Remove-Item $outPath -Force }
$null = $repo.OpenDiagram($diagramId)
Start-Sleep -Seconds 2
$null = $project.PutDiagramImageToFile($diagramId, $outPath, 0)
try { $null = $repo.CloseDiagram($diagramId) } catch { }
if (-not (Test-Path $outPath)) { throw "no image written: $outPath" }
$bytes = (Get-Item $outPath).Length
if ($bytes -lt 5000) { throw "suspiciously small render ($bytes bytes): $outPath" }
Write-Output "exported: $outPath ($bytes bytes)"
}
try {
$app = New-Object -ComObject EA.App
$app.Visible = $false
$repo = $app.Repository
$null = $repo.OpenFile($modelPath)
$root = $repo.Models.GetAt([int]0)
$project = $repo.GetProjectInterface()
$step = 'use case overview'
$ucModel = $root.Packages.GetByName('Requirements').Packages.GetByName('Use Case Model')
$ucModel.Diagrams.Refresh()
$overview = $ucModel.Diagrams.GetByName('Skara Brae Use Case Overview')
Export-Diagram $project $repo $overview.DiagramID (Join-Path $reqDir 'use-case-overview.png')
$step = 'realization diagrams'
$analysisDesign = $root.Packages.GetByName('Analysis-Design')
$analysisDesign.Packages.Refresh()
for ($p = 0; $p -lt $analysisDesign.Packages.Count; $p++) {
$pkg = $analysisDesign.Packages.GetAt([int]$p)
if ($pkg.Name -notlike 'UC-?? Realization') { continue }
$pkg.Diagrams.Refresh()
if ($pkg.Diagrams.Count -lt 1) { throw "no diagram in $($pkg.Name)" }
$number = $pkg.Name.Substring(3, 2)
Export-Diagram $project $repo $pkg.Diagrams.GetAt([int]0).DiagramID (Join-Path $adDir ("uc-{0}-realization.png" -f $number.ToLower()))
}
$step = 'component architecture'
$design = $analysisDesign.Packages.GetByName('Design')
$design.Diagrams.Refresh()
$componentDiagram = $design.Diagrams.GetByName('Component Architecture')
Export-Diagram $project $repo $componentDiagram.DiagramID (Join-Path $adDir 'component-architecture.png')
$null = $repo.CloseFile()
Write-Output 'EXPORT OK - 14 diagrams rendered'
}
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 }
}

View file

@ -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 }
}

View file

@ -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())