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>
24 KiB
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'scalculate()never reads them, andcharacters.service.tshas 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.tsalready computes danger from{attack, armor, hp}only, never fromlevel/experience(world.service.ts:214-219,hunting.service.ts:186-189both passbaseAttack/baseHpdirectly, bypassing evenCharacterStatsService). Zero changes needed here — spec §33's requirement is already satisfied by existing code.CharacterItemalready supports stacking (quantitycolumn, unique index on(characterId, itemDefinitionId)) — Trade Goods/Trophies need no new inventory infrastructure, only newItemTypevalues.LootTableEntry/LootTablealready 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/silverMaxare read in exactly one place:combat-reward.service.ts:79-84.CombatReward.experienceGrantedpersists 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 forrenown.- 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 wrongItemType. 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 is1790000000000-ExtendCombatEventTypes.ts. New migration(s) start at1791000000000. - 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(oneHttpExceptionsubclass + 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 1–15 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 1–15 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 1–15 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 1–15, 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:experiencefield removed.grantVictoryRewards:experiencecomputation andcharacter.experience +=line removed;character.silver += silverstays (silver roll mechanism unchanged, see R7);CombatReward.experienceGrantedno longer set (column removed).- Seed data:
Aschenratte.experienceRewarddeleted from the seed shape (column gone from entity);silverMin/silverMaxset to0for 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. NewReputationEntry/TurnInResultinterfaces mirroring the API DTOs.InventoryItem.item.type(if surfaced) reflects the newItemTypevalues;requiredLevelremoved if present anywhere in the web model.- Topbar (
top-bar.component.html):Stufe {{ character.level }}→Renown {{ character.renown }};data-top-bar-experienceXP block deleted entirely. - Combat victory screen (
combat-page.component.html/.spec.ts):data-reward-experienceblock and its three referencing tests deleted (R16);rewards.silverblock stays. - New
ReputationDisplayComponent(apps/web/src/app/shared/reputation-display/): standalone component, inputentry: ReputationEntry, renders faction name, rank label,current / nextThreshold(or just current ifnextThresholdis 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.