feat(shops): sell loot bags, honour bypass conditions, explain locks
This commit is contained in:
@@ -5,6 +5,8 @@ export type ShopErrorCode =
|
|||||||
| 'SHOP_DISABLED'
|
| 'SHOP_DISABLED'
|
||||||
| 'SHOP_OFFER_NOT_FOUND'
|
| 'SHOP_OFFER_NOT_FOUND'
|
||||||
| 'SHOP_OFFER_LOCKED'
|
| 'SHOP_OFFER_LOCKED'
|
||||||
|
| 'MERCHANT_REPUTATION_TOO_LOW'
|
||||||
|
| 'SHOP_BAG_ALREADY_OWNED'
|
||||||
| 'SHOP_INVALID_QUANTITY'
|
| 'SHOP_INVALID_QUANTITY'
|
||||||
| 'SHOP_INSUFFICIENT_SILVER';
|
| 'SHOP_INSUFFICIENT_SILVER';
|
||||||
|
|
||||||
@@ -57,6 +59,34 @@ export function shopOfferLocked(): ShopDomainError {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The specific case of `SHOP_OFFER_LOCKED` where reputation is what is short
|
||||||
|
* (Playable Slice 0.8.5 §6).
|
||||||
|
*
|
||||||
|
* Added alongside the generic code rather than replacing it: a gate on a quest
|
||||||
|
* flag or an item is still `SHOP_OFFER_LOCKED`, and telling a player to earn
|
||||||
|
* reputation they do not need would be worse than saying nothing specific.
|
||||||
|
*/
|
||||||
|
export function merchantReputationTooLow(): ShopDomainError {
|
||||||
|
return new ShopDomainError(
|
||||||
|
'MERCHANT_REPUTATION_TOO_LOW',
|
||||||
|
HttpStatus.FORBIDDEN,
|
||||||
|
'You have not earned enough standing for this yet.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only the roomiest active bag per category counts (Slice 0.7.5 §6), so a
|
||||||
|
* second copy grants nothing. Selling one would be taking Silver for nothing.
|
||||||
|
*/
|
||||||
|
export function shopBagAlreadyOwned(): ShopDomainError {
|
||||||
|
return new ShopDomainError(
|
||||||
|
'SHOP_BAG_ALREADY_OWNED',
|
||||||
|
HttpStatus.CONFLICT,
|
||||||
|
'You already carry that.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function shopInvalidQuantity(): ShopDomainError {
|
export function shopInvalidQuantity(): ShopDomainError {
|
||||||
return new ShopDomainError(
|
return new ShopDomainError(
|
||||||
'SHOP_INVALID_QUANTITY',
|
'SHOP_INVALID_QUANTITY',
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
import { DataSource, EntityManager } from 'typeorm';
|
import { DataSource, EntityManager } from 'typeorm';
|
||||||
import { Character } from '../characters/entities/character.entity';
|
import { Character } from '../characters/entities/character.entity';
|
||||||
import { GameConditionService } from '../conditions/game-condition.service';
|
import { GameConditionService } from '../conditions/game-condition.service';
|
||||||
|
import {
|
||||||
|
ComparisonOperator,
|
||||||
|
GameCondition,
|
||||||
|
GameConditionType,
|
||||||
|
} from '../conditions/game-condition.types';
|
||||||
import { CharacterItem } from '../items/entities/character-item.entity';
|
import { CharacterItem } from '../items/entities/character-item.entity';
|
||||||
|
import { LootCategory } from '../items/loot-category.enum';
|
||||||
|
import { CharacterLootBag } from '../loot-bags/entities/character-loot-bag.entity';
|
||||||
|
import { LootBagDefinition } from '../loot-bags/entities/loot-bag-definition.entity';
|
||||||
import { NpcService } from '../npcs/npc.service';
|
import { NpcService } from '../npcs/npc.service';
|
||||||
|
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
|
||||||
import { NpcShop } from './entities/npc-shop.entity';
|
import { NpcShop } from './entities/npc-shop.entity';
|
||||||
import { ShopOffer } from './entities/shop-offer.entity';
|
import { ShopOffer } from './entities/shop-offer.entity';
|
||||||
import { ShopService } from './shop.service';
|
import { ShopService } from './shop.service';
|
||||||
@@ -18,6 +27,16 @@ interface Fixture {
|
|||||||
offerRepeatable?: boolean;
|
offerRepeatable?: boolean;
|
||||||
offerQuantity?: number;
|
offerQuantity?: number;
|
||||||
ownedPotions?: number | null;
|
ownedPotions?: number | null;
|
||||||
|
/** Replaces the single item offer with one that sells the trophy pouch. */
|
||||||
|
bagOffer?: boolean;
|
||||||
|
/** Whether the character already holds the bag the offer sells. */
|
||||||
|
ownsBag?: boolean;
|
||||||
|
/** Conditions on the offer, so a test can gate it on reputation. */
|
||||||
|
conditions?: GameCondition[];
|
||||||
|
/** The alternative way in (slice §7). */
|
||||||
|
bypassConditions?: GameCondition[];
|
||||||
|
/** Which of `conditions` / `bypassConditions` the fake engine says hold. */
|
||||||
|
bypassPasses?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWorld(fixture: Fixture = {}) {
|
function createWorld(fixture: Fixture = {}) {
|
||||||
@@ -36,26 +55,61 @@ function createWorld(fixture: Fixture = {}) {
|
|||||||
quantity: fixture.ownedPotions,
|
quantity: fixture.ownedPotions,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const bag = {
|
||||||
|
id: 'bag-trophy-pouch',
|
||||||
|
key: 'basic-trophy-pouch',
|
||||||
|
name: 'Basic Trophy Pouch',
|
||||||
|
lootCategory: LootCategory.RAIDER_TROPHY,
|
||||||
|
capacity: 5,
|
||||||
|
iconPath: '/images/items/basic-trophy-pouch.png',
|
||||||
|
};
|
||||||
|
|
||||||
|
const grantedBags: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
const offers = [
|
const offers = [
|
||||||
{
|
fixture.bagOffer
|
||||||
id: 'offer-1',
|
? {
|
||||||
shopId: 'shop-1',
|
id: 'offer-bag',
|
||||||
itemDefinitionId: 'item-potion',
|
shopId: 'shop-1',
|
||||||
currencyType: 'SILVER',
|
itemDefinitionId: null,
|
||||||
price: 12,
|
lootBagDefinitionId: bag.id,
|
||||||
quantity: fixture.offerQuantity ?? 1,
|
currencyType: 'SILVER',
|
||||||
repeatable: fixture.offerRepeatable ?? true,
|
price: 40,
|
||||||
sortOrder: 1,
|
quantity: 1,
|
||||||
conditions: [],
|
repeatable: false,
|
||||||
enabled: true,
|
sortOrder: 1,
|
||||||
itemDefinition: {
|
conditions: fixture.conditions ?? [],
|
||||||
id: 'item-potion',
|
bypassConditions: fixture.bypassConditions ?? [],
|
||||||
key: 'small-healing-potion',
|
enabled: true,
|
||||||
name: 'Small Healing Potion',
|
itemDefinition: null,
|
||||||
description: 'A bitter draught.',
|
lootBagDefinition: bag,
|
||||||
iconPath: '/images/items/potion.png',
|
}
|
||||||
},
|
: {
|
||||||
},
|
id: 'offer-1',
|
||||||
|
shopId: 'shop-1',
|
||||||
|
itemDefinitionId: 'item-potion',
|
||||||
|
lootBagDefinitionId: null,
|
||||||
|
currencyType: 'SILVER',
|
||||||
|
price: 12,
|
||||||
|
quantity: fixture.offerQuantity ?? 1,
|
||||||
|
repeatable: fixture.offerRepeatable ?? true,
|
||||||
|
sortOrder: 1,
|
||||||
|
conditions: fixture.conditions ?? [],
|
||||||
|
bypassConditions: fixture.bypassConditions ?? [],
|
||||||
|
enabled: true,
|
||||||
|
itemDefinition: {
|
||||||
|
id: 'item-potion',
|
||||||
|
key: 'small-healing-potion',
|
||||||
|
name: 'Small Healing Potion',
|
||||||
|
description: 'A bitter draught.',
|
||||||
|
iconPath: '/images/items/potion.png',
|
||||||
|
weaponDamage: 0,
|
||||||
|
bonusAttack: 0,
|
||||||
|
bonusHp: 0,
|
||||||
|
bonusArmor: 0,
|
||||||
|
},
|
||||||
|
lootBagDefinition: null,
|
||||||
|
},
|
||||||
] as unknown as ShopOffer[];
|
] as unknown as ShopOffer[];
|
||||||
|
|
||||||
const repositories = (entity: unknown) => {
|
const repositories = (entity: unknown) => {
|
||||||
@@ -88,9 +142,35 @@ function createWorld(fixture: Fixture = {}) {
|
|||||||
return {
|
return {
|
||||||
findOne: () => Promise.resolve(owned),
|
findOne: () => Promise.resolve(owned),
|
||||||
create: (row: Record<string, unknown>) => row,
|
create: (row: Record<string, unknown>) => row,
|
||||||
save: async (row: Record<string, unknown>) => {
|
save: (row: Record<string, unknown>) => {
|
||||||
grantedItems.push(row);
|
grantedItems.push(row);
|
||||||
return row;
|
return Promise.resolve(row);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (entity === ReputationFaction) {
|
||||||
|
return {
|
||||||
|
find: () =>
|
||||||
|
Promise.resolve([
|
||||||
|
{ id: 'faction-1', key: 'border-guard', name: 'Border Watch' },
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (entity === LootBagDefinition) {
|
||||||
|
return { findOneBy: () => Promise.resolve(bag) };
|
||||||
|
}
|
||||||
|
if (entity === CharacterLootBag) {
|
||||||
|
return {
|
||||||
|
findOne: () =>
|
||||||
|
Promise.resolve(
|
||||||
|
fixture.ownsBag
|
||||||
|
? { characterId: CHARACTER_ID, lootBagDefinitionId: bag.id }
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
create: (row: Record<string, unknown>) => row,
|
||||||
|
save: (row: Record<string, unknown>) => {
|
||||||
|
grantedBags.push(row);
|
||||||
|
return Promise.resolve(row);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -105,7 +185,27 @@ function createWorld(fixture: Fixture = {}) {
|
|||||||
} as unknown as DataSource;
|
} as unknown as DataSource;
|
||||||
|
|
||||||
const conditions = {
|
const conditions = {
|
||||||
evaluate: jest.fn(() => Promise.resolve(fixture.offerUnlocked ?? true)),
|
evaluate: jest.fn(
|
||||||
|
(_context: unknown, list: GameCondition[] | undefined) => {
|
||||||
|
// The fixture distinguishes the two lists by identity, so a test can say
|
||||||
|
// "the gate is shut but the bypass is open".
|
||||||
|
if (list === fixture.bypassConditions) {
|
||||||
|
return Promise.resolve(fixture.bypassPasses ?? false);
|
||||||
|
}
|
||||||
|
// `offerUnlocked` is the fixture's switch for the offer's own gate,
|
||||||
|
// whatever the conditions expressing it happen to be.
|
||||||
|
return Promise.resolve(fixture.offerUnlocked ?? true);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
describe: jest.fn((_context: unknown, list: GameCondition[] | undefined) =>
|
||||||
|
Promise.resolve(
|
||||||
|
(list ?? []).map((condition) => ({
|
||||||
|
condition,
|
||||||
|
met: fixture.offerUnlocked ?? true,
|
||||||
|
actual: 14,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
),
|
||||||
} as unknown as GameConditionService;
|
} as unknown as GameConditionService;
|
||||||
|
|
||||||
const npcs = {
|
const npcs = {
|
||||||
@@ -116,6 +216,7 @@ function createWorld(fixture: Fixture = {}) {
|
|||||||
service: new ShopService(dataSource, conditions, npcs),
|
service: new ShopService(dataSource, conditions, npcs),
|
||||||
character,
|
character,
|
||||||
grantedItems,
|
grantedItems,
|
||||||
|
grantedBags,
|
||||||
owned,
|
owned,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -261,4 +362,159 @@ describe('ShopService', () => {
|
|||||||
expect(result.quantity).toBe(6);
|
expect(result.quantity).toBe(6);
|
||||||
expect(result.silverSpent).toBe(24);
|
expect(result.silverSpent).toBe(24);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows the requirement and the current value on a locked offer', async () => {
|
||||||
|
const world = createWorld({
|
||||||
|
offerUnlocked: false,
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
type: GameConditionType.REGION_REPUTATION,
|
||||||
|
key: 'border-guard',
|
||||||
|
operator: ComparisonOperator.GTE,
|
||||||
|
value: 25,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const view = await world.service.getShopView(CHARACTER_ID, MERCHANT_KEY);
|
||||||
|
|
||||||
|
// Locked, but still listed: a visible reward is a goal (slice §5).
|
||||||
|
expect(view.offers).toHaveLength(1);
|
||||||
|
expect(view.offers[0].unlocked).toBe(false);
|
||||||
|
expect(view.offers[0].requirements).toEqual([
|
||||||
|
{
|
||||||
|
label: 'Requires Border Watch Reputation 25',
|
||||||
|
current: 14,
|
||||||
|
required: 25,
|
||||||
|
met: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists a bag offer with its capacity as the effect', async () => {
|
||||||
|
const world = createWorld({ bagOffer: true, silver: 100 });
|
||||||
|
|
||||||
|
const view = await world.service.getShopView(CHARACTER_ID, MERCHANT_KEY);
|
||||||
|
|
||||||
|
expect(view.offers[0]).toMatchObject({
|
||||||
|
itemKey: 'basic-trophy-pouch',
|
||||||
|
itemName: 'Basic Trophy Pouch',
|
||||||
|
price: 40,
|
||||||
|
effectSummary: 'Capacity: 5 Raider Trophies',
|
||||||
|
unlocked: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grants a bag rather than stacking it as an item', async () => {
|
||||||
|
const world = createWorld({ bagOffer: true, silver: 100 });
|
||||||
|
|
||||||
|
const result = await world.service.purchase(
|
||||||
|
CHARACTER_ID,
|
||||||
|
MERCHANT_KEY,
|
||||||
|
'basic-trophy-pouch',
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.silverSpent).toBe(40);
|
||||||
|
expect(world.grantedItems).toHaveLength(0);
|
||||||
|
expect(world.grantedBags[0]).toMatchObject({
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
lootBagDefinitionId: 'bag-trophy-pouch',
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to sell a bag the character already carries', async () => {
|
||||||
|
// A second copy grants nothing (only the roomiest active bag per category
|
||||||
|
// counts) so charging for it would be taking Silver for nothing.
|
||||||
|
const world = createWorld({ bagOffer: true, ownsBag: true, silver: 100 });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
world.service.purchase(
|
||||||
|
CHARACTER_ID,
|
||||||
|
MERCHANT_KEY,
|
||||||
|
'basic-trophy-pouch',
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ code: 'SHOP_BAG_ALREADY_OWNED' });
|
||||||
|
|
||||||
|
expect(world.character.silver).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names reputation as the reason when a reputation gate is what blocks', async () => {
|
||||||
|
const world = createWorld({
|
||||||
|
offerUnlocked: false,
|
||||||
|
silver: 1000,
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
type: GameConditionType.REGION_REPUTATION,
|
||||||
|
key: 'border-guard',
|
||||||
|
operator: ComparisonOperator.GTE,
|
||||||
|
value: 25,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
world.service.purchase(
|
||||||
|
CHARACTER_ID,
|
||||||
|
MERCHANT_KEY,
|
||||||
|
'small-healing-potion',
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ code: 'MERCHANT_REPUTATION_TOO_LOW' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens an offer whose bypass holds even though its conditions do not', async () => {
|
||||||
|
// The Slice 0.9 referral: the warden's word is worth more than the
|
||||||
|
// reputation the player has not earned yet (slice §7).
|
||||||
|
const bypassConditions: GameCondition[] = [
|
||||||
|
{
|
||||||
|
type: GameConditionType.FLAG_SET,
|
||||||
|
key: 'referred-by-south-gate-warden',
|
||||||
|
value: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const world = createWorld({
|
||||||
|
bagOffer: true,
|
||||||
|
silver: 100,
|
||||||
|
offerUnlocked: false,
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
type: GameConditionType.REGION_REPUTATION,
|
||||||
|
key: 'border-guard',
|
||||||
|
operator: ComparisonOperator.GTE,
|
||||||
|
value: 40,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
bypassConditions,
|
||||||
|
bypassPasses: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await world.service.purchase(
|
||||||
|
CHARACTER_ID,
|
||||||
|
MERCHANT_KEY,
|
||||||
|
'basic-trophy-pouch',
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.silverSpent).toBe(40);
|
||||||
|
expect(world.grantedBags).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still charges the price when a requirement is met', async () => {
|
||||||
|
// Reputation opens the offer; it does not pay for it (slice §10).
|
||||||
|
const world = createWorld({ bagOffer: true, silver: 10 });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
world.service.purchase(
|
||||||
|
CHARACTER_ID,
|
||||||
|
MERCHANT_KEY,
|
||||||
|
'basic-trophy-pouch',
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ code: 'SHOP_INSUFFICIENT_SILVER' });
|
||||||
|
|
||||||
|
expect(world.grantedBags).toHaveLength(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource, EntityManager } from 'typeorm';
|
||||||
import { Character } from '../characters/entities/character.entity';
|
import { Character } from '../characters/entities/character.entity';
|
||||||
import { GameConditionService } from '../conditions/game-condition.service';
|
import { GameConditionService } from '../conditions/game-condition.service';
|
||||||
|
import { GameConditionType } from '../conditions/game-condition.types';
|
||||||
import { CharacterItem } from '../items/entities/character-item.entity';
|
import { CharacterItem } from '../items/entities/character-item.entity';
|
||||||
|
import { CharacterLootBag } from '../loot-bags/entities/character-loot-bag.entity';
|
||||||
import { NpcService } from '../npcs/npc.service';
|
import { NpcService } from '../npcs/npc.service';
|
||||||
|
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
|
||||||
import { NpcShop } from './entities/npc-shop.entity';
|
import { NpcShop } from './entities/npc-shop.entity';
|
||||||
import { ShopOffer } from './entities/shop-offer.entity';
|
import { ShopOffer } from './entities/shop-offer.entity';
|
||||||
|
import {
|
||||||
|
describeBagEffect,
|
||||||
|
describeItemEffect,
|
||||||
|
describeRequirement,
|
||||||
|
ShopOfferRequirementDto,
|
||||||
|
} from './offer-presentation';
|
||||||
import {
|
import {
|
||||||
characterNotFound,
|
characterNotFound,
|
||||||
|
merchantReputationTooLow,
|
||||||
|
shopBagAlreadyOwned,
|
||||||
shopDisabled,
|
shopDisabled,
|
||||||
shopInsufficientSilver,
|
shopInsufficientSilver,
|
||||||
shopInvalidQuantity,
|
shopInvalidQuantity,
|
||||||
@@ -26,12 +37,34 @@ export interface ShopOfferDto {
|
|||||||
currencyType: string;
|
currencyType: string;
|
||||||
price: number;
|
price: number;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
|
/** What buying it does: weapon stats, or a bag's capacity (slice §8). */
|
||||||
|
effectSummary: string | null;
|
||||||
|
/** Why it is locked, and how close the player is (slice §5). Empty when open. */
|
||||||
|
requirements: ShopOfferRequirementDto[];
|
||||||
/** False when the offer's conditions are not met (Slice 0.8.5 content). */
|
/** False when the offer's conditions are not met (Slice 0.8.5 content). */
|
||||||
unlocked: boolean;
|
unlocked: boolean;
|
||||||
/** True when the character simply cannot afford an otherwise open offer. */
|
/** True when the character simply cannot afford an otherwise open offer. */
|
||||||
affordable: boolean;
|
affordable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What an offer sells, flattened out of whichever target it points at.
|
||||||
|
*
|
||||||
|
* Both shapes reduce to a key, a name, an icon and an effect, which is all the
|
||||||
|
* presentation layer needs -- and `kind` plus `definitionId` is all the grant
|
||||||
|
* needs, so the branch on offer kind lives in one place instead of being spread
|
||||||
|
* across the view and the purchase path.
|
||||||
|
*/
|
||||||
|
interface OfferTarget {
|
||||||
|
kind: 'item' | 'bag';
|
||||||
|
definitionId: string;
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
iconPath: string;
|
||||||
|
effectSummary: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ShopViewDto {
|
export interface ShopViewDto {
|
||||||
shopKey: string;
|
shopKey: string;
|
||||||
shopName: string;
|
shopName: string;
|
||||||
@@ -79,31 +112,53 @@ export class ShopService {
|
|||||||
|
|
||||||
const offers = await this.dataSource.getRepository(ShopOffer).find({
|
const offers = await this.dataSource.getRepository(ShopOffer).find({
|
||||||
where: { shopId: shop.id, enabled: true },
|
where: { shopId: shop.id, enabled: true },
|
||||||
relations: { itemDefinition: true },
|
relations: { itemDefinition: true, lootBagDefinition: true },
|
||||||
order: { sortOrder: 'ASC' },
|
order: { sortOrder: 'ASC' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// One read for the whole view: every reputation requirement needs a
|
||||||
|
// display name, and offers commonly gate on the same faction.
|
||||||
|
const factionNames = new Map(
|
||||||
|
(await this.dataSource.getRepository(ReputationFaction).find()).map(
|
||||||
|
(faction) => [faction.key, faction.name],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const context = { characterId, npcId };
|
||||||
const view: ShopOfferDto[] = [];
|
const view: ShopOfferDto[] = [];
|
||||||
for (const offer of offers) {
|
for (const offer of offers) {
|
||||||
const unlocked = await this.conditions.evaluate(
|
const target = this.resolveTarget(offer);
|
||||||
{ characterId, npcId },
|
if (!target) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gate = await this.evaluateGate(context, offer);
|
||||||
|
// Described even when open, so the UI can show a requirement the player
|
||||||
|
// has already met rather than having it vanish on unlock. Only
|
||||||
|
// `conditions` are described: a bypass is content's private exception,
|
||||||
|
// not something the player is told to go and satisfy.
|
||||||
|
const outcomes = await this.conditions.describe(
|
||||||
|
context,
|
||||||
offer.conditions,
|
offer.conditions,
|
||||||
);
|
);
|
||||||
|
const requirements = outcomes
|
||||||
|
.map((outcome) => describeRequirement(outcome, factionNames))
|
||||||
|
.filter(
|
||||||
|
(requirement): requirement is ShopOfferRequirementDto =>
|
||||||
|
requirement !== null,
|
||||||
|
);
|
||||||
|
|
||||||
// TEMPORARY (Task 1 of Slice 0.8.5): `itemDefinition` became nullable
|
|
||||||
// when the offer table gained a loot-bag target alongside the item one.
|
|
||||||
// Every offer sold an item until Task 4 adds bag offers here, so the
|
|
||||||
// assertion is safe for now -- Task 4 replaces it with a real branch on
|
|
||||||
// offer kind.
|
|
||||||
view.push({
|
view.push({
|
||||||
itemKey: offer.itemDefinition!.key,
|
itemKey: target.key,
|
||||||
itemName: offer.itemDefinition!.name,
|
itemName: target.name,
|
||||||
itemDescription: offer.itemDefinition!.description,
|
itemDescription: target.description,
|
||||||
iconPath: offer.itemDefinition!.iconPath,
|
iconPath: target.iconPath,
|
||||||
currencyType: offer.currencyType,
|
currencyType: offer.currencyType,
|
||||||
price: offer.price,
|
price: offer.price,
|
||||||
quantity: offer.quantity,
|
quantity: offer.quantity,
|
||||||
unlocked,
|
effectSummary: target.effectSummary,
|
||||||
|
requirements,
|
||||||
|
unlocked: gate.open,
|
||||||
affordable: character.silver >= offer.price,
|
affordable: character.silver >= offer.price,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -146,51 +201,80 @@ export class ShopService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Matched on the joined definition's business key: the offer table is
|
// Matched on the joined definition's business key: the offer table is
|
||||||
// keyed by item definition id, while the request carries the stable key.
|
// keyed by definition id, while the request carries the stable key.
|
||||||
const offers = await manager.getRepository(ShopOffer).find({
|
const offers = await manager.getRepository(ShopOffer).find({
|
||||||
where: { shopId: shop.id, enabled: true },
|
where: { shopId: shop.id, enabled: true },
|
||||||
relations: { itemDefinition: true },
|
relations: { itemDefinition: true, lootBagDefinition: true },
|
||||||
});
|
});
|
||||||
const match = offers.find(
|
let match: ShopOffer | undefined;
|
||||||
(candidate) => candidate.itemDefinition!.key === itemKey,
|
let target: OfferTarget | undefined;
|
||||||
);
|
for (const candidate of offers) {
|
||||||
|
const candidateTarget = this.resolveTarget(candidate);
|
||||||
|
if (candidateTarget?.key === itemKey) {
|
||||||
|
match = candidate;
|
||||||
|
target = candidateTarget;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!match) {
|
if (!match || !target) {
|
||||||
throw shopOfferNotFound();
|
throw shopOfferNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const unlocked = await this.conditions.evaluate(
|
const gate = await this.evaluateGate(
|
||||||
{ characterId, npcId },
|
{ characterId, npcId },
|
||||||
match.conditions,
|
match,
|
||||||
manager,
|
manager,
|
||||||
);
|
);
|
||||||
if (!unlocked) {
|
if (!gate.open) {
|
||||||
throw shopOfferLocked();
|
throw gate.reputationBlocked
|
||||||
|
? merchantReputationTooLow()
|
||||||
|
: shopOfferLocked();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!match.repeatable && quantity > 1) {
|
if (!match.repeatable && quantity > 1) {
|
||||||
throw shopInvalidQuantity();
|
throw shopInvalidQuantity();
|
||||||
}
|
}
|
||||||
|
// A bag is one object, not a stack: buying two grants nothing extra.
|
||||||
|
if (target.kind === 'bag' && quantity > 1) {
|
||||||
|
throw shopInvalidQuantity();
|
||||||
|
}
|
||||||
|
|
||||||
const silverSpent = match.price * quantity;
|
const silverSpent = match.price * quantity;
|
||||||
if (character.silver < silverSpent) {
|
if (character.silver < silverSpent) {
|
||||||
throw shopInsufficientSilver();
|
throw shopInsufficientSilver();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Checked before the debit: the transaction would roll the Silver back
|
||||||
|
// anyway, but failing on the cheap read keeps the error honest about
|
||||||
|
// what went wrong.
|
||||||
|
if (target.kind === 'bag') {
|
||||||
|
const owned = await manager.getRepository(CharacterLootBag).findOne({
|
||||||
|
where: { characterId, lootBagDefinitionId: target.definitionId },
|
||||||
|
});
|
||||||
|
if (owned) {
|
||||||
|
throw shopBagAlreadyOwned();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
character.silver -= silverSpent;
|
character.silver -= silverSpent;
|
||||||
await characters.save(character);
|
await characters.save(character);
|
||||||
|
|
||||||
await this.grantItem(
|
if (target.kind === 'bag') {
|
||||||
manager,
|
await this.grantLootBag(manager, characterId, target.definitionId);
|
||||||
characterId,
|
} else {
|
||||||
match.itemDefinitionId!,
|
await this.grantItem(
|
||||||
match.quantity * quantity,
|
manager,
|
||||||
);
|
characterId,
|
||||||
|
target.definitionId,
|
||||||
|
match.quantity * quantity,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
shopKey: shop.key,
|
shopKey: shop.key,
|
||||||
itemKey,
|
itemKey,
|
||||||
itemName: match.itemDefinition!.name,
|
itemName: target.name,
|
||||||
quantity: match.quantity * quantity,
|
quantity: match.quantity * quantity,
|
||||||
silverSpent,
|
silverSpent,
|
||||||
silverBalance: character.silver,
|
silverBalance: character.silver,
|
||||||
@@ -198,6 +282,110 @@ export class ShopService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What an offer sells, whichever kind of thing that is.
|
||||||
|
*
|
||||||
|
* The two targets are mutually exclusive by `CHK_shop_offers_single_target`,
|
||||||
|
* so resolving them here lets the view and the purchase path agree on what an
|
||||||
|
* offer *is* without either of them branching on offer kind itself.
|
||||||
|
*/
|
||||||
|
private resolveTarget(offer: ShopOffer): OfferTarget | null {
|
||||||
|
if (offer.itemDefinition) {
|
||||||
|
return {
|
||||||
|
kind: 'item',
|
||||||
|
definitionId: offer.itemDefinition.id,
|
||||||
|
key: offer.itemDefinition.key,
|
||||||
|
name: offer.itemDefinition.name,
|
||||||
|
description: offer.itemDefinition.description,
|
||||||
|
iconPath: offer.itemDefinition.iconPath,
|
||||||
|
effectSummary: describeItemEffect(offer.itemDefinition),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (offer.lootBagDefinition) {
|
||||||
|
return {
|
||||||
|
kind: 'bag',
|
||||||
|
definitionId: offer.lootBagDefinition.id,
|
||||||
|
key: offer.lootBagDefinition.key,
|
||||||
|
name: offer.lootBagDefinition.name,
|
||||||
|
// A bag definition carries no flavour text of its own; the capacity
|
||||||
|
// line is the honest description of what it is.
|
||||||
|
description: describeBagEffect(offer.lootBagDefinition),
|
||||||
|
iconPath: offer.lootBagDefinition.iconPath,
|
||||||
|
effectSummary: describeBagEffect(offer.lootBagDefinition),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// CHK_shop_offers_single_target makes this unreachable through the
|
||||||
|
// database. Skipping the row beats rendering an offer that sells nothing.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an offer is open, and why not when it is shut.
|
||||||
|
*
|
||||||
|
* `conditions` OR `bypassConditions` -- the whole exception model (slice §7).
|
||||||
|
* A referral does not lower the requirement; it provides a second, narrower
|
||||||
|
* door that content opens deliberately.
|
||||||
|
*/
|
||||||
|
private async evaluateGate(
|
||||||
|
context: { characterId: string; npcId: string },
|
||||||
|
offer: ShopOffer,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<{ open: boolean; reputationBlocked: boolean }> {
|
||||||
|
if (await this.conditions.evaluate(context, offer.conditions, manager)) {
|
||||||
|
return { open: true, reputationBlocked: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const bypass = offer.bypassConditions ?? [];
|
||||||
|
if (
|
||||||
|
bypass.length > 0 &&
|
||||||
|
(await this.conditions.evaluate(context, bypass, manager))
|
||||||
|
) {
|
||||||
|
return { open: true, reputationBlocked: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which error to raise depends on what is actually short, so the player is
|
||||||
|
// told to earn reputation only when reputation is the thing missing.
|
||||||
|
const outcomes = await this.conditions.describe(
|
||||||
|
context,
|
||||||
|
offer.conditions,
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
const reputationBlocked = outcomes.some(
|
||||||
|
(outcome) =>
|
||||||
|
!outcome.met &&
|
||||||
|
outcome.condition.type === GameConditionType.REGION_REPUTATION,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { open: false, reputationBlocked };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hands over a bag, once.
|
||||||
|
*
|
||||||
|
* A second copy of the same bag grants nothing -- only the roomiest active
|
||||||
|
* bag per category counts (Slice 0.7.5 §6) -- so a repeat purchase is
|
||||||
|
* refused rather than silently charged. The unique index on
|
||||||
|
* (character_id, loot_bag_definition_id) is the real guarantee; this check
|
||||||
|
* is what turns a constraint violation into an explainable domain error.
|
||||||
|
*/
|
||||||
|
private async grantLootBag(
|
||||||
|
manager: { getRepository: DataSource['getRepository'] },
|
||||||
|
characterId: string,
|
||||||
|
lootBagDefinitionId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const bags = manager.getRepository(CharacterLootBag);
|
||||||
|
const existing = await bags.findOne({
|
||||||
|
where: { characterId, lootBagDefinitionId },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw shopBagAlreadyOwned();
|
||||||
|
}
|
||||||
|
|
||||||
|
await bags.save(
|
||||||
|
bags.create({ characterId, lootBagDefinitionId, active: true }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds to an existing stack or starts a new one.
|
* Adds to an existing stack or starts a new one.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import { Character } from '../characters/entities/character.entity';
|
import { Character } from '../characters/entities/character.entity';
|
||||||
import { ConditionsModule } from '../conditions/conditions.module';
|
import { ConditionsModule } from '../conditions/conditions.module';
|
||||||
import { CharacterItem } from '../items/entities/character-item.entity';
|
import { CharacterItem } from '../items/entities/character-item.entity';
|
||||||
|
import { CharacterLootBag } from '../loot-bags/entities/character-loot-bag.entity';
|
||||||
|
import { LootBagDefinition } from '../loot-bags/entities/loot-bag-definition.entity';
|
||||||
import { NpcsModule } from '../npcs/npcs.module';
|
import { NpcsModule } from '../npcs/npcs.module';
|
||||||
|
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
|
||||||
import { NpcShop } from './entities/npc-shop.entity';
|
import { NpcShop } from './entities/npc-shop.entity';
|
||||||
import { ShopOffer } from './entities/shop-offer.entity';
|
import { ShopOffer } from './entities/shop-offer.entity';
|
||||||
import { ShopController } from './shop.controller';
|
import { ShopController } from './shop.controller';
|
||||||
@@ -12,7 +15,15 @@ import { ShopService } from './shop.service';
|
|||||||
/** Buying things for Silver (NPC spec §15, §29). */
|
/** Buying things for Silver (NPC spec §15, §29). */
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Character, CharacterItem, NpcShop, ShopOffer]),
|
TypeOrmModule.forFeature([
|
||||||
|
Character,
|
||||||
|
CharacterItem,
|
||||||
|
CharacterLootBag,
|
||||||
|
LootBagDefinition,
|
||||||
|
NpcShop,
|
||||||
|
ReputationFaction,
|
||||||
|
ShopOffer,
|
||||||
|
]),
|
||||||
ConditionsModule,
|
ConditionsModule,
|
||||||
NpcsModule,
|
NpcsModule,
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user