173 lines
5.7 KiB
TypeScript
173 lines
5.7 KiB
TypeScript
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<DataSource, 'getRepository'>;
|
|
|
|
/**
|
|
* 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<LootCapacityDto[]> {
|
|
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<LootCapacityBudget> {
|
|
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<Map<LootCategory, number>> {
|
|
const items = await db.getRepository(CharacterItem).find({
|
|
where: { characterId },
|
|
relations: { itemDefinition: true },
|
|
});
|
|
|
|
const totals = new Map<LootCategory, number>();
|
|
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<Map<LootCategory, LootBagDefinition>> {
|
|
const owned = await db.getRepository(CharacterLootBag).find({
|
|
where: { characterId, active: true },
|
|
relations: { lootBagDefinition: true },
|
|
});
|
|
|
|
const best = new Map<LootCategory, LootBagDefinition>();
|
|
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<LootCategory, number>) {}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|