feat(shops): describe offer requirements and effects in English

This commit is contained in:
Bastian Wagner
2026-08-22 18:14:12 +02:00
parent 9bd28b5e2c
commit 149a9521be
2 changed files with 250 additions and 0 deletions

View File

@@ -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');
});
});

View File

@@ -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<Record<LootCategory, string>> = {
[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<string, string>,
): 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}`;
}