Files
ashen-realms/docs/superpowers/specs/2026-08-21-renown-reputation-foundation-design.md
Bastian Wagner 65dfb466cd Add design doc for Playable Slice 0.6.5 (Renown & Reputation Foundation)
Records every judgment call the spec left open — Renown/Reputation
data model, service signatures, migration strategy, combat reward
pipeline changes, and API/frontend surface — as an explicit, auditable
ruling with its cost if wrong, per instruction to implement this slice
without confirmation stops.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 08:21:14 +02:00

282 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Playable Slice 0.6.5 — Renown & Reputation Foundation: Design
**Spec:** `docs/playable-slices/Ashen Realms Playable Slice 0.6.5_ Renown & Reputation Foundation.md`
**Status:** Approved by explicit user instruction to implement without confirmation stops. Every non-obvious call the spec left open is recorded below as a ruling, with its cost if wrong, so it is auditable after the fact.
## 1. Scope confirmation
This is architectural: it removes a core progression system (Character XP/Level), adds five new tables, three new services, one enum migration, and touches the combat reward pipeline, equipment gate, and two frontend surfaces. Full brainstorming path, single spec (not decomposed — the spec is already scoped to "foundation," and splitting further would separate pieces that must land together, e.g. removing `level` and adding `renown` in the same migration).
## 2. Current-state findings that shape this design
(From a full codebase survey on `master` post-Slice-0.6-merge.)
- `Character.level`/`Character.experience` (`character.entity.ts:20-24`) are **already fully vestigial**`character-stats.service.ts`'s `calculate()` never reads them, and `characters.service.ts` has no level-up logic anywhere. They are pure display + one validation gate. This means removing them has zero combat-math blast radius.
- The equipment level gate is the **only** consumer of `requiredLevel`: `equipment.service.ts:105-107`, `if (definition.requiredLevel > character.level) throw itemLevelRequirementNotMet();`. One call site to change.
- `danger-rating.ts` already computes danger from `{attack, armor, hp}` only, never from `level`/`experience` (`world.service.ts:214-219`, `hunting.service.ts:186-189` both pass `baseAttack`/`baseHp` directly, bypassing even `CharacterStatsService`). **Zero changes needed here** — spec §33's requirement is already satisfied by existing code.
- `CharacterItem` already supports stacking (`quantity` column, unique index on `(characterId, itemDefinitionId)`) — Trade Goods/Trophies need no new inventory infrastructure, only new `ItemType` values.
- `LootTableEntry`/`LootTable` already model exactly the "monster drops item with chance/quantity" mechanism spec §22 needs for Aschenfell/Räuberabzeichen — reuse directly, no new mechanism.
- `MonsterDefinition.experienceReward`/`silverMin`/`silverMax` are read in exactly one place: `combat-reward.service.ts:79-84`. `CombatReward.experienceGranted` persists it. No other consumers found.
- `GET /api/characters/me` (`characters.controller.ts`) is the existing character endpoint already feeding the web Topbar — the natural home for `renown`.
- One seeded item already uses `ItemType.MATERIAL` (`ash-pelt` / Aschenfell, `item-content.ts:161-168`) — **this is exactly the Trade Good the spec wants seeded**, just under the wrong `ItemType`. Migrating its type is the seed change, not a new item.
- No Grenzmarken entity/service exists anywhere — only a code comment referencing a balancing doc. Spec §23's "remove existing Grenzmarken code" is a no-op; nothing to delete.
- Migration convention: chronological timestamp-prefixed files in `apps/api/src/database/migrations/`, latest is `1790000000000-ExtendCombatEventTypes.ts`. New migration(s) start at `1791000000000`.
- Module layout convention (from `equipment/`): `X.module.ts`, `X.service.ts` (DTOs as exported interfaces at top), `X.service.spec.ts`, `X.controller.ts`, `X.errors.ts` (one `HttpException` subclass + one factory function per error code), `dto/*.dto.ts`, `entities/*.entity.ts`.
- Migration test convention: assert TypeORM entity metadata only, never execute against a live DB (matches Slice 0.6's precedent, confirmed no test suite in this project ever opens a real Postgres connection).
## 3. Rulings
Each ruling: what was decided, why, and the cost if it turns out wrong.
**R1 — Renown storage: single accumulating `renown: number` column, no separate points/rank split.**
Spec offers two options (A: points + derived rank from thresholds, B: milestones advance rank directly) and says "prefer the simplest design that fits." Renown 115 is itself the displayed value (unlike Reputation, which has named ranks distinct from its raw number) — so there is nothing for a second "rank" concept to add. `renownReward` on a milestone is a small positive integer (almost always 1) added directly to `character.renown`, clamped to `[1, 15]` since the V1 power-curve table only has 15 entries and the spec frames 115 as the whole target range for this vertical slice.
*Cost if wrong:* a later slice wanting Renown > 15 needs one clamp constant changed and the power-curve table extended — cheap, isolated.
**R2 — Renown milestone completion recomputes `baseHp`/`baseAttack` from the spec's exact power-curve table, replacing (not adding to) the previous values.**
There is no existing level-up flow to migrate (confirmed: none exists in code today). `RenownService.completeMilestone` looks up `RENOWN_BASE_STATS[newRenown]` and sets `character.baseHp`/`character.baseAttack` to that row's values directly — this is a *lookup*, not an increment, so it's idempotent and immune to double-application drift.
*Cost if wrong:* stat table is a single exported array; wrong values are a one-line data fix, not a structural problem.
**R3 — `ItemType` enum: `WEAPON`/`ARMOR` collapse into `EQUIPMENT` (the item's own `equipmentSlot` already disambiguates); `MATERIAL` becomes `TRADE_GOOD`; add `TROPHY` and `QUEST_ITEM`.**
Matches spec §16 exactly. The one existing `MATERIAL` item (Aschenfell) becomes the seeded Trade Good the spec asks for in §21 — no new item needed, just a type correction. Migration: convert the enum column to text, `UPDATE` old values to new ones, drop and recreate the Postgres enum type with only the five new values (mirrors the exact down()-migration technique already used in `1790000000000-ExtendCombatEventTypes.ts`'s rollback), convert the column back. This fully removes the old enum labels rather than leaving them as unused dead weight.
*Cost if wrong:* enum migrations are reversible via the same technique in `down()`; low risk, precedented pattern.
**R4 — `ItemDefinition.requiredLevel` column is dropped entirely, not just unused.**
Spec doesn't explicitly mandate dropping the column, only removing the validation. But keeping a dead, unused-by-any-service integer column that used to gate content is exactly the "unused player Level merely for compatibility" anti-pattern spec §13 forbids for Character — applying the same principle here for consistency. DTOs and any web-side surfacing of `requiredLevel` are removed too.
*Cost if wrong:* re-adding a dropped column is a trivial follow-up migration; no data is silently lost since dev data is disposable per spec §13.
**R5 — Single migration, not staged.**
Spec §32 allows staged migrations "if dropping old fields in the same migration would make development migration unnecessarily risky." Since all affected data is disposable dev/demo data (spec §13 explicitly sanctions this), and TypeORM's migration-per-PR convention here already treats each slice as one atomic schema step, one migration (`1791000000000-CreateRenownAndReputation.ts`) covers: Character column changes, ItemDefinition column removal, ItemType enum rebuild, and all five new tables (`reputation_factions`, `character_reputation`, `renown_milestone_definitions`, `character_renown_milestones`, `turn_in_definitions`).
*Cost if wrong:* if this ever needs re-running against real (non-disposable) data, it would need splitting — but no such data exists yet in this project.
**R6 — Character migration data step: `renown = LEAST(GREATEST(level, 1), 15)` before dropping `level`/`experience`.**
Directly implements spec §13's suggested "old Level 1 → Renown 1" mapping, clamped into the valid 115 range. `experience` has no analog and is simply dropped.
*Cost if wrong:* dev-only demo character; a wrong seed value is fixed by reseeding.
**R7 — `MonsterDefinition.silverMin`/`silverMax`/`experienceReward` columns and the reward-roll mechanism stay in the schema and in `CombatRewardService`; only `experienceReward` is deleted (concept removed) while `silverMin`/`silverMax` are zeroed in seed data for all currently-seeded monsters.**
Spec §15 frames a direct currency drop as a legitimate *exception* mechanism ("unless the monster explicitly has a lore-valid direct currency drop... an exception rather than the default system"), not something to delete. The cleanest way to keep the mechanism available for a future lore-valid monster while making today's content compliant is: keep the `silverMin`/`silverMax` roll in code (a monster with both set to 0 always rolls 0 — harmless), but delete `experienceReward` and the `character.experience +=` line entirely, since XP has no "legitimate exception" carve-out anywhere in the spec — it is fully abolished (§1, §33). This is the more data-driven choice per §42 (no special-case code to gate the exception; content data alone decides), and required zero seed-monster stat redesign beyond zeroing two columns.
*Cost if wrong:* if the "exception" framing turns out unwanted, deleting the roll mechanism later is a small, contained change (one method body, one DTO field).
**R8 — `CombatReward.experienceGranted` column and `CombatRewardDto.experience` field are both deleted.**
Follows directly from R7 — once XP is gone as a concept, persisting a granted-XP audit trail is dead weight. `CombatRewardDto` becomes `{ silver: number; items: CombatRewardItemDto[] }`.
*Cost if wrong:* trivial to re-add a column; no external consumer beyond this same slice's own new code.
**R9 — API surface: extend the existing `GET /api/characters/me` response with `renown: number` (replacing `level`/`experience`); add a new `GET /api/reputation` endpoint returning all enabled factions with the character's reputation (defaulting unrepresented factions to 0/Stranger); do not add a separate `GET /api/renown` endpoint.**
Spec §25 explicitly allows "a combined character endpoint... if that matches the existing architecture better" — it already does, since the Topbar already consumes this exact endpoint. A standalone `GET /api/renown` would be redundant. Reputation is faction-scoped list data with no existing analog on the character endpoint, so it gets its own endpoint, matching the reusable-component framing of spec §27.
*Cost if wrong:* adding a thin `GET /api/renown` alias later is a 5-line controller method; no architecture rework needed.
**R10 — `getCharacterReputation` returns a row for every *enabled* `ReputationFaction`, not only ones the character has actually interacted with.**
Spec §36 explicitly requires "character starts at 0 Reputation for unknown faction" to be a tested, observable behavior — this only holds if the read path itself synthesizes a default `{ reputation: 0, rank: STRANGER }` row for factions with no `CharacterReputation` record, rather than silently omitting them.
*Cost if wrong:* a caller expecting a sparse list instead of a dense one would need one filter added; no data loss.
**R11 — `RenownService`/`ReputationService` both accept an optional `EntityManager` scope parameter (mirroring `CharacterStatsService.calculate(character, scope?)` and `CombatRewardService.grantVictoryRewards(manager, combat)`), so `TurnInService` can call both inside its own transaction.**
This is the established pattern in this codebase for "service usable standalone or composed into a larger transaction." `TurnInService.turnIn` needs both a reputation grant and (for a future milestone-linked turn-in) a possible renown grant to commit atomically with the item consumption and silver grant — spec §20's atomicity requirement demands this.
*Cost if wrong:* none identified; this is the codebase's existing convention, not a new pattern.
**R12 — `TurnInDefinition` ships without `firstTurnInMilestoneKey`/`minimumQuantity`/`maximumQuantity`.**
Spec §19 explicitly lists these as "optional later fields" and says "do not add these unless actually needed" — none of §21's minimal seed content needs them.
*Cost if wrong:* additive migration later; no rework of existing rows.
**R13 — No Renown milestone content is seeded.**
Spec §5 says implement the *system* so later slices can define milestones through data, and explicitly says "do not implement all of these content milestones in Slice 0.6.5." The system is proven end-to-end via service-level tests using ad-hoc fixture milestones, not via seeded demo content. Seeding zero milestones keeps this slice's content scope minimal (matches §21's explicit minimal-content list, which does not mention milestones).
*Cost if wrong:* seeding one milestone later is a one-row `INSERT` in a seed file; the service and schema are already fully proven.
**R14 — Reputation rank thresholds and labels live in one pure resolver function (`resolveReputationRank(reputation: number)`), mirroring the existing `danger-rating.ts` pattern exactly** — a hardcoded threshold table, sorted descending, first match wins, German display labels alongside stable English keys (`STRANGER`/`Fremder`, `TOLERATED`/`Geduldet`, `KNOWN`/`Bekannt`, `RECOGNIZED`/`Anerkannt`, `TRUSTED`/`Vertraut`, `ESTEEMED`/`Geachtet`).
Spec §10 explicitly demands "one authoritative reputation-rank resolver," and the codebase already has a proven template for exactly this shape.
*Cost if wrong:* isolated pure function; trivial to adjust thresholds or labels.
**R15 — The reusable Reputation display component is built and tested but not wired into any page.**
Spec §27 explicitly says "this component does not need its final dedicated Reputation screen yet." DoD requires the presentation to exist and be reusable, not that it appear in a specific screen today (no character/merchant screen is in scope for this slice's non-goals list either).
*Cost if wrong:* wiring an already-built, already-tested component into a page is frontend integration work with no design risk.
**R16 — Web-side `CombatReward` model and victory-screen UI drop the XP block entirely** (`data-reward-experience`, the "Erfahrung"/"+X XP" line) rather than hiding it conditionally.
Spec §26/§37 explicitly forbid displaying an XP value anywhere, and R8 already removes the field from the DTO, so there is nothing to conditionally hide — the block is dead markup once the field doesn't exist.
*Cost if wrong:* re-adding a `@if` block is trivial; no architecture impact.
## 4. Data model
### Character (modified)
```
- level: integer → REMOVED
- experience: integer → REMOVED
+ renown: integer NOT NULL DEFAULT 1 (range 115, enforced by RenownService, not a DB CHECK constraint — matches existing style, no CHECK constraints used elsewhere in this schema)
```
### ItemDefinition (modified)
```
- required_level: integer → REMOVED
type: item_type_enum → rebuilt: EQUIPMENT | TRADE_GOOD | TROPHY | QUEST_ITEM | CONSUMABLE
(was: WEAPON | ARMOR | MATERIAL | CONSUMABLE)
```
### ReputationFaction (new)
```
id uuid PK
key varchar(100) UNIQUE -- e.g. 'border-guard'
name varchar(150) -- e.g. 'Grenzwacht'
description text
region_key varchar(100)
enabled boolean NOT NULL DEFAULT true
created_at, updated_at
```
### CharacterReputation (new)
```
id uuid PK
character_id uuid FK → characters ON DELETE CASCADE
faction_id uuid FK → reputation_factions ON DELETE RESTRICT
reputation integer NOT NULL DEFAULT 0
created_at, updated_at
UNIQUE(character_id, faction_id)
```
### RenownMilestoneDefinition (new)
```
id uuid PK
key varchar(100) UNIQUE
name varchar(150)
description text
renown_reward integer NOT NULL
repeatable boolean NOT NULL DEFAULT false
enabled boolean NOT NULL DEFAULT true
created_at, updated_at
```
### CharacterRenownMilestone (new)
```
id uuid PK
character_id uuid FK → characters ON DELETE CASCADE
milestone_id uuid FK → renown_milestone_definitions ON DELETE RESTRICT
completed_at timestamptz NOT NULL
times_completed integer NOT NULL DEFAULT 1
UNIQUE(character_id, milestone_id) -- repeatable milestones update this same row (increment times_completed), never insert a second row
```
### TurnInDefinition (new)
```
id uuid PK
key varchar(100) UNIQUE
item_definition_id uuid FK → item_definitions ON DELETE RESTRICT
faction_id uuid FK → reputation_factions ON DELETE RESTRICT
silver_reward_per_item integer NOT NULL
reputation_reward_per_item integer NOT NULL
repeatable boolean NOT NULL DEFAULT true
enabled boolean NOT NULL DEFAULT true
created_at, updated_at
```
### CombatReward (modified)
```
- experience_granted: integer → REMOVED
```
## 5. Renown power curve (verbatim from spec §4)
```ts
export const RENOWN_BASE_STATS: Record<number, { baseHp: number; baseAttack: number }> = {
1: { baseHp: 100, baseAttack: 6 }, 2: { baseHp: 104, baseAttack: 6 },
3: { baseHp: 107, baseAttack: 7 }, 4: { baseHp: 111, baseAttack: 7 },
5: { baseHp: 114, baseAttack: 8 }, 6: { baseHp: 118, baseAttack: 8 },
7: { baseHp: 121, baseAttack: 9 }, 8: { baseHp: 125, baseAttack: 9 },
9: { baseHp: 128, baseAttack: 9 }, 10: { baseHp: 132, baseAttack: 10 },
11: { baseHp: 135, baseAttack: 10 }, 12: { baseHp: 139, baseAttack: 11 },
13: { baseHp: 142, baseAttack: 11 }, 14: { baseHp: 145, baseAttack: 11 },
15: { baseHp: 148, baseAttack: 12 },
};
```
## 6. Reputation ranks (verbatim from spec §10, German labels added per R14)
```ts
export const REPUTATION_RANKS = [
{ threshold: 1200, key: 'ESTEEMED', label: 'Geachtet' },
{ threshold: 800, key: 'TRUSTED', label: 'Vertraut' },
{ threshold: 500, key: 'RECOGNIZED', label: 'Anerkannt' },
{ threshold: 250, key: 'KNOWN', label: 'Bekannt' },
{ threshold: 100, key: 'TOLERATED', label: 'Geduldet' },
{ threshold: 0, key: 'STRANGER', label: 'Fremder' },
] as const; // sorted descending; first threshold ≤ reputation wins
```
## 7. Services
### RenownService
```ts
completeMilestone(characterId: string, milestoneKey: string, manager?: EntityManager): Promise<RenownMilestoneResult>
interface RenownMilestoneResult {
milestoneKey: string;
previousRenown: number;
newRenown: number;
renownGranted: boolean; // false if already completed (non-repeatable) or already at cap 15
}
```
Errors: `renownMilestoneNotFound` (404), `renownMilestoneDisabled` (409), `renownMilestoneAlreadyCompleted` (409, only thrown for non-repeatable re-completion — repeatable milestones never throw this).
### ReputationService
```ts
grantReputation(characterId: string, factionKey: string, amount: number, manager?: EntityManager): Promise<ReputationGrantResult>
getCharacterReputation(characterId: string): Promise<CharacterReputationDto[]>
interface ReputationGrantResult {
factionKey: string; previousReputation: number; newReputation: number;
previousRank: string; newRank: string; rankChanged: boolean;
}
interface CharacterReputationDto {
factionKey: string; factionName: string; reputation: number; rank: string; rankLabel: string;
nextThreshold: number | null; // null once at ESTEEMED, the top rank
}
```
Errors: `reputationFactionNotFound` (404).
### TurnInService
```ts
turnIn(characterId: string, turnInKey: string, quantity: number): Promise<TurnInResult>
interface TurnInResult {
turnInKey: string; quantityConsumed: number; silverGranted: number;
reputationResult: ReputationGrantResult;
}
```
Errors: `turnInNotFound` (404), `turnInDisabled` (409), `turnInInsufficientQuantity` (409), `turnInInvalidQuantity` (400, for quantity ≤ 0).
Transactional: locks the character, verifies/decrements `CharacterItem.quantity` (deletes the row if it reaches 0), grants silver, calls `ReputationService.grantReputation` with the same manager — all-or-nothing.
## 8. Combat reward pipeline changes
- `CombatRewardDto`: `experience` field removed.
- `grantVictoryRewards`: `experience` computation and `character.experience +=` line removed; `character.silver += silver` stays (silver roll mechanism unchanged, see R7); `CombatReward.experienceGranted` no longer set (column removed).
- Seed data: `Aschenratte.experienceReward` deleted from the seed shape (column gone from entity); `silverMin`/`silverMax` set to `0` for every currently-seeded monster (Aschenratte, Verwilderter Straßenhund, Straßenräuber, Verkohlter Plünderer, and any others in the seed file).
## 9. Equipment gate change
`equipment.service.ts`: delete the `requiredLevel > character.level` check and its error path entirely. `itemLevelRequirementNotMet` error factory and its `ITEM_LEVEL_REQUIREMENT_NOT_MET` code removed from `equipment.errors.ts` (dead code once nothing throws it).
## 10. API endpoints
```
GET /api/characters/me → { ..., renown: number } (level/experience fields removed)
GET /api/reputation → CharacterReputationDto[] (dense: one entry per enabled faction)
POST /api/turn-ins → { turnInKey: string; quantity: number } → TurnInResult
```
`POST /api/turn-ins` DTO validates `turnInKey` (string) and `quantity` (positive integer) only — no reward fields accepted from the client, per spec §25's explicit prohibition list.
## 11. Frontend changes
- **`game-api.models.ts`**: `CharacterResponse.level`/`.experience``.renown: number`. New `ReputationEntry`/`TurnInResult` interfaces mirroring the API DTOs. `InventoryItem.item.type` (if surfaced) reflects the new `ItemType` values; `requiredLevel` removed if present anywhere in the web model.
- **Topbar** (`top-bar.component.html`): `Stufe {{ character.level }}``Renown {{ character.renown }}`; `data-top-bar-experience` XP block deleted entirely.
- **Combat victory screen** (`combat-page.component.html`/`.spec.ts`): `data-reward-experience` block and its three referencing tests deleted (R16); `rewards.silver` block stays.
- **New `ReputationDisplayComponent`** (`apps/web/src/app/shared/reputation-display/`): standalone component, input `entry: ReputationEntry`, renders faction name, rank label, `current / nextThreshold` (or just current if `nextThreshold` is null, at Esteemed), a progress bar. Built and unit-tested; not wired into any page (R15).
## 12. Non-goals (restated from spec §39, binding for this slice)
No merchant UI, no NPC dialogue, no quests, no bag capacity, no crafting/professions, no trading/auction house, no reputation decay, no daily caps/quests, no faction wars/competing factions, no discounts/dynamic prices, no prestige Renown, no achievements system, no Dämmerwald/Ruins reputation content, no full 15-rank unique content authoring.
## 13. Required tests (from spec §36/§37, all must exist and pass)
**Renown:** normal kill grants no Renown · milestone grants Renown · non-repeatable milestone can't double-reward · repeatable milestone can re-reward · base stats match Renown via the power-curve table · clamps at 15.
**Reputation:** starts at 0 for a faction the character has no row for · grant persists · rank resolver returns correct rank at and around every threshold · crossing a threshold reports `rankChanged: true` · not crossing reports `false` · client cannot supply reputation directly (DTO whitelist).
**Turn-In:** consumes correct quantity · grants configured silver · grants configured reputation · insufficient quantity rejects and mutates nothing · disabled/unknown turn-in rejects and mutates nothing · multi-item quantity math is server-computed, not client-supplied.
**Equipment:** an owned, sufficiently-high-tier item equips regardless of `renown` (no level gate remains) — direct regression test replacing the deleted `requiredLevel` rejection test.
**Combat reward:** WON combat grants no `experience` field at all (not zero — absent from the DTO shape) · silver stays 0 for every currently-seeded monster · loot roll (Aschenfell/Räuberabzeichen per §22) still works.
**Frontend:** Topbar shows `Renown` not `Stufe`/Level · no XP value rendered anywhere · `ReputationDisplayComponent` renders faction/rank/progress correctly at various thresholds (including the top rank with no next threshold) · turn-in request payload contains only `turnInKey`/`quantity` · combat victory screen no longer renders an XP block.
## 14. Definition of Done
Mirrors spec §44 exactly — see that section for the full checklist. Every item is covered by one or more of the rulings/sections above; nothing in §44 is left unaddressed by this design.