docs
This commit is contained in:
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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user