docs
This commit is contained in:
64
apps/api/src/loot-bags/entities/character-loot-bag.entity.ts
Normal file
64
apps/api/src/loot-bags/entities/character-loot-bag.entity.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { Character } from '../../characters/entities/character.entity';
|
||||
import { LootBagDefinition } from './loot-bag-definition.entity';
|
||||
|
||||
/**
|
||||
* A bag one character owns, and whether they are currently carrying it
|
||||
* (Playable Slice 0.7.5 §6).
|
||||
*
|
||||
* Its own loadout, not an equipment slot: `CharacterEquipment` is about combat
|
||||
* stats and armor slots, and mixing the two would let a bag compete with a
|
||||
* chest piece.
|
||||
*
|
||||
* V1 allows one active bag per loot category (spec §6). The category lives on
|
||||
* the definition rather than being copied here, so the database cannot express
|
||||
* that as a partial unique index; `LootCapacityService` resolves a duplicate
|
||||
* deterministically instead of trusting row order. What the unique index below
|
||||
* *does* guarantee is that a character never holds the same bag twice.
|
||||
*/
|
||||
@Entity({ name: 'character_loot_bags' })
|
||||
@Index(
|
||||
'IDX_character_loot_bags_character_definition',
|
||||
['characterId', 'lootBagDefinitionId'],
|
||||
{ unique: true },
|
||||
)
|
||||
@Index('IDX_character_loot_bags_character', ['characterId'])
|
||||
export class CharacterLootBag {
|
||||
@PrimaryGeneratedColumn('uuid', { name: 'id' })
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'character_id', type: 'uuid' })
|
||||
characterId!: string;
|
||||
|
||||
@Column({ name: 'loot_bag_definition_id', type: 'uuid' })
|
||||
lootBagDefinitionId!: string;
|
||||
|
||||
// An owned but stowed bag grants nothing. Only an active bag counts toward
|
||||
// capacity, which is what makes "only active bag affects capacity"
|
||||
// (spec §13) a real rule rather than an accident of ownership.
|
||||
@Column({ name: 'active', type: 'boolean', default: true })
|
||||
active!: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
|
||||
@ManyToOne(() => Character, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'character_id' })
|
||||
character!: Character;
|
||||
|
||||
@ManyToOne(() => LootBagDefinition, { onDelete: 'RESTRICT' })
|
||||
@JoinColumn({ name: 'loot_bag_definition_id' })
|
||||
lootBagDefinition!: LootBagDefinition;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { LootCategory } from '../../items/loot-category.enum';
|
||||
|
||||
/**
|
||||
* A bag that raises the carrying capacity of exactly one loot category
|
||||
* (Playable Slice 0.7.5 §6, §7).
|
||||
*
|
||||
* Content, not player state: capacity is balancing data, tuned here rather
|
||||
* than in the service that reads it. Deliberately not an `ItemDefinition` --
|
||||
* a bag is never equipped into an armor slot, never rolls as loot, and has no
|
||||
* combat stats (spec §6).
|
||||
*/
|
||||
@Entity({ name: 'loot_bag_definitions' })
|
||||
@Index('IDX_loot_bag_definitions_key', ['key'], { unique: true })
|
||||
export class LootBagDefinition {
|
||||
@PrimaryGeneratedColumn('uuid', { name: 'id' })
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'key', type: 'varchar', length: 100 })
|
||||
key!: string;
|
||||
|
||||
@Column({ name: 'name', type: 'varchar', length: 150 })
|
||||
name!: string;
|
||||
|
||||
@Column({
|
||||
name: 'loot_category',
|
||||
type: 'enum',
|
||||
enum: LootCategory,
|
||||
enumName: 'loot_category_enum',
|
||||
})
|
||||
lootCategory!: LootCategory;
|
||||
|
||||
@Column({ name: 'capacity', type: 'integer' })
|
||||
capacity!: number;
|
||||
|
||||
@Column({ name: 'icon_path', type: 'varchar', length: 255 })
|
||||
iconPath!: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
20
apps/api/src/loot-bags/loot-bags.controller.ts
Normal file
20
apps/api/src/loot-bags/loot-bags.controller.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
|
||||
import { LootCapacityDto, LootCapacityService } from './loot-capacity.service';
|
||||
|
||||
@Controller('loot-bags')
|
||||
export class LootBagsController {
|
||||
constructor(private readonly lootCapacity: LootCapacityService) {}
|
||||
|
||||
/**
|
||||
* Carrying state per loot category (spec §11).
|
||||
*
|
||||
* Its own route rather than a field on the inventory response: the hunt
|
||||
* screen needs this between fights and has no reason to pull the whole item
|
||||
* list to get it (spec §12).
|
||||
*/
|
||||
@Get('capacities')
|
||||
getCapacities(): Promise<LootCapacityDto[]> {
|
||||
return this.lootCapacity.getCapacities(DEMO_CHARACTER_ID);
|
||||
}
|
||||
}
|
||||
31
apps/api/src/loot-bags/loot-bags.module.ts
Normal file
31
apps/api/src/loot-bags/loot-bags.module.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { CharacterItem } from '../items/entities/character-item.entity';
|
||||
import { CharacterLootBag } from './entities/character-loot-bag.entity';
|
||||
import { LootBagDefinition } from './entities/loot-bag-definition.entity';
|
||||
import { LootBagsController } from './loot-bags.controller';
|
||||
import { LootCapacityService } from './loot-capacity.service';
|
||||
|
||||
/**
|
||||
* Owns carrying capacity for loot categories (Playable Slice 0.7.5).
|
||||
*
|
||||
* `LootCapacityService` resolves its repositories off the injected DataSource
|
||||
* rather than through constructor injection, because reward granting calls it
|
||||
* with the transaction's own EntityManager. The `forFeature` registration is
|
||||
* still required: the runtime config uses `autoLoadEntities`, which only sees
|
||||
* entities a module declares, so without this the metadata for these tables
|
||||
* would never be registered at all.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
CharacterItem,
|
||||
CharacterLootBag,
|
||||
LootBagDefinition,
|
||||
]),
|
||||
],
|
||||
controllers: [LootBagsController],
|
||||
providers: [LootCapacityService],
|
||||
exports: [LootCapacityService],
|
||||
})
|
||||
export class LootBagsModule {}
|
||||
333
apps/api/src/loot-bags/loot-capacity.service.spec.ts
Normal file
333
apps/api/src/loot-bags/loot-capacity.service.spec.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
import { DataSource, EntityTarget } from 'typeorm';
|
||||
import { CharacterItem } from '../items/entities/character-item.entity';
|
||||
import { ItemDefinition } from '../items/entities/item-definition.entity';
|
||||
import { LootCategory } from '../items/loot-category.enum';
|
||||
import { CharacterLootBag } from './entities/character-loot-bag.entity';
|
||||
import { LootBagDefinition } from './entities/loot-bag-definition.entity';
|
||||
import {
|
||||
DEFAULT_LOOT_CAPACITY,
|
||||
LootCapacityBudget,
|
||||
LootCapacityService,
|
||||
} from './loot-capacity.service';
|
||||
|
||||
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
|
||||
const OTHER_CHARACTER_ID = '10000000-0000-4000-8000-000000000002';
|
||||
|
||||
const HIDE_BAG: LootBagDefinition = {
|
||||
id: 'a0000000-0000-4000-8000-000000000001',
|
||||
key: 'basic-hide-bag',
|
||||
name: 'Basic Hide Bag',
|
||||
lootCategory: LootCategory.HIDE,
|
||||
capacity: 5,
|
||||
iconPath: '/images/items/basic-hide-bag.png',
|
||||
} as LootBagDefinition;
|
||||
|
||||
const TROPHY_POUCH: LootBagDefinition = {
|
||||
id: 'a0000000-0000-4000-8000-000000000002',
|
||||
key: 'basic-trophy-pouch',
|
||||
name: 'Basic Trophy Pouch',
|
||||
lootCategory: LootCategory.RAIDER_TROPHY,
|
||||
capacity: 5,
|
||||
iconPath: '/images/items/basic-trophy-pouch.png',
|
||||
} as LootBagDefinition;
|
||||
|
||||
interface State {
|
||||
characterItems: CharacterItem[];
|
||||
characterLootBags: CharacterLootBag[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Only what the service actually calls: `find` with a `where` clause. The
|
||||
* `relations` option is ignored, so fixtures attach the joined row the way
|
||||
* TypeORM would have hydrated it.
|
||||
*/
|
||||
class FakeRepository<T> {
|
||||
constructor(private readonly rows: T[]) {}
|
||||
|
||||
find(options: { where: Partial<T> }): Promise<T[]> {
|
||||
return Promise.resolve(
|
||||
this.rows.filter((row) =>
|
||||
Object.entries(options.where).every(
|
||||
([key, value]) => row[key as keyof T] === value,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function fakeDataSource(state: State): DataSource {
|
||||
return {
|
||||
getRepository: <T>(target: EntityTarget<T>) => {
|
||||
if (target === CharacterItem) {
|
||||
return new FakeRepository(state.characterItems) as never;
|
||||
}
|
||||
if (target === CharacterLootBag) {
|
||||
return new FakeRepository(state.characterLootBags) as never;
|
||||
}
|
||||
throw new Error('Unsupported repository');
|
||||
},
|
||||
} as unknown as DataSource;
|
||||
}
|
||||
|
||||
function tradeGood(
|
||||
itemDefinitionId: string,
|
||||
lootCategory: LootCategory | null,
|
||||
): ItemDefinition {
|
||||
return { id: itemDefinitionId, lootCategory } as ItemDefinition;
|
||||
}
|
||||
|
||||
function ownedItem(
|
||||
characterId: string,
|
||||
quantity: number,
|
||||
lootCategory: LootCategory | null,
|
||||
itemDefinitionId = `item-${lootCategory ?? 'none'}`,
|
||||
): CharacterItem {
|
||||
return {
|
||||
id: `character-item-${itemDefinitionId}-${characterId}`,
|
||||
characterId,
|
||||
itemDefinitionId,
|
||||
quantity,
|
||||
itemDefinition: tradeGood(itemDefinitionId, lootCategory),
|
||||
} as CharacterItem;
|
||||
}
|
||||
|
||||
function ownedBag(
|
||||
characterId: string,
|
||||
definition: LootBagDefinition,
|
||||
active = true,
|
||||
): CharacterLootBag {
|
||||
return {
|
||||
id: `bag-${definition.key}-${characterId}`,
|
||||
characterId,
|
||||
lootBagDefinitionId: definition.id,
|
||||
active,
|
||||
lootBagDefinition: definition,
|
||||
} as CharacterLootBag;
|
||||
}
|
||||
|
||||
function createService(state: Partial<State> = {}) {
|
||||
const full: State = {
|
||||
characterItems: state.characterItems ?? [],
|
||||
characterLootBags: state.characterLootBags ?? [],
|
||||
};
|
||||
return new LootCapacityService(fakeDataSource(full));
|
||||
}
|
||||
|
||||
async function capacityOf(
|
||||
service: LootCapacityService,
|
||||
category: LootCategory,
|
||||
) {
|
||||
const capacities = await service.getCapacities(CHARACTER_ID);
|
||||
const entry = capacities.find((row) => row.category === category);
|
||||
if (!entry) {
|
||||
throw new Error(`No capacity reported for ${category}`);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
describe('LootCapacityService', () => {
|
||||
describe('capacity (spec §2, §8)', () => {
|
||||
it('reports every known loot category, even the empty ones', async () => {
|
||||
const capacities = await createService().getCapacities(CHARACTER_ID);
|
||||
|
||||
// The UI renders a row per category; an absent category would silently
|
||||
// vanish from the carrying strip.
|
||||
expect(capacities.map((entry) => entry.category)).toEqual(
|
||||
Object.values(LootCategory),
|
||||
);
|
||||
});
|
||||
|
||||
it('gives a bagless character capacity 1', async () => {
|
||||
const entry = await capacityOf(createService(), LootCategory.HIDE);
|
||||
|
||||
expect(entry.capacity).toBe(DEFAULT_LOOT_CAPACITY);
|
||||
expect(entry.capacity).toBe(1);
|
||||
expect(entry.bag).toBeNull();
|
||||
});
|
||||
|
||||
it('raises HIDE to 5 with a Basic Hide Bag, and names the bag', async () => {
|
||||
const service = createService({
|
||||
characterLootBags: [ownedBag(CHARACTER_ID, HIDE_BAG)],
|
||||
});
|
||||
|
||||
const entry = await capacityOf(service, LootCategory.HIDE);
|
||||
|
||||
expect(entry.capacity).toBe(5);
|
||||
expect(entry.bag).toEqual({
|
||||
key: 'basic-hide-bag',
|
||||
name: 'Basic Hide Bag',
|
||||
iconPath: '/images/items/basic-hide-bag.png',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not let a hide bag raise the trophy category', async () => {
|
||||
const service = createService({
|
||||
characterLootBags: [ownedBag(CHARACTER_ID, HIDE_BAG)],
|
||||
});
|
||||
|
||||
const trophies = await capacityOf(service, LootCategory.RAIDER_TROPHY);
|
||||
|
||||
// A bag lifts exactly its own configured category (spec §14).
|
||||
expect(trophies.capacity).toBe(DEFAULT_LOOT_CAPACITY);
|
||||
expect(trophies.bag).toBeNull();
|
||||
});
|
||||
|
||||
it('lets two bags raise their own categories side by side', async () => {
|
||||
const service = createService({
|
||||
characterLootBags: [
|
||||
ownedBag(CHARACTER_ID, HIDE_BAG),
|
||||
ownedBag(CHARACTER_ID, TROPHY_POUCH),
|
||||
],
|
||||
});
|
||||
|
||||
expect((await capacityOf(service, LootCategory.HIDE)).bag?.key).toBe(
|
||||
'basic-hide-bag',
|
||||
);
|
||||
expect(
|
||||
(await capacityOf(service, LootCategory.RAIDER_TROPHY)).bag?.key,
|
||||
).toBe('basic-trophy-pouch');
|
||||
});
|
||||
|
||||
it('ignores an owned but inactive bag', async () => {
|
||||
const service = createService({
|
||||
characterLootBags: [ownedBag(CHARACTER_ID, HIDE_BAG, false)],
|
||||
});
|
||||
|
||||
const entry = await capacityOf(service, LootCategory.HIDE);
|
||||
|
||||
expect(entry.capacity).toBe(DEFAULT_LOOT_CAPACITY);
|
||||
expect(entry.bag).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves two active bags of one category to the roomier one', async () => {
|
||||
const smallBag: LootBagDefinition = {
|
||||
...HIDE_BAG,
|
||||
id: 'small',
|
||||
key: 'worn-hide-sack',
|
||||
capacity: 2,
|
||||
};
|
||||
const service = createService({
|
||||
characterLootBags: [
|
||||
ownedBag(CHARACTER_ID, smallBag),
|
||||
ownedBag(CHARACTER_ID, HIDE_BAG),
|
||||
],
|
||||
});
|
||||
|
||||
// V1 expects one active bag per category. If state ever violates that,
|
||||
// the answer must still be deterministic rather than row-order luck.
|
||||
expect((await capacityOf(service, LootCategory.HIDE)).capacity).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('current fill', () => {
|
||||
it('sums every owned stack in a category', async () => {
|
||||
const service = createService({
|
||||
characterItems: [
|
||||
ownedItem(CHARACTER_ID, 2, LootCategory.HIDE, 'ash-pelt'),
|
||||
ownedItem(CHARACTER_ID, 1, LootCategory.HIDE, 'tough-hide'),
|
||||
],
|
||||
});
|
||||
|
||||
expect((await capacityOf(service, LootCategory.HIDE)).current).toBe(3);
|
||||
});
|
||||
|
||||
it('does not count equipment or consumables against any category', async () => {
|
||||
const service = createService({
|
||||
characterItems: [
|
||||
ownedItem(CHARACTER_ID, 9, null, 'bandit-blade'),
|
||||
ownedItem(CHARACTER_ID, 1, LootCategory.HIDE, 'ash-pelt'),
|
||||
],
|
||||
});
|
||||
|
||||
// Spec §8: normal items are outside the loot-bag system entirely.
|
||||
expect((await capacityOf(service, LootCategory.HIDE)).current).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('security (spec §13)', () => {
|
||||
it('derives capacity only from this character, never another one', async () => {
|
||||
const service = createService({
|
||||
characterItems: [
|
||||
ownedItem(OTHER_CHARACTER_ID, 4, LootCategory.HIDE, 'ash-pelt'),
|
||||
],
|
||||
characterLootBags: [ownedBag(OTHER_CHARACTER_ID, HIDE_BAG)],
|
||||
});
|
||||
|
||||
const entry = await capacityOf(service, LootCategory.HIDE);
|
||||
|
||||
// Someone else's bag must not raise this character's capacity, and
|
||||
// someone else's pelts must not fill it.
|
||||
expect(entry.capacity).toBe(DEFAULT_LOOT_CAPACITY);
|
||||
expect(entry.current).toBe(0);
|
||||
expect(entry.bag).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores a bag the character does not own, however the id arrives', async () => {
|
||||
const service = createService({
|
||||
characterLootBags: [ownedBag(OTHER_CHARACTER_ID, HIDE_BAG)],
|
||||
});
|
||||
|
||||
// Capacity is read from persisted ownership rows only. There is no
|
||||
// parameter anywhere on this service through which a client could name
|
||||
// a bag, which is what makes a faked one impossible rather than merely
|
||||
// rejected.
|
||||
const budget = await service.createBudget(CHARACTER_ID);
|
||||
expect(budget.take(LootCategory.HIDE, 5)).toBe(DEFAULT_LOOT_CAPACITY);
|
||||
});
|
||||
});
|
||||
|
||||
describe('budget', () => {
|
||||
it('offers the free room, not the total capacity', async () => {
|
||||
const service = createService({
|
||||
characterItems: [
|
||||
ownedItem(CHARACTER_ID, 4, LootCategory.HIDE, 'ash-pelt'),
|
||||
],
|
||||
characterLootBags: [ownedBag(CHARACTER_ID, HIDE_BAG)],
|
||||
});
|
||||
|
||||
const budget = await service.createBudget(CHARACTER_ID);
|
||||
|
||||
expect(budget.take(LootCategory.HIDE, 5)).toBe(1);
|
||||
});
|
||||
|
||||
it('reads a character carrying more than they can as simply full', async () => {
|
||||
const service = createService({
|
||||
// Over capacity: a bag was unequipped, or a definition was retuned
|
||||
// downward. This must never become a negative that lets loot through.
|
||||
characterItems: [
|
||||
ownedItem(CHARACTER_ID, 9, LootCategory.HIDE, 'ash-pelt'),
|
||||
],
|
||||
});
|
||||
|
||||
const budget = await service.createBudget(CHARACTER_ID);
|
||||
|
||||
expect(budget.take(LootCategory.HIDE, 1)).toBe(0);
|
||||
});
|
||||
|
||||
it('never limits an item outside every category', () => {
|
||||
const budget = new LootCapacityBudget(new Map([[LootCategory.HIDE, 0]]));
|
||||
|
||||
expect(budget.take(null, 3)).toBe(3);
|
||||
});
|
||||
|
||||
it('spends down across repeated takes', () => {
|
||||
const budget = new LootCapacityBudget(new Map([[LootCategory.HIDE, 2]]));
|
||||
|
||||
expect(budget.take(LootCategory.HIDE, 1)).toBe(1);
|
||||
expect(budget.take(LootCategory.HIDE, 5)).toBe(1);
|
||||
expect(budget.take(LootCategory.HIDE, 1)).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps categories independent of one another', () => {
|
||||
const budget = new LootCapacityBudget(
|
||||
new Map([
|
||||
[LootCategory.HIDE, 0],
|
||||
[LootCategory.RAIDER_TROPHY, 2],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(budget.take(LootCategory.HIDE, 1)).toBe(0);
|
||||
expect(budget.take(LootCategory.RAIDER_TROPHY, 1)).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
172
apps/api/src/loot-bags/loot-capacity.service.ts
Normal file
172
apps/api/src/loot-bags/loot-capacity.service.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user