From 88b98aa6055872627bf7cf6ac2dc4dfa8c62b486 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Fri, 21 Aug 2026 08:42:29 +0200 Subject: [PATCH] feat(renown): add the Renown power-curve table and reputation-rank resolver --- apps/api/src/renown/renown-base-stats.spec.ts | 29 ++++++++++++++++ apps/api/src/renown/renown-base-stats.ts | 24 ++++++++++++++ .../src/reputation/reputation-rank.spec.ts | 33 +++++++++++++++++++ apps/api/src/reputation/reputation-rank.ts | 30 +++++++++++++++++ 4 files changed, 116 insertions(+) create mode 100644 apps/api/src/renown/renown-base-stats.spec.ts create mode 100644 apps/api/src/renown/renown-base-stats.ts create mode 100644 apps/api/src/reputation/reputation-rank.spec.ts create mode 100644 apps/api/src/reputation/reputation-rank.ts diff --git a/apps/api/src/renown/renown-base-stats.spec.ts b/apps/api/src/renown/renown-base-stats.spec.ts new file mode 100644 index 0000000..2cc1b7a --- /dev/null +++ b/apps/api/src/renown/renown-base-stats.spec.ts @@ -0,0 +1,29 @@ +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, + ); + } + }); +}); diff --git a/apps/api/src/renown/renown-base-stats.ts b/apps/api/src/renown/renown-base-stats.ts new file mode 100644 index 0000000..1de0b59 --- /dev/null +++ b/apps/api/src/renown/renown-base-stats.ts @@ -0,0 +1,24 @@ +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 = { + 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 }, +}; diff --git a/apps/api/src/reputation/reputation-rank.spec.ts b/apps/api/src/reputation/reputation-rank.spec.ts new file mode 100644 index 0000000..5750049 --- /dev/null +++ b/apps/api/src/reputation/reputation-rank.spec.ts @@ -0,0 +1,33 @@ +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); + }); +}); diff --git a/apps/api/src/reputation/reputation-rank.ts b/apps/api/src/reputation/reputation-rank.ts new file mode 100644 index 0000000..f688506 --- /dev/null +++ b/apps/api/src/reputation/reputation-rank.ts @@ -0,0 +1,30 @@ +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, + }; +}