docs
This commit is contained in:
15
apps/api/src/shops/dto/shop-purchase.dto.ts
Normal file
15
apps/api/src/shops/dto/shop-purchase.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { IsInt, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
export class ShopPurchaseDto {
|
||||
@IsString()
|
||||
itemKey!: string;
|
||||
|
||||
/**
|
||||
* How many lots to buy. Bounded so one request cannot ask for a quantity
|
||||
* whose price overflows before the affordability check can reject it.
|
||||
*/
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(99)
|
||||
quantity!: number;
|
||||
}
|
||||
47
apps/api/src/shops/entities/npc-shop.entity.ts
Normal file
47
apps/api/src/shops/entities/npc-shop.entity.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { NpcDefinition } from '../../npcs/entities/npc-definition.entity';
|
||||
|
||||
/**
|
||||
* A shop belonging to an NPC (NPC spec §15).
|
||||
*
|
||||
* A shop is a thing an NPC *has*, not a kind of NPC it *is*, which is why an
|
||||
* NPC may own none, one, or later several (spec §15, §37.1).
|
||||
*/
|
||||
@Entity({ name: 'npc_shops' })
|
||||
@Index('IDX_npc_shops_key', ['key'], { unique: true })
|
||||
@Index('IDX_npc_shops_npc', ['npcId'])
|
||||
export class NpcShop {
|
||||
@PrimaryGeneratedColumn('uuid', { name: 'id' })
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'key', type: 'varchar', length: 100 })
|
||||
key!: string;
|
||||
|
||||
@Column({ name: 'npc_id', type: 'uuid' })
|
||||
npcId!: string;
|
||||
|
||||
@Column({ name: 'name', type: 'varchar', length: 150 })
|
||||
name!: string;
|
||||
|
||||
@Column({ name: 'enabled', type: 'boolean', default: true })
|
||||
enabled!: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
|
||||
@ManyToOne(() => NpcDefinition, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'npc_id' })
|
||||
npc!: NpcDefinition;
|
||||
}
|
||||
77
apps/api/src/shops/entities/shop-offer.entity.ts
Normal file
77
apps/api/src/shops/entities/shop-offer.entity.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import type { GameCondition } from '../../conditions/game-condition.types';
|
||||
import { ItemDefinition } from '../../items/entities/item-definition.entity';
|
||||
import { NpcShop } from './npc-shop.entity';
|
||||
|
||||
/**
|
||||
* One thing a shop sells (NPC spec §15, §16).
|
||||
*
|
||||
* Availability rides on the offer's own conditions rather than on a separate
|
||||
* shop per reputation rank (spec §16). Slice 0.8 seeds no conditions at all --
|
||||
* gated offers are Slice 0.8.5 -- but the column exists now so that slice is
|
||||
* content work rather than a schema change.
|
||||
*/
|
||||
@Entity({ name: 'shop_offers' })
|
||||
@Index('IDX_shop_offers_shop_item', ['shopId', 'itemDefinitionId'], {
|
||||
unique: true,
|
||||
})
|
||||
export class ShopOffer {
|
||||
@PrimaryGeneratedColumn('uuid', { name: 'id' })
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'shop_id', type: 'uuid' })
|
||||
shopId!: string;
|
||||
|
||||
@Column({ name: 'item_definition_id', type: 'uuid' })
|
||||
itemDefinitionId!: string;
|
||||
|
||||
/**
|
||||
* Which currency `price` is denominated in. Only SILVER exists today; the
|
||||
* column keeps later currencies (spec §16 mentions Dämmermarken) from
|
||||
* needing a migration to the price column itself.
|
||||
*/
|
||||
@Column({ name: 'currency_type', type: 'varchar', length: 50 })
|
||||
currencyType!: string;
|
||||
|
||||
@Column({ name: 'price', type: 'integer' })
|
||||
price!: number;
|
||||
|
||||
/** How many the offer sells at once. */
|
||||
@Column({ name: 'quantity', type: 'integer', default: 1 })
|
||||
quantity!: number;
|
||||
|
||||
@Column({ name: 'repeatable', type: 'boolean', default: true })
|
||||
repeatable!: boolean;
|
||||
|
||||
@Column({ name: 'sort_order', type: 'integer', default: 0 })
|
||||
sortOrder!: number;
|
||||
|
||||
@Column({ name: 'conditions', type: 'jsonb', default: () => "'[]'::jsonb" })
|
||||
conditions!: GameCondition[];
|
||||
|
||||
@Column({ name: 'enabled', type: 'boolean', default: true })
|
||||
enabled!: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
|
||||
@ManyToOne(() => NpcShop, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'shop_id' })
|
||||
shop!: NpcShop;
|
||||
|
||||
@ManyToOne(() => ItemDefinition, { onDelete: 'RESTRICT' })
|
||||
@JoinColumn({ name: 'item_definition_id' })
|
||||
itemDefinition!: ItemDefinition;
|
||||
}
|
||||
32
apps/api/src/shops/shop.controller.ts
Normal file
32
apps/api/src/shops/shop.controller.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
||||
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
|
||||
import { ShopPurchaseDto } from './dto/shop-purchase.dto';
|
||||
import {
|
||||
ShopPurchaseResultDto,
|
||||
ShopService,
|
||||
ShopViewDto,
|
||||
} from './shop.service';
|
||||
|
||||
/** Shop endpoints for one merchant (NPC spec §22). */
|
||||
@Controller('merchants/:merchantKey/shop')
|
||||
export class ShopController {
|
||||
constructor(private readonly shopService: ShopService) {}
|
||||
|
||||
@Get()
|
||||
getShop(@Param('merchantKey') merchantKey: string): Promise<ShopViewDto> {
|
||||
return this.shopService.getShopView(DEMO_CHARACTER_ID, merchantKey);
|
||||
}
|
||||
|
||||
@Post('purchase')
|
||||
purchase(
|
||||
@Param('merchantKey') merchantKey: string,
|
||||
@Body() request: ShopPurchaseDto,
|
||||
): Promise<ShopPurchaseResultDto> {
|
||||
return this.shopService.purchase(
|
||||
DEMO_CHARACTER_ID,
|
||||
merchantKey,
|
||||
request.itemKey,
|
||||
request.quantity,
|
||||
);
|
||||
}
|
||||
}
|
||||
76
apps/api/src/shops/shop.errors.ts
Normal file
76
apps/api/src/shops/shop.errors.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
|
||||
export type ShopErrorCode =
|
||||
| 'SHOP_NOT_FOUND'
|
||||
| 'SHOP_DISABLED'
|
||||
| 'SHOP_OFFER_NOT_FOUND'
|
||||
| 'SHOP_OFFER_LOCKED'
|
||||
| 'SHOP_INVALID_QUANTITY'
|
||||
| 'SHOP_INSUFFICIENT_SILVER';
|
||||
|
||||
export class ShopDomainError extends HttpException {
|
||||
constructor(
|
||||
public readonly code: ShopErrorCode,
|
||||
status: HttpStatus,
|
||||
message: string,
|
||||
) {
|
||||
super({ statusCode: status, code, message }, status);
|
||||
}
|
||||
}
|
||||
|
||||
export function shopNotFound(): ShopDomainError {
|
||||
return new ShopDomainError(
|
||||
'SHOP_NOT_FOUND',
|
||||
HttpStatus.NOT_FOUND,
|
||||
'This merchant has nothing to sell.',
|
||||
);
|
||||
}
|
||||
|
||||
export function shopDisabled(): ShopDomainError {
|
||||
return new ShopDomainError(
|
||||
'SHOP_DISABLED',
|
||||
HttpStatus.CONFLICT,
|
||||
'This shop is closed.',
|
||||
);
|
||||
}
|
||||
|
||||
export function shopOfferNotFound(): ShopDomainError {
|
||||
return new ShopDomainError(
|
||||
'SHOP_OFFER_NOT_FOUND',
|
||||
HttpStatus.NOT_FOUND,
|
||||
'This merchant does not stock that.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The offer exists but its conditions are not met.
|
||||
*
|
||||
* Slice 0.8 seeds no gated offers, but the check is enforced from the start:
|
||||
* Slice 0.8.5 adds locked offers as content, and a gate that only existed in
|
||||
* the UI would be no gate at all (NPC spec §33, §37.9).
|
||||
*/
|
||||
export function shopOfferLocked(): ShopDomainError {
|
||||
return new ShopDomainError(
|
||||
'SHOP_OFFER_LOCKED',
|
||||
HttpStatus.FORBIDDEN,
|
||||
'You have not earned the right to buy this yet.',
|
||||
);
|
||||
}
|
||||
|
||||
export function shopInvalidQuantity(): ShopDomainError {
|
||||
return new ShopDomainError(
|
||||
'SHOP_INVALID_QUANTITY',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
'Quantity must be a positive whole number.',
|
||||
);
|
||||
}
|
||||
|
||||
export function shopInsufficientSilver(): ShopDomainError {
|
||||
return new ShopDomainError(
|
||||
'SHOP_INSUFFICIENT_SILVER',
|
||||
HttpStatus.CONFLICT,
|
||||
'You cannot afford that.',
|
||||
);
|
||||
}
|
||||
|
||||
export { characterNotFound } from '../travel/travel.errors';
|
||||
264
apps/api/src/shops/shop.service.spec.ts
Normal file
264
apps/api/src/shops/shop.service.spec.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import { Character } from '../characters/entities/character.entity';
|
||||
import { GameConditionService } from '../conditions/game-condition.service';
|
||||
import { CharacterItem } from '../items/entities/character-item.entity';
|
||||
import { NpcService } from '../npcs/npc.service';
|
||||
import { NpcShop } from './entities/npc-shop.entity';
|
||||
import { ShopOffer } from './entities/shop-offer.entity';
|
||||
import { ShopService } from './shop.service';
|
||||
|
||||
const CHARACTER_ID = 'character-1';
|
||||
const MERCHANT_KEY = 'borin-quartermaster';
|
||||
|
||||
interface Fixture {
|
||||
silver?: number;
|
||||
shopEnabled?: boolean;
|
||||
hasShop?: boolean;
|
||||
offerUnlocked?: boolean;
|
||||
offerRepeatable?: boolean;
|
||||
offerQuantity?: number;
|
||||
ownedPotions?: number | null;
|
||||
}
|
||||
|
||||
function createWorld(fixture: Fixture = {}) {
|
||||
const character = {
|
||||
id: CHARACTER_ID,
|
||||
silver: fixture.silver ?? 100,
|
||||
} as Character;
|
||||
|
||||
const grantedItems: Array<Record<string, unknown>> = [];
|
||||
const owned =
|
||||
fixture.ownedPotions === undefined || fixture.ownedPotions === null
|
||||
? null
|
||||
: {
|
||||
characterId: CHARACTER_ID,
|
||||
itemDefinitionId: 'item-potion',
|
||||
quantity: fixture.ownedPotions,
|
||||
};
|
||||
|
||||
const offers = [
|
||||
{
|
||||
id: 'offer-1',
|
||||
shopId: 'shop-1',
|
||||
itemDefinitionId: 'item-potion',
|
||||
currencyType: 'SILVER',
|
||||
price: 12,
|
||||
quantity: fixture.offerQuantity ?? 1,
|
||||
repeatable: fixture.offerRepeatable ?? true,
|
||||
sortOrder: 1,
|
||||
conditions: [],
|
||||
enabled: true,
|
||||
itemDefinition: {
|
||||
id: 'item-potion',
|
||||
key: 'small-healing-potion',
|
||||
name: 'Small Healing Potion',
|
||||
description: 'A bitter draught.',
|
||||
iconPath: '/images/items/potion.png',
|
||||
},
|
||||
},
|
||||
] as unknown as ShopOffer[];
|
||||
|
||||
const repositories = (entity: unknown) => {
|
||||
if (entity === Character) {
|
||||
return {
|
||||
findOne: () => Promise.resolve(character),
|
||||
findOneBy: () => Promise.resolve(character),
|
||||
save: (row: Character) => Promise.resolve(row),
|
||||
};
|
||||
}
|
||||
if (entity === NpcShop) {
|
||||
return {
|
||||
findOneBy: () =>
|
||||
Promise.resolve(
|
||||
(fixture.hasShop ?? true)
|
||||
? {
|
||||
id: 'shop-1',
|
||||
key: 'borin-supplies',
|
||||
name: "Quartermaster's Supplies",
|
||||
enabled: fixture.shopEnabled ?? true,
|
||||
}
|
||||
: null,
|
||||
),
|
||||
};
|
||||
}
|
||||
if (entity === ShopOffer) {
|
||||
return { find: () => Promise.resolve(offers) };
|
||||
}
|
||||
if (entity === CharacterItem) {
|
||||
return {
|
||||
findOne: () => Promise.resolve(owned),
|
||||
create: (row: Record<string, unknown>) => row,
|
||||
save: async (row: Record<string, unknown>) => {
|
||||
grantedItems.push(row);
|
||||
return row;
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error('Unexpected repository');
|
||||
};
|
||||
|
||||
const manager = { getRepository: repositories } as unknown as EntityManager;
|
||||
const dataSource = {
|
||||
getRepository: repositories,
|
||||
transaction: async <T>(run: (m: EntityManager) => Promise<T>) =>
|
||||
run(manager),
|
||||
} as unknown as DataSource;
|
||||
|
||||
const conditions = {
|
||||
evaluate: jest.fn(() => Promise.resolve(fixture.offerUnlocked ?? true)),
|
||||
} as unknown as GameConditionService;
|
||||
|
||||
const npcs = {
|
||||
requireReachableNpc: jest.fn(() => Promise.resolve({ id: 'npc-1' })),
|
||||
} as unknown as NpcService;
|
||||
|
||||
return {
|
||||
service: new ShopService(dataSource, conditions, npcs),
|
||||
character,
|
||||
grantedItems,
|
||||
owned,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ShopService', () => {
|
||||
it('lists offers with the price the server holds', async () => {
|
||||
const world = createWorld({ silver: 100 });
|
||||
|
||||
const view = await world.service.getShopView(CHARACTER_ID, MERCHANT_KEY);
|
||||
|
||||
expect(view.silver).toBe(100);
|
||||
expect(view.offers[0]).toMatchObject({
|
||||
itemKey: 'small-healing-potion',
|
||||
price: 12,
|
||||
unlocked: true,
|
||||
affordable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks an offer the character cannot afford without hiding it', async () => {
|
||||
const world = createWorld({ silver: 3 });
|
||||
|
||||
const view = await world.service.getShopView(CHARACTER_ID, MERCHANT_KEY);
|
||||
|
||||
expect(view.offers[0]).toMatchObject({ unlocked: true, affordable: false });
|
||||
});
|
||||
|
||||
it('debits Silver and grants the item', async () => {
|
||||
const world = createWorld({ silver: 50 });
|
||||
|
||||
const result = await world.service.purchase(
|
||||
CHARACTER_ID,
|
||||
MERCHANT_KEY,
|
||||
'small-healing-potion',
|
||||
2,
|
||||
);
|
||||
|
||||
expect(result.silverSpent).toBe(24);
|
||||
expect(world.character.silver).toBe(26);
|
||||
expect(world.grantedItems[0]).toMatchObject({ quantity: 2 });
|
||||
});
|
||||
|
||||
it('stacks onto an existing pile rather than starting a second one', async () => {
|
||||
const world = createWorld({ silver: 50, ownedPotions: 3 });
|
||||
|
||||
await world.service.purchase(
|
||||
CHARACTER_ID,
|
||||
MERCHANT_KEY,
|
||||
'small-healing-potion',
|
||||
1,
|
||||
);
|
||||
|
||||
expect(world.grantedItems[0]).toMatchObject({ quantity: 4 });
|
||||
});
|
||||
|
||||
it('refuses a purchase the character cannot afford, leaving Silver intact', async () => {
|
||||
const world = createWorld({ silver: 5 });
|
||||
|
||||
await expect(
|
||||
world.service.purchase(
|
||||
CHARACTER_ID,
|
||||
MERCHANT_KEY,
|
||||
'small-healing-potion',
|
||||
1,
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'SHOP_INSUFFICIENT_SILVER' });
|
||||
|
||||
expect(world.character.silver).toBe(5);
|
||||
expect(world.grantedItems).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('refuses a locked offer even when the request asks for it directly', async () => {
|
||||
// The gate is enforced server-side, so hiding it in the UI is not the
|
||||
// protection (NPC spec §33: "gesperrtes Item kann nicht direkt über API
|
||||
// gekauft werden").
|
||||
const world = createWorld({ offerUnlocked: false, silver: 1000 });
|
||||
|
||||
await expect(
|
||||
world.service.purchase(
|
||||
CHARACTER_ID,
|
||||
MERCHANT_KEY,
|
||||
'small-healing-potion',
|
||||
1,
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'SHOP_OFFER_LOCKED' });
|
||||
|
||||
expect(world.character.silver).toBe(1000);
|
||||
});
|
||||
|
||||
it('refuses an item the shop does not stock', async () => {
|
||||
const world = createWorld();
|
||||
|
||||
await expect(
|
||||
world.service.purchase(CHARACTER_ID, MERCHANT_KEY, 'ash-blade', 1),
|
||||
).rejects.toMatchObject({ code: 'SHOP_OFFER_NOT_FOUND' });
|
||||
});
|
||||
|
||||
it('refuses a non-positive quantity', async () => {
|
||||
const world = createWorld();
|
||||
|
||||
await expect(
|
||||
world.service.purchase(
|
||||
CHARACTER_ID,
|
||||
MERCHANT_KEY,
|
||||
'small-healing-potion',
|
||||
0,
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'SHOP_INVALID_QUANTITY' });
|
||||
});
|
||||
|
||||
it('refuses to buy a one-off offer more than once in a request', async () => {
|
||||
const world = createWorld({ offerRepeatable: false });
|
||||
|
||||
await expect(
|
||||
world.service.purchase(
|
||||
CHARACTER_ID,
|
||||
MERCHANT_KEY,
|
||||
'small-healing-potion',
|
||||
2,
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'SHOP_INVALID_QUANTITY' });
|
||||
});
|
||||
|
||||
it('refuses a closed shop', async () => {
|
||||
const world = createWorld({ shopEnabled: false });
|
||||
|
||||
await expect(
|
||||
world.service.getShopView(CHARACTER_ID, MERCHANT_KEY),
|
||||
).rejects.toMatchObject({ code: 'SHOP_DISABLED' });
|
||||
});
|
||||
|
||||
it('grants the offer bundle size, not the request count', async () => {
|
||||
// An offer that sells three at a time, bought twice, is six items.
|
||||
const world = createWorld({ silver: 100, offerQuantity: 3 });
|
||||
|
||||
const result = await world.service.purchase(
|
||||
CHARACTER_ID,
|
||||
MERCHANT_KEY,
|
||||
'small-healing-potion',
|
||||
2,
|
||||
);
|
||||
|
||||
expect(result.quantity).toBe(6);
|
||||
expect(result.silverSpent).toBe(24);
|
||||
});
|
||||
});
|
||||
243
apps/api/src/shops/shop.service.ts
Normal file
243
apps/api/src/shops/shop.service.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { Character } from '../characters/entities/character.entity';
|
||||
import { GameConditionService } from '../conditions/game-condition.service';
|
||||
import { CharacterItem } from '../items/entities/character-item.entity';
|
||||
import { NpcService } from '../npcs/npc.service';
|
||||
import { NpcShop } from './entities/npc-shop.entity';
|
||||
import { ShopOffer } from './entities/shop-offer.entity';
|
||||
import {
|
||||
characterNotFound,
|
||||
shopDisabled,
|
||||
shopInsufficientSilver,
|
||||
shopInvalidQuantity,
|
||||
shopNotFound,
|
||||
shopOfferLocked,
|
||||
shopOfferNotFound,
|
||||
} from './shop.errors';
|
||||
|
||||
export const SILVER_CURRENCY = 'SILVER';
|
||||
|
||||
export interface ShopOfferDto {
|
||||
itemKey: string;
|
||||
itemName: string;
|
||||
itemDescription: string;
|
||||
iconPath: string;
|
||||
currencyType: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
/** False when the offer's conditions are not met (Slice 0.8.5 content). */
|
||||
unlocked: boolean;
|
||||
/** True when the character simply cannot afford an otherwise open offer. */
|
||||
affordable: boolean;
|
||||
}
|
||||
|
||||
export interface ShopViewDto {
|
||||
shopKey: string;
|
||||
shopName: string;
|
||||
npcKey: string;
|
||||
silver: number;
|
||||
offers: ShopOfferDto[];
|
||||
}
|
||||
|
||||
export interface ShopPurchaseResultDto {
|
||||
shopKey: string;
|
||||
itemKey: string;
|
||||
itemName: string;
|
||||
quantity: number;
|
||||
silverSpent: number;
|
||||
silverBalance: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sells goods for Silver (NPC spec §15, §30).
|
||||
*
|
||||
* Prices and availability are read from content on every call. The request
|
||||
* names an item and a count and nothing else, so a client cannot set its own
|
||||
* price or open a locked offer (spec §37.9, §33).
|
||||
*/
|
||||
@Injectable()
|
||||
export class ShopService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly conditions: GameConditionService,
|
||||
private readonly npcs: NpcService,
|
||||
) {}
|
||||
|
||||
async getShopView(
|
||||
characterId: string,
|
||||
merchantKey: string,
|
||||
): Promise<ShopViewDto> {
|
||||
const { shop, npcId } = await this.requireShop(characterId, merchantKey);
|
||||
|
||||
const character = await this.dataSource
|
||||
.getRepository(Character)
|
||||
.findOneBy({ id: characterId });
|
||||
if (!character) {
|
||||
throw characterNotFound();
|
||||
}
|
||||
|
||||
const offers = await this.dataSource.getRepository(ShopOffer).find({
|
||||
where: { shopId: shop.id, enabled: true },
|
||||
relations: { itemDefinition: true },
|
||||
order: { sortOrder: 'ASC' },
|
||||
});
|
||||
|
||||
const view: ShopOfferDto[] = [];
|
||||
for (const offer of offers) {
|
||||
const unlocked = await this.conditions.evaluate(
|
||||
{ characterId, npcId },
|
||||
offer.conditions,
|
||||
);
|
||||
|
||||
view.push({
|
||||
itemKey: offer.itemDefinition.key,
|
||||
itemName: offer.itemDefinition.name,
|
||||
itemDescription: offer.itemDefinition.description,
|
||||
iconPath: offer.itemDefinition.iconPath,
|
||||
currencyType: offer.currencyType,
|
||||
price: offer.price,
|
||||
quantity: offer.quantity,
|
||||
unlocked,
|
||||
affordable: character.silver >= offer.price,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
shopKey: shop.key,
|
||||
shopName: shop.name,
|
||||
npcKey: merchantKey,
|
||||
silver: character.silver,
|
||||
offers: view,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Buys `quantity` lots of one offer, atomically (spec §31).
|
||||
*
|
||||
* Silver is debited and the item granted in the same transaction, so a
|
||||
* failure cannot leave the character paid-up and empty-handed.
|
||||
*/
|
||||
async purchase(
|
||||
characterId: string,
|
||||
merchantKey: string,
|
||||
itemKey: string,
|
||||
quantity: number,
|
||||
): Promise<ShopPurchaseResultDto> {
|
||||
if (!Number.isInteger(quantity) || quantity <= 0) {
|
||||
throw shopInvalidQuantity();
|
||||
}
|
||||
|
||||
const { shop, npcId } = await this.requireShop(characterId, merchantKey);
|
||||
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const characters = manager.getRepository(Character);
|
||||
const character = await characters.findOne({
|
||||
where: { id: characterId },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
if (!character) {
|
||||
throw characterNotFound();
|
||||
}
|
||||
|
||||
// Matched on the joined definition's business key: the offer table is
|
||||
// keyed by item definition id, while the request carries the stable key.
|
||||
const offers = await manager.getRepository(ShopOffer).find({
|
||||
where: { shopId: shop.id, enabled: true },
|
||||
relations: { itemDefinition: true },
|
||||
});
|
||||
const match = offers.find(
|
||||
(candidate) => candidate.itemDefinition.key === itemKey,
|
||||
);
|
||||
|
||||
if (!match) {
|
||||
throw shopOfferNotFound();
|
||||
}
|
||||
|
||||
const unlocked = await this.conditions.evaluate(
|
||||
{ characterId, npcId },
|
||||
match.conditions,
|
||||
manager,
|
||||
);
|
||||
if (!unlocked) {
|
||||
throw shopOfferLocked();
|
||||
}
|
||||
|
||||
if (!match.repeatable && quantity > 1) {
|
||||
throw shopInvalidQuantity();
|
||||
}
|
||||
|
||||
const silverSpent = match.price * quantity;
|
||||
if (character.silver < silverSpent) {
|
||||
throw shopInsufficientSilver();
|
||||
}
|
||||
|
||||
character.silver -= silverSpent;
|
||||
await characters.save(character);
|
||||
|
||||
await this.grantItem(
|
||||
manager,
|
||||
characterId,
|
||||
match.itemDefinitionId,
|
||||
match.quantity * quantity,
|
||||
);
|
||||
|
||||
return {
|
||||
shopKey: shop.key,
|
||||
itemKey,
|
||||
itemName: match.itemDefinition.name,
|
||||
quantity: match.quantity * quantity,
|
||||
silverSpent,
|
||||
silverBalance: character.silver,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds to an existing stack or starts a new one.
|
||||
*
|
||||
* Purchases deliberately ignore loot-bag capacity: bags limit trade goods
|
||||
* carried out of a hunt, and equipment and consumables are unaffected by
|
||||
* them (Slice 0.7.5 §8).
|
||||
*/
|
||||
private async grantItem(
|
||||
manager: { getRepository: DataSource['getRepository'] },
|
||||
characterId: string,
|
||||
itemDefinitionId: string,
|
||||
quantity: number,
|
||||
): Promise<void> {
|
||||
const characterItems = manager.getRepository(CharacterItem);
|
||||
const existing = await characterItems.findOne({
|
||||
where: { characterId, itemDefinitionId },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
existing.quantity += quantity;
|
||||
await characterItems.save(existing);
|
||||
return;
|
||||
}
|
||||
|
||||
await characterItems.save(
|
||||
characterItems.create({ characterId, itemDefinitionId, quantity }),
|
||||
);
|
||||
}
|
||||
|
||||
private async requireShop(
|
||||
characterId: string,
|
||||
merchantKey: string,
|
||||
): Promise<{ shop: NpcShop; npcId: string }> {
|
||||
const npc = await this.npcs.requireReachableNpc(characterId, merchantKey);
|
||||
|
||||
const shop = await this.dataSource
|
||||
.getRepository(NpcShop)
|
||||
.findOneBy({ npcId: npc.id });
|
||||
if (!shop) {
|
||||
throw shopNotFound();
|
||||
}
|
||||
if (!shop.enabled) {
|
||||
throw shopDisabled();
|
||||
}
|
||||
|
||||
return { shop, npcId: npc.id };
|
||||
}
|
||||
}
|
||||
23
apps/api/src/shops/shops.module.ts
Normal file
23
apps/api/src/shops/shops.module.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Character } from '../characters/entities/character.entity';
|
||||
import { ConditionsModule } from '../conditions/conditions.module';
|
||||
import { CharacterItem } from '../items/entities/character-item.entity';
|
||||
import { NpcsModule } from '../npcs/npcs.module';
|
||||
import { NpcShop } from './entities/npc-shop.entity';
|
||||
import { ShopOffer } from './entities/shop-offer.entity';
|
||||
import { ShopController } from './shop.controller';
|
||||
import { ShopService } from './shop.service';
|
||||
|
||||
/** Buying things for Silver (NPC spec §15, §29). */
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Character, CharacterItem, NpcShop, ShopOffer]),
|
||||
ConditionsModule,
|
||||
NpcsModule,
|
||||
],
|
||||
controllers: [ShopController],
|
||||
providers: [ShopService],
|
||||
exports: [ShopService],
|
||||
})
|
||||
export class ShopsModule {}
|
||||
Reference in New Issue
Block a user