import { Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { CharacterItem } from '../items/entities/character-item.entity'; import { LootCategory } from '../items/loot-category.enum'; import { CharacterLootBag } from './entities/character-loot-bag.entity'; import { LootBagDefinition } from './entities/loot-bag-definition.entity'; /** * Carrying capacity of a loot category with no bag at all (spec §2). * * One, not zero: the player must always be able to bring *something* home * from a hunt, or a bagless character could never obtain the goods that buy * the first bag. */ export const DEFAULT_LOOT_CAPACITY = 1; export interface LootCapacityBagDto { key: string; name: string; iconPath: string; } export interface LootCapacityDto { category: LootCategory; current: number; capacity: number; /** The active bag behind `capacity`, or null when it is the bagless default. */ bag: LootCapacityBagDto | null; } // Both DataSource and EntityManager expose this; naming it keeps the read path // usable inside and outside a transaction without a union type. type RepositoryScope = Pick; /** * The single authority on how much of each loot category a character can * carry (spec §8). * * Everything is derived from persisted state — owned bags and owned items — * so a client can neither submit a capacity nor claim a bag it does not have * (spec §13). Nothing here reads a request. */ @Injectable() export class LootCapacityService { constructor(private readonly dataSource: DataSource) {} /** Capacity and current fill for every known loot category. */ async getCapacities( characterId: string, scope?: RepositoryScope, ): Promise { const db = scope ?? this.dataSource; const [carried, bags] = await Promise.all([ this.loadCarriedTotals(characterId, db), this.loadActiveBags(characterId, db), ]); return Object.values(LootCategory).map((category) => { const bag = bags.get(category); return { category, current: carried.get(category) ?? 0, capacity: bag?.capacity ?? DEFAULT_LOOT_CAPACITY, bag: bag ? { key: bag.key, name: bag.name, iconPath: bag.iconPath } : null, }; }); } /** * How much of `category` still fits, as a live budget the caller can spend * down across several items of one reward (spec §10). * * Clamped at zero: a capacity that shrank below what the character already * carries — a bag unequipped, a definition retuned — must read as "no room", * never as a negative that would let a grant through. */ async createBudget( characterId: string, scope?: RepositoryScope, ): Promise { const capacities = await this.getCapacities(characterId, scope); return new LootCapacityBudget( new Map( capacities.map((entry) => [ entry.category, Math.max(0, entry.capacity - entry.current), ]), ), ); } /** * Sums owned quantities per category. Items outside every loot category — * equipment, consumables — are absent from the result entirely, which is * what makes them unaffected by bag capacity (spec §8). */ private async loadCarriedTotals( characterId: string, db: RepositoryScope, ): Promise> { const items = await db.getRepository(CharacterItem).find({ where: { characterId }, relations: { itemDefinition: true }, }); const totals = new Map(); for (const item of items) { const category = item.itemDefinition.lootCategory; if (!category) { continue; } totals.set(category, (totals.get(category) ?? 0) + item.quantity); } return totals; } /** * The active bag per category. V1 expects at most one, but if a future bug * or a hand-edited row leaves two active in one category, the roomiest wins * — a deterministic answer beats whichever row the query happened to return * first, and erring toward the player is the harmless direction. */ private async loadActiveBags( characterId: string, db: RepositoryScope, ): Promise> { const owned = await db.getRepository(CharacterLootBag).find({ where: { characterId, active: true }, relations: { lootBagDefinition: true }, }); const best = new Map(); for (const { lootBagDefinition: definition } of owned) { const current = best.get(definition.lootCategory); if (!current || definition.capacity > current.capacity) { best.set(definition.lootCategory, definition); } } return best; } } /** * The remaining room per category for one reward grant. * * Handed out by `createBudget` and spent down as items are granted, so two * hides in the same reward cannot both slip through the last free slot * (spec §10). Deliberately a plain object with no database access: the * arithmetic is pure and directly testable. */ export class LootCapacityBudget { constructor(private readonly remaining: Map) {} /** * Reserves up to `quantity` units of `category` and reports how much fit. * * An item with no loot category is not a trade good and is never limited — * that is how a Bandit Hood still drops into a full hide bag (spec §9). */ take(category: LootCategory | null, quantity: number): number { if (category === null) { return quantity; } const free = this.remaining.get(category) ?? DEFAULT_LOOT_CAPACITY; const granted = Math.min(free, quantity); this.remaining.set(category, free - granted); return granted; } }