diff --git a/apps/api/src/shops/offer-presentation.spec.ts b/apps/api/src/shops/offer-presentation.spec.ts new file mode 100644 index 0000000..3d85155 --- /dev/null +++ b/apps/api/src/shops/offer-presentation.spec.ts @@ -0,0 +1,146 @@ +import { + ComparisonOperator, + GameConditionType, +} from '../conditions/game-condition.types'; +import { LootCategory } from '../items/loot-category.enum'; +import { + describeBagEffect, + describeItemEffect, + describeRequirement, +} from './offer-presentation'; + +const FACTIONS = new Map([['border-guard', 'Border Watch']]); + +describe('describeRequirement', () => { + it('names the faction and the threshold for a reputation gate', () => { + const requirement = describeRequirement( + { + condition: { + type: GameConditionType.REGION_REPUTATION, + key: 'border-guard', + operator: ComparisonOperator.GTE, + value: 25, + }, + met: false, + actual: 14, + }, + FACTIONS, + ); + + expect(requirement).toEqual({ + label: 'Requires Border Watch Reputation 25', + current: 14, + required: 25, + met: false, + }); + }); + + it('describes a World Renown gate', () => { + const requirement = describeRequirement( + { + condition: { + type: GameConditionType.WORLD_RENOWN, + operator: ComparisonOperator.GTE, + value: 3, + }, + met: false, + actual: 1, + }, + FACTIONS, + ); + + expect(requirement).toMatchObject({ + label: 'Requires World Renown 3', + current: 1, + required: 3, + }); + }); + + it('falls back to the faction key when the faction is unknown', () => { + // A gate naming a faction that is not seeded still has to render as + // something, and the key is more useful to a player than a blank. + const requirement = describeRequirement( + { + condition: { + type: GameConditionType.REGION_REPUTATION, + key: 'dusk-hunters', + operator: ComparisonOperator.GTE, + value: 10, + }, + met: false, + actual: 0, + }, + FACTIONS, + ); + + expect(requirement?.label).toBe('Requires dusk-hunters Reputation 10'); + }); + + it('renders no line for a condition type the player is not shown', () => { + // A dialogue flag is an internal gate. Naming it would spoil the quest + // that sets it, and the offer already renders as locked without it. + expect( + describeRequirement( + { + condition: { + type: GameConditionType.FLAG_SET, + key: 'referred-by-south-gate-warden', + value: true, + }, + met: false, + actual: null, + }, + FACTIONS, + ), + ).toBeNull(); + }); +}); + +describe('describeItemEffect', () => { + it('summarises a weapon', () => { + expect( + describeItemEffect({ + weaponDamage: 11, + bonusAttack: 1, + bonusHp: 0, + bonusArmor: 0, + }), + ).toBe('11 Weapon Damage, +1 Attack'); + }); + + it('summarises armour', () => { + expect( + describeItemEffect({ + weaponDamage: 0, + bonusAttack: 0, + bonusHp: 5, + bonusArmor: 4, + }), + ).toBe('+5 HP, +4 Armor'); + }); + + it('has nothing to say about an item with no stats', () => { + expect( + describeItemEffect({ + weaponDamage: 0, + bonusAttack: 0, + bonusHp: 0, + bonusArmor: 0, + }), + ).toBeNull(); + }); +}); + +describe('describeBagEffect', () => { + it('states what the bag carries and how much', () => { + expect( + describeBagEffect({ capacity: 5, lootCategory: LootCategory.RAIDER_TROPHY }), + ).toBe('Capacity: 5 Raider Trophies'); + }); + + it('uses the plural label of the category', () => { + expect( + describeBagEffect({ capacity: 5, lootCategory: LootCategory.HIDE }), + ).toBe('Capacity: 5 Hides'); + }); +}); diff --git a/apps/api/src/shops/offer-presentation.ts b/apps/api/src/shops/offer-presentation.ts new file mode 100644 index 0000000..8181402 --- /dev/null +++ b/apps/api/src/shops/offer-presentation.ts @@ -0,0 +1,104 @@ +import type { ConditionOutcome } from '../conditions/game-condition.service'; +import { GameConditionType } from '../conditions/game-condition.types'; +import { LootCategory } from '../items/loot-category.enum'; + +/** + * One requirement as the player reads it (Playable Slice 0.8.5 §5, §8). + * + * `current` is what makes a locked offer a goal rather than a wall: seeing + * "Requires 25 / Current 14" tells the player how much further to go. + */ +export interface ShopOfferRequirementDto { + label: string; + current: number | null; + required: number | null; + met: boolean; +} + +/** Plural, because a capacity counts things. Mirrors the web strip's labels. */ +const CATEGORY_LABELS: Readonly> = { + [LootCategory.HIDE]: 'Hides', + [LootCategory.RAIDER_TROPHY]: 'Raider Trophies', +}; + +/** + * Turns one evaluated condition into a player-facing line, or into nothing. + * + * Only the two gate types slice §8 names are rendered. Everything else -- + * dialogue flags above all -- returns null: the offer still shows as locked, + * but an internal gate is not announced, and a quest flag named on a shop row + * would spoil the Slice 0.9 tutorial before the quest exists. + */ +export function describeRequirement( + outcome: ConditionOutcome, + factionNames: ReadonlyMap, +): ShopOfferRequirementDto | null { + const { condition, met, actual } = outcome; + const required = Number(condition.value); + if (!Number.isFinite(required)) { + return null; + } + + switch (condition.type) { + case GameConditionType.REGION_REPUTATION: { + if (!condition.key) { + return null; + } + // The key is a poor label, but a blank is worse -- a gate on a faction + // that is not seeded yet should still read as something. + const faction = factionNames.get(condition.key) ?? condition.key; + return { + label: `Requires ${faction} Reputation ${required}`, + current: actual, + required, + met, + }; + } + case GameConditionType.WORLD_RENOWN: + return { + label: `Requires World Renown ${required}`, + current: actual, + required, + met, + }; + default: + return null; + } +} + +/** + * What an item does, in one line (slice §8 "relevant effect"). + * + * Stats only. The flavour text is already carried separately, and repeating it + * here would push the actual numbers off the row. + */ +export function describeItemEffect(item: { + weaponDamage: number; + bonusAttack: number; + bonusHp: number; + bonusArmor: number; +}): string | null { + const parts: string[] = []; + if (item.weaponDamage > 0) { + parts.push(`${item.weaponDamage} Weapon Damage`); + } + if (item.bonusAttack > 0) { + parts.push(`+${item.bonusAttack} Attack`); + } + if (item.bonusHp > 0) { + parts.push(`+${item.bonusHp} HP`); + } + if (item.bonusArmor > 0) { + parts.push(`+${item.bonusArmor} Armor`); + } + return parts.length > 0 ? parts.join(', ') : null; +} + +/** What a bag does: one category, one capacity (Slice 0.7.5 §6). */ +export function describeBagEffect(bag: { + capacity: number; + lootCategory: LootCategory; +}): string { + const label = CATEGORY_LABELS[bag.lootCategory] ?? bag.lootCategory; + return `Capacity: ${bag.capacity} ${label}`; +}