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

View File

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

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

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

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

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