feat(shops): describe offer requirements and effects in English
This commit is contained in:
146
apps/api/src/shops/offer-presentation.spec.ts
Normal file
146
apps/api/src/shops/offer-presentation.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
104
apps/api/src/shops/offer-presentation.ts
Normal file
104
apps/api/src/shops/offer-presentation.ts
Normal 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}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user