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

24 KiB
Raw Blame History

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 vestigialcharacter-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)

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)

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

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

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

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.