16 tasks: pure-function power curve and rank resolver, one migration covering Character/ItemDefinition/ItemType/CombatReward changes plus five new tables, three new domain services (Renown, Reputation, TurnIn), XP removal from the combat reward pipeline, requiredLevel gate removal, seed content for Grenzwacht/Räuberabzeichen/turn-ins, a fixture sweep, and the web-side Renown/Reputation surfaces. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
142 KiB
Playable Slice 0.6.5 — Renown & Reputation Foundation Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace Character level/experience with a milestone-driven renown (1–15), add a Regional Reputation system (factions, ranks, server-authoritative grants), and a data-driven trophy/trade-good turn-in loop — removing classical XP and the requiredLevel equipment gate everywhere they appear.
Architecture: Three new NestJS domain modules (renown, reputation, turn-in) follow the exact equipment/ module layout already established in this codebase (module.ts / service.ts with co-located DTO interfaces / service.spec.ts / controller.ts / errors.ts). RenownService and ReputationService both accept an optional EntityManager scope parameter (matching CharacterStatsService.calculate(character, scope?)), so TurnInService can compose a reputation grant into its own atomic transaction. One migration carries every schema change (Character/ItemDefinition column changes, the item_type_enum rebuild, and all five new tables) since all affected data is disposable dev/demo content. The combat reward pipeline, equipment gate, and two frontend surfaces (Topbar, combat victory screen) each lose their XP/Level dependency in a dedicated task; a final project-wide grep sweep is the closing gate.
Tech Stack: NestJS + TypeORM + PostgreSQL (API), Angular (signals, standalone components) + Vitest (Web), Jest (API).
Spec: docs/playable-slices/Ashen Realms – Playable Slice 0.6.5_ Renown & Reputation Foundation.md
Design: docs/superpowers/specs/2026-08-21-renown-reputation-foundation-design.md (read this first — it records every judgment call this plan makes, with its cost if wrong, as rulings R1–R16)
Global Constraints
- Renown range is 1–15 for this vertical slice;
RENOWN_BASE_STATS(design §5) is the sole source ofbaseHp/baseAttackonce a milestone changes renown (R2). - Reputation rank thresholds (design §6): 0 Stranger, 100 Tolerated, 250 Known, 500 Recognized, 800 Trusted, 1200 Esteemed.
- Normal monster kills never grant Renown directly (spec §2.2, §5) and never grant XP (XP is fully removed, not renamed).
- A non-repeatable milestone must never grant its reward twice, even under retry/double-click (spec §12, §31).
POST /api/turn-insaccepts only{ turnInKey: string; quantity: number }— every reward value is server-computed from persisted content, never client-supplied (spec §25).GET /api/characters/megainsrenown: number;level/experienceare removed, not deprecated (R9).- No separate
GET /api/renownendpoint (R9). NewGET /api/reputationreturns one entry per enabledReputationFaction, defaulting an interaction-less faction to{ reputation: 0, rank: 'STRANGER' }(R10). ItemDefinition.requiredLevelis deleted, not merely unused (R4); equipping an owned item is never level-gated again.ItemTypebecomes exactlyEQUIPMENT | TRADE_GOOD | TROPHY | QUEST_ITEM | CONSUMABLE(R3).MonsterDefinition.experienceRewardis deleted from the schema and every seed row;silverMin/silverMaxstay in the schema (a legitimate future "lore-valid direct drop" mechanism, R7) but are set to0for every currently-seeded monster.- Migration test convention in this codebase (confirmed from
loot-and-rewards.migration.spec.ts): mock aQueryRunner, invokemigration.up(queryRunner)/migration.down(queryRunner)directly, and assert on the captured raw SQL strings — never a real Postgres connection. Task 2 follows this pattern (it is more rigorous than the metadata-only style used for smaller migrations, and appropriate given this migration drops columns and rebuilds an enum type). - Entity-only changes (no new SQL) are verified via
getMetadataArgsStorage()metadata assertions, matching Slice 0.6's precedent.
Task 1: Renown power curve & reputation rank resolver
Files:
- Create:
apps/api/src/renown/renown-base-stats.ts - Create:
apps/api/src/renown/renown-base-stats.spec.ts - Create:
apps/api/src/reputation/reputation-rank.ts - Create:
apps/api/src/reputation/reputation-rank.spec.ts
Interfaces:
- Produces:
RENOWN_BASE_STATS: Record<number, { baseHp: number; baseAttack: number }>(keys 1–15) andRENOWN_MAX = 15,RENOWN_MIN = 1.resolveReputationRank(reputation: number): { key: string; label: string; threshold: number; nextThreshold: number | null }. Task 4 (RenownService) consumesRENOWN_BASE_STATS/RENOWN_MIN/RENOWN_MAX. Task 5 (ReputationService) consumesresolveReputationRank.
These are pure functions with no DB dependency, following the exact pattern of the existing apps/api/src/hunting/danger-rating.ts.
- Step 1: Write the failing tests for the Renown base-stat table
Create apps/api/src/renown/renown-base-stats.spec.ts:
import { RENOWN_BASE_STATS, RENOWN_MAX, RENOWN_MIN } from './renown-base-stats';
describe('RENOWN_BASE_STATS', () => {
it('spans exactly Renown 1 through 15', () => {
expect(RENOWN_MIN).toBe(1);
expect(RENOWN_MAX).toBe(15);
expect(Object.keys(RENOWN_BASE_STATS).map(Number).sort((a, b) => a - b)).toEqual(
Array.from({ length: 15 }, (_, i) => i + 1),
);
});
it('matches the exact V1 reference table from spec §4', () => {
expect(RENOWN_BASE_STATS[1]).toEqual({ baseHp: 100, baseAttack: 6 });
expect(RENOWN_BASE_STATS[5]).toEqual({ baseHp: 114, baseAttack: 8 });
expect(RENOWN_BASE_STATS[10]).toEqual({ baseHp: 132, baseAttack: 10 });
expect(RENOWN_BASE_STATS[15]).toEqual({ baseHp: 148, baseAttack: 12 });
});
it('is monotonically non-decreasing in both stats as Renown increases', () => {
for (let renown = 2; renown <= 15; renown += 1) {
expect(RENOWN_BASE_STATS[renown].baseHp).toBeGreaterThanOrEqual(
RENOWN_BASE_STATS[renown - 1].baseHp,
);
expect(RENOWN_BASE_STATS[renown].baseAttack).toBeGreaterThanOrEqual(
RENOWN_BASE_STATS[renown - 1].baseAttack,
);
}
});
});
- Step 2: Run to verify it fails
Run: npm run test --workspace=@ashen-realms/api -- renown-base-stats.spec.ts
Expected: FAIL — module ./renown-base-stats does not exist.
- Step 3: Implement the table
Create apps/api/src/renown/renown-base-stats.ts:
export const RENOWN_MIN = 1;
export const RENOWN_MAX = 15;
// Verbatim from Playable Slice 0.6.5 spec §4. Distributes the old Level 1-7
// total base-stat progression (100 HP / 6 Attack -> 148 HP / 12 Attack)
// across Renown 1-15. RenownService looks this up on every milestone
// completion; it is never incremented, only reassigned by rank (design R2).
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 },
};
- Step 4: Run to verify it passes
Run: npm run test --workspace=@ashen-realms/api -- renown-base-stats.spec.ts
Expected: PASS (3 tests)
- Step 5: Write the failing tests for the reputation rank resolver
Create apps/api/src/reputation/reputation-rank.spec.ts:
import { resolveReputationRank } from './reputation-rank';
describe('resolveReputationRank', () => {
it('resolves 0 reputation to Stranger with a next threshold of 100', () => {
expect(resolveReputationRank(0)).toEqual({
key: 'STRANGER',
label: 'Fremder',
threshold: 0,
nextThreshold: 100,
});
});
it('resolves exactly at a threshold to that rank, not the one below', () => {
expect(resolveReputationRank(100).key).toBe('TOLERATED');
expect(resolveReputationRank(99).key).toBe('STRANGER');
});
it('resolves every documented threshold to its exact rank', () => {
expect(resolveReputationRank(250).key).toBe('KNOWN');
expect(resolveReputationRank(500).key).toBe('RECOGNIZED');
expect(resolveReputationRank(800).key).toBe('TRUSTED');
expect(resolveReputationRank(1200).key).toBe('ESTEEMED');
});
it('has no next threshold once at the top rank', () => {
expect(resolveReputationRank(1200).nextThreshold).toBeNull();
expect(resolveReputationRank(50_000).nextThreshold).toBeNull();
});
it('reports the correct nextThreshold mid-range', () => {
expect(resolveReputationRank(300).nextThreshold).toBe(500);
});
});
- Step 6: Run to verify it fails
Run: npm run test --workspace=@ashen-realms/api -- reputation-rank.spec.ts
Expected: FAIL — module ./reputation-rank does not exist.
- Step 7: Implement the resolver
Create apps/api/src/reputation/reputation-rank.ts:
export interface ReputationRankInfo {
key: string;
label: string;
threshold: number;
nextThreshold: number | null;
}
// Verbatim thresholds from spec §10, German labels for the German-language UI.
// Sorted descending: the first entry whose threshold <= reputation wins.
const REPUTATION_RANKS: ReadonlyArray<{ threshold: number; key: string; label: string }> = [
{ 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' },
];
export function resolveReputationRank(reputation: number): ReputationRankInfo {
const index = REPUTATION_RANKS.findIndex((rank) => reputation >= rank.threshold);
const rank = REPUTATION_RANKS[index];
const nextRank = index > 0 ? REPUTATION_RANKS[index - 1] : null;
return {
key: rank.key,
label: rank.label,
threshold: rank.threshold,
nextThreshold: nextRank ? nextRank.threshold : null,
};
}
- Step 8: Run to verify it passes
Run: npm run test --workspace=@ashen-realms/api -- reputation-rank.spec.ts
Expected: PASS (5 tests)
- Step 9: Commit
git add apps/api/src/renown/renown-base-stats.ts apps/api/src/renown/renown-base-stats.spec.ts apps/api/src/reputation/reputation-rank.ts apps/api/src/reputation/reputation-rank.spec.ts
git commit -m "feat(renown): add the Renown power-curve table and reputation-rank resolver"
Task 2: Migration — Character/ItemDefinition schema changes and five new tables
Files:
- Create:
apps/api/src/database/migrations/1791000000000-CreateRenownAndReputation.ts - Create:
apps/api/src/database/migrations/renown-and-reputation.migration.spec.ts
Interfaces:
-
Consumes: nothing (pure SQL).
-
Produces: the
renowncolumn oncharacters(replacinglevel/experience), the rebuiltitem_type_enum, the droppedrequired_levelcolumn onitem_definitions, the droppedexperience_grantedcolumn oncombat_rewards, and five new tables (reputation_factions,character_reputation,renown_milestone_definitions,character_renown_milestones,turn_in_definitions). Task 3 (entities) must declare columns matching these exact names. -
Step 1: Write the failing migration spec
Create apps/api/src/database/migrations/renown-and-reputation.migration.spec.ts:
import { QueryRunner } from 'typeorm';
import { CreateRenownAndReputation1791000000000 } from './1791000000000-CreateRenownAndReputation';
describe('CreateRenownAndReputation1791000000000', () => {
async function runUp() {
const query = jest.fn().mockResolvedValue(undefined);
const queryRunner = { query } as unknown as QueryRunner;
const migration = new CreateRenownAndReputation1791000000000();
await migration.up(queryRunner);
return query.mock.calls.map(([sql]) => sql as string);
}
async function runUpThenDown() {
const query = jest.fn().mockResolvedValue(undefined);
const queryRunner = { query } as unknown as QueryRunner;
const migration = new CreateRenownAndReputation1791000000000();
await migration.up(queryRunner);
const upCount = query.mock.calls.length;
await migration.down(queryRunner);
return query.mock.calls.slice(upCount).map(([sql]) => sql as string);
}
it('replaces level/experience with a renown column on characters', async () => {
const up = await runUp();
expect(up).toEqual(
expect.arrayContaining([
expect.stringContaining('ALTER TABLE "characters" ADD COLUMN "renown"'),
expect.stringContaining('DROP COLUMN "level"'),
expect.stringContaining('DROP COLUMN "experience"'),
]),
);
});
it('clamps existing level into the 1-15 Renown range before dropping it', async () => {
const up = await runUp();
expect(up).toEqual(
expect.arrayContaining([expect.stringContaining('LEAST(GREATEST("level", 1), 15)')]),
);
});
it('enforces the renown range at the database level', async () => {
const up = await runUp();
expect(up).toEqual(
expect.arrayContaining([
expect.stringContaining('CHK_characters_renown'),
]),
);
});
it('drops required_level from item_definitions', async () => {
const up = await runUp();
expect(up).toEqual(
expect.arrayContaining([
expect.stringContaining('ALTER TABLE "item_definitions" DROP COLUMN "required_level"'),
]),
);
});
it('rebuilds item_type_enum with exactly the five Slice 0.6.5 values, migrating existing rows', async () => {
const up = await runUp();
expect(up).toEqual(
expect.arrayContaining([
expect.stringContaining(`SET "type" = 'EQUIPMENT' WHERE "type" IN ('WEAPON', 'ARMOR')`),
expect.stringContaining(`SET "type" = 'TRADE_GOOD' WHERE "type" = 'MATERIAL'`),
expect.stringContaining('DROP TYPE "item_type_enum"'),
expect.stringContaining(
`CREATE TYPE "item_type_enum" AS ENUM ('EQUIPMENT', 'TRADE_GOOD', 'TROPHY', 'QUEST_ITEM', 'CONSUMABLE')`,
),
]),
);
});
it('drops experience_granted from combat_rewards', async () => {
const up = await runUp();
expect(up).toEqual(
expect.arrayContaining([
expect.stringContaining('ALTER TABLE "combat_rewards" DROP COLUMN "experience_granted"'),
]),
);
});
it('creates all five new tables with their unique constraints', async () => {
const up = await runUp();
expect(up).toEqual(
expect.arrayContaining([
expect.stringContaining('CREATE TABLE "reputation_factions"'),
expect.stringContaining('CREATE UNIQUE INDEX "IDX_reputation_factions_key"'),
expect.stringContaining('CREATE TABLE "character_reputation"'),
expect.stringContaining(
'CREATE UNIQUE INDEX "IDX_character_reputation_character_faction" ON "character_reputation" ("character_id", "faction_id")',
),
expect.stringContaining('CREATE TABLE "renown_milestone_definitions"'),
expect.stringContaining('CREATE UNIQUE INDEX "IDX_renown_milestone_definitions_key"'),
expect.stringContaining('CREATE TABLE "character_renown_milestones"'),
expect.stringContaining(
'CREATE UNIQUE INDEX "IDX_character_renown_milestones_character_milestone" ON "character_renown_milestones" ("character_id", "milestone_id")',
),
expect.stringContaining('CREATE TABLE "turn_in_definitions"'),
expect.stringContaining('CREATE UNIQUE INDEX "IDX_turn_in_definitions_key"'),
]),
);
});
it('down() reverses every up() step in exact opposite order, dropping the five new tables first', async () => {
const down = await runUpThenDown();
expect(down).toEqual(
expect.arrayContaining([
expect.stringContaining('DROP TABLE "turn_in_definitions"'),
expect.stringContaining('DROP TABLE "character_renown_milestones"'),
expect.stringContaining('DROP TABLE "renown_milestone_definitions"'),
expect.stringContaining('DROP TABLE "character_reputation"'),
expect.stringContaining('DROP TABLE "reputation_factions"'),
expect.stringContaining('ALTER TABLE "combat_rewards" ADD COLUMN "experience_granted"'),
expect.stringContaining('ALTER TABLE "item_definitions" ADD COLUMN "required_level"'),
expect.stringContaining('ALTER TABLE "characters" ADD COLUMN "level"'),
expect.stringContaining('ALTER TABLE "characters" ADD COLUMN "experience"'),
expect.stringContaining('ALTER TABLE "characters" DROP COLUMN "renown"'),
]),
);
// The dropped turn_in_definitions table must be the very first statement
// in down() -- it's the last thing up() created, and it has FK
// dependencies on reputation_factions/item_definitions that must be
// gone before those tables can be touched.
expect(down[0]).toContain('DROP TABLE "turn_in_definitions"');
});
});
- Step 2: Run to verify it fails
Run: npm run test --workspace=@ashen-realms/api -- renown-and-reputation.migration.spec.ts
Expected: FAIL — module ./1791000000000-CreateRenownAndReputation does not exist.
- Step 3: Write the migration
Create apps/api/src/database/migrations/1791000000000-CreateRenownAndReputation.ts:
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateRenownAndReputation1791000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// --- Character: level/experience -> renown (spec §13, design R1/R6) ---
await queryRunner.query(
'ALTER TABLE "characters" ADD COLUMN "renown" integer NOT NULL DEFAULT 1',
);
await queryRunner.query(
'UPDATE "characters" SET "renown" = LEAST(GREATEST("level", 1), 15)',
);
await queryRunner.query(
'ALTER TABLE "characters" ADD CONSTRAINT "CHK_characters_renown" CHECK ("renown" >= 1 AND "renown" <= 15)',
);
await queryRunner.query('ALTER TABLE "characters" DROP COLUMN "level"');
await queryRunner.query('ALTER TABLE "characters" DROP COLUMN "experience"');
// --- ItemDefinition: drop requiredLevel (spec §14, design R4) ---
await queryRunner.query(
'ALTER TABLE "item_definitions" DROP COLUMN "required_level"',
);
// --- ItemType enum rebuild (spec §16, design R3): WEAPON/ARMOR -> ---
// EQUIPMENT, MATERIAL -> TRADE_GOOD, add TROPHY/QUEST_ITEM. Postgres has
// no ALTER TYPE ... RENAME VALUE across all supported versions here, so
// this converts the column to text, migrates the data, rebuilds the
// type, and converts back -- the same technique already used by
// 1790000000000-ExtendCombatEventTypes.ts's down().
await queryRunner.query(
'ALTER TABLE "item_definitions" ALTER COLUMN "type" TYPE varchar USING "type"::text',
);
await queryRunner.query(
`UPDATE "item_definitions" SET "type" = 'EQUIPMENT' WHERE "type" IN ('WEAPON', 'ARMOR')`,
);
await queryRunner.query(
`UPDATE "item_definitions" SET "type" = 'TRADE_GOOD' WHERE "type" = 'MATERIAL'`,
);
await queryRunner.query('DROP TYPE "item_type_enum"');
await queryRunner.query(
`CREATE TYPE "item_type_enum" AS ENUM ('EQUIPMENT', 'TRADE_GOOD', 'TROPHY', 'QUEST_ITEM', 'CONSUMABLE')`,
);
await queryRunner.query(
'ALTER TABLE "item_definitions" ALTER COLUMN "type" TYPE "item_type_enum" USING "type"::"item_type_enum"',
);
// --- CombatReward: drop the XP audit column (spec §1, design R8) ---
await queryRunner.query(
'ALTER TABLE "combat_rewards" DROP COLUMN "experience_granted"',
);
// --- ReputationFaction (spec §9) ---
await queryRunner.query(`CREATE TABLE "reputation_factions" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"key" character varying(100) NOT NULL,
"name" character varying(150) NOT NULL,
"description" text NOT NULL,
"region_key" character varying(100) NOT NULL,
"enabled" boolean NOT NULL DEFAULT true,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_reputation_factions" PRIMARY KEY ("id")
)`);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_reputation_factions_key" ON "reputation_factions" ("key")',
);
// --- CharacterReputation (spec §9) ---
await queryRunner.query(`CREATE TABLE "character_reputation" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"character_id" uuid NOT NULL,
"faction_id" uuid NOT NULL,
"reputation" integer NOT NULL DEFAULT 0,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_character_reputation" PRIMARY KEY ("id"),
CONSTRAINT "FK_character_reputation_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
CONSTRAINT "FK_character_reputation_faction" FOREIGN KEY ("faction_id") REFERENCES "reputation_factions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
)`);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_character_reputation_character_faction" ON "character_reputation" ("character_id", "faction_id")',
);
// --- RenownMilestoneDefinition (spec §5) ---
await queryRunner.query(`CREATE TABLE "renown_milestone_definitions" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"key" character varying(100) NOT NULL,
"name" character varying(150) NOT NULL,
"description" text NOT NULL,
"renown_reward" integer NOT NULL,
"repeatable" boolean NOT NULL DEFAULT false,
"enabled" boolean NOT NULL DEFAULT true,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_renown_milestone_definitions" PRIMARY KEY ("id")
)`);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_renown_milestone_definitions_key" ON "renown_milestone_definitions" ("key")',
);
// --- CharacterRenownMilestone (spec §5) ---
await queryRunner.query(`CREATE TABLE "character_renown_milestones" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"character_id" uuid NOT NULL,
"milestone_id" uuid NOT NULL,
"completed_at" TIMESTAMP WITH TIME ZONE NOT NULL,
"times_completed" integer NOT NULL DEFAULT 1,
CONSTRAINT "PK_character_renown_milestones" PRIMARY KEY ("id"),
CONSTRAINT "FK_character_renown_milestones_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
CONSTRAINT "FK_character_renown_milestones_milestone" FOREIGN KEY ("milestone_id") REFERENCES "renown_milestone_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
)`);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_character_renown_milestones_character_milestone" ON "character_renown_milestones" ("character_id", "milestone_id")',
);
// --- TurnInDefinition (spec §19) ---
await queryRunner.query(`CREATE TABLE "turn_in_definitions" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"key" character varying(100) NOT NULL,
"item_definition_id" uuid NOT NULL,
"faction_id" uuid NOT NULL,
"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" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_turn_in_definitions" PRIMARY KEY ("id"),
CONSTRAINT "FK_turn_in_definitions_item_definition" FOREIGN KEY ("item_definition_id") REFERENCES "item_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION,
CONSTRAINT "FK_turn_in_definitions_faction" FOREIGN KEY ("faction_id") REFERENCES "reputation_factions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
)`);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_turn_in_definitions_key" ON "turn_in_definitions" ("key")',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TABLE "turn_in_definitions"');
await queryRunner.query(
'DROP INDEX "IDX_character_renown_milestones_character_milestone"',
);
await queryRunner.query('DROP TABLE "character_renown_milestones"');
await queryRunner.query('DROP INDEX "IDX_renown_milestone_definitions_key"');
await queryRunner.query('DROP TABLE "renown_milestone_definitions"');
await queryRunner.query('DROP INDEX "IDX_character_reputation_character_faction"');
await queryRunner.query('DROP TABLE "character_reputation"');
await queryRunner.query('DROP INDEX "IDX_reputation_factions_key"');
await queryRunner.query('DROP TABLE "reputation_factions"');
await queryRunner.query(
'ALTER TABLE "combat_rewards" ADD COLUMN "experience_granted" integer NOT NULL DEFAULT 0',
);
await queryRunner.query(
'ALTER TABLE "item_definitions" ALTER COLUMN "type" TYPE varchar USING "type"::text',
);
await queryRunner.query('DROP TYPE "item_type_enum"');
await queryRunner.query(
"CREATE TYPE \"item_type_enum\" AS ENUM ('WEAPON', 'ARMOR', 'MATERIAL', 'CONSUMABLE')",
);
await queryRunner.query(
`UPDATE "item_definitions" SET "type" = 'MATERIAL' WHERE "type" = 'TRADE_GOOD'`,
);
// TROPHY/QUEST_ITEM/EQUIPMENT have no clean pre-image; best-effort revert
// for genuinely disposable dev data (design R5).
await queryRunner.query(
`UPDATE "item_definitions" SET "type" = 'WEAPON' WHERE "type" IN ('EQUIPMENT', 'TROPHY', 'QUEST_ITEM')`,
);
await queryRunner.query(
'ALTER TABLE "item_definitions" ALTER COLUMN "type" TYPE "item_type_enum" USING "type"::"item_type_enum"',
);
await queryRunner.query(
'ALTER TABLE "item_definitions" ADD COLUMN "required_level" integer NOT NULL DEFAULT 1',
);
await queryRunner.query('ALTER TABLE "characters" ADD COLUMN "level" integer NOT NULL DEFAULT 1');
await queryRunner.query(
'ALTER TABLE "characters" ADD COLUMN "experience" integer NOT NULL DEFAULT 0',
);
await queryRunner.query('UPDATE "characters" SET "level" = "renown"');
await queryRunner.query('ALTER TABLE "characters" DROP CONSTRAINT "CHK_characters_renown"');
await queryRunner.query('ALTER TABLE "characters" DROP COLUMN "renown"');
}
}
- Step 4: Run to verify all migration tests pass
Run: npm run test --workspace=@ashen-realms/api -- renown-and-reputation.migration.spec.ts
Expected: PASS (8 tests)
- Step 5: Run the full API suite to confirm nothing else broke
Run: npm run test --workspace=@ashen-realms/api
Expected: This WILL currently fail elsewhere (entities in Task 3 haven't changed yet, so nothing references this migration's new tables/columns) — a clean migration file with no consumers cannot break other tests. Confirm the failure count matches the pre-existing baseline (0 new failures attributable to this migration file).
- Step 6: Commit
git add apps/api/src/database/migrations/1791000000000-CreateRenownAndReputation.ts apps/api/src/database/migrations/renown-and-reputation.migration.spec.ts
git commit -m "feat(renown): migrate Character/ItemDefinition schema and add Reputation/Renown/TurnIn tables"
Task 3: Entities — new tables, Character/ItemDefinition/CombatReward edits, ItemType enum
Files:
- Create:
apps/api/src/reputation/entities/reputation-faction.entity.ts - Create:
apps/api/src/reputation/entities/character-reputation.entity.ts - Create:
apps/api/src/renown/entities/renown-milestone-definition.entity.ts - Create:
apps/api/src/renown/entities/character-renown-milestone.entity.ts - Create:
apps/api/src/turn-in/entities/turn-in-definition.entity.ts - Modify:
apps/api/src/characters/entities/character.entity.ts - Modify:
apps/api/src/items/entities/item-definition.entity.ts - Modify:
apps/api/src/items/item-type.enum.ts - Modify:
apps/api/src/rewards/entities/combat-reward.entity.ts - Create:
apps/api/src/database/migrations/renown-and-reputation-entities.metadata.spec.ts
Interfaces:
-
Consumes: table/column names from Task 2's migration (must match exactly).
-
Produces:
Character.renown: number(replacinglevel/experience).ItemDefinitionwithoutrequiredLevel.ItemType = 'EQUIPMENT' | 'TRADE_GOOD' | 'TROPHY' | 'QUEST_ITEM' | 'CONSUMABLE'.CombatRewardwithoutexperienceGranted.ReputationFaction,CharacterReputation,RenownMilestoneDefinition,CharacterRenownMilestone,TurnInDefinitionentity classes. Tasks 4/5/6 (services) import these entities directly. -
Step 1: Write the failing metadata spec
Create apps/api/src/database/migrations/renown-and-reputation-entities.metadata.spec.ts:
import 'reflect-metadata';
import { getMetadataArgsStorage } from 'typeorm';
import { Character } from '../../characters/entities/character.entity';
import { ItemDefinition } from '../../items/entities/item-definition.entity';
import { ItemType } from '../../items/item-type.enum';
import { CombatReward } from '../../rewards/entities/combat-reward.entity';
import { ReputationFaction } from '../../reputation/entities/reputation-faction.entity';
import { CharacterReputation } from '../../reputation/entities/character-reputation.entity';
import { RenownMilestoneDefinition } from '../../renown/entities/renown-milestone-definition.entity';
import { CharacterRenownMilestone } from '../../renown/entities/character-renown-milestone.entity';
import { TurnInDefinition } from '../../turn-in/entities/turn-in-definition.entity';
function columnNames(target: unknown): string[] {
return getMetadataArgsStorage()
.columns.filter((column) => column.target === target)
.map((column) => column.propertyName);
}
function uniqueIndexFor(target: unknown, columns: string[]): boolean {
const index = getMetadataArgsStorage().indices.find(
(candidate) =>
candidate.target === target && columns.every((column) => candidate.columns?.includes(column)),
);
const meta = index as typeof index & { options?: { unique?: boolean }; unique?: boolean };
return (meta?.options?.unique ?? meta?.unique) === true;
}
describe('Slice 0.6.5 entity metadata', () => {
it('Character exposes renown and no longer exposes level/experience', () => {
const names = columnNames(Character);
expect(names).toContain('renown');
expect(names).not.toContain('level');
expect(names).not.toContain('experience');
});
it('ItemDefinition no longer exposes requiredLevel', () => {
expect(columnNames(ItemDefinition)).not.toContain('requiredLevel');
});
it('ItemType has exactly the five Slice 0.6.5 values', () => {
expect(Object.values(ItemType).sort()).toEqual(
['CONSUMABLE', 'EQUIPMENT', 'QUEST_ITEM', 'TRADE_GOOD', 'TROPHY'].sort(),
);
});
it('CombatReward no longer exposes experienceGranted', () => {
expect(columnNames(CombatReward)).not.toContain('experienceGranted');
});
it('ReputationFaction has a unique key', () => {
expect(uniqueIndexFor(ReputationFaction, ['key'])).toBe(true);
});
it('CharacterReputation enforces one row per character per faction', () => {
expect(uniqueIndexFor(CharacterReputation, ['characterId', 'factionId'])).toBe(true);
});
it('RenownMilestoneDefinition has a unique key', () => {
expect(uniqueIndexFor(RenownMilestoneDefinition, ['key'])).toBe(true);
});
it('CharacterRenownMilestone enforces one row per character per milestone', () => {
expect(
uniqueIndexFor(CharacterRenownMilestone, ['characterId', 'milestoneId']),
).toBe(true);
});
it('TurnInDefinition has a unique key', () => {
expect(uniqueIndexFor(TurnInDefinition, ['key'])).toBe(true);
});
});
- Step 2: Run to verify it fails
Run: npm run test --workspace=@ashen-realms/api -- renown-and-reputation-entities.metadata.spec.ts
Expected: FAIL — none of the new entity modules exist yet.
- Step 3: Create the five new entities
Create apps/api/src/reputation/entities/reputation-faction.entity.ts:
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity({ name: 'reputation_factions' })
@Index('IDX_reputation_factions_key', ['key'], { unique: true })
export class ReputationFaction {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'key', type: 'varchar', length: 100 })
key!: string;
@Column({ name: 'name', type: 'varchar', length: 150 })
name!: string;
@Column({ name: 'description', type: 'text' })
description!: string;
@Column({ name: 'region_key', type: 'varchar', length: 100 })
regionKey!: string;
@Column({ name: 'enabled', type: 'boolean' })
enabled!: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
}
Create apps/api/src/reputation/entities/character-reputation.entity.ts:
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Character } from '../../characters/entities/character.entity';
import { ReputationFaction } from './reputation-faction.entity';
@Entity({ name: 'character_reputation' })
@Index('IDX_character_reputation_character_faction', ['characterId', 'factionId'], {
unique: true,
})
export class CharacterReputation {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'character_id', type: 'uuid' })
characterId!: string;
@Column({ name: 'faction_id', type: 'uuid' })
factionId!: string;
@Column({ name: 'reputation', type: 'integer' })
reputation!: number;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@ManyToOne(() => Character, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'character_id' })
character!: Character;
@ManyToOne(() => ReputationFaction, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'faction_id' })
faction!: ReputationFaction;
}
Create apps/api/src/renown/entities/renown-milestone-definition.entity.ts:
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity({ name: 'renown_milestone_definitions' })
@Index('IDX_renown_milestone_definitions_key', ['key'], { unique: true })
export class RenownMilestoneDefinition {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'key', type: 'varchar', length: 100 })
key!: string;
@Column({ name: 'name', type: 'varchar', length: 150 })
name!: string;
@Column({ name: 'description', type: 'text' })
description!: string;
@Column({ name: 'renown_reward', type: 'integer' })
renownReward!: number;
@Column({ name: 'repeatable', type: 'boolean' })
repeatable!: boolean;
@Column({ name: 'enabled', type: 'boolean' })
enabled!: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
}
Create apps/api/src/renown/entities/character-renown-milestone.entity.ts:
import {
Column,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { Character } from '../../characters/entities/character.entity';
import { RenownMilestoneDefinition } from './renown-milestone-definition.entity';
@Entity({ name: 'character_renown_milestones' })
@Index('IDX_character_renown_milestones_character_milestone', ['characterId', 'milestoneId'], {
unique: true,
})
export class CharacterRenownMilestone {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'character_id', type: 'uuid' })
characterId!: string;
@Column({ name: 'milestone_id', type: 'uuid' })
milestoneId!: string;
@Column({ name: 'completed_at', type: 'timestamptz' })
completedAt!: Date;
@Column({ name: 'times_completed', type: 'integer' })
timesCompleted!: number;
@ManyToOne(() => Character, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'character_id' })
character!: Character;
@ManyToOne(() => RenownMilestoneDefinition, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'milestone_id' })
milestone!: RenownMilestoneDefinition;
}
Create apps/api/src/turn-in/entities/turn-in-definition.entity.ts:
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { ItemDefinition } from '../../items/entities/item-definition.entity';
import { ReputationFaction } from '../../reputation/entities/reputation-faction.entity';
@Entity({ name: 'turn_in_definitions' })
@Index('IDX_turn_in_definitions_key', ['key'], { unique: true })
export class TurnInDefinition {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'key', type: 'varchar', length: 100 })
key!: string;
@Column({ name: 'item_definition_id', type: 'uuid' })
itemDefinitionId!: string;
@Column({ name: 'faction_id', type: 'uuid' })
factionId!: string;
@Column({ name: 'silver_reward_per_item', type: 'integer' })
silverRewardPerItem!: number;
@Column({ name: 'reputation_reward_per_item', type: 'integer' })
reputationRewardPerItem!: number;
@Column({ name: 'repeatable', type: 'boolean' })
repeatable!: boolean;
@Column({ name: 'enabled', type: 'boolean' })
enabled!: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@ManyToOne(() => ItemDefinition, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'item_definition_id' })
itemDefinition!: ItemDefinition;
@ManyToOne(() => ReputationFaction, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'faction_id' })
faction!: ReputationFaction;
}
- Step 4: Modify the Character entity
In apps/api/src/characters/entities/character.entity.ts, replace:
@Column({ name: 'level', type: 'integer' })
level!: number;
@Column({ name: 'experience', type: 'integer' })
experience!: number;
with:
@Column({ name: 'renown', type: 'integer' })
renown!: number;
- Step 5: Modify the ItemDefinition entity
In apps/api/src/items/entities/item-definition.entity.ts, delete this block entirely:
@Column({ name: 'required_level', type: 'integer' })
requiredLevel!: number;
- Step 6: Modify the ItemType enum
Replace the full contents of apps/api/src/items/item-type.enum.ts:
export enum ItemType {
EQUIPMENT = 'EQUIPMENT',
TRADE_GOOD = 'TRADE_GOOD',
TROPHY = 'TROPHY',
QUEST_ITEM = 'QUEST_ITEM',
CONSUMABLE = 'CONSUMABLE',
}
- Step 7: Modify the CombatReward entity
In apps/api/src/rewards/entities/combat-reward.entity.ts, delete this block entirely:
@Column({ name: 'experience_granted', type: 'integer' })
experienceGranted!: number;
- Step 8: Run to verify the metadata spec passes
Run: npm run test --workspace=@ashen-realms/api -- renown-and-reputation-entities.metadata.spec.ts
Expected: PASS (9 tests)
- Step 9: Run the full API suite
Run: npm run test --workspace=@ashen-realms/api
Expected: FAIL — many existing files reference Character.level/.experience, ItemDefinition.requiredLevel, ItemType.WEAPON/.ARMOR/.MATERIAL, and CombatReward.experienceGranted/CombatRewardDto.experience. This is expected: Tasks 6–10 fix every one of these call sites. Confirm the failures are all TS2xxx/property-mismatch errors in files this plan's later tasks touch (equipment.service.ts, combat-reward.service.ts, characters.service.ts, seed files, and their specs) — not in files unrelated to this slice.
- Step 10: Commit
git add apps/api/src/reputation/entities/reputation-faction.entity.ts apps/api/src/reputation/entities/character-reputation.entity.ts apps/api/src/renown/entities/renown-milestone-definition.entity.ts apps/api/src/renown/entities/character-renown-milestone.entity.ts apps/api/src/turn-in/entities/turn-in-definition.entity.ts apps/api/src/characters/entities/character.entity.ts apps/api/src/items/entities/item-definition.entity.ts apps/api/src/items/item-type.enum.ts apps/api/src/rewards/entities/combat-reward.entity.ts apps/api/src/database/migrations/renown-and-reputation-entities.metadata.spec.ts
git commit -m "feat(renown): add Reputation/Renown/TurnIn entities and update Character/ItemDefinition/CombatReward"
Task 4: RenownService
Files:
- Create:
apps/api/src/renown/renown.service.ts - Create:
apps/api/src/renown/renown.service.spec.ts - Create:
apps/api/src/renown/renown.errors.ts - Create:
apps/api/src/renown/renown.module.ts - Modify:
apps/api/src/app.module.ts
Interfaces:
-
Consumes:
RENOWN_BASE_STATS,RENOWN_MAX(Task 1).Character,RenownMilestoneDefinition,CharacterRenownMilestoneentities (Task 3). -
Produces:
RenownService.completeMilestone(characterId, milestoneKey, manager?): Promise<RenownMilestoneResult>. Consumed only by tests in this task for now (spec §5's "system exists" requirement — content wiring is out of scope per design R13); Task 6 (TurnInService) does NOT call this (design R12 — no milestone linkage on turn-ins in V1). -
Step 1: Write the failing errors file test indirectly via the service spec (errors have no dedicated spec in this codebase's convention — see
equipment.errors.ts, untested standalone)
No separate error-file test; errors are exercised through the service spec below, matching the EquipmentDomainError/CombatDomainError convention (never unit-tested in isolation in this codebase).
- Step 2: Write the failing service spec
Create apps/api/src/renown/renown.service.spec.ts:
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import { CharacterRenownMilestone } from './entities/character-renown-milestone.entity';
import { RenownMilestoneDefinition } from './entities/renown-milestone-definition.entity';
import { RenownDomainError } from './renown.errors';
import { RenownService } from './renown.service';
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
const MILESTONE_ID = '90000000-0000-4000-8000-000000000001';
interface State {
characters: Character[];
milestones: RenownMilestoneDefinition[];
characterMilestones: CharacterRenownMilestone[];
}
class FakeRepository<T extends { id: string }> {
constructor(
private readonly rows: T[],
private readonly prefix: string,
private readonly inTransaction: boolean,
) {}
findOne(options: { where: Partial<T>; lock?: { mode: string } }): Promise<T | null> {
if (options.lock && !this.inTransaction) {
throw new Error('Pessimistic locks require a transaction');
}
return Promise.resolve(this.rows.find((row) => this.matches(row, options.where)) ?? null);
}
findOneBy(where: Partial<T>): Promise<T | null> {
return Promise.resolve(this.rows.find((row) => this.matches(row, where)) ?? null);
}
create(values: Partial<T>): T {
return { ...values } as T;
}
save(entity: T): Promise<T> {
if (!entity.id) {
entity.id = `${this.prefix}-${this.rows.length + 1}`;
}
const index = this.rows.findIndex((row) => row.id === entity.id);
if (index === -1) {
this.rows.push(entity);
} else {
this.rows[index] = entity;
}
return Promise.resolve(entity);
}
private matches(row: T, where: Partial<T>): boolean {
return Object.entries(where).every(([key, value]) => row[key as keyof T] === value);
}
}
class FakeDataSource {
constructor(public state: State) {}
getRepository<T extends { id: string }>(target: EntityTarget<T>) {
return this.repoFor(target, false);
}
async transaction<T>(work: (manager: EntityManager) => Promise<T>): Promise<T> {
return work({
getRepository: <U extends { id: string }>(target: EntityTarget<U>) => this.repoFor(target, true),
} as unknown as EntityManager);
}
private repoFor<T extends { id: string }>(target: EntityTarget<T>, inTransaction: boolean) {
if (target === Character) return new FakeRepository(this.state.characters, 'character', inTransaction) as never;
if (target === RenownMilestoneDefinition)
return new FakeRepository(this.state.milestones, 'milestone', inTransaction) as never;
if (target === CharacterRenownMilestone)
return new FakeRepository(this.state.characterMilestones, 'char-milestone', inTransaction) as never;
throw new Error('Unsupported repository');
}
}
function character(overrides: Partial<Character> = {}): Character {
return { id: CHARACTER_ID, renown: 1, baseHp: 100, baseAttack: 6, ...overrides } as Character;
}
function milestone(overrides: Partial<RenownMilestoneDefinition> = {}): RenownMilestoneDefinition {
return {
id: MILESTONE_ID,
key: 'first-hunt',
name: 'Erste erfolgreiche Jagd',
description: '',
renownReward: 1,
repeatable: false,
enabled: true,
...overrides,
} as RenownMilestoneDefinition;
}
function createState(overrides: Partial<State> = {}): State {
return {
characters: [character()],
milestones: [milestone()],
characterMilestones: [],
...overrides,
};
}
function createService(state: State) {
const dataSource = new FakeDataSource(state);
return { service: new RenownService(dataSource as unknown as DataSource), state };
}
async function expectRenownDomainError(promise: Promise<unknown>, code: string): Promise<void> {
let error: unknown;
try {
await promise;
} catch (cause) {
error = cause;
}
expect(error).toBeInstanceOf(RenownDomainError);
if (!(error instanceof RenownDomainError)) {
throw new Error('Expected RenownDomainError');
}
expect(error.code).toBe(code);
}
describe('RenownService', () => {
describe('completeMilestone', () => {
it('grants renown and recomputes base stats from the power-curve table', async () => {
const { service, state } = createService(createState());
const result = await service.completeMilestone(CHARACTER_ID, 'first-hunt');
expect(result).toEqual({
milestoneKey: 'first-hunt',
previousRenown: 1,
newRenown: 2,
renownGranted: true,
});
expect(state.characters[0].renown).toBe(2);
expect(state.characters[0].baseHp).toBe(104);
expect(state.characters[0].baseAttack).toBe(6);
});
it('records the completion with a timestamp and timesCompleted = 1', async () => {
const { service, state } = createService(createState());
await service.completeMilestone(CHARACTER_ID, 'first-hunt');
expect(state.characterMilestones).toHaveLength(1);
expect(state.characterMilestones[0]).toMatchObject({
characterId: CHARACTER_ID,
milestoneId: MILESTONE_ID,
timesCompleted: 1,
});
expect(state.characterMilestones[0].completedAt).toBeInstanceOf(Date);
});
it('rejects completing a non-repeatable milestone twice, granting renown only once', async () => {
const { service, state } = createService(createState());
await service.completeMilestone(CHARACTER_ID, 'first-hunt');
await expectRenownDomainError(
service.completeMilestone(CHARACTER_ID, 'first-hunt'),
'RENOWN_MILESTONE_ALREADY_COMPLETED',
);
expect(state.characters[0].renown).toBe(2);
expect(state.characterMilestones).toHaveLength(1);
});
it('allows a repeatable milestone to grant renown again, incrementing timesCompleted', async () => {
const state = createState({ milestones: [milestone({ repeatable: true })] });
const { service } = createService(state);
await service.completeMilestone(CHARACTER_ID, 'first-hunt');
const result = await service.completeMilestone(CHARACTER_ID, 'first-hunt');
expect(result.renownGranted).toBe(true);
expect(result.newRenown).toBe(3);
expect(state.characterMilestones).toHaveLength(1);
expect(state.characterMilestones[0].timesCompleted).toBe(2);
});
it('clamps renown at 15 and does not grant beyond the cap', async () => {
const state = createState({
characters: [character({ renown: 15, baseHp: 148, baseAttack: 12 })],
milestones: [milestone({ repeatable: true })],
});
const { service } = createService(state);
const result = await service.completeMilestone(CHARACTER_ID, 'first-hunt');
expect(result).toEqual({
milestoneKey: 'first-hunt',
previousRenown: 15,
newRenown: 15,
renownGranted: false,
});
expect(state.characters[0].baseHp).toBe(148);
});
it('rejects an unknown milestone key', async () => {
const { service } = createService(createState());
await expectRenownDomainError(
service.completeMilestone(CHARACTER_ID, 'unknown-milestone'),
'RENOWN_MILESTONE_NOT_FOUND',
);
});
it('rejects a disabled milestone', async () => {
const state = createState({ milestones: [milestone({ enabled: false })] });
const { service } = createService(state);
await expectRenownDomainError(
service.completeMilestone(CHARACTER_ID, 'first-hunt'),
'RENOWN_MILESTONE_DISABLED',
);
});
it('rejects an unknown character', async () => {
const { service } = createService(createState());
await expectRenownDomainError(
service.completeMilestone('unknown-character', 'first-hunt'),
'CHARACTER_NOT_FOUND',
);
});
});
});
- Step 3: Run to verify it fails
Run: npm run test --workspace=@ashen-realms/api -- renown.service.spec.ts
Expected: FAIL — ./renown.errors and ./renown.service don't exist.
- Step 4: Write the errors file
Create apps/api/src/renown/renown.errors.ts:
import { HttpException, HttpStatus } from '@nestjs/common';
export type RenownErrorCode =
| 'RENOWN_MILESTONE_NOT_FOUND'
| 'RENOWN_MILESTONE_DISABLED'
| 'RENOWN_MILESTONE_ALREADY_COMPLETED';
export class RenownDomainError extends HttpException {
constructor(
public readonly code: RenownErrorCode,
status: HttpStatus,
message: string,
) {
super({ statusCode: status, code, message }, status);
}
}
export function renownMilestoneNotFound(): RenownDomainError {
return new RenownDomainError(
'RENOWN_MILESTONE_NOT_FOUND',
HttpStatus.NOT_FOUND,
'This Renown milestone could not be found.',
);
}
export function renownMilestoneDisabled(): RenownDomainError {
return new RenownDomainError(
'RENOWN_MILESTONE_DISABLED',
HttpStatus.CONFLICT,
'This Renown milestone is not currently active.',
);
}
export function renownMilestoneAlreadyCompleted(): RenownDomainError {
return new RenownDomainError(
'RENOWN_MILESTONE_ALREADY_COMPLETED',
HttpStatus.CONFLICT,
'This Renown milestone has already been completed.',
);
}
export { characterNotFound } from '../travel/travel.errors';
- Step 5: Write the service
Create apps/api/src/renown/renown.service.ts:
import { Injectable } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import { CharacterRenownMilestone } from './entities/character-renown-milestone.entity';
import { RenownMilestoneDefinition } from './entities/renown-milestone-definition.entity';
import { RENOWN_BASE_STATS, RENOWN_MAX } from './renown-base-stats';
import {
characterNotFound,
renownMilestoneAlreadyCompleted,
renownMilestoneDisabled,
renownMilestoneNotFound,
} from './renown.errors';
export interface RenownMilestoneResult {
milestoneKey: string;
previousRenown: number;
newRenown: number;
renownGranted: boolean;
}
@Injectable()
export class RenownService {
constructor(private readonly dataSource: DataSource) {}
/**
* Completes a Renown milestone exactly once for non-repeatable milestones
* (spec §12, §31). Recomputes baseHp/baseAttack from the power-curve
* table on every grant -- a lookup, not an increment, so it is immune to
* double-application drift (design R2).
*/
async completeMilestone(
characterId: string,
milestoneKey: string,
manager?: EntityManager,
): Promise<RenownMilestoneResult> {
const run = async (txManager: EntityManager): Promise<RenownMilestoneResult> => {
const characters = txManager.getRepository(Character);
const milestones = txManager.getRepository(RenownMilestoneDefinition);
const completions = txManager.getRepository(CharacterRenownMilestone);
const character = await characters.findOne({
where: { id: characterId },
lock: { mode: 'pessimistic_write' },
});
if (!character) {
throw characterNotFound();
}
const milestone = await milestones.findOneBy({ key: milestoneKey });
if (!milestone) {
throw renownMilestoneNotFound();
}
if (!milestone.enabled) {
throw renownMilestoneDisabled();
}
const existing = await completions.findOne({
where: { characterId, milestoneId: milestone.id },
lock: { mode: 'pessimistic_write' },
});
if (existing && !milestone.repeatable) {
throw renownMilestoneAlreadyCompleted();
}
const previousRenown = character.renown;
const newRenown = Math.min(RENOWN_MAX, previousRenown + milestone.renownReward);
const renownGranted = newRenown !== previousRenown;
if (renownGranted) {
character.renown = newRenown;
const baseStats = RENOWN_BASE_STATS[newRenown];
character.baseHp = baseStats.baseHp;
character.baseAttack = baseStats.baseAttack;
await characters.save(character);
}
if (existing) {
existing.timesCompleted += 1;
existing.completedAt = new Date();
await completions.save(existing);
} else {
await completions.save(
completions.create({
characterId,
milestoneId: milestone.id,
completedAt: new Date(),
timesCompleted: 1,
}),
);
}
return { milestoneKey, previousRenown, newRenown, renownGranted };
};
return manager ? run(manager) : this.dataSource.transaction(run);
}
}
- Step 6: Run to verify the service spec passes
Run: npm run test --workspace=@ashen-realms/api -- renown.service.spec.ts
Expected: PASS (8 tests)
- Step 7: Create the module and register it
Create apps/api/src/renown/renown.module.ts:
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Character } from '../characters/entities/character.entity';
import { CharacterRenownMilestone } from './entities/character-renown-milestone.entity';
import { RenownMilestoneDefinition } from './entities/renown-milestone-definition.entity';
import { RenownService } from './renown.service';
@Module({
imports: [
TypeOrmModule.forFeature([Character, RenownMilestoneDefinition, CharacterRenownMilestone]),
],
providers: [RenownService],
exports: [RenownService],
})
export class RenownModule {}
In apps/api/src/app.module.ts, add the import and register it in the imports array:
import { RenownModule } from './renown/renown.module';
EquipmentModule,
InventoryModule,
RenownModule,
- Step 8: Run the full API suite
Run: npm run test --workspace=@ashen-realms/api
Expected: The same pre-existing failures from Task 3's Step 9 remain (nothing in this task touches those files); no new failures.
- Step 9: Commit
git add apps/api/src/renown/renown.service.ts apps/api/src/renown/renown.service.spec.ts apps/api/src/renown/renown.errors.ts apps/api/src/renown/renown.module.ts apps/api/src/app.module.ts
git commit -m "feat(renown): add RenownService with milestone completion and base-stat recomputation"
Task 5: ReputationService and GET /api/reputation
Files:
- Create:
apps/api/src/reputation/reputation.service.ts - Create:
apps/api/src/reputation/reputation.service.spec.ts - Create:
apps/api/src/reputation/reputation.errors.ts - Create:
apps/api/src/reputation/reputation.controller.ts - Create:
apps/api/src/reputation/reputation.controller.spec.ts - Create:
apps/api/src/reputation/reputation.module.ts - Modify:
apps/api/src/app.module.ts
Interfaces:
-
Consumes:
resolveReputationRank(Task 1).ReputationFaction,CharacterReputationentities (Task 3). -
Produces:
ReputationService.grantReputation(characterId, factionKey, amount, manager?): Promise<ReputationGrantResult>,ReputationService.getCharacterReputation(characterId): Promise<CharacterReputationDto[]>.GET /api/reputation. Task 6 (TurnInService) consumesgrantReputationwith a shared manager. -
Step 1: Write the failing service spec
Create apps/api/src/reputation/reputation.service.spec.ts:
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
import { CharacterReputation } from './entities/character-reputation.entity';
import { ReputationFaction } from './entities/reputation-faction.entity';
import { ReputationDomainError } from './reputation.errors';
import { ReputationService } from './reputation.service';
const FACTION_ID = '80000000-0000-4000-8000-000000000001';
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
interface State {
factions: ReputationFaction[];
characterReputation: CharacterReputation[];
}
class FakeRepository<T extends { id: string }> {
constructor(
private readonly rows: T[],
private readonly prefix: string,
private readonly inTransaction: boolean,
) {}
findOne(options: { where: Partial<T>; lock?: { mode: string } }): Promise<T | null> {
if (options.lock && !this.inTransaction) {
throw new Error('Pessimistic locks require a transaction');
}
return Promise.resolve(this.rows.find((row) => this.matches(row, options.where)) ?? null);
}
findOneBy(where: Partial<T>): Promise<T | null> {
return Promise.resolve(this.rows.find((row) => this.matches(row, where)) ?? null);
}
find(options: { where?: Partial<T> } = {}): Promise<T[]> {
return Promise.resolve(
options.where ? this.rows.filter((row) => this.matches(row, options.where!)) : [...this.rows],
);
}
create(values: Partial<T>): T {
return { ...values } as T;
}
save(entity: T): Promise<T> {
if (!entity.id) {
entity.id = `${this.prefix}-${this.rows.length + 1}`;
}
const index = this.rows.findIndex((row) => row.id === entity.id);
if (index === -1) {
this.rows.push(entity);
} else {
this.rows[index] = entity;
}
return Promise.resolve(entity);
}
private matches(row: T, where: Partial<T>): boolean {
return Object.entries(where).every(([key, value]) => row[key as keyof T] === value);
}
}
class FakeDataSource {
constructor(public state: State) {}
getRepository<T extends { id: string }>(target: EntityTarget<T>) {
return this.repoFor(target, false);
}
async transaction<T>(work: (manager: EntityManager) => Promise<T>): Promise<T> {
return work({
getRepository: <U extends { id: string }>(target: EntityTarget<U>) => this.repoFor(target, true),
} as unknown as EntityManager);
}
private repoFor<T extends { id: string }>(target: EntityTarget<T>, inTransaction: boolean) {
if (target === ReputationFaction)
return new FakeRepository(this.state.factions, 'faction', inTransaction) as never;
if (target === CharacterReputation)
return new FakeRepository(this.state.characterReputation, 'char-rep', inTransaction) as never;
throw new Error('Unsupported repository');
}
}
function faction(overrides: Partial<ReputationFaction> = {}): ReputationFaction {
return {
id: FACTION_ID,
key: 'border-guard',
name: 'Grenzwacht',
description: '',
regionKey: 'ashen-fields',
enabled: true,
...overrides,
} as ReputationFaction;
}
function createState(overrides: Partial<State> = {}): State {
return { factions: [faction()], characterReputation: [], ...overrides };
}
function createService(state: State) {
const dataSource = new FakeDataSource(state);
return { service: new ReputationService(dataSource as unknown as DataSource), state };
}
async function expectReputationDomainError(promise: Promise<unknown>, code: string): Promise<void> {
let error: unknown;
try {
await promise;
} catch (cause) {
error = cause;
}
expect(error).toBeInstanceOf(ReputationDomainError);
if (!(error instanceof ReputationDomainError)) {
throw new Error('Expected ReputationDomainError');
}
expect(error.code).toBe(code);
}
describe('ReputationService', () => {
describe('grantReputation', () => {
it('creates a reputation row starting from 0 on the first grant', async () => {
const { service, state } = createService(createState());
const result = await service.grantReputation(CHARACTER_ID, 'border-guard', 40);
expect(result).toEqual({
factionKey: 'border-guard',
previousReputation: 0,
newReputation: 40,
previousRank: 'STRANGER',
newRank: 'STRANGER',
rankChanged: false,
});
expect(state.characterReputation).toHaveLength(1);
expect(state.characterReputation[0].reputation).toBe(40);
});
it('accumulates onto an existing reputation row', async () => {
const state = createState({
characterReputation: [
{ id: 'existing', characterId: CHARACTER_ID, factionId: FACTION_ID, reputation: 90 } as CharacterReputation,
],
});
const { service } = createService(state);
const result = await service.grantReputation(CHARACTER_ID, 'border-guard', 20);
expect(result.previousReputation).toBe(90);
expect(result.newReputation).toBe(110);
});
it('reports rankChanged when a grant crosses a threshold', async () => {
const state = createState({
characterReputation: [
{ id: 'existing', characterId: CHARACTER_ID, factionId: FACTION_ID, reputation: 90 } as CharacterReputation,
],
});
const { service } = createService(state);
const result = await service.grantReputation(CHARACTER_ID, 'border-guard', 20);
expect(result.previousRank).toBe('STRANGER');
expect(result.newRank).toBe('TOLERATED');
expect(result.rankChanged).toBe(true);
});
it('reports rankChanged: false when a grant stays within the same rank', async () => {
const state = createState({
characterReputation: [
{ id: 'existing', characterId: CHARACTER_ID, factionId: FACTION_ID, reputation: 120 } as CharacterReputation,
],
});
const { service } = createService(state);
const result = await service.grantReputation(CHARACTER_ID, 'border-guard', 10);
expect(result.rankChanged).toBe(false);
});
it('rejects an unknown faction key', async () => {
const { service } = createService(createState());
await expectReputationDomainError(
service.grantReputation(CHARACTER_ID, 'unknown-faction', 10),
'REPUTATION_FACTION_NOT_FOUND',
);
});
});
describe('getCharacterReputation', () => {
it('returns 0 Reputation / Stranger for a faction the character has never interacted with', async () => {
const { service } = createService(createState());
const result = await service.getCharacterReputation(CHARACTER_ID);
expect(result).toEqual([
{
factionKey: 'border-guard',
factionName: 'Grenzwacht',
reputation: 0,
rank: 'STRANGER',
rankLabel: 'Fremder',
nextThreshold: 100,
},
]);
});
it('reflects a persisted grant', async () => {
const state = createState();
const { service } = createService(state);
await service.grantReputation(CHARACTER_ID, 'border-guard', 300);
const result = await service.getCharacterReputation(CHARACTER_ID);
expect(result[0]).toEqual({
factionKey: 'border-guard',
factionName: 'Grenzwacht',
reputation: 300,
rank: 'KNOWN',
rankLabel: 'Bekannt',
nextThreshold: 500,
});
});
it('omits a disabled faction', async () => {
const state = createState({ factions: [faction({ enabled: false })] });
const { service } = createService(state);
const result = await service.getCharacterReputation(CHARACTER_ID);
expect(result).toEqual([]);
});
});
});
- Step 2: Run to verify it fails
Run: npm run test --workspace=@ashen-realms/api -- reputation.service.spec.ts
Expected: FAIL — module files don't exist.
- Step 3: Write the errors file
Create apps/api/src/reputation/reputation.errors.ts:
import { HttpException, HttpStatus } from '@nestjs/common';
export type ReputationErrorCode = 'REPUTATION_FACTION_NOT_FOUND';
export class ReputationDomainError extends HttpException {
constructor(
public readonly code: ReputationErrorCode,
status: HttpStatus,
message: string,
) {
super({ statusCode: status, code, message }, status);
}
}
export function reputationFactionNotFound(): ReputationDomainError {
return new ReputationDomainError(
'REPUTATION_FACTION_NOT_FOUND',
HttpStatus.NOT_FOUND,
'This faction could not be found.',
);
}
export { characterNotFound } from '../travel/travel.errors';
- Step 4: Write the service
Create apps/api/src/reputation/reputation.service.ts:
import { Injectable } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { CharacterReputation } from './entities/character-reputation.entity';
import { ReputationFaction } from './entities/reputation-faction.entity';
import { reputationFactionNotFound } from './reputation.errors';
import { resolveReputationRank } from './reputation-rank';
export interface ReputationGrantResult {
factionKey: string;
previousReputation: number;
newReputation: number;
previousRank: string;
newRank: string;
rankChanged: boolean;
}
export interface CharacterReputationDto {
factionKey: string;
factionName: string;
reputation: number;
rank: string;
rankLabel: string;
nextThreshold: number | null;
}
type RepositoryScope = Pick<DataSource, 'getRepository'>;
@Injectable()
export class ReputationService {
constructor(private readonly dataSource: DataSource) {}
/** Grants Regional Reputation, server-authoritative (spec §11, §30). */
async grantReputation(
characterId: string,
factionKey: string,
amount: number,
manager?: EntityManager,
): Promise<ReputationGrantResult> {
const run = async (txManager: EntityManager): Promise<ReputationGrantResult> => {
const factions = txManager.getRepository(ReputationFaction);
const reputations = txManager.getRepository(CharacterReputation);
const faction = await factions.findOneBy({ key: factionKey });
if (!faction) {
throw reputationFactionNotFound();
}
const existing = await reputations.findOne({
where: { characterId, factionId: faction.id },
lock: { mode: 'pessimistic_write' },
});
const previousReputation = existing?.reputation ?? 0;
const newReputation = previousReputation + amount;
if (existing) {
existing.reputation = newReputation;
await reputations.save(existing);
} else {
await reputations.save(
reputations.create({ characterId, factionId: faction.id, reputation: newReputation }),
);
}
const previousRank = resolveReputationRank(previousReputation);
const newRank = resolveReputationRank(newReputation);
return {
factionKey,
previousReputation,
newReputation,
previousRank: previousRank.key,
newRank: newRank.key,
rankChanged: previousRank.key !== newRank.key,
};
};
return manager ? run(manager) : this.dataSource.transaction(run);
}
/**
* Every enabled faction, one entry each -- a faction the character has
* never interacted with reads as 0 Reputation / Stranger (spec §36,
* design R10), not as an absent entry.
*/
async getCharacterReputation(characterId: string): Promise<CharacterReputationDto[]> {
const scope: RepositoryScope = this.dataSource;
const factions = await scope.getRepository(ReputationFaction).find({ where: { enabled: true } });
const reputations = await scope.getRepository(CharacterReputation).find({ where: { characterId } });
return factions.map((faction) => {
const existing = reputations.find((row) => row.factionId === faction.id);
const reputation = existing?.reputation ?? 0;
const rank = resolveReputationRank(reputation);
return {
factionKey: faction.key,
factionName: faction.name,
reputation,
rank: rank.key,
rankLabel: rank.label,
nextThreshold: rank.nextThreshold,
};
});
}
}
- Step 5: Run to verify the service spec passes
Run: npm run test --workspace=@ashen-realms/api -- reputation.service.spec.ts
Expected: PASS (8 tests)
- Step 6: Write the failing controller spec
Create apps/api/src/reputation/reputation.controller.spec.ts:
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { App } from 'supertest/types';
import { configureApplication } from '../app.config';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { ReputationController } from './reputation.controller';
import { ReputationService } from './reputation.service';
describe('ReputationController', () => {
let app: INestApplication<App>;
const getCharacterReputation = jest.fn();
beforeEach(async () => {
getCharacterReputation.mockReset();
const module = await Test.createTestingModule({
controllers: [ReputationController],
providers: [{ provide: ReputationService, useValue: { getCharacterReputation } }],
}).compile();
app = module.createNestApplication<App>();
configureApplication(app);
await app.init();
});
afterEach(async () => {
await app.close();
});
it('delegates GET /api/reputation to reputationService.getCharacterReputation', async () => {
const entries = [
{
factionKey: 'border-guard',
factionName: 'Grenzwacht',
reputation: 40,
rank: 'STRANGER',
rankLabel: 'Fremder',
nextThreshold: 100,
},
];
getCharacterReputation.mockResolvedValue(entries);
const response = await request(app.getHttpServer()).get('/api/reputation').expect(200);
expect(getCharacterReputation).toHaveBeenCalledWith(DEMO_CHARACTER_ID);
expect(response.body).toEqual(entries);
});
});
- Step 7: Run to verify it fails
Run: npm run test --workspace=@ashen-realms/api -- reputation.controller.spec.ts
Expected: FAIL — ./reputation.controller doesn't exist.
- Step 8: Write the controller and module
Create apps/api/src/reputation/reputation.controller.ts:
import { Controller, Get } from '@nestjs/common';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { CharacterReputationDto, ReputationService } from './reputation.service';
@Controller('reputation')
export class ReputationController {
constructor(private readonly reputationService: ReputationService) {}
@Get()
getCharacterReputation(): Promise<CharacterReputationDto[]> {
return this.reputationService.getCharacterReputation(DEMO_CHARACTER_ID);
}
}
Create apps/api/src/reputation/reputation.module.ts:
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CharacterReputation } from './entities/character-reputation.entity';
import { ReputationFaction } from './entities/reputation-faction.entity';
import { ReputationController } from './reputation.controller';
import { ReputationService } from './reputation.service';
@Module({
imports: [TypeOrmModule.forFeature([ReputationFaction, CharacterReputation])],
controllers: [ReputationController],
providers: [ReputationService],
exports: [ReputationService],
})
export class ReputationModule {}
In apps/api/src/app.module.ts, add the import and register it:
import { ReputationModule } from './reputation/reputation.module';
RenownModule,
ReputationModule,
- Step 9: Run to verify the controller spec passes
Run: npm run test --workspace=@ashen-realms/api -- reputation.controller.spec.ts
Expected: PASS (1 test)
- Step 10: Run the full API suite
Run: npm run test --workspace=@ashen-realms/api
Expected: Same pre-existing failures from Task 3; no new failures.
- Step 11: Commit
git add apps/api/src/reputation/
git add apps/api/src/app.module.ts
git commit -m "feat(reputation): add ReputationService, rank resolver integration, and GET /api/reputation"
Task 6: TurnInService and POST /api/turn-ins
Files:
- Create:
apps/api/src/turn-in/turn-in.service.ts - Create:
apps/api/src/turn-in/turn-in.service.spec.ts - Create:
apps/api/src/turn-in/turn-in.errors.ts - Create:
apps/api/src/turn-in/turn-in.controller.ts - Create:
apps/api/src/turn-in/turn-in.controller.spec.ts - Create:
apps/api/src/turn-in/dto/turn-in.dto.ts - Create:
apps/api/src/turn-in/turn-in.module.ts - Modify:
apps/api/src/app.module.ts
Interfaces:
-
Consumes:
ReputationService.grantReputation(characterId, factionKey, amount, manager?)(Task 5).TurnInDefinitionentity (Task 3).Character,CharacterItementities. -
Produces:
TurnInService.turnIn(characterId, turnInKey, quantity): Promise<TurnInResult>.POST /api/turn-ins. -
Step 1: Write the failing service spec
Create apps/api/src/turn-in/turn-in.service.spec.ts:
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { CharacterReputation } from '../reputation/entities/character-reputation.entity';
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
import { ReputationService } from '../reputation/reputation.service';
import { TurnInDefinition } from './entities/turn-in-definition.entity';
import { TurnInDomainError } from './turn-in.errors';
import { TurnInService } from './turn-in.service';
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
const FACTION_ID = '80000000-0000-4000-8000-000000000001';
const ITEM_DEFINITION_ID = '50000000-0000-4000-8000-00000000000c';
const CHARACTER_ITEM_ID = '95000000-0000-4000-8000-000000000001';
interface State {
characters: Character[];
characterItems: CharacterItem[];
turnIns: TurnInDefinition[];
factions: ReputationFaction[];
characterReputation: CharacterReputation[];
}
class FakeRepository<T extends { id: string }> {
constructor(
private readonly rows: T[],
private readonly prefix: string,
private readonly inTransaction: boolean,
) {}
findOne(options: { where: Partial<T>; lock?: { mode: string } }): Promise<T | null> {
if (options.lock && !this.inTransaction) {
throw new Error('Pessimistic locks require a transaction');
}
return Promise.resolve(this.rows.find((row) => this.matches(row, options.where)) ?? null);
}
findOneBy(where: Partial<T>): Promise<T | null> {
return Promise.resolve(this.rows.find((row) => this.matches(row, where)) ?? null);
}
find(options: { where?: Partial<T> } = {}): Promise<T[]> {
return Promise.resolve(
options.where ? this.rows.filter((row) => this.matches(row, options.where!)) : [...this.rows],
);
}
create(values: Partial<T>): T {
return { ...values } as T;
}
save(entity: T): Promise<T> {
if (!entity.id) {
entity.id = `${this.prefix}-${this.rows.length + 1}`;
}
const index = this.rows.findIndex((row) => row.id === entity.id);
if (index === -1) {
this.rows.push(entity);
} else {
this.rows[index] = entity;
}
return Promise.resolve(entity);
}
remove(entity: T): Promise<T> {
const index = this.rows.findIndex((row) => row.id === entity.id);
if (index !== -1) {
this.rows.splice(index, 1);
}
return Promise.resolve(entity);
}
private matches(row: T, where: Partial<T>): boolean {
return Object.entries(where).every(([key, value]) => row[key as keyof T] === value);
}
}
class FakeDataSource {
constructor(public state: State) {}
getRepository<T extends { id: string }>(target: EntityTarget<T>) {
return this.repoFor(target, false);
}
async transaction<T>(work: (manager: EntityManager) => Promise<T>): Promise<T> {
return work({
getRepository: <U extends { id: string }>(target: EntityTarget<U>) => this.repoFor(target, true),
} as unknown as EntityManager);
}
private repoFor<T extends { id: string }>(target: EntityTarget<T>, inTransaction: boolean) {
if (target === Character) return new FakeRepository(this.state.characters, 'character', inTransaction) as never;
if (target === CharacterItem)
return new FakeRepository(this.state.characterItems, 'char-item', inTransaction) as never;
if (target === TurnInDefinition)
return new FakeRepository(this.state.turnIns, 'turn-in', inTransaction) as never;
if (target === ReputationFaction)
return new FakeRepository(this.state.factions, 'faction', inTransaction) as never;
if (target === CharacterReputation)
return new FakeRepository(this.state.characterReputation, 'char-rep', inTransaction) as never;
throw new Error('Unsupported repository');
}
}
function character(overrides: Partial<Character> = {}): Character {
return { id: CHARACTER_ID, silver: 10, ...overrides } as Character;
}
function characterItem(overrides: Partial<CharacterItem> = {}): CharacterItem {
return {
id: CHARACTER_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: ITEM_DEFINITION_ID,
quantity: 5,
...overrides,
} as CharacterItem;
}
function turnInDefinition(overrides: Partial<TurnInDefinition> = {}): TurnInDefinition {
return {
id: 'turn-in-1',
key: 'ash-pelt-border-guard',
itemDefinitionId: ITEM_DEFINITION_ID,
factionId: FACTION_ID,
silverRewardPerItem: 4,
reputationRewardPerItem: 1,
repeatable: true,
enabled: true,
...overrides,
} as TurnInDefinition;
}
function faction(overrides: Partial<ReputationFaction> = {}): ReputationFaction {
return { id: FACTION_ID, key: 'border-guard', name: 'Grenzwacht', enabled: true, ...overrides } as ReputationFaction;
}
function createState(overrides: Partial<State> = {}): State {
return {
characters: [character()],
characterItems: [characterItem()],
turnIns: [turnInDefinition()],
factions: [faction()],
characterReputation: [],
...overrides,
};
}
function createService(state: State) {
const dataSource = new FakeDataSource(state);
const reputationService = new ReputationService(dataSource as unknown as DataSource);
return {
service: new TurnInService(dataSource as unknown as DataSource, reputationService),
state,
};
}
async function expectTurnInDomainError(promise: Promise<unknown>, code: string): Promise<void> {
let error: unknown;
try {
await promise;
} catch (cause) {
error = cause;
}
expect(error).toBeInstanceOf(TurnInDomainError);
if (!(error instanceof TurnInDomainError)) {
throw new Error('Expected TurnInDomainError');
}
expect(error.code).toBe(code);
}
describe('TurnInService', () => {
describe('turnIn', () => {
it('consumes the exact quantity, grants silver and reputation atomically', async () => {
const { service, state } = createService(createState());
const result = await service.turnIn(CHARACTER_ID, 'ash-pelt-border-guard', 3);
expect(result).toEqual({
turnInKey: 'ash-pelt-border-guard',
quantityConsumed: 3,
silverGranted: 12,
reputationResult: {
factionKey: 'border-guard',
previousReputation: 0,
newReputation: 3,
previousRank: 'STRANGER',
newRank: 'STRANGER',
rankChanged: false,
},
});
expect(state.characterItems[0].quantity).toBe(2);
expect(state.characters[0].silver).toBe(22);
expect(state.characterReputation[0].reputation).toBe(3);
});
it('deletes the CharacterItem row once its quantity reaches 0', async () => {
const { service, state } = createService(createState({ characterItems: [characterItem({ quantity: 3 })] }));
await service.turnIn(CHARACTER_ID, 'ash-pelt-border-guard', 3);
expect(state.characterItems).toHaveLength(0);
});
it('rejects turning in more than the character owns, mutating nothing', async () => {
const { service, state } = createService(createState({ characterItems: [characterItem({ quantity: 2 })] }));
await expectTurnInDomainError(
service.turnIn(CHARACTER_ID, 'ash-pelt-border-guard', 3),
'TURN_IN_INSUFFICIENT_QUANTITY',
);
expect(state.characterItems[0].quantity).toBe(2);
expect(state.characters[0].silver).toBe(10);
expect(state.characterReputation).toHaveLength(0);
});
it('rejects a zero or negative quantity', async () => {
const { service } = createService(createState());
await expectTurnInDomainError(
service.turnIn(CHARACTER_ID, 'ash-pelt-border-guard', 0),
'TURN_IN_INVALID_QUANTITY',
);
});
it('rejects an unknown turn-in key, mutating nothing', async () => {
const { service, state } = createService(createState());
await expectTurnInDomainError(
service.turnIn(CHARACTER_ID, 'unknown-turn-in', 1),
'TURN_IN_NOT_FOUND',
);
expect(state.characters[0].silver).toBe(10);
});
it('rejects a disabled turn-in', async () => {
const state = createState({ turnIns: [turnInDefinition({ enabled: false })] });
const { service } = createService(state);
await expectTurnInDomainError(
service.turnIn(CHARACTER_ID, 'ash-pelt-border-guard', 1),
'TURN_IN_DISABLED',
);
});
it('computes multi-item rewards server-side from the definition, not from client input', async () => {
const state = createState({
turnIns: [turnInDefinition({ silverRewardPerItem: 12, reputationRewardPerItem: 4 })],
});
const { service, state: resultState } = createService(state);
const result = await service.turnIn(CHARACTER_ID, 'ash-pelt-border-guard', 5);
expect(result.silverGranted).toBe(60);
expect(result.reputationResult.newReputation).toBe(20);
expect(resultState.characters[0].silver).toBe(70);
});
});
});
- Step 2: Run to verify it fails
Run: npm run test --workspace=@ashen-realms/api -- turn-in.service.spec.ts
Expected: FAIL — module files don't exist.
- Step 3: Write the errors file
Create apps/api/src/turn-in/turn-in.errors.ts:
import { HttpException, HttpStatus } from '@nestjs/common';
export type TurnInErrorCode =
| 'TURN_IN_NOT_FOUND'
| 'TURN_IN_DISABLED'
| 'TURN_IN_INVALID_QUANTITY'
| 'TURN_IN_INSUFFICIENT_QUANTITY';
export class TurnInDomainError extends HttpException {
constructor(
public readonly code: TurnInErrorCode,
status: HttpStatus,
message: string,
) {
super({ statusCode: status, code, message }, status);
}
}
export function turnInNotFound(): TurnInDomainError {
return new TurnInDomainError(
'TURN_IN_NOT_FOUND',
HttpStatus.NOT_FOUND,
'This turn-in could not be found.',
);
}
export function turnInDisabled(): TurnInDomainError {
return new TurnInDomainError(
'TURN_IN_DISABLED',
HttpStatus.CONFLICT,
'This turn-in is not currently available.',
);
}
export function turnInInvalidQuantity(): TurnInDomainError {
return new TurnInDomainError(
'TURN_IN_INVALID_QUANTITY',
HttpStatus.BAD_REQUEST,
'Quantity must be a positive integer.',
);
}
export function turnInInsufficientQuantity(): TurnInDomainError {
return new TurnInDomainError(
'TURN_IN_INSUFFICIENT_QUANTITY',
HttpStatus.CONFLICT,
'The character does not own enough of this item.',
);
}
export { characterNotFound } from '../travel/travel.errors';
- Step 4: Write the service
Create apps/api/src/turn-in/turn-in.service.ts:
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
import { ReputationGrantResult, ReputationService } from '../reputation/reputation.service';
import { TurnInDefinition } from './entities/turn-in-definition.entity';
import {
characterNotFound,
turnInDisabled,
turnInInsufficientQuantity,
turnInInvalidQuantity,
turnInNotFound,
} from './turn-in.errors';
export interface TurnInResult {
turnInKey: string;
quantityConsumed: number;
silverGranted: number;
reputationResult: ReputationGrantResult;
}
@Injectable()
export class TurnInService {
constructor(
private readonly dataSource: DataSource,
private readonly reputationService: ReputationService,
) {}
/**
* Consumes loot and grants Silver + Reputation atomically (spec §20,
* §31): a failed turn-in leaves items, silver, and reputation completely
* untouched. The reward math is entirely server-computed from persisted
* content -- the client supplies only `turnInKey`/`quantity` (spec §25).
*/
async turnIn(characterId: string, turnInKey: string, quantity: number): Promise<TurnInResult> {
if (!Number.isInteger(quantity) || quantity <= 0) {
throw turnInInvalidQuantity();
}
return this.dataSource.transaction(async (manager) => {
const characters = manager.getRepository(Character);
const characterItems = manager.getRepository(CharacterItem);
const turnIns = manager.getRepository(TurnInDefinition);
const factions = manager.getRepository(ReputationFaction);
const character = await characters.findOne({
where: { id: characterId },
lock: { mode: 'pessimistic_write' },
});
if (!character) {
throw characterNotFound();
}
const definition = await turnIns.findOneBy({ key: turnInKey });
if (!definition) {
throw turnInNotFound();
}
if (!definition.enabled) {
throw turnInDisabled();
}
const characterItem = await characterItems.findOne({
where: { characterId, itemDefinitionId: definition.itemDefinitionId },
lock: { mode: 'pessimistic_write' },
});
if (!characterItem || characterItem.quantity < quantity) {
throw turnInInsufficientQuantity();
}
if (characterItem.quantity === quantity) {
await characterItems.remove(characterItem);
} else {
characterItem.quantity -= quantity;
await characterItems.save(characterItem);
}
const silverGranted = definition.silverRewardPerItem * quantity;
character.silver += silverGranted;
await characters.save(character);
const faction = await factions.findOneBy({ id: definition.factionId });
if (!faction) {
throw turnInNotFound();
}
const reputationResult = await this.reputationService.grantReputation(
characterId,
faction.key,
definition.reputationRewardPerItem * quantity,
manager,
);
return { turnInKey, quantityConsumed: quantity, silverGranted, reputationResult };
});
}
}
- Step 5: Run to verify the service spec passes
Run: npm run test --workspace=@ashen-realms/api -- turn-in.service.spec.ts
Expected: PASS (7 tests)
- Step 6: Write the DTO and the failing controller spec
Create apps/api/src/turn-in/dto/turn-in.dto.ts:
import { IsInt, IsString, Min } from 'class-validator';
export class TurnInDto {
@IsString()
turnInKey!: string;
@IsInt()
@Min(1)
quantity!: number;
}
Create apps/api/src/turn-in/turn-in.controller.spec.ts:
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { App } from 'supertest/types';
import { configureApplication } from '../app.config';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { TurnInController } from './turn-in.controller';
import { TurnInService } from './turn-in.service';
describe('TurnInController', () => {
let app: INestApplication<App>;
const turnIn = jest.fn();
beforeEach(async () => {
turnIn.mockReset();
const module = await Test.createTestingModule({
controllers: [TurnInController],
providers: [{ provide: TurnInService, useValue: { turnIn } }],
}).compile();
app = module.createNestApplication<App>();
configureApplication(app);
await app.init();
});
afterEach(async () => {
await app.close();
});
it('delegates POST /api/turn-ins with only turnInKey and quantity', async () => {
const result = {
turnInKey: 'ash-pelt-border-guard',
quantityConsumed: 3,
silverGranted: 12,
reputationResult: {
factionKey: 'border-guard',
previousReputation: 0,
newReputation: 3,
previousRank: 'STRANGER',
newRank: 'STRANGER',
rankChanged: false,
},
};
turnIn.mockResolvedValue(result);
const response = await request(app.getHttpServer())
.post('/api/turn-ins')
.send({ turnInKey: 'ash-pelt-border-guard', quantity: 3 })
.expect(201);
expect(turnIn).toHaveBeenCalledWith(DEMO_CHARACTER_ID, 'ash-pelt-border-guard', 3);
expect(response.body).toEqual(result);
});
it('rejects server-owned reward fields the client must never send', async () => {
await request(app.getHttpServer())
.post('/api/turn-ins')
.send({ turnInKey: 'ash-pelt-border-guard', quantity: 3, silverGranted: 999, reputationReward: 999 })
.expect(400);
expect(turnIn).not.toHaveBeenCalled();
});
it('rejects a missing quantity', async () => {
await request(app.getHttpServer())
.post('/api/turn-ins')
.send({ turnInKey: 'ash-pelt-border-guard' })
.expect(400);
expect(turnIn).not.toHaveBeenCalled();
});
});
- Step 7: Run to verify it fails
Run: npm run test --workspace=@ashen-realms/api -- turn-in.controller.spec.ts
Expected: FAIL — ./turn-in.controller doesn't exist.
- Step 8: Write the controller and module
Create apps/api/src/turn-in/turn-in.controller.ts:
import { Body, Controller, Post } from '@nestjs/common';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { TurnInDto } from './dto/turn-in.dto';
import { TurnInResult, TurnInService } from './turn-in.service';
@Controller('turn-ins')
export class TurnInController {
constructor(private readonly turnInService: TurnInService) {}
@Post()
turnIn(@Body() request: TurnInDto): Promise<TurnInResult> {
return this.turnInService.turnIn(DEMO_CHARACTER_ID, request.turnInKey, request.quantity);
}
}
Create apps/api/src/turn-in/turn-in.module.ts:
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Character } from '../characters/entities/character.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
import { ReputationModule } from '../reputation/reputation.module';
import { TurnInDefinition } from './entities/turn-in-definition.entity';
import { TurnInController } from './turn-in.controller';
import { TurnInService } from './turn-in.service';
@Module({
imports: [
TypeOrmModule.forFeature([Character, CharacterItem, TurnInDefinition, ReputationFaction]),
ReputationModule,
],
controllers: [TurnInController],
providers: [TurnInService],
exports: [TurnInService],
})
export class TurnInModule {}
In apps/api/src/app.module.ts, add the import and register it:
import { TurnInModule } from './turn-in/turn-in.module';
ReputationModule,
TurnInModule,
Note that the DTO's request-validation guard (rejecting extra fields like silverGranted) relies on the same global ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }) (or equivalent) that combat-action.dto.ts and equip-item.dto.ts already rely on via configureApplication — no new global setup is needed, this DTO just needs to exist and be the controller's @Body() type.
- Step 9: Run to verify the controller spec passes
Run: npm run test --workspace=@ashen-realms/api -- turn-in.controller.spec.ts
Expected: PASS (3 tests)
- Step 10: Run the full API suite
Run: npm run test --workspace=@ashen-realms/api
Expected: Same pre-existing failures from Task 3; no new failures.
- Step 11: Commit
git add apps/api/src/turn-in/
git add apps/api/src/app.module.ts
git commit -m "feat(turn-in): add TurnInService with atomic item/silver/reputation exchange and POST /api/turn-ins"
Task 7: Combat reward pipeline — remove XP
Files:
- Modify:
apps/api/src/rewards/combat-reward.service.ts - Modify:
apps/api/src/rewards/combat-reward.service.spec.ts
Interfaces:
-
Consumes:
MonsterDefinition(unchanged shape exceptexperienceRewardremoved by Task 9). -
Produces:
CombatRewardDto = { silver: number; items: CombatRewardItemDto[] }(noexperiencefield). Task 15 (web combat victory screen) consumes this shape. -
Step 1: Update the combat-reward service
In apps/api/src/rewards/combat-reward.service.ts, remove the experience: number; line from the CombatRewardDto interface (near the top of the file):
export interface CombatRewardDto {
silver: number;
items: CombatRewardItemDto[];
}
In grantVictoryRewards, remove the experience computation and its use:
const experience = monster.experienceReward;
const silver = rollInclusive(
becomes:
const silver = rollInclusive(
Remove the character.experience += experience; line entirely (keep character.silver += silver;):
character.experience += experience;
character.silver += silver;
becomes:
character.silver += silver;
Remove experienceGranted: experience, from the rewards.create({...}) call:
rewards.create({
combatId: combat.id,
characterId: combat.characterId,
experienceGranted: experience,
silverGranted: silver,
}),
becomes:
rewards.create({
combatId: combat.id,
characterId: combat.characterId,
silverGranted: silver,
}),
Change the final return statement from return { experience, silver, items }; to return { silver, items };.
In toDto, remove experience: reward.experienceGranted, from the returned object, leaving just silver: reward.silverGranted, and items,.
- Step 2: Update the existing spec's fixtures and assertions
In apps/api/src/rewards/combat-reward.service.spec.ts:
Change the characters fixture in createState (around line 123) from { id: CHARACTER_ID, experience: 12, silver: 3 } as Character to { id: CHARACTER_ID, silver: 3 } as Character.
Change the monsters fixtures (around lines 128, 136) to remove experienceReward: 8, and experienceReward: 16, respectively, leaving silverMin/silverMax/lootTableId.
In 'rejects an ACTIVE combat' (around line 192), remove the line expect(state.characters[0].experience).toBe(12);.
In 'grants rewards for a WON combat' (around line 210), change expect(reward).toEqual({ experience: 8, silver: 6, items: [] }); to expect(reward).toEqual({ silver: 6, items: [] });.
Rename 'grants 8 XP and a silver roll inside 4-7, persisted on the character' (around line 216) to 'grants a silver roll inside 4-7, persisted on the character', and inside it remove expect(reward.experience).toBe(8); and expect(state.characters[0].experience).toBe(20);, keeping the two silver assertions.
Rename 'grants 16 XP and a silver roll inside 9-15' (around line 245) to 'grants a silver roll inside 9-15', and inside it remove expect(reward.experience).toBe(16);, keeping expect(reward.silver).toBe(9);.
- Step 3: Run to verify the reward spec passes
Run: npm run test --workspace=@ashen-realms/api -- combat-reward.service.spec.ts
Expected: PASS (all tests in this file, no experience references remain)
- Step 4: Run the full API suite
Run: npm run test --workspace=@ashen-realms/api
Expected: Fewer failures than before (this file's contribution to the Task 3 baseline is now resolved); remaining failures are in files Tasks 8–10 fix.
- Step 5: Commit
git add apps/api/src/rewards/combat-reward.service.ts apps/api/src/rewards/combat-reward.service.spec.ts
git commit -m "feat(rewards): remove classical XP from the combat reward pipeline"
Task 8: Equipment gate removal
Files:
- Modify:
apps/api/src/equipment/equipment.service.ts - Modify:
apps/api/src/equipment/equipment.errors.ts - Modify:
apps/api/src/equipment/equipment.service.spec.ts
Interfaces:
-
Consumes: nothing new.
-
Produces:
EquipmentService.equipno longer throwsITEM_LEVEL_REQUIREMENT_NOT_METunder any circumstance. -
Step 1: Update the service
In apps/api/src/equipment/equipment.service.ts, remove itemLevelRequirementNotMet from the import block:
import {
characterInCombat,
characterItemNotFound,
characterNotFound,
itemLevelRequirementNotMet,
itemNotEquippable,
itemNotOwned,
} from './equipment.errors';
becomes:
import {
characterInCombat,
characterItemNotFound,
characterNotFound,
itemNotEquippable,
itemNotOwned,
} from './equipment.errors';
Delete the level-gate check entirely:
if (definition.requiredLevel > character.level) {
throw itemLevelRequirementNotMet();
}
- Step 2: Remove the now-unused error
In apps/api/src/equipment/equipment.errors.ts, remove 'ITEM_LEVEL_REQUIREMENT_NOT_MET' from the EquipmentErrorCode union and delete the itemLevelRequirementNotMet function entirely.
- Step 3: Update the spec — fixtures and the replaced test
In apps/api/src/equipment/equipment.service.spec.ts:
Remove requiredLevel: 1, from the itemDefinition() factory's default return object (around line 146).
Remove level: 1, from the character() factory's default return object (around line 163).
Replace the 'rejects equipping an item above the character level' test (around line 303) with a positive regression test proving the gate is truly gone:
it('equips an owned item regardless of the item content that used to carry a level requirement', async () => {
const highTierHelm = itemDefinition({
id: BANDIT_HOOD_ITEM_ID,
key: 'bandit-hood',
equipmentSlot: EquipmentSlot.HEAD,
tier: 5,
});
const { service } = createHarness({
itemDefinitions: [highTierHelm],
characterItems: [
{
id: BANDIT_HOOD_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: highTierHelm.id,
quantity: 1,
} as CharacterItem,
],
});
const result = await service.equip(CHARACTER_ID, BANDIT_HOOD_ITEM_ID);
expect(result.slots.HEAD?.characterItemId).toBe(BANDIT_HOOD_ITEM_ID);
});
- Step 4: Run to verify the equipment spec passes
Run: npm run test --workspace=@ashen-realms/api -- equipment.service.spec.ts
Expected: PASS
- Step 5: Run the full API suite
Run: npm run test --workspace=@ashen-realms/api
Expected: Fewer remaining failures; the equipment module's contribution to the Task 3 baseline is resolved.
- Step 6: Commit
git add apps/api/src/equipment/equipment.service.ts apps/api/src/equipment/equipment.errors.ts apps/api/src/equipment/equipment.service.spec.ts
git commit -m "feat(equipment): remove the requiredLevel equipment gate — owned items are always equippable"
Task 9: Seed content — ItemType migration, Räuberabzeichen, Grenzwacht, turn-ins
Files:
- Modify:
apps/api/src/database/seeds/item.constants.ts - Modify:
apps/api/src/database/seeds/item-content.ts - Modify:
apps/api/src/database/seeds/vertical-slice.seed.ts - Create:
apps/api/src/database/seeds/reputation-content.ts - Create:
apps/api/src/database/seeds/turn-in-content.ts - Modify:
apps/api/src/database/seeds/vertical-slice.seed.spec.ts
Interfaces:
-
Consumes:
ItemType(Task 3),ReputationFaction/TurnInDefinitionentities (Task 3). -
Produces: seeded content proving spec §21/§22 end-to-end — this is the last piece needed before the seed function runs cleanly.
-
Step 1: Add the Räuberabzeichen item id and rewrite item-content.ts's
ItemTypeusages
In apps/api/src/database/seeds/item.constants.ts, add a new key to ITEM_IDS:
export const ITEM_IDS = {
'worn-short-sword': '50000000-0000-4000-8000-000000000001',
'bandit-blade': '50000000-0000-4000-8000-000000000002',
'ash-blade': '50000000-0000-4000-8000-000000000003',
'bandit-hood': '50000000-0000-4000-8000-000000000004',
'reinforced-leather-jacket': '50000000-0000-4000-8000-000000000005',
'raider-gloves': '50000000-0000-4000-8000-000000000006',
'guardsman-legs': '50000000-0000-4000-8000-000000000007',
'ash-boots': '50000000-0000-4000-8000-000000000008',
'borderwatch-sigil': '50000000-0000-4000-8000-000000000009',
'burned-captain-pendant': '50000000-0000-4000-8000-00000000000a',
'small-healing-potion': '50000000-0000-4000-8000-00000000000b',
'ash-pelt': '50000000-0000-4000-8000-00000000000c',
'bandit-insignia': '50000000-0000-4000-8000-00000000000d',
} as const;
- Step 2: Update item-content.ts
In apps/api/src/database/seeds/item-content.ts, remove the requiredLevel: 1, line from the item() factory function's returned object (the SeedItemDefinition interface's requiredLevel: number; field is also removed).
Change every ItemType.WEAPON and ItemType.ARMOR call site in ITEM_DEFINITIONS to ItemType.EQUIPMENT (9 occurrences: worn-short-sword, bandit-blade, ash-blade, bandit-hood, reinforced-leather-jacket, raider-gloves, guardsman-legs, ash-boots, borderwatch-sigil, burned-captain-pendant).
Change ash-pelt's ItemType.MATERIAL to ItemType.TRADE_GOOD, and update its description comment to reflect it is now a Trade Good (spec §17), not a generic material.
Add a new Trophy item entry after ash-pelt (spec §18, §21):
item(
'bandit-insignia',
'Räuberabzeichen',
'Ein grob geprägtes Zeichen, das einen Straßenräuber als Mitglied seiner Bande auswies.',
ItemType.TROPHY,
null,
ItemRarity.COMMON,
),
];
(replace the closing ]; of ITEM_DEFINITIONS accordingly — this new item is the last entry in the array).
- Step 3: Add a Räuberabzeichen loot table entry
Still in item-content.ts, add one entry to LOOT_TABLE_ENTRIES so Straßenräuber can drop the new trophy (spec §22):
export const LOOT_TABLE_ENTRIES: SeedLootTableEntry[] = [
entry(ASH_RAT_LOOT_TABLE_ID, 'ash-pelt', 1, '0.6000'),
entry(ASH_RAT_LOOT_TABLE_ID, 'worn-short-sword', 2, '0.0800'),
entry(ROAD_BANDIT_LOOT_TABLE_ID, 'bandit-blade', 1, '0.1800'),
entry(ROAD_BANDIT_LOOT_TABLE_ID, 'bandit-hood', 2, '0.1200'),
entry(ROAD_BANDIT_LOOT_TABLE_ID, 'raider-gloves', 3, '0.0800'),
entry(ROAD_BANDIT_LOOT_TABLE_ID, 'bandit-insignia', 4, '0.4000'),
];
- Step 4: Create the reputation faction seed content
Create apps/api/src/database/seeds/reputation-content.ts:
export const BORDER_GUARD_FACTION_ID = '80000000-0000-4000-8000-000000000001';
export interface SeedReputationFaction {
id: string;
key: string;
name: string;
description: string;
regionKey: string;
enabled: boolean;
}
// Only Grenzwacht is seeded and usable in Slice 0.6.5 (spec §8). Dämmerjäger
// ('dusk-hunters') and Letzte Wacht ('last-watch') are future-region keys,
// intentionally not seeded yet.
export const REPUTATION_FACTIONS: SeedReputationFaction[] = [
{
id: BORDER_GUARD_FACTION_ID,
key: 'border-guard',
name: 'Grenzwacht',
description:
'Die letzten organisierten Wächter der Aschenfelder, die verbliebene Handelsrouten und Außenposten schützen.',
regionKey: 'ashen-fields',
enabled: true,
},
];
- Step 5: Create the turn-in seed content
Create apps/api/src/database/seeds/turn-in-content.ts:
import { ITEM_IDS } from './item.constants';
import { BORDER_GUARD_FACTION_ID } from './reputation-content';
export interface SeedTurnInDefinition {
id: string;
key: string;
itemDefinitionId: string;
factionId: string;
silverRewardPerItem: number;
reputationRewardPerItem: number;
repeatable: boolean;
enabled: boolean;
}
// Initial development values (spec §21), kept in content so they can be
// tuned without touching TurnInService's logic (spec §42).
export const TURN_IN_DEFINITIONS: SeedTurnInDefinition[] = [
{
id: '90000000-0000-4000-8000-000000000001',
key: 'ash-pelt-border-guard',
itemDefinitionId: ITEM_IDS['ash-pelt'],
factionId: BORDER_GUARD_FACTION_ID,
silverRewardPerItem: 4,
reputationRewardPerItem: 1,
repeatable: true,
enabled: true,
},
{
id: '90000000-0000-4000-8000-000000000002',
key: 'bandit-insignia-border-guard',
itemDefinitionId: ITEM_IDS['bandit-insignia'],
factionId: BORDER_GUARD_FACTION_ID,
silverRewardPerItem: 12,
reputationRewardPerItem: 4,
repeatable: true,
enabled: true,
},
];
- Step 6: Wire the new seed content and the Character migration into
vertical-slice.seed.ts, and zero out monster silver/XP
In apps/api/src/database/seeds/vertical-slice.seed.ts:
Add new imports:
import { ReputationFaction } from '../../reputation/entities/reputation-faction.entity';
import { TurnInDefinition } from '../../turn-in/entities/turn-in-definition.entity';
import { REPUTATION_FACTIONS } from './reputation-content';
import { TURN_IN_DEFINITIONS } from './turn-in-content';
Add repository declarations near the top of seedVisibleVerticalSlice:
const reputationFactionRepository = dataSource.getRepository(ReputationFaction);
const turnInDefinitionRepository = dataSource.getRepository(TurnInDefinition);
Right after the existing await lootEntryRepository.upsert(...) call, add:
await reputationFactionRepository.upsert(REPUTATION_FACTIONS, ['key']);
await turnInDefinitionRepository.upsert(TURN_IN_DEFINITIONS, ['key']);
In the monsters array, remove every experienceReward: N, line (4 occurrences: Aschenratte, Verwilderter Straßenhund, Straßenräuber, Verkohlter Plünderer) and set every silverMin/silverMax pair to 0 (spec §22/§34 — the direct-Silver mechanism stays available in code for a future lore-valid monster, but nothing currently seeded uses it):
{
id: ASH_RAT_MONSTER_ID,
key: 'ash-rat',
name: 'Aschenratte',
level: 1,
maxHp: 45,
attack: 5,
armor: 0,
silverMin: 0,
silverMax: 0,
artworkPath: '/images/monsters/ash-rat.png',
iconPath: '/images/combat/icons/ash-rat-128.png',
lootTableId: ASH_RAT_LOOT_TABLE_ID,
},
{
id: WILD_ROAD_DOG_MONSTER_ID,
key: 'wild-road-dog',
name: 'Verwilderter Straßenhund',
level: 1,
maxHp: 55,
attack: 7,
armor: 0,
silverMin: 0,
silverMax: 0,
artworkPath: '/images/monsters/wild-road-dog.png',
iconPath: '/images/combat/icons/wild-road-dog-128.png',
lootTableId: ASH_RAT_LOOT_TABLE_ID,
},
{
id: ROAD_BANDIT_MONSTER_ID,
key: 'road-bandit',
name: 'Straßenräuber',
level: 2,
maxHp: 75,
attack: 9,
armor: 5,
silverMin: 0,
silverMax: 0,
artworkPath: '/images/monsters/road-bandit.png',
iconPath: '/images/combat/icons/road-bandit-128.png',
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
},
{
id: CHARRED_LOOTER_MONSTER_ID,
key: 'charred-looter',
name: 'Verkohlter Plünderer',
level: 2,
maxHp: 85,
attack: 11,
armor: 6,
silverMin: 0,
silverMax: 0,
artworkPath: '/images/monsters/charred-looter.png',
iconPath: '/images/combat/icons/charred-looter-128.png',
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
},
In the demo character's characterRepository.insert({...}) call, replace level: 1, experience: 0, with renown: 1,:
await characterRepository.insert({
id: DEMO_CHARACTER_ID,
name: 'Aric Duskwalker',
renown: 1,
silver: 0,
baseHp: 100,
baseAttack: 6,
currentHp: 100,
currentLocationId: southGateId,
});
- Step 7: Fix the seed spec's assertions
In apps/api/src/database/seeds/vertical-slice.seed.spec.ts, update every assertion the grep in Task 3's Step 9 surfaced as failing in this file:
- Line ~122/161 area: replace
experience: 39,(or whatever demo-character XP value is asserted) with the equivalentrenown: 1,assertion, and remove anylevel:/experience:fields from the asserted demo-character object, addingrenown: 1in their place. - Line ~172/184 area: the monster fixtures/assertions with
level: 1,/level: 2,— leavelevelalone (monster level is unaffected), but remove theexperienceReward: 8,/experienceReward: 16,lines and update anysilverMin/silverMaxassertions to0. - Line ~196/203 area: same monster-level-stays, XP-goes treatment for the remaining two monster fixtures if present.
Read the file's actual current assertions before editing (this plan's earlier grep found the line numbers; the exact surrounding assertion shape must be read fresh since it may have shifted slightly after Task 3's entity changes already altered compile behavior). The rule is mechanical: any Character-shaped assertion drops level/experience and gains renown: 1; any MonsterDefinition-shaped assertion keeps level (monster level, unrelated to Character), drops experienceReward, and sets silverMin/silverMax to 0.
Also add a new assertion block proving the new seed content lands: after the existing monster/item assertions, add:
it('seeds the Grenzwacht faction', async () => {
const faction = await dataSource
.getRepository(ReputationFaction)
.findOneBy({ key: 'border-guard' });
expect(faction).toMatchObject({ name: 'Grenzwacht', enabled: true });
});
it('seeds both turn-in definitions', async () => {
const turnIns = await dataSource.getRepository(TurnInDefinition).find();
expect(turnIns.map((t) => t.key).sort()).toEqual([
'ash-pelt-border-guard',
'bandit-insignia-border-guard',
]);
});
(Add import { ReputationFaction } from '../../reputation/entities/reputation-faction.entity'; and import { TurnInDefinition } from '../../turn-in/entities/turn-in-definition.entity'; to the top of the file — match this test file's existing pattern for how it obtains a dataSource/repository, whether that's a real test-database connection or a fake; read the file's setup block first and follow its established convention exactly, since this determines whether these two new assertions can run as written or need adapting to the file's existing harness.)
- Step 8: Run to verify the seed spec passes
Run: npm run test --workspace=@ashen-realms/api -- vertical-slice.seed.spec.ts
Expected: PASS
- Step 9: Run the full API suite
Run: npm run test --workspace=@ashen-realms/api
Expected: Fewer remaining failures. Any file this task didn't touch that still fails (e.g. hunting.service.spec.ts's character()/monsterDefinition() fixtures) is Task 10's job.
- Step 10: Commit
git add apps/api/src/database/seeds/
git commit -m "feat(content): migrate ItemType, seed Räuberabzeichen/Grenzwacht/turn-ins, zero direct monster rewards"
Task 10: Sweep remaining API fixtures
Files:
- Modify:
apps/api/src/hunting/hunting.service.spec.ts - Modify: any other file the full suite still flags after Task 9 (see Step 1)
Interfaces:
- Consumes: nothing new.
- Produces: a fully green API suite with zero references to
Character.level/.experience,ItemDefinition.requiredLevel,MonsterDefinition.experienceReward,CombatReward.experienceGranted, or the oldItemType.WEAPON/.ARMOR/.MATERIALvalues anywhere in the test suite.
This is a sweep task: the exact file list cannot be fully enumerated in advance because Task 3's entity changes ripple through every fixture across the codebase that builds a Character/ItemDefinition/MonsterDefinition/CombatReward object literal with a full type annotation (not behind an as X cast — those, per design's empirical finding in the Slice 0.6 plan, are unaffected by TypeScript's structural widening through a type assertion). Follow the rule, not a fixed file list.
- Step 1: Run the full API suite and collect every remaining failure
Run: npm run test --workspace=@ashen-realms/api
Read every failure. Each one falls into exactly one of these categories:
- A
Character-typed fixture withlevel/experiencefields (direct object literal or a factory function with aCharacter/Partial<Character>return-type annotation): removelevel/experience, addrenown: <a plausible 1-15 value, typically 1>. - A
MonsterDefinition-typed fixture withexperienceReward: remove the field. Leavelevelalone — it is the monster's own level, unrelated to Character (spec §33 explicitly preserves monster level as internal balancing metadata). - An
ItemDefinition-typed fixture withrequiredLevel: remove the field. - A
CombatReward-typed fixture withexperienceGranted, or an assertion readingreward.experienceGranted/CombatRewardDto.experience: remove the field/assertion. - A reference to the old
ItemType.WEAPON/ItemType.ARMOR/ItemType.MATERIAL: change toItemType.EQUIPMENT(for WEAPON/ARMOR) orItemType.TRADE_GOOD(for MATERIAL), matching Task 9's Step 2 mapping.
The file this plan already knows will need this treatment: apps/api/src/hunting/hunting.service.spec.ts — its character() factory (around line 232-247) has a full Character return-type annotation with level: 1, experience: 0,; its monsterDefinition() factory (around line 249-269) has a full MonsterDefinition return-type annotation with experienceReward: 10,. Fix both per rules 1 and 2 above.
- Step 2: Fix every failure found in Step 1
Apply the rule from Step 1 to every file the suite flags. Do not guess ahead of the compiler/test output — fix exactly what is broken, re-run, repeat until clean.
- Step 3: Run the full API suite until it is completely green
Run: npm run test --workspace=@ashen-realms/api
Expected: PASS, zero failures, across all suites.
- Step 4: Grep-verify no residual references remain
Run:
grep -rn "\.experienceReward\|experienceGranted\|character\.level\b\|character\.experience\b\|ItemType\.WEAPON\|ItemType\.ARMOR\|ItemType\.MATERIAL\|requiredLevel" apps/api/src --include="*.ts"
Expected: no output (or only comments/documentation referencing the old system historically, which is acceptable — but no live code or test assertion). If anything real turns up, fix it and re-run.
- Step 5: Commit
git add -A
git commit -m "fix(api): sweep every remaining test fixture off level/experience/requiredLevel/experienceReward"
Task 11: GET /api/characters/me exposes renown
Files:
- Modify:
apps/api/src/characters/characters.service.ts - Modify:
apps/api/src/characters/characters.service.spec.ts
Interfaces:
-
Consumes:
Character.renown(Task 3). -
Produces:
CharactersService.getDemoCharacter()returns{ ..., renown: number }instead of{ ..., level: number, experience: number }. -
Step 1: Update the service
In apps/api/src/characters/characters.service.ts, replace:
level: character.level,
experience: character.experience,
with:
renown: character.renown,
- Step 2: Fix the existing spec
Read apps/api/src/characters/characters.service.spec.ts and update every fixture/assertion referencing level/experience on a Character or on getDemoCharacter()'s return value to use renown instead, following the same rule as Task 10.
- Step 3: Run to verify
Run: npm run test --workspace=@ashen-realms/api -- characters.service.spec.ts
Expected: PASS
- Step 4: Run the full API suite
Run: npm run test --workspace=@ashen-realms/api
Expected: PASS, fully green (this is the last API-side task; the suite should have zero failures now).
- Step 5: Commit
git add apps/api/src/characters/characters.service.ts apps/api/src/characters/characters.service.spec.ts
git commit -m "feat(characters): expose renown on GET /api/characters/me instead of level/experience"
Task 12: Web models and API client
Files:
- Modify:
apps/web/src/app/core/api/game-api.models.ts - Modify:
apps/web/src/app/core/api/game-api.service.ts - Modify:
apps/web/src/app/core/api/game-api.service.spec.ts
Interfaces:
-
Consumes: nothing new (this task defines the web-side contract matching the API).
-
Produces:
CharacterResponse.renown: number(nolevel/experience).CombatRewardwithoutexperience.InventoryItem.itemwithoutrequiredLevel. NewReputationEntry/TurnInResultinterfaces.GameApiService.getReputation(),.turnIn(turnInKey, quantity). Tasks 13–16 (Topbar, inventory panel, combat page, Reputation component) all consume this file's types. -
Step 1: Update
CharacterResponse
In apps/web/src/app/core/api/game-api.models.ts, replace:
export interface CharacterResponse {
id: string;
name: string;
level: number;
experience: number;
silver: number;
currentHp: number;
maxHp: number;
attack: number;
currentLocation: LocationSummary;
}
with:
export interface CharacterResponse {
id: string;
name: string;
renown: number;
silver: number;
currentHp: number;
maxHp: number;
attack: number;
currentLocation: LocationSummary;
}
- Step 2: Update
CombatRewardandInventoryItem
Remove the experience: number; field from the CombatReward interface, leaving silver and items.
Remove the requiredLevel: number; field from InventoryItem.item.
- Step 3: Add the Reputation and TurnIn model types
Add these new interfaces (near the bottom of the file, after the existing equipment/inventory types):
export interface ReputationEntry {
factionKey: string;
factionName: string;
reputation: number;
rank: string;
rankLabel: string;
nextThreshold: number | null;
}
export interface TurnInResult {
turnInKey: string;
quantityConsumed: number;
silverGranted: number;
reputationResult: {
factionKey: string;
previousReputation: number;
newReputation: number;
previousRank: string;
newRank: string;
rankChanged: boolean;
};
}
- Step 4: Add the failing API client tests
In apps/web/src/app/core/api/game-api.service.spec.ts, add two new tests following this file's existing pattern (each existing test calls a GameApiService method and asserts the HttpClient call):
it('fetches reputation', () => {
service.getReputation().subscribe();
const req = httpMock.expectOne('/api/reputation');
expect(req.request.method).toBe('GET');
req.flush([]);
});
it('submits a turn-in with only turnInKey and quantity', () => {
service.turnIn('ash-pelt-border-guard', 3).subscribe();
const req = httpMock.expectOne('/api/turn-ins');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual({ turnInKey: 'ash-pelt-border-guard', quantity: 3 });
req.flush({
turnInKey: 'ash-pelt-border-guard',
quantityConsumed: 3,
silverGranted: 12,
reputationResult: {
factionKey: 'border-guard',
previousReputation: 0,
newReputation: 3,
previousRank: 'STRANGER',
newRank: 'STRANGER',
rankChanged: false,
},
});
});
(Read the file's existing test setup first — it uses HttpClientTestingModule/HttpTestingController per its established pattern; match the exact httpMock/TestBed setup already present rather than reintroducing it.)
- Step 5: Run to verify the new tests fail
Run: npx ng test --watch=false --include='**/game-api.service.spec.ts' (from apps/web)
Expected: FAIL — getReputation/turnIn don't exist on GameApiService.
- Step 6: Add the client methods
In apps/web/src/app/core/api/game-api.service.ts, add the import of the new types:
import {
CharacterResponse,
Combat,
CombatAction,
CurrentLocationResponse,
CurrentTravel,
EquipmentResponse,
HuntResult,
InventoryResponse,
LocationInteractionResult,
ReputationEntry,
TurnInResult,
} from './game-api.models';
Add two new methods at the end of the class, before the closing brace:
getReputation(): Observable<ReputationEntry[]> {
return this.http.get<ReputationEntry[]>('/api/reputation');
}
turnIn(turnInKey: string, quantity: number): Observable<TurnInResult> {
return this.http.post<TurnInResult>('/api/turn-ins', { turnInKey, quantity });
}
- Step 7: Run to verify the new tests pass
Run: npx ng test --watch=false --include='**/game-api.service.spec.ts' (from apps/web)
Expected: PASS
- Step 8: Run the full web suite
Run: npm run test --workspace=@ashen-realms/web
Expected: FAIL — every consumer of the now-changed CharacterResponse/CombatReward/InventoryItem types (Topbar, combat page, inventory panel, and their specs) fails to compile. This is expected; Tasks 13–16 fix each consumer.
- Step 9: Commit
git add apps/web/src/app/core/api/game-api.models.ts apps/web/src/app/core/api/game-api.service.ts apps/web/src/app/core/api/game-api.service.spec.ts
git commit -m "feat(renown): add Renown/Reputation/TurnIn web models and API client methods"
Task 13: Topbar — Renown instead of Level, no XP
Files:
- Modify:
apps/web/src/app/layout/top-bar/top-bar.component.html - Modify:
apps/web/src/app/layout/top-bar/top-bar.component.spec.ts(create if it does not already exist — check first)
Interfaces:
-
Consumes:
CharacterResponse.renown(Task 12). -
Produces: nothing consumed by later tasks.
-
Step 1: Check for an existing spec file
Run: find apps/web/src/app/layout/top-bar -name "*.spec.ts" (or the PowerShell equivalent Get-ChildItem). If top-bar.component.spec.ts exists, read it fully before editing (its exact test harness and existing assertions determine how to phrase the new/changed tests below). If it does not exist, this step creates one.
- Step 2: Update the template
In apps/web/src/app/layout/top-bar/top-bar.component.html, replace:
<span class="top-bar__level">Stufe {{ character.level }}</span>
with:
<span class="top-bar__level">Renown {{ character.renown }}</span>
Delete the entire XP resource block:
<div class="top-bar__resource" data-top-bar-experience>
<dt>XP</dt>
<dd>{{ character.experience }}</dd>
</div>
(Leave the data-top-bar-silver block untouched.)
- Step 3: Write or update the spec
If top-bar.component.spec.ts already exists and has tests referencing character.level/.experience or data-top-bar-experience, update them: replace any level/experience fields in the test's CharacterResponse fixture with renown: N, change any assertion expecting "Stufe N" text to expect "Renown N", and delete any assertion checking for [data-top-bar-experience] presence — replace it with an assertion that it is absent:
it('shows Renown instead of Level, and displays no XP value', async () => {
// ... existing setup, with the character fixture using `renown: 3` instead of level/experience ...
expect(element.textContent).toContain('Renown 3');
expect(element.querySelector('[data-top-bar-experience]')).toBeNull();
});
If no spec file exists, create apps/web/src/app/layout/top-bar/top-bar.component.spec.ts following the standalone-component TestBed pattern used elsewhere in this codebase (e.g. combat-page.component.spec.ts's TestBed.configureTestingModule({ imports: [TopBarComponent] }) + fixture.componentRef.setInput('character', ...) + fixture.detectChanges()), with at minimum the test above plus a basic render test confirming name/HP/silver still show.
- Step 4: Run to verify
Run: npx ng test --watch=false --include='**/top-bar.component.spec.ts' (from apps/web)
Expected: PASS
- Step 5: Commit
git add apps/web/src/app/layout/top-bar/
git commit -m "feat(web): show Renown instead of Level in the Topbar, remove the XP display"
Task 14: Inventory panel — remove the requiredLevel gate
Files:
- Modify:
apps/web/src/app/features/inventory/inventory-detail-panel.component.ts - Modify:
apps/web/src/app/features/inventory/inventory-detail-panel.component.html - Modify:
apps/web/src/app/features/inventory/inventory-detail-panel.component.spec.ts - Modify:
apps/web/src/app/features/inventory/inventory-page.component.ts - Modify:
apps/web/src/app/features/inventory/inventory-page.component.html - Modify:
apps/web/src/app/features/inventory/inventory-page.component.spec.ts(only if it referencescharacterLevel)
Interfaces:
-
Consumes:
InventoryItemwithoutrequiredLevel(Task 12). -
Produces:
InventoryDetailPanelComponentwith nocharacterLevelinput; equipping an owned item is never blocked by level. -
Step 1: Simplify the detail panel component
In apps/web/src/app/features/inventory/inventory-detail-panel.component.ts, remove the characterLevel input:
readonly characterLevel = input(1);
Remove the meetsLevelRequirement computed property entirely:
protected readonly meetsLevelRequirement = computed(() => {
const item = this.item();
return item ? item.item.requiredLevel <= this.characterLevel() : true;
});
- Step 2: Simplify the detail panel template
In apps/web/src/app/features/inventory/inventory-detail-panel.component.html, delete the entire "Benötigte Stufe" <dl class="detail__meta"> block (the one showing item.item.requiredLevel):
@if (isEquippable()) {
<dl class="detail__meta">
<div class="detail__stat">
<dt>Benötigte Stufe</dt>
<dd>
<span class="detail__value" [class.detail__value--unmet]="!meetsLevelRequirement()">
{{ item.item.requiredLevel }}
</span>
</dd>
</div>
</dl>
}
Simplify the three-branch equip-button logic down to two branches by deleting the @else if (!meetsLevelRequirement()) branch:
@if (item.equipped) {
<span class="detail__equipped" data-detail-equipped>Ausgerüstet</span>
} @else if (!isEquippable()) {
<span class="detail__note">Nicht ausrüstbar</span>
} @else if (!meetsLevelRequirement()) {
<button type="button" class="detail__equip" data-detail-equip disabled>
Benötigt Stufe {{ item.item.requiredLevel }}
</button>
} @else {
<button
type="button"
class="detail__equip"
data-detail-equip
[disabled]="busy()"
(click)="onEquip()"
>
Ausrüsten
</button>
}
becomes:
@if (item.equipped) {
<span class="detail__equipped" data-detail-equipped>Ausgerüstet</span>
} @else if (!isEquippable()) {
<span class="detail__note">Nicht ausrüstbar</span>
} @else {
<button
type="button"
class="detail__equip"
data-detail-equip
[disabled]="busy()"
(click)="onEquip()"
>
Ausrüsten
</button>
}
- Step 3: Fix the detail panel spec
In apps/web/src/app/features/inventory/inventory-detail-panel.component.spec.ts:
Remove characterLevel?: number; from the setup options interface (around line 47) and the corresponding fixture.componentRef.setInput('characterLevel', ...) call (around line 55).
Find the test around line 98 that sets requiredLevel: 5 with characterLevel: 1 to exercise the now-deleted level-gate branch. Read this test in full and replace it with a test proving the button is now always the plain "Ausrüsten" button for any owned equippable item regardless of what requiredLevel used to be (the field no longer exists on the model at all, so this test simply confirms the equip button renders normally):
it('always shows the plain equip button for an owned equippable item', async () => {
const fixture = await setup({ item: banditBlade });
const element = fixture.nativeElement as HTMLElement;
const button = element.querySelector<HTMLButtonElement>('[data-detail-equip]');
expect(button?.disabled).toBe(false);
expect(button?.textContent?.trim()).toBe('Ausrüsten');
});
(Adapt the exact fixture variable name — banditBlade or whatever this file's existing item fixtures are called — to match what's actually defined in the file; read it first.)
Search the rest of this spec file for any other requiredLevel/characterLevel/meetsLevelRequirement references and remove them following the same rule.
- Step 4: Remove
characterLevelwiring from the inventory page
In apps/web/src/app/features/inventory/inventory-page.component.ts, delete:
protected readonly characterLevel = computed(() => this.worldStore.character()?.level ?? 1);
In apps/web/src/app/features/inventory/inventory-page.component.html, delete the [characterLevel]="characterLevel()" binding from the <app-inventory-detail-panel> element.
- Step 5: Fix the inventory page spec if it references
characterLevel
If apps/web/src/app/features/inventory/inventory-page.component.spec.ts sets up a worldStore.character() fixture with level/experience, update it to use renown instead (per the Task 10/11 rule), and remove any assertion checking the characterLevel input was passed through.
- Step 6: Run to verify
Run: npx ng test --watch=false --include='**/inventory-detail-panel.component.spec.ts' --include='**/inventory-page.component.spec.ts' (from apps/web)
Expected: PASS
- Step 7: Commit
git add apps/web/src/app/features/inventory/
git commit -m "feat(inventory): remove the requiredLevel equip gate from the detail panel"
Task 15: Combat victory screen — remove the XP block
Files:
- Modify:
apps/web/src/app/features/combat/combat-page/combat-page.component.html - Modify:
apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts
Interfaces:
-
Consumes:
CombatRewardwithoutexperience(Task 12). -
Produces: nothing consumed by later tasks.
-
Step 1: Delete the XP block from the template
In apps/web/src/app/features/combat/combat-page/combat-page.component.html, delete this block from the rewards <dl class="rewards__currencies">:
<div class="rewards__currency" data-reward-experience>
<dt>Erfahrung</dt>
<dd>+{{ rewards.experience }} XP</dd>
</div>
(Leave the data-reward-silver block untouched.)
- Step 2: Fix the three referencing tests
In apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts:
Delete the assertion expect(element.querySelector('[data-reward-experience]')?.textContent).toContain('8'); (around line 537) from the 'shows the granted XP and silver on the victory screen' test — rename that test to 'shows the granted silver on the victory screen' and keep only the silver assertion.
Delete the assertion expect(element.querySelector('[data-reward-experience]')).toBeTruthy(); (around line 581) from the 'renders a dropped item with its icon, name, and rarity' test (this line was asserting an unrelated fact about the same screen — just remove it, the test's actual purpose is unaffected).
Delete the assertion expect(element.querySelector('[data-reward-experience]')?.textContent).toContain('16'); (around line 594) from the 'renders the persisted rewards of an already-won combat loaded from the server' test — keep its silver assertion.
Also find every rewards: fixture object literal in this spec file that includes an experience: field (e.g. { experience: 8, silver: 6, items: [] }) and remove the experience: key from each — the CombatReward type no longer has this field, so a fixture that includes it will fail to compile.
- Step 3: Run to verify
Run: npx ng test --watch=false --include='**/combat-page.component.spec.ts' (from apps/web)
Expected: PASS
- Step 4: Commit
git add apps/web/src/app/features/combat/combat-page/
git commit -m "feat(combat): remove the XP reward block from the victory screen"
Task 16: Reputation display component
Files:
- Create:
apps/web/src/app/shared/reputation-display/reputation-display.component.ts - Create:
apps/web/src/app/shared/reputation-display/reputation-display.component.html - Create:
apps/web/src/app/shared/reputation-display/reputation-display.component.scss - Create:
apps/web/src/app/shared/reputation-display/reputation-display.component.spec.ts
Interfaces:
-
Consumes:
ReputationEntry(Task 12). -
Produces: a standalone, reusable component. Not wired into any page in this slice (design R15) — its spec is the only consumer.
-
Step 1: Write the failing spec
Create apps/web/src/app/shared/reputation-display/reputation-display.component.spec.ts:
import { TestBed } from '@angular/core/testing';
import type { ReputationEntry } from '../../core/api/game-api.models';
import { ReputationDisplayComponent } from './reputation-display.component';
async function setup(entry: ReputationEntry) {
await TestBed.configureTestingModule({ imports: [ReputationDisplayComponent] }).compileComponents();
const fixture = TestBed.createComponent(ReputationDisplayComponent);
fixture.componentRef.setInput('entry', entry);
fixture.detectChanges();
return fixture;
}
describe('ReputationDisplayComponent', () => {
it('renders the faction name and rank', async () => {
const fixture = await setup({
factionKey: 'border-guard',
factionName: 'Grenzwacht',
reputation: 320,
rank: 'KNOWN',
rankLabel: 'Bekannt',
nextThreshold: 500,
});
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('Grenzwacht');
expect(element.textContent).toContain('Bekannt');
});
it('shows current reputation against the next threshold', async () => {
const fixture = await setup({
factionKey: 'border-guard',
factionName: 'Grenzwacht',
reputation: 320,
rank: 'KNOWN',
rankLabel: 'Bekannt',
nextThreshold: 500,
});
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('320');
expect(element.textContent).toContain('500');
});
it('renders a progress fraction between 0 and 100', async () => {
const fixture = await setup({
factionKey: 'border-guard',
factionName: 'Grenzwacht',
reputation: 320,
rank: 'KNOWN',
rankLabel: 'Bekannt',
nextThreshold: 500,
});
const element = fixture.nativeElement as HTMLElement;
const fill = element.querySelector<HTMLElement>('[data-reputation-progress-fill]');
const width = parseFloat(fill?.style.inlineSize ?? '0');
expect(width).toBeGreaterThan(0);
expect(width).toBeLessThanOrEqual(100);
});
it('handles the top rank with no next threshold without crashing or showing a bogus progress bar', async () => {
const fixture = await setup({
factionKey: 'border-guard',
factionName: 'Grenzwacht',
reputation: 1500,
rank: 'ESTEEMED',
rankLabel: 'Geachtet',
nextThreshold: null,
});
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('Geachtet');
expect(element.querySelector('[data-reputation-max]')).toBeTruthy();
});
});
- Step 2: Run to verify it fails
Run: npx ng test --watch=false --include='**/reputation-display.component.spec.ts' (from apps/web)
Expected: FAIL — component doesn't exist.
- Step 3: Write the component
Create apps/web/src/app/shared/reputation-display/reputation-display.component.ts:
import { Component, computed, input } from '@angular/core';
import type { ReputationEntry } from '../../core/api/game-api.models';
/**
* Reusable Regional Reputation presentation (spec §27). No dedicated screen
* hosts this yet -- it is built and tested standalone so a Character
* screen, merchant, or NPC dialog can embed it later.
*/
@Component({
selector: 'app-reputation-display',
templateUrl: './reputation-display.component.html',
styleUrl: './reputation-display.component.scss',
})
export class ReputationDisplayComponent {
readonly entry = input.required<ReputationEntry>();
// Progress toward the next threshold, measured from 0 rather than from the
// current rank's own floor: the API DTO exposes `reputation` and
// `nextThreshold` only. A more precise bar would need a `currentThreshold`
// field that nothing else needs yet, so this stays coarse on purpose.
protected readonly progressPercent = computed(() => {
const entry = this.entry();
if (entry.nextThreshold === null) {
return 100;
}
return Math.min(100, Math.max(0, (entry.reputation / entry.nextThreshold) * 100));
});
}
- Step 4: Write the template
Create apps/web/src/app/shared/reputation-display/reputation-display.component.html:
<div class="reputation-display" [attr.aria-label]="entry().factionName + ' Ruf'">
<div class="reputation-display__header">
<span class="reputation-display__faction">{{ entry().factionName }}</span>
<span class="reputation-display__rank">{{ entry().rankLabel }}</span>
</div>
@if (entry().nextThreshold !== null) {
<div class="reputation-display__progress" role="progressbar"
[attr.aria-valuenow]="entry().reputation"
[attr.aria-valuemin]="0"
[attr.aria-valuemax]="entry().nextThreshold"
>
<span class="reputation-display__track" aria-hidden="true">
<span
class="reputation-display__fill"
data-reputation-progress-fill
[style.inline-size.%]="progressPercent()"
></span>
</span>
<span class="reputation-display__value">{{ entry().reputation }} / {{ entry().nextThreshold }}</span>
</div>
} @else {
<p class="reputation-display__max" data-reputation-max>{{ entry().reputation }} — Höchster Rang erreicht</p>
}
</div>
- Step 5: Write minimal styles
Create apps/web/src/app/shared/reputation-display/reputation-display.component.scss:
.reputation-display {
display: grid;
gap: var(--ar-space-2);
padding: var(--ar-space-3);
border: 1px solid var(--ar-border);
border-radius: var(--ar-radius-sm);
background: var(--ar-panel);
}
.reputation-display__header {
display: flex;
justify-content: space-between;
font-family: Georgia, 'Times New Roman', serif;
}
.reputation-display__rank {
color: var(--ar-gold);
}
.reputation-display__progress {
display: flex;
align-items: center;
gap: var(--ar-space-2);
}
.reputation-display__track {
position: relative;
flex: 1;
block-size: 0.5rem;
border-radius: var(--ar-radius-sm);
background: var(--ar-border);
overflow: hidden;
}
.reputation-display__fill {
position: absolute;
inset-block: 0;
inset-inline-start: 0;
background: var(--ar-gold);
}
.reputation-display__value {
font-variant-numeric: tabular-nums;
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
}
.reputation-display__max {
margin: 0;
color: var(--ar-gold);
font-size: var(--ar-font-sm);
}
(If any --ar-* custom property above doesn't exist in this project's global stylesheet, check apps/web/src/styles.scss or the combat page's SCSS for the actual token names in use and substitute the closest real equivalent — do not invent new tokens.)
- Step 6: Run to verify all tests pass
Run: npx ng test --watch=false --include='**/reputation-display.component.spec.ts' (from apps/web)
Expected: PASS (4 tests)
- Step 7: Commit
git add apps/web/src/app/shared/reputation-display/
git commit -m "feat(reputation): add the reusable Regional Reputation display component"
Final Verification
- Step 1: Run both full test suites
npm run test --workspace=@ashen-realms/api
npm run test --workspace=@ashen-realms/web
Expected: PASS for both, zero failures.
- Step 2: Project-wide sweep grep (spec §33, Definition of Done's final item)
grep -rniE "\bxp\b|experienceReward|levelUp|requiredLevel|playerLevel|characterLevel" apps/api/src apps/web/src --include="*.ts" --include="*.html"
Every hit must be one of: (a) a monster/location level/minRecommendedLevel/maxRecommendedLevel field (spec §33 explicitly preserves these), (b) a comment referencing the old system historically for context, or (c) nothing at all. If a hit is live code or a test assertion still depending on removed XP/Level/requiredLevel behavior, it is a bug — fix it and re-run both suites.
- Step 3: Manually sanity-check the Definition of Done (spec §44)
Start the dev servers, load the demo character, and confirm:
- The Topbar shows "Renown 1", not "Stufe 1", and no XP number anywhere.
- Fighting an Aschenratte or Straßenräuber to victory shows no XP reward line; silver stays at 0 for these monsters; loot (Aschenfell / Räuberabzeichen chance) still drops.
- Equipping any owned item works regardless of its former level requirement.
POST /api/turn-inswith{ turnInKey: 'ash-pelt-border-guard', quantity: 1 }(via curl or the browser devtools network tab against a manually-added Aschenfell in inventory) grants 4 Silver and 1 Grenzwacht Reputation, andGET /api/reputationreflects it afterward.TypeORMmigration1791000000000-CreateRenownAndReputationruns cleanly against a real Postgres instance withnpm run db:migrate(this environment's local dev database — confirm the connection target before running).