This commit is contained in:
Bastian Wagner
2026-08-22 16:41:47 +02:00
parent dfa62fd152
commit 081c9f83f9
137 changed files with 11594 additions and 1302 deletions

View 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 };
}
}