Files
ashen-realms/apps/api/src/loot-bags/loot-capacity.service.ts
Bastian Wagner 081c9f83f9 docs
2026-08-22 16:41:47 +02:00

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