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

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

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

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

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

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

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

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