Merge branch 'worktree-playable-slice-0.5-first-upgrade'

# Conflicts:
#	apps/web/src/app/core/api/game-api.service.ts
#	apps/web/src/app/features/combat/combat-page/combat-page.component.html
#	apps/web/src/app/features/combat/combat-page/combat-page.component.scss
#	apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts
#	apps/web/src/app/features/combat/combat-page/combat-page.component.ts
This commit is contained in:
Bastian Wagner
2026-08-20 19:42:10 +02:00
46 changed files with 3051 additions and 74 deletions

View File

@@ -2,8 +2,10 @@ import { Module } from '@nestjs/common';
import { CharactersModule } from './characters/characters.module';
import { CombatModule } from './combat/combat.module';
import { DatabaseModule } from './database/database.module';
import { EquipmentModule } from './equipment/equipment.module';
import { HealthModule } from './health/health.module';
import { HuntingModule } from './hunting/hunting.module';
import { InventoryModule } from './inventory/inventory.module';
import { TravelModule } from './travel/travel.module';
import { WorldModule } from './world/world.module';
@@ -16,6 +18,8 @@ import { WorldModule } from './world/world.module';
WorldModule,
HuntingModule,
CombatModule,
EquipmentModule,
InventoryModule,
],
})
export class AppModule {}

View File

@@ -1,16 +0,0 @@
import { CharacterCombatStatsService } from './character-combat-stats.service';
import { Character } from './entities/character.entity';
describe('CharacterCombatStatsService', () => {
it('derives combat stats from the character, with a temporary fixed weapon/armor stand-in', () => {
const service = new CharacterCombatStatsService();
const character = { baseHp: 100, baseAttack: 6 } as Character;
expect(service.getStats(character)).toEqual({
maxHp: 100,
attack: 6,
weaponDamage: 8,
armor: 6,
});
});
});

View File

@@ -1,28 +0,0 @@
import { Injectable } from '@nestjs/common';
import { Character } from './entities/character.entity';
export interface CharacterCombatStats {
maxHp: number;
attack: number;
weaponDamage: number;
armor: number;
}
// TEMPORARY (Slice 0.3): there is no equipment system yet. These constants
// stand in for the starting weapon/armor until Slice 0.5 introduces real
// equipment. Replacing them there must not change this method's signature
// or the combat API it feeds (spec §10).
const TEMPORARY_WEAPON_DAMAGE = 8;
const TEMPORARY_ARMOR = 6;
@Injectable()
export class CharacterCombatStatsService {
getStats(character: Character): CharacterCombatStats {
return {
maxHp: character.baseHp,
attack: character.baseAttack,
weaponDamage: TEMPORARY_WEAPON_DAMAGE,
armor: TEMPORARY_ARMOR,
};
}
}

View File

@@ -0,0 +1,115 @@
// apps/api/src/characters/character-stats.service.spec.ts
import { DataSource } from 'typeorm';
import { CharacterStatsService } from './character-stats.service';
import { Character } from './entities/character.entity';
import { EquipmentSlot } from '../items/equipment-slot.enum';
import { ItemDefinition } from '../items/entities/item-definition.entity';
type EquippedFixture = {
slot: EquipmentSlot;
item: Partial<ItemDefinition>;
};
function fakeScope(equipped: EquippedFixture[]): Pick<DataSource, 'getRepository'> {
const rows = equipped.map((entry) => ({
slot: entry.slot,
characterItem: {
itemDefinition: {
weaponDamage: 0,
bonusHp: 0,
bonusAttack: 0,
bonusArmor: 0,
...entry.item,
},
},
}));
return {
getRepository: () => ({ find: async () => rows }) as never,
} as unknown as Pick<DataSource, 'getRepository'>;
}
function character(overrides: Partial<Character> = {}): Character {
return {
id: 'character-1',
baseHp: 100,
baseAttack: 6,
currentHp: 90,
...overrides,
} as Character;
}
describe('CharacterStatsService', () => {
const service = new CharacterStatsService({} as DataSource);
it('derives stats from the starting weapon alone', async () => {
const scope = fakeScope([{ slot: EquipmentSlot.WEAPON, item: { weaponDamage: 8 } }]);
const stats = await service.calculate(character(), scope);
expect(stats.attack).toBe(6);
expect(stats.weaponDamage).toBe(8);
expect(stats.maxHp).toBe(100);
expect(stats.armor).toBe(0);
});
it('applies Räuberklinge\'s weapon damage and bonus attack', async () => {
const scope = fakeScope([
{ slot: EquipmentSlot.WEAPON, item: { weaponDamage: 11, bonusAttack: 1 } },
]);
const stats = await service.calculate(character(), scope);
expect(stats.attack).toBe(7);
expect(stats.weaponDamage).toBe(11);
});
it('sums bonusArmor across multiple equipped armor pieces', async () => {
const scope = fakeScope([
{ slot: EquipmentSlot.HEAD, item: { bonusArmor: 3 } },
{ slot: EquipmentSlot.CHEST, item: { bonusArmor: 7 } },
]);
const stats = await service.calculate(character(), scope);
expect(stats.armor).toBe(10);
});
it('sums bonusHp across equipped items on top of base HP', async () => {
const scope = fakeScope([
{ slot: EquipmentSlot.HEAD, item: { bonusHp: 5 } },
{ slot: EquipmentSlot.CHEST, item: { bonusHp: 10 } },
]);
const stats = await service.calculate(character(), scope);
expect(stats.maxHp).toBe(115);
});
it('reports weaponDamage as 0 when no weapon is equipped', async () => {
const scope = fakeScope([{ slot: EquipmentSlot.HEAD, item: { bonusArmor: 3 } }]);
const stats = await service.calculate(character(), scope);
expect(stats.weaponDamage).toBe(0);
});
it('calculates Combat Power as HP/10 + attack*2 + weaponDamage*2 + armor*1.5', async () => {
const scope = fakeScope([
{ slot: EquipmentSlot.WEAPON, item: { weaponDamage: 11, bonusAttack: 1 } },
{ slot: EquipmentSlot.HEAD, item: { bonusArmor: 3, bonusHp: 5 } },
]);
const stats = await service.calculate(character(), scope);
// maxHp=105, attack=7, weaponDamage=11, armor=3
expect(stats.combatPower).toBe(105 / 10 + 7 * 2 + 11 * 2 + 3 * 1.5);
});
it('passes currentHp through unchanged from the character', async () => {
const scope = fakeScope([]);
const stats = await service.calculate(character({ currentHp: 42 }), scope);
expect(stats.currentHp).toBe(42);
});
});

View File

@@ -0,0 +1,64 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
import { EquipmentSlot } from '../items/equipment-slot.enum';
import { Character } from './entities/character.entity';
export interface EffectiveCharacterStats {
maxHp: number;
currentHp: number;
attack: number;
weaponDamage: number;
armor: number;
combatPower: number;
}
type RepositoryScope = Pick<DataSource, 'getRepository'>;
/**
* Single authoritative source of effective character stats (spec §18).
* Replaces the Slice 0.3 `CharacterCombatStatsService` shortcut.
*/
@Injectable()
export class CharacterStatsService {
constructor(private readonly dataSource: DataSource) {}
async calculate(
character: Character,
scope?: RepositoryScope,
): Promise<EffectiveCharacterStats> {
const db = scope ?? this.dataSource;
const equipped = await db.getRepository(CharacterEquipment).find({
where: { characterId: character.id },
relations: { characterItem: { itemDefinition: true } },
});
let weaponDamage = 0;
let bonusHp = 0;
let bonusAttack = 0;
let bonusArmor = 0;
for (const slot of equipped) {
const definition = slot.characterItem.itemDefinition;
if (slot.slot === EquipmentSlot.WEAPON) {
weaponDamage = definition.weaponDamage;
}
bonusHp += definition.bonusHp;
bonusAttack += definition.bonusAttack;
bonusArmor += definition.bonusArmor;
}
const maxHp = character.baseHp + bonusHp;
const attack = character.baseAttack + bonusAttack;
const armor = bonusArmor;
return {
maxHp,
currentHp: character.currentHp,
attack,
weaponDamage,
armor,
combatPower: maxHp / 10 + attack * 2 + weaponDamage * 2 + armor * 1.5,
};
}
}

View File

@@ -1,6 +1,6 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CharacterCombatStatsService } from './character-combat-stats.service';
import { CharacterStatsService } from './character-stats.service';
import { CharactersController } from './characters.controller';
import { CharactersService } from './characters.service';
import { Character } from './entities/character.entity';
@@ -8,7 +8,7 @@ import { Character } from './entities/character.entity';
@Module({
imports: [TypeOrmModule.forFeature([Character])],
controllers: [CharactersController],
providers: [CharactersService, CharacterCombatStatsService],
exports: [CharacterCombatStatsService],
providers: [CharactersService, CharacterStatsService],
exports: [CharacterStatsService],
})
export class CharactersModule {}

View File

@@ -2,11 +2,27 @@ import { NotFoundException } from '@nestjs/common';
import { Repository } from 'typeorm';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { SOUTH_GATE_ID } from '../database/seeds/vertical-slice.constants';
import { CharacterStatsService } from './character-stats.service';
import { Character } from './entities/character.entity';
import { CharactersService } from './characters.service';
function fakeCharacterStats(
overrides: Partial<{ maxHp: number; attack: number }> = {},
): CharacterStatsService {
return {
calculate: jest.fn().mockResolvedValue({
maxHp: overrides.maxHp ?? 100,
currentHp: 100,
attack: overrides.attack ?? 6,
weaponDamage: 8,
armor: 0,
combatPower: 0,
}),
} as unknown as CharacterStatsService;
}
describe('CharactersService', () => {
it('returns the demo character with its current location summary', async () => {
it('returns the demo character with effective attack/HP and its location summary', async () => {
const repository = {
findOne: jest.fn().mockResolvedValue({
id: DEMO_CHARACTER_ID,
@@ -20,11 +36,12 @@ describe('CharactersService', () => {
currentLocation: {
id: SOUTH_GATE_ID,
key: 'south-gate',
name: 'S\u00fcdtor von Graufurt',
name: 'Südtor von Graufurt',
},
}),
} as unknown as Repository<Character>;
const service = new CharactersService(repository);
const characterStats = fakeCharacterStats({ maxHp: 115, attack: 7 });
const service = new CharactersService(repository, characterStats);
await expect(service.getDemoCharacter()).resolves.toEqual({
id: DEMO_CHARACTER_ID,
@@ -33,18 +50,17 @@ describe('CharactersService', () => {
experience: 0,
silver: 0,
currentHp: 100,
maxHp: 100,
attack: 6,
maxHp: 115,
attack: 7,
currentLocation: {
id: SOUTH_GATE_ID,
key: 'south-gate',
name: 'S\u00fcdtor von Graufurt',
name: 'Südtor von Graufurt',
},
});
expect(repository.findOne).toHaveBeenCalledWith({
where: { id: DEMO_CHARACTER_ID },
relations: { currentLocation: true },
});
expect(characterStats.calculate).toHaveBeenCalledWith(
expect.objectContaining({ id: DEMO_CHARACTER_ID }),
);
});
it('exposes the persisted silver so the HUD never has to guess', async () => {
@@ -65,7 +81,7 @@ describe('CharactersService', () => {
},
}),
} as unknown as Repository<Character>;
const service = new CharactersService(repository);
const service = new CharactersService(repository, fakeCharacterStats());
await expect(service.getDemoCharacter()).resolves.toEqual(
expect.objectContaining({ experience: 24, silver: 18 }),
@@ -76,7 +92,7 @@ describe('CharactersService', () => {
const repository = {
findOne: jest.fn().mockResolvedValue(null),
} as unknown as Repository<Character>;
const service = new CharactersService(repository);
const service = new CharactersService(repository, fakeCharacterStats());
await expect(service.getDemoCharacter()).rejects.toBeInstanceOf(
NotFoundException,

View File

@@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { CharacterStatsService } from './character-stats.service';
import { Character } from './entities/character.entity';
@Injectable()
@@ -9,6 +10,7 @@ export class CharactersService {
constructor(
@InjectRepository(Character)
private readonly characters: Repository<Character>,
private readonly characterStats: CharacterStatsService,
) {}
async getDemoCharacter() {
@@ -21,6 +23,8 @@ export class CharactersService {
throw new NotFoundException('Demo character has not been seeded');
}
const stats = await this.characterStats.calculate(character);
return {
id: character.id,
name: character.name,
@@ -28,8 +32,8 @@ export class CharactersService {
experience: character.experience,
silver: character.silver,
currentHp: character.currentHp,
maxHp: character.baseHp,
attack: character.baseAttack,
maxHp: stats.maxHp,
attack: stats.attack,
currentLocation: {
id: character.currentLocation.id,
key: character.currentLocation.key,

View File

@@ -0,0 +1,378 @@
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
import { CharacterStatsService } from '../characters/character-stats.service';
import { Character } from '../characters/entities/character.entity';
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
import { EquipmentService } from '../equipment/equipment.service';
import { Hunt } from '../hunting/entities/hunt.entity';
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
import { HuntEncounterStatus } from '../hunting/hunt-encounter-status.enum';
import { HuntStatus } from '../hunting/hunt-status.enum';
import { CharacterItem } from '../items/entities/character-item.entity';
import { ItemDefinition } from '../items/entities/item-definition.entity';
import { EquipmentSlot } from '../items/equipment-slot.enum';
import { ItemRarity } from '../items/item-rarity.enum';
import { ItemType } from '../items/item-type.enum';
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
import { CombatRewardService } from '../rewards/combat-reward.service';
import { TravelService } from '../travel/travel.service';
import { CombatAction } from './combat-action.enum';
import { CombatEngineService } from './combat-engine.service';
import { CombatService } from './combat.service';
import { CombatEvent } from './entities/combat-event.entity';
import { Combat } from './entities/combat.entity';
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
const HUNT_ID = '20000000-0000-4000-8000-000000000001';
const MONSTER_ID = '40000000-0000-4000-8000-000000000001';
const WORN_SWORD_DEFINITION_ID = '50000000-0000-4000-8000-000000000001';
const BANDIT_BLADE_DEFINITION_ID = '50000000-0000-4000-8000-000000000002';
const WORN_SWORD_ITEM_ID = '70000000-0000-4000-8000-000000000001';
const BANDIT_BLADE_ITEM_ID = '70000000-0000-4000-8000-000000000002';
interface FakeState {
characters: Character[];
hunts: Hunt[];
huntEncounters: HuntEncounter[];
monsters: MonsterDefinition[];
combats: Combat[];
combatEvents: CombatEvent[];
itemDefinitions: ItemDefinition[];
characterItems: CharacterItem[];
characterEquipment: CharacterEquipment[];
}
class FakeRepository<T extends { id: string }> {
constructor(
private readonly state: FakeState,
private readonly target: EntityTarget<T>,
private readonly dataSource: FakeDataSource,
) {}
findOne(options: {
where: Partial<T>;
relations?: Record<string, unknown>;
lock?: { mode: string };
}): Promise<T | null> {
const row = this.rows().find((candidate) => this.matches(candidate, options.where)) ?? null;
return Promise.resolve(row ? this.withRelations(row, options.relations) : null);
}
findOneBy(where: Partial<T>): Promise<T | null> {
return Promise.resolve(this.rows().find((row) => this.matches(row, where)) ?? null);
}
find(options: {
where: Partial<T>;
relations?: Record<string, unknown>;
order?: Partial<Record<keyof T, 'ASC' | 'DESC'>>;
}): Promise<T[]> {
const matched = this.rows().filter((row) => this.matches(row, options.where));
return Promise.resolve(matched.map((row) => this.withRelations(row, options.relations)));
}
count(options: { where: Partial<T> }): Promise<number> {
return Promise.resolve(this.rows().filter((row) => this.matches(row, options.where)).length);
}
create(values: Partial<T>): T {
return { ...values } as T;
}
save(entity: T): Promise<T> {
if (!entity.id) {
entity.id = this.dataSource.nextId(this.targetName());
}
const rows = this.rows();
const index = rows.findIndex((row) => row.id === entity.id);
if (index === -1) {
rows.push(entity);
} else {
rows[index] = entity;
}
return Promise.resolve(entity);
}
private withRelations(row: T, relations?: Record<string, unknown>): T {
if (!relations) {
return row;
}
const copy = { ...row } as T & Record<string, unknown>;
if (this.target === CharacterItem && relations['itemDefinition']) {
const itemDefinitionId = (row as unknown as CharacterItem).itemDefinitionId;
copy['itemDefinition'] = this.state.itemDefinitions.find((d) => d.id === itemDefinitionId);
}
if (this.target === CharacterEquipment && relations['characterItem']) {
const characterItemId = (row as unknown as CharacterEquipment).characterItemId;
const characterItem = this.state.characterItems.find((ci) => ci.id === characterItemId);
copy['characterItem'] = characterItem
? {
...characterItem,
itemDefinition: this.state.itemDefinitions.find(
(d) => d.id === characterItem.itemDefinitionId,
),
}
: undefined;
}
return copy as T;
}
private rows(): T[] {
if (this.target === Character) return this.state.characters as T[];
if (this.target === Hunt) return this.state.hunts as T[];
if (this.target === HuntEncounter) return this.state.huntEncounters as T[];
if (this.target === MonsterDefinition) return this.state.monsters as T[];
if (this.target === Combat) return this.state.combats as T[];
if (this.target === CombatEvent) return this.state.combatEvents as T[];
if (this.target === ItemDefinition) return this.state.itemDefinitions as T[];
if (this.target === CharacterItem) return this.state.characterItems as T[];
if (this.target === CharacterEquipment) return this.state.characterEquipment as T[];
throw new Error(`Unsupported repository ${this.targetName()}`);
}
private matches(row: T, where: Partial<T>): boolean {
return Object.entries(where).every(([key, value]) => row[key as keyof T] === value);
}
private targetName(): string {
return typeof this.target === 'function' ? this.target.name : 'EntitySchema';
}
}
class FakeDataSource {
private readonly idCounters = new Map<string, number>();
constructor(public state: FakeState) {}
getRepository<T extends { id: string }>(target: EntityTarget<T>) {
return new FakeRepository(this.state, target, this);
}
async transaction<T>(work: (manager: EntityManager) => Promise<T>): Promise<T> {
return work({
getRepository: <U extends { id: string }>(target: EntityTarget<U>) =>
this.getRepository(target),
} as unknown as EntityManager);
}
nextId(targetName: string): string {
const next = (this.idCounters.get(targetName) ?? 0) + 1;
this.idCounters.set(targetName, next);
return `${targetName.toLowerCase()}-generated-${next}`;
}
}
function character(overrides: Partial<Character> = {}): Character {
return {
id: CHARACTER_ID,
name: 'Aric Duskwalker',
level: 1,
experience: 0,
silver: 0,
baseHp: 100,
baseAttack: 6,
currentHp: 100,
currentLocationId: 'location-1',
createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
...overrides,
} as Character;
}
function monster(overrides: Partial<MonsterDefinition> = {}): MonsterDefinition {
return {
id: MONSTER_ID,
key: 'road-bandit',
name: 'Straßenräuber',
level: 2,
maxHp: 75,
attack: 9,
armor: 5,
experienceReward: 16,
silverMin: 9,
silverMax: 15,
artworkPath: '/images/monsters/road-bandit.png',
createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
...overrides,
} as MonsterDefinition;
}
function hunt(overrides: Partial<Hunt> = {}): Hunt {
return {
id: HUNT_ID,
characterId: CHARACTER_ID,
locationId: 'location-1',
status: HuntStatus.ACTIVE,
createdAt: new Date('2026-08-18T09:00:00.000Z'),
...overrides,
} as Hunt;
}
function encounter(id: string, overrides: Partial<HuntEncounter> = {}): HuntEncounter {
return {
id,
huntId: HUNT_ID,
monsterDefinitionId: MONSTER_ID,
position: 0,
status: HuntEncounterStatus.AVAILABLE,
createdAt: new Date('2026-08-18T09:00:00.000Z'),
...overrides,
} as HuntEncounter;
}
function itemDefinition(overrides: Partial<ItemDefinition> = {}): ItemDefinition {
return {
id: WORN_SWORD_DEFINITION_ID,
key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert',
description: '',
type: ItemType.WEAPON,
equipmentSlot: EquipmentSlot.WEAPON,
rarity: ItemRarity.COMMON,
tier: 1,
requiredLevel: 1,
weaponDamage: 8,
bonusHp: 0,
bonusAttack: 0,
bonusArmor: 0,
sellPrice: 0,
iconPath: '/images/items/worn-short-sword.png',
createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
...overrides,
} as ItemDefinition;
}
function fakeTravelService(): TravelService {
return { completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }) } as unknown as TravelService;
}
function fakeRewardService(): CombatRewardService {
return {
grantVictoryRewards: jest.fn().mockResolvedValue({ experience: 0, silver: 0, items: [] }),
loadRewards: jest.fn().mockResolvedValue(null),
} as unknown as CombatRewardService;
}
function createHarness() {
const state: FakeState = {
characters: [character()],
hunts: [hunt()],
huntEncounters: [],
monsters: [monster()],
combats: [],
combatEvents: [],
itemDefinitions: [
itemDefinition(),
itemDefinition({
id: BANDIT_BLADE_DEFINITION_ID,
key: 'bandit-blade',
name: 'Räuberklinge',
weaponDamage: 11,
bonusAttack: 1,
iconPath: '/images/items/bandit-blade.png',
}),
],
characterItems: [
{
id: WORN_SWORD_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: WORN_SWORD_DEFINITION_ID,
quantity: 1,
createdAt: new Date(),
updatedAt: new Date(),
} as CharacterItem,
{
id: BANDIT_BLADE_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: BANDIT_BLADE_DEFINITION_ID,
quantity: 1,
createdAt: new Date(),
updatedAt: new Date(),
} as CharacterItem,
],
characterEquipment: [
{
id: 'equip-1',
characterId: CHARACTER_ID,
slot: EquipmentSlot.WEAPON,
characterItemId: WORN_SWORD_ITEM_ID,
createdAt: new Date(),
updatedAt: new Date(),
} as CharacterEquipment,
],
};
const dataSource = new FakeDataSource(state);
const characterStats = new CharacterStatsService(dataSource as unknown as DataSource);
const equipmentService = new EquipmentService(dataSource as unknown as DataSource, characterStats);
const combatService = new CombatService(
dataSource as unknown as DataSource,
fakeTravelService(),
new CombatEngineService(),
characterStats,
fakeRewardService(),
);
return { state, equipmentService, combatService };
}
describe('equipping Räuberklinge increases combat damage (spec §45, §60)', () => {
it('deals more damage against the same monster after the upgrade than before it', async () => {
const { state, combatService, equipmentService } = createHarness();
state.huntEncounters.push(encounter('encounter-1'));
const before = await combatService.startCombat(CHARACTER_ID, 'encounter-1');
let beforeDamage = 0;
let beforeResult = before;
for (let round = 0; round < 10 && beforeResult.status === 'ACTIVE'; round += 1) {
beforeResult = await combatService.performAction(CHARACTER_ID, before.id, CombatAction.ATTACK);
if (round === 0) {
beforeDamage = before.monster.maxHp - beforeResult.monster.currentHp;
}
}
// Equipment cannot change during an active combat (spec §46), so this
// first fight must be resolved to completion before equipping.
expect(beforeResult.status).not.toBe('ACTIVE');
await equipmentService.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID);
state.huntEncounters.push(encounter('encounter-2'));
const after = await combatService.startCombat(CHARACTER_ID, 'encounter-2');
const afterResult = await combatService.performAction(CHARACTER_ID, after.id, CombatAction.ATTACK);
const afterDamage = after.monster.maxHp - afterResult.monster.currentHp;
// (6+8) vs 5 armor -> round(14 * 60/65) = 13
expect(beforeDamage).toBe(13);
// (7+11) vs 5 armor -> round(18 * 60/65) = 17
expect(afterDamage).toBe(17);
expect(afterDamage).toBeGreaterThan(beforeDamage);
});
it('rejects equipping during an active combat, and never retroactively rewrites a finished combat\'s snapshot', async () => {
const { state, combatService, equipmentService } = createHarness();
state.huntEncounters.push(encounter('encounter-1'));
const combat = await combatService.startCombat(CHARACTER_ID, 'encounter-1');
// Equipment cannot change while this combat is ACTIVE (spec §46).
await expect(
equipmentService.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID),
).rejects.toMatchObject({ code: 'CHARACTER_IN_COMBAT' });
// Resolve the fight, then equip — the already-finished combat's snapshot
// (status/round) must stay exactly what it was when the fight ended.
let result = combat;
for (let round = 0; round < 10 && result.status === 'ACTIVE'; round += 1) {
result = await combatService.performAction(CHARACTER_ID, combat.id, CombatAction.ATTACK);
}
expect(result.status).not.toBe('ACTIVE');
await equipmentService.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID);
// The finished combat's playerState snapshot (written once at startCombat)
// must not be retroactively rewritten by equipping after the fight ends.
expect(state.combats[0].playerState).toEqual({ attack: 6, weaponDamage: 8, armor: 0 });
const reloaded = await combatService.getCombat(CHARACTER_ID, combat.id);
expect(reloaded.status).toBe(result.status);
expect(reloaded.round).toBe(result.round);
});
});

View File

@@ -1,5 +1,5 @@
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
import { CharacterCombatStatsService } from '../characters/character-combat-stats.service';
import { CharacterStatsService } from '../characters/character-stats.service';
import { Character } from '../characters/entities/character.entity';
import { Hunt } from '../hunting/entities/hunt.entity';
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
@@ -242,6 +242,19 @@ function createState(overrides: Partial<FakeState> = {}): FakeState {
};
}
function fakeCharacterStats(): CharacterStatsService {
return {
calculate: jest.fn(async (character: Character) => ({
maxHp: character.baseHp,
currentHp: character.currentHp,
attack: character.baseAttack,
weaponDamage: 8,
armor: 6,
combatPower: 0,
})),
} as unknown as CharacterStatsService;
}
function fakeTravelService(
status: 'IDLE' | 'TRAVELLING' = 'IDLE',
): TravelService {
@@ -271,7 +284,7 @@ function createService(
const dataSource = new FakeDataSource(state);
const travelService = options.travelService ?? fakeTravelService();
const combatEngine = new CombatEngineService();
const characterCombatStats = new CharacterCombatStatsService();
const characterCombatStats = fakeCharacterStats();
const service = new CombatService(
dataSource as unknown as DataSource,
travelService,
@@ -785,7 +798,7 @@ describe('CombatService', () => {
dataSource as unknown as DataSource,
fakeTravelService(),
new CombatEngineService(),
new CharacterCombatStatsService(),
fakeCharacterStats(),
rewards,
);
@@ -833,7 +846,7 @@ describe('CombatService', () => {
dataSource as unknown as DataSource,
fakeTravelService(),
new CombatEngineService(),
new CharacterCombatStatsService(),
fakeCharacterStats(),
rewards,
);
@@ -890,7 +903,7 @@ describe('CombatService', () => {
dataSource as unknown as DataSource,
fakeTravelService(),
new CombatEngineService(),
new CharacterCombatStatsService(),
fakeCharacterStats(),
rewards,
);
@@ -927,7 +940,7 @@ describe('CombatService', () => {
dataSource as unknown as DataSource,
fakeTravelService(),
new CombatEngineService(),
new CharacterCombatStatsService(),
fakeCharacterStats(),
fakeRewardService({
// Genuinely write XP/silver through the transaction's manager
// before failing, so the assertions below prove the rollback

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { DataSource, Repository } from 'typeorm';
import { CharacterCombatStatsService } from '../characters/character-combat-stats.service';
import { CharacterStatsService } from '../characters/character-stats.service';
import { Character } from '../characters/entities/character.entity';
import { Hunt } from '../hunting/entities/hunt.entity';
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
@@ -69,7 +69,7 @@ export class CombatService {
private readonly dataSource: DataSource,
private readonly travelService: TravelService,
private readonly combatEngine: CombatEngineService,
private readonly characterCombatStats: CharacterCombatStatsService,
private readonly characterStats: CharacterStatsService,
private readonly combatRewards: CombatRewardService,
) {}
@@ -126,7 +126,7 @@ export class CombatService {
throw invalidHuntEncounter();
}
const playerStats = this.characterCombatStats.getStats(character);
const playerStats = await this.characterStats.calculate(character, manager);
const combat = combats.create({
characterId,

View File

@@ -0,0 +1,34 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateEquipment1789000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// Reuses the "equipment_slot_enum" type created by CreateLootAndRewards.
await queryRunner.query(`CREATE TABLE "character_equipment" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"character_id" uuid NOT NULL,
"slot" "equipment_slot_enum" NOT NULL,
"character_item_id" uuid NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_character_equipment" PRIMARY KEY ("id"),
CONSTRAINT "FK_character_equipment_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
CONSTRAINT "FK_character_equipment_character_item" FOREIGN KEY ("character_item_id") REFERENCES "character_items"("id") ON DELETE CASCADE ON UPDATE NO ACTION
)`);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_character_equipment_character_slot" ON "character_equipment" ("character_id", "slot")',
);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_character_equipment_character_item" ON "character_equipment" ("character_item_id")',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'DROP INDEX "IDX_character_equipment_character_item"',
);
await queryRunner.query(
'DROP INDEX "IDX_character_equipment_character_slot"',
);
await queryRunner.query('DROP TABLE "character_equipment"');
}
}

View File

@@ -0,0 +1,52 @@
import 'reflect-metadata';
import { getMetadataArgsStorage } from 'typeorm';
import { CharacterEquipment } from '../../equipment/entities/character-equipment.entity';
describe('character_equipment schema', () => {
it('stores slot as a non-nullable equipment_slot_enum column', () => {
const metadata = getMetadataArgsStorage();
const column = metadata.columns.find(
(candidate) =>
candidate.target === CharacterEquipment && candidate.propertyName === 'slot',
);
expect(column).toBeDefined();
expect(column?.options.type).toBe('enum');
expect(column?.options.enumName).toBe('equipment_slot_enum');
expect(column?.options.nullable).toBeFalsy();
});
it('enforces one equipped item per character per slot', () => {
const metadata = getMetadataArgsStorage();
const index = metadata.indices.find(
(candidate) =>
candidate.target === CharacterEquipment &&
candidate.columns?.includes('characterId') &&
candidate.columns?.includes('slot'),
);
expect(index).toBeDefined();
const indexMetadata = index as typeof index & {
options?: { unique?: boolean };
unique?: boolean;
};
expect(indexMetadata?.options?.unique ?? indexMetadata?.unique).toBe(true);
});
it('forbids one CharacterItem from occupying more than one equipment slot', () => {
const metadata = getMetadataArgsStorage();
const index = metadata.indices.find(
(candidate) =>
candidate.target === CharacterEquipment &&
candidate.columns?.length === 1 &&
candidate.columns?.includes('characterItemId'),
);
expect(index).toBeDefined();
const indexMetadata = index as typeof index & {
options?: { unique?: boolean };
unique?: boolean;
};
expect(indexMetadata?.options?.unique ?? indexMetadata?.unique).toBe(true);
});
});

View File

@@ -1,5 +1,7 @@
import { DataSource } from 'typeorm';
import { Character } from '../../characters/entities/character.entity';
import { CharacterEquipment } from '../../equipment/entities/character-equipment.entity';
import { CharacterItem } from '../../items/entities/character-item.entity';
import { ItemDefinition } from '../../items/entities/item-definition.entity';
import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity';
import { LootTable } from '../../loot/entities/loot-table.entity';
@@ -7,6 +9,7 @@ import { LocationMonster } from '../../monsters/entities/location-monster.entity
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
import { LocationConnection } from '../../world/entities/location-connection.entity';
import { LocationDefinition } from '../../world/entities/location-definition.entity';
import { DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID } from '../../demo/demo-character.constants';
import { ASH_RAT_LOOT_TABLE_ID, ITEM_IDS, ROAD_BANDIT_LOOT_TABLE_ID } from './item.constants';
import { seedVisibleVerticalSlice } from './vertical-slice.seed';
@@ -76,6 +79,8 @@ function createDataSource(
itemRepository: InMemoryRepository = new InMemoryRepository(),
lootTableRepository: InMemoryRepository = new InMemoryRepository(),
lootEntryRepository: InMemoryRepository = new InMemoryRepository(),
characterItemRepository: InMemoryRepository = new InMemoryRepository(),
characterEquipmentRepository: InMemoryRepository = new InMemoryRepository(),
): DataSource {
return {
getRepository: jest.fn((entity: unknown) => {
@@ -87,6 +92,8 @@ function createDataSource(
if (entity === ItemDefinition) return itemRepository;
if (entity === LootTable) return lootTableRepository;
if (entity === LootTableEntry) return lootEntryRepository;
if (entity === CharacterItem) return characterItemRepository;
if (entity === CharacterEquipment) return characterEquipmentRepository;
throw new Error('Unexpected repository');
}),
@@ -449,4 +456,125 @@ describe('seedVisibleVerticalSlice', () => {
]),
);
});
it('seeds the starting sword as a real, equipped CharacterItem idempotently', async () => {
const locationRepository = new InMemoryRepository();
const connectionRepository = new InMemoryRepository();
const characterRepository = new InMemoryRepository();
const monsterRepository = new InMemoryRepository();
const locationMonsterRepository = new InMemoryRepository();
const characterItemRepository = new InMemoryRepository();
const characterEquipmentRepository = new InMemoryRepository();
const dataSource = createDataSource(
locationRepository,
connectionRepository,
characterRepository,
monsterRepository,
locationMonsterRepository,
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
characterItemRepository,
characterEquipmentRepository,
);
await seedVisibleVerticalSlice(dataSource);
await seedVisibleVerticalSlice(dataSource);
expect(characterItemRepository.rows).toHaveLength(1);
expect(characterItemRepository.rows[0]).toEqual(
expect.objectContaining({
characterId: DEMO_CHARACTER_ID,
itemDefinitionId: ITEM_IDS['worn-short-sword'],
quantity: 1,
}),
);
expect(characterEquipmentRepository.rows).toHaveLength(1);
expect(characterEquipmentRepository.rows[0]).toEqual(
expect.objectContaining({
characterId: DEMO_CHARACTER_ID,
slot: 'WEAPON',
characterItemId: DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID,
}),
);
});
it('never re-equips the starting sword once the player has equipped different gear', async () => {
const locationRepository = new InMemoryRepository();
const connectionRepository = new InMemoryRepository();
const characterRepository = new InMemoryRepository();
const monsterRepository = new InMemoryRepository();
const locationMonsterRepository = new InMemoryRepository();
const characterItemRepository = new InMemoryRepository();
const characterEquipmentRepository = new InMemoryRepository();
const dataSource = createDataSource(
locationRepository,
connectionRepository,
characterRepository,
monsterRepository,
locationMonsterRepository,
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
characterItemRepository,
characterEquipmentRepository,
);
await seedVisibleVerticalSlice(dataSource);
// Simulate the player having equipped earned loot instead.
characterEquipmentRepository.rows[0]['characterItemId'] = 'earned-bandit-blade-item-id';
await seedVisibleVerticalSlice(dataSource);
expect(characterEquipmentRepository.rows).toHaveLength(1);
expect(characterEquipmentRepository.rows[0]['characterItemId']).toBe(
'earned-bandit-blade-item-id',
);
expect(characterItemRepository.rows).toHaveLength(1);
});
it('reuses a naturally-looted starting sword instead of inserting a duplicate CharacterItem', async () => {
const locationRepository = new InMemoryRepository();
const connectionRepository = new InMemoryRepository();
const characterRepository = new InMemoryRepository();
const monsterRepository = new InMemoryRepository();
const locationMonsterRepository = new InMemoryRepository();
const characterItemRepository = new InMemoryRepository();
const characterEquipmentRepository = new InMemoryRepository();
const dataSource = createDataSource(
locationRepository,
connectionRepository,
characterRepository,
monsterRepository,
locationMonsterRepository,
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
characterItemRepository,
characterEquipmentRepository,
);
// Simulate the demo character having already looted a worn-short-sword
// naturally, under a DB-generated id that differs from the seed's
// stable literal constant.
const naturallyLootedItemId = 'naturally-looted-sword-item-id';
characterItemRepository.rows.push({
id: naturallyLootedItemId,
characterId: DEMO_CHARACTER_ID,
itemDefinitionId: ITEM_IDS['worn-short-sword'],
quantity: 1,
});
await seedVisibleVerticalSlice(dataSource);
expect(characterItemRepository.rows).toHaveLength(1);
expect(characterEquipmentRepository.rows).toHaveLength(1);
expect(characterEquipmentRepository.rows[0]).toEqual(
expect.objectContaining({
characterId: DEMO_CHARACTER_ID,
slot: 'WEAPON',
characterItemId: naturallyLootedItemId,
}),
);
});
});

View File

@@ -1,6 +1,13 @@
import { DataSource } from 'typeorm';
import { DEMO_CHARACTER_ID } from '../../demo/demo-character.constants';
import {
DEMO_CHARACTER_ID,
DEMO_CHARACTER_STARTING_WEAPON_EQUIPMENT_ID,
DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID,
} from '../../demo/demo-character.constants';
import { Character } from '../../characters/entities/character.entity';
import { CharacterEquipment } from '../../equipment/entities/character-equipment.entity';
import { CharacterItem } from '../../items/entities/character-item.entity';
import { EquipmentSlot } from '../../items/equipment-slot.enum';
import { ItemDefinition } from '../../items/entities/item-definition.entity';
import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity';
import { LootTable } from '../../loot/entities/loot-table.entity';
@@ -12,6 +19,7 @@ import { LocationDefinition } from '../../world/entities/location-definition.ent
import { ITEM_DEFINITIONS, LOOT_TABLES, LOOT_TABLE_ENTRIES } from './item-content';
import {
ASH_RAT_LOOT_TABLE_ID,
ITEM_IDS,
ROAD_BANDIT_LOOT_TABLE_ID,
} from './item.constants';
import {
@@ -238,4 +246,42 @@ export async function seedVisibleVerticalSlice(
currentLocationId: southGateId,
});
}
const characterItemRepository = dataSource.getRepository(CharacterItem);
const characterEquipmentRepository = dataSource.getRepository(CharacterEquipment);
// Starting loadout is weapon-only -- no starter armor piece exists in
// content yet -- so the demo character's effective armor (sum of equipped
// bonusArmor) is 0 until the player loots and equips bandit-hood (+3
// armor). This is a deliberate tradeoff, not a bug: Slice 0.5 spec §19
// says to preserve the existing demo balance "as closely as the
// implemented content allows" and explicitly forbids fabricating a full
// starter gear set just to hit the old hardcoded TEMPORARY_ARMOR = 6.
const existingStartingSword = await characterItemRepository.findOneBy({
characterId: DEMO_CHARACTER_ID,
itemDefinitionId: ITEM_IDS['worn-short-sword'],
});
const startingSwordItemId =
existingStartingSword?.id ?? DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID;
if (!existingStartingSword) {
await characterItemRepository.insert({
id: DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID,
characterId: DEMO_CHARACTER_ID,
itemDefinitionId: ITEM_IDS['worn-short-sword'],
quantity: 1,
});
}
const existingWeaponEquipment = await characterEquipmentRepository.findOneBy({
characterId: DEMO_CHARACTER_ID,
slot: EquipmentSlot.WEAPON,
});
if (!existingWeaponEquipment) {
await characterEquipmentRepository.insert({
id: DEMO_CHARACTER_STARTING_WEAPON_EQUIPMENT_ID,
characterId: DEMO_CHARACTER_ID,
slot: EquipmentSlot.WEAPON,
characterItemId: startingSwordItemId,
});
}
}

View File

@@ -1 +1,5 @@
export const DEMO_CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
export const DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID =
'10000000-0000-4000-8000-000000000002';
export const DEMO_CHARACTER_STARTING_WEAPON_EQUIPMENT_ID =
'10000000-0000-4000-8000-000000000003';

View File

@@ -0,0 +1,6 @@
import { IsUUID } from 'class-validator';
export class EquipItemDto {
@IsUUID()
characterItemId!: string;
}

View File

@@ -0,0 +1,59 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Character } from '../../characters/entities/character.entity';
import { CharacterItem } from '../../items/entities/character-item.entity';
import { EquipmentSlot } from '../../items/equipment-slot.enum';
/**
* One equipped item in one slot for one character (spec §12).
*
* `characterItemId` must belong to `characterId` — enforced by
* `EquipmentService`, never by the client (spec §13).
*/
@Entity({ name: 'character_equipment' })
@Index('IDX_character_equipment_character_slot', ['characterId', 'slot'], {
unique: true,
})
@Index('IDX_character_equipment_character_item', ['characterItemId'], {
unique: true,
})
export class CharacterEquipment {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'character_id', type: 'uuid' })
characterId!: string;
@Column({
name: 'slot',
type: 'enum',
enum: EquipmentSlot,
enumName: 'equipment_slot_enum',
})
slot!: EquipmentSlot;
@Column({ name: 'character_item_id', type: 'uuid' })
characterItemId!: string;
@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(() => CharacterItem, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'character_item_id' })
characterItem!: CharacterItem;
}

View File

@@ -0,0 +1,19 @@
import { Body, Controller, Get, Post } from '@nestjs/common';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { EquipItemDto } from './dto/equip-item.dto';
import { EquipmentResponseDto, EquipmentService } from './equipment.service';
@Controller('equipment')
export class EquipmentController {
constructor(private readonly equipmentService: EquipmentService) {}
@Get()
getEquipment(): Promise<EquipmentResponseDto> {
return this.equipmentService.getEquipment(DEMO_CHARACTER_ID);
}
@Post()
equip(@Body() request: EquipItemDto): Promise<EquipmentResponseDto> {
return this.equipmentService.equip(DEMO_CHARACTER_ID, request.characterItemId);
}
}

View File

@@ -0,0 +1,71 @@
import { HttpException, HttpStatus } from '@nestjs/common';
export type EquipmentErrorCode =
| 'CHARACTER_ITEM_NOT_FOUND'
| 'ITEM_NOT_OWNED'
| 'ITEM_NOT_EQUIPPABLE'
| 'ITEM_LEVEL_REQUIREMENT_NOT_MET'
| 'INVALID_EQUIPMENT_SLOT'
| 'CHARACTER_IN_COMBAT';
export class EquipmentDomainError extends HttpException {
constructor(
public readonly code: EquipmentErrorCode,
status: HttpStatus,
message: string,
) {
super({ statusCode: status, code, message }, status);
}
}
export function characterItemNotFound(): EquipmentDomainError {
return new EquipmentDomainError(
'CHARACTER_ITEM_NOT_FOUND',
HttpStatus.NOT_FOUND,
'This item could not be found.',
);
}
export function itemNotOwned(): EquipmentDomainError {
return new EquipmentDomainError(
'ITEM_NOT_OWNED',
HttpStatus.FORBIDDEN,
'This item does not belong to the character.',
);
}
export function itemNotEquippable(): EquipmentDomainError {
return new EquipmentDomainError(
'ITEM_NOT_EQUIPPABLE',
HttpStatus.BAD_REQUEST,
'This item cannot be equipped.',
);
}
export function itemLevelRequirementNotMet(): EquipmentDomainError {
return new EquipmentDomainError(
'ITEM_LEVEL_REQUIREMENT_NOT_MET',
HttpStatus.BAD_REQUEST,
"The character does not meet this item's level requirement.",
);
}
// Defensive: slot is always derived from the item definition server-side, so
// this is unreachable in practice (spec §27 still names it explicitly).
export function invalidEquipmentSlot(): EquipmentDomainError {
return new EquipmentDomainError(
'INVALID_EQUIPMENT_SLOT',
HttpStatus.BAD_REQUEST,
'This item does not target a valid equipment slot.',
);
}
export function characterInCombat(): EquipmentDomainError {
return new EquipmentDomainError(
'CHARACTER_IN_COMBAT',
HttpStatus.CONFLICT,
'Equipment cannot be changed during an active combat.',
);
}
export { characterNotFound } from '../travel/travel.errors';

View File

@@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CharactersModule } from '../characters/characters.module';
import { Character } from '../characters/entities/character.entity';
import { Combat } from '../combat/entities/combat.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { ItemDefinition } from '../items/entities/item-definition.entity';
import { CharacterEquipment } from './entities/character-equipment.entity';
import { EquipmentController } from './equipment.controller';
import { EquipmentService } from './equipment.service';
@Module({
imports: [
TypeOrmModule.forFeature([Character, Combat, CharacterItem, ItemDefinition, CharacterEquipment]),
CharactersModule,
],
controllers: [EquipmentController],
providers: [EquipmentService],
exports: [EquipmentService],
})
export class EquipmentModule {}

View File

@@ -0,0 +1,431 @@
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
import { CharacterStatsService } from '../characters/character-stats.service';
import { Character } from '../characters/entities/character.entity';
import { CombatStatus } from '../combat/combat-status.enum';
import { Combat } from '../combat/entities/combat.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { ItemDefinition } from '../items/entities/item-definition.entity';
import { EquipmentSlot } from '../items/equipment-slot.enum';
import { ItemRarity } from '../items/item-rarity.enum';
import { ItemType } from '../items/item-type.enum';
import { CharacterEquipment } from './entities/character-equipment.entity';
import { EquipmentDomainError } from './equipment.errors';
import { EquipmentService } from './equipment.service';
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
const OTHER_CHARACTER_ID = '10000000-0000-4000-8000-000000000002';
const WORN_SWORD_ITEM_ID = '70000000-0000-4000-8000-000000000001';
const BANDIT_BLADE_ITEM_ID = '70000000-0000-4000-8000-000000000002';
const BANDIT_HOOD_ITEM_ID = '70000000-0000-4000-8000-000000000003';
interface State {
characters: Character[];
itemDefinitions: ItemDefinition[];
characterItems: CharacterItem[];
characterEquipment: CharacterEquipment[];
combats: Combat[];
}
class FakeRepository<T extends { id: string }> {
constructor(
private readonly state: State,
private readonly target: EntityTarget<T>,
private readonly dataSource: FakeDataSource,
) {}
findOne(options: {
where: Partial<T>;
relations?: Record<string, unknown>;
lock?: { mode: string };
}): Promise<T | null> {
const row = this.rows().find((candidate) => this.matches(candidate, options.where)) ?? null;
return Promise.resolve(row ? this.withRelations(row, options.relations) : null);
}
findOneBy(where: Partial<T>): Promise<T | null> {
return Promise.resolve(this.rows().find((row) => this.matches(row, where)) ?? null);
}
find(options: { where: Partial<T>; relations?: Record<string, unknown> }): Promise<T[]> {
const matched = this.rows().filter((row) => this.matches(row, options.where));
return Promise.resolve(matched.map((row) => this.withRelations(row, options.relations)));
}
create(values: Partial<T>): T {
return { ...values } as T;
}
save(entity: T): Promise<T> {
if (!entity.id) {
entity.id = this.dataSource.nextId(this.targetName());
}
const rows = this.rows();
const index = rows.findIndex((row) => row.id === entity.id);
if (index === -1) {
rows.push(entity);
} else {
rows[index] = entity;
}
return Promise.resolve(entity);
}
private withRelations(row: T, relations?: Record<string, unknown>): T {
if (!relations) {
return row;
}
const copy = { ...row } as T & Record<string, unknown>;
if (this.target === CharacterItem && relations['itemDefinition']) {
const itemDefinitionId = (row as unknown as CharacterItem).itemDefinitionId;
copy['itemDefinition'] = this.state.itemDefinitions.find((d) => d.id === itemDefinitionId);
}
if (this.target === CharacterEquipment && relations['characterItem']) {
const characterItemId = (row as unknown as CharacterEquipment).characterItemId;
const characterItem = this.state.characterItems.find((ci) => ci.id === characterItemId);
copy['characterItem'] = characterItem
? {
...characterItem,
itemDefinition: this.state.itemDefinitions.find(
(d) => d.id === characterItem.itemDefinitionId,
),
}
: undefined;
}
return copy as T;
}
private rows(): T[] {
if (this.target === Character) return this.state.characters as T[];
if (this.target === ItemDefinition) return this.state.itemDefinitions as T[];
if (this.target === CharacterItem) return this.state.characterItems as T[];
if (this.target === CharacterEquipment) return this.state.characterEquipment as T[];
if (this.target === Combat) return this.state.combats as T[];
throw new Error(`Unsupported repository ${this.targetName()}`);
}
private matches(row: T, where: Partial<T>): boolean {
return Object.entries(where).every(([key, value]) => row[key as keyof T] === value);
}
private targetName(): string {
return typeof this.target === 'function' ? this.target.name : 'EntitySchema';
}
}
class FakeDataSource {
private readonly idCounters = new Map<string, number>();
constructor(public state: State) {}
getRepository<T extends { id: string }>(target: EntityTarget<T>) {
return new FakeRepository(this.state, target, this);
}
async transaction<T>(work: (manager: EntityManager) => Promise<T>): Promise<T> {
return work({
getRepository: <U extends { id: string }>(target: EntityTarget<U>) =>
this.getRepository(target),
} as unknown as EntityManager);
}
nextId(targetName: string): string {
const next = (this.idCounters.get(targetName) ?? 0) + 1;
this.idCounters.set(targetName, next);
return `${targetName.toLowerCase()}-generated-${next}`;
}
}
function itemDefinition(overrides: Partial<ItemDefinition> = {}): ItemDefinition {
return {
id: 'def-worn-sword',
key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert',
description: '',
type: ItemType.WEAPON,
equipmentSlot: EquipmentSlot.WEAPON,
rarity: ItemRarity.COMMON,
tier: 1,
requiredLevel: 1,
weaponDamage: 8,
bonusHp: 0,
bonusAttack: 0,
bonusArmor: 0,
sellPrice: 0,
iconPath: '/images/items/worn-short-sword.png',
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
} as ItemDefinition;
}
function character(overrides: Partial<Character> = {}): Character {
return {
id: CHARACTER_ID,
name: 'Aric Duskwalker',
level: 1,
baseHp: 100,
baseAttack: 6,
currentHp: 100,
...overrides,
} as Character;
}
function createHarness(state: Partial<State> = {}) {
const fullState: State = {
characters: [character()],
itemDefinitions: [],
characterItems: [],
characterEquipment: [],
combats: [],
...state,
};
const dataSource = new FakeDataSource(fullState);
const characterStats = new CharacterStatsService(dataSource as unknown as DataSource);
const service = new EquipmentService(dataSource as unknown as DataSource, characterStats);
return { state: fullState, service };
}
async function expectEquipmentDomainError(promise: Promise<unknown>, code: string): Promise<void> {
let error: unknown;
try {
await promise;
} catch (cause) {
error = cause;
}
expect(error).toBeInstanceOf(EquipmentDomainError);
if (!(error instanceof EquipmentDomainError)) {
throw new Error('Expected EquipmentDomainError');
}
expect(error.code).toBe(code);
}
describe('EquipmentService', () => {
describe('equip', () => {
it('equips an owned weapon into the WEAPON slot', async () => {
const wornSword = itemDefinition();
const { state, service } = createHarness({
itemDefinitions: [wornSword],
characterItems: [
{
id: WORN_SWORD_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: wornSword.id,
quantity: 1,
} as CharacterItem,
],
});
const result = await service.equip(CHARACTER_ID, WORN_SWORD_ITEM_ID);
expect(result.slots.WEAPON).toEqual({
characterItemId: WORN_SWORD_ITEM_ID,
item: {
key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert',
rarity: 'COMMON',
iconPath: '/images/items/worn-short-sword.png',
},
});
expect(state.characterEquipment).toHaveLength(1);
});
it('replaces the equipped weapon without deleting the old CharacterItem', async () => {
const wornSword = itemDefinition();
const banditBlade = itemDefinition({
id: 'def-bandit-blade',
key: 'bandit-blade',
name: 'Räuberklinge',
weaponDamage: 11,
bonusAttack: 1,
iconPath: '/images/items/bandit-blade.png',
});
const { state, service } = createHarness({
itemDefinitions: [wornSword, banditBlade],
characterItems: [
{
id: WORN_SWORD_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: wornSword.id,
quantity: 1,
} as CharacterItem,
{
id: BANDIT_BLADE_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: banditBlade.id,
quantity: 1,
} as CharacterItem,
],
characterEquipment: [
{
id: 'equip-1',
characterId: CHARACTER_ID,
slot: EquipmentSlot.WEAPON,
characterItemId: WORN_SWORD_ITEM_ID,
} as CharacterEquipment,
],
});
const result = await service.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID);
expect(result.slots.WEAPON?.characterItemId).toBe(BANDIT_BLADE_ITEM_ID);
expect(state.characterEquipment).toHaveLength(1);
expect(state.characterItems.find((i) => i.id === WORN_SWORD_ITEM_ID)).toBeDefined();
});
it('rejects equipping an item owned by a different character', async () => {
const wornSword = itemDefinition();
const { service } = createHarness({
characters: [character(), character({ id: OTHER_CHARACTER_ID })],
itemDefinitions: [wornSword],
characterItems: [
{
id: WORN_SWORD_ITEM_ID,
characterId: OTHER_CHARACTER_ID,
itemDefinitionId: wornSword.id,
quantity: 1,
} as CharacterItem,
],
});
await expectEquipmentDomainError(
service.equip(CHARACTER_ID, WORN_SWORD_ITEM_ID),
'ITEM_NOT_OWNED',
);
});
it('rejects equipping an unknown CharacterItem id', async () => {
const { service } = createHarness();
await expectEquipmentDomainError(
service.equip(CHARACTER_ID, 'unknown-item'),
'CHARACTER_ITEM_NOT_FOUND',
);
});
it('rejects equipping an item above the character level', async () => {
const highLevelHelm = itemDefinition({
id: BANDIT_HOOD_ITEM_ID,
key: 'bandit-hood',
equipmentSlot: EquipmentSlot.HEAD,
requiredLevel: 5,
});
const { service } = createHarness({
itemDefinitions: [highLevelHelm],
characterItems: [
{
id: BANDIT_HOOD_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: highLevelHelm.id,
quantity: 1,
} as CharacterItem,
],
});
await expectEquipmentDomainError(
service.equip(CHARACTER_ID, BANDIT_HOOD_ITEM_ID),
'ITEM_LEVEL_REQUIREMENT_NOT_MET',
);
});
it('rejects equipping a non-equippable item', async () => {
const material = itemDefinition({
id: 'def-ash-pelt',
key: 'ash-pelt',
type: ItemType.MATERIAL,
equipmentSlot: null,
});
const { service } = createHarness({
itemDefinitions: [material],
characterItems: [
{
id: 'item-ash-pelt',
characterId: CHARACTER_ID,
itemDefinitionId: material.id,
quantity: 1,
} as CharacterItem,
],
});
await expectEquipmentDomainError(
service.equip(CHARACTER_ID, 'item-ash-pelt'),
'ITEM_NOT_EQUIPPABLE',
);
});
it('never produces two equipped weapons when the same slot is equipped repeatedly', async () => {
const wornSword = itemDefinition();
const banditBlade = itemDefinition({
id: 'def-bandit-blade',
key: 'bandit-blade',
weaponDamage: 11,
bonusAttack: 1,
iconPath: '/images/items/bandit-blade.png',
});
const { state, service } = createHarness({
itemDefinitions: [wornSword, banditBlade],
characterItems: [
{
id: WORN_SWORD_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: wornSword.id,
quantity: 1,
} as CharacterItem,
{
id: BANDIT_BLADE_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: banditBlade.id,
quantity: 1,
} as CharacterItem,
],
});
// Sequential repeats stand in for the concurrent case here (a real race
// is guarded by the DB's UNIQUE(character_id, slot) constraint from
// Task 1, which a synchronous fake repository cannot exercise).
await service.equip(CHARACTER_ID, WORN_SWORD_ITEM_ID);
await service.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID);
await service.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID);
expect(state.characterEquipment).toHaveLength(1);
expect(state.characterEquipment[0].characterItemId).toBe(BANDIT_BLADE_ITEM_ID);
});
it('rejects equipping while the character has an active combat', async () => {
const wornSword = itemDefinition();
const { service } = createHarness({
itemDefinitions: [wornSword],
characterItems: [
{
id: WORN_SWORD_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: wornSword.id,
quantity: 1,
} as CharacterItem,
],
combats: [{ id: 'combat-1', characterId: CHARACTER_ID, status: CombatStatus.ACTIVE } as Combat],
});
await expectEquipmentDomainError(
service.equip(CHARACTER_ID, WORN_SWORD_ITEM_ID),
'CHARACTER_IN_COMBAT',
);
});
});
describe('getEquipment', () => {
it('returns empty slots and base stats when nothing is equipped', async () => {
const { service } = createHarness();
const result = await service.getEquipment(CHARACTER_ID);
expect(result.slots).toEqual({
WEAPON: null,
HEAD: null,
CHEST: null,
HANDS: null,
LEGS: null,
FEET: null,
AMULET: null,
});
expect(result.stats).toEqual({ maxHp: 100, attack: 6, weaponDamage: 0, armor: 0 });
});
});
});

View File

@@ -0,0 +1,168 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { CharacterStatsService } from '../characters/character-stats.service';
import { Character } from '../characters/entities/character.entity';
import { CombatStatus } from '../combat/combat-status.enum';
import { Combat } from '../combat/entities/combat.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { EquipmentSlot } from '../items/equipment-slot.enum';
import { ItemRarity } from '../items/item-rarity.enum';
import { CharacterEquipment } from './entities/character-equipment.entity';
import {
characterInCombat,
characterItemNotFound,
characterNotFound,
itemLevelRequirementNotMet,
itemNotEquippable,
itemNotOwned,
} from './equipment.errors';
export interface EquipmentSlotItemDto {
characterItemId: string;
item: {
key: string;
name: string;
rarity: ItemRarity;
iconPath: string;
};
}
export type EquipmentSlotsDto = Record<EquipmentSlot, EquipmentSlotItemDto | null>;
export interface EquipmentStatsDto {
maxHp: number;
attack: number;
weaponDamage: number;
armor: number;
}
export interface EquipmentResponseDto {
slots: EquipmentSlotsDto;
stats: EquipmentStatsDto;
}
type RepositoryScope = Pick<DataSource, 'getRepository'>;
@Injectable()
export class EquipmentService {
constructor(
private readonly dataSource: DataSource,
private readonly characterStats: CharacterStatsService,
) {}
async getEquipment(characterId: string): Promise<EquipmentResponseDto> {
const character = await this.dataSource
.getRepository(Character)
.findOneBy({ id: characterId });
if (!character) {
throw characterNotFound();
}
return this.buildResponse(character, this.dataSource);
}
/**
* Equips (or replaces) one slot for `characterId` with `characterItemId`
* (spec §14, §28). Runs in one transaction: the old item is unequipped by
* being overwritten, never deleted (spec §15).
*/
async equip(characterId: string, characterItemId: string): Promise<EquipmentResponseDto> {
return this.dataSource.transaction(async (manager) => {
const characters = manager.getRepository(Character);
const combats = manager.getRepository(Combat);
const characterItems = manager.getRepository(CharacterItem);
const equipmentRepo = manager.getRepository(CharacterEquipment);
const character = await characters.findOne({
where: { id: characterId },
lock: { mode: 'pessimistic_write' },
});
if (!character) {
throw characterNotFound();
}
const activeCombat = await combats.findOne({
where: { characterId, status: CombatStatus.ACTIVE },
});
if (activeCombat) {
throw characterInCombat();
}
const characterItem = await characterItems.findOne({
where: { id: characterItemId },
relations: { itemDefinition: true },
});
if (!characterItem) {
throw characterItemNotFound();
}
if (characterItem.characterId !== characterId) {
throw itemNotOwned();
}
const definition = characterItem.itemDefinition;
if (!definition.equipmentSlot) {
throw itemNotEquippable();
}
if (definition.requiredLevel > character.level) {
throw itemLevelRequirementNotMet();
}
const existing = await equipmentRepo.findOne({
where: { characterId, slot: definition.equipmentSlot },
lock: { mode: 'pessimistic_write' },
});
if (existing) {
existing.characterItemId = characterItem.id;
await equipmentRepo.save(existing);
} else {
await equipmentRepo.save(
equipmentRepo.create({
characterId,
slot: definition.equipmentSlot,
characterItemId: characterItem.id,
}),
);
}
return this.buildResponse(character, manager);
});
}
private async buildResponse(
character: Character,
scope: RepositoryScope,
): Promise<EquipmentResponseDto> {
const equipped = await scope.getRepository(CharacterEquipment).find({
where: { characterId: character.id },
relations: { characterItem: { itemDefinition: true } },
});
const slots = Object.fromEntries(
Object.values(EquipmentSlot).map((slot) => [slot, null]),
) as EquipmentSlotsDto;
for (const row of equipped) {
const definition = row.characterItem.itemDefinition;
slots[row.slot] = {
characterItemId: row.characterItemId,
item: {
key: definition.key,
name: definition.name,
rarity: definition.rarity,
iconPath: definition.iconPath,
},
};
}
const stats = await this.characterStats.calculate(character, scope);
return {
slots,
stats: {
maxHp: stats.maxHp,
attack: stats.attack,
weaponDamage: stats.weaponDamage,
armor: stats.armor,
},
};
}
}

View File

@@ -0,0 +1,13 @@
import { Controller, Get } from '@nestjs/common';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { InventoryResponseDto, InventoryService } from './inventory.service';
@Controller('inventory')
export class InventoryController {
constructor(private readonly inventoryService: InventoryService) {}
@Get()
getInventory(): Promise<InventoryResponseDto> {
return this.inventoryService.getInventory(DEMO_CHARACTER_ID);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { InventoryController } from './inventory.controller';
import { InventoryService } from './inventory.service';
@Module({
imports: [TypeOrmModule.forFeature([CharacterItem, CharacterEquipment])],
controllers: [InventoryController],
providers: [InventoryService],
})
export class InventoryModule {}

View File

@@ -0,0 +1,97 @@
import { Repository } from 'typeorm';
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { EquipmentSlot } from '../items/equipment-slot.enum';
import { ItemRarity } from '../items/item-rarity.enum';
import { ItemType } from '../items/item-type.enum';
import { InventoryService } from './inventory.service';
const CHARACTER_ID = 'character-1';
function characterItem(overrides: Partial<CharacterItem> = {}): CharacterItem {
return {
id: 'item-1',
characterId: CHARACTER_ID,
itemDefinitionId: 'def-1',
quantity: 1,
createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
itemDefinition: {
key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert',
rarity: ItemRarity.COMMON,
type: ItemType.WEAPON,
equipmentSlot: EquipmentSlot.WEAPON,
requiredLevel: 1,
weaponDamage: 8,
bonusAttack: 0,
bonusHp: 0,
bonusArmor: 0,
iconPath: '/images/items/worn-short-sword.png',
},
...overrides,
} as CharacterItem;
}
describe('InventoryService', () => {
it('returns only the current character\'s items with definition data, quantity, and equipped state', async () => {
const items = [
characterItem({ id: 'item-1', quantity: 1 }),
characterItem({ id: 'item-2', quantity: 3, itemDefinitionId: 'def-2' }),
];
const characterItems = {
find: jest.fn().mockResolvedValue(items),
} as unknown as Repository<CharacterItem>;
const equipment = {
find: jest.fn().mockResolvedValue([
{ characterItemId: 'item-1', slot: EquipmentSlot.WEAPON } as CharacterEquipment,
]),
} as unknown as Repository<CharacterEquipment>;
const service = new InventoryService(characterItems, equipment);
const result = await service.getInventory(CHARACTER_ID);
expect(characterItems.find).toHaveBeenCalledWith({
where: { characterId: CHARACTER_ID },
relations: { itemDefinition: true },
order: { createdAt: 'ASC' },
});
expect(result.items).toEqual([
{
id: 'item-1',
quantity: 1,
equipped: true,
item: {
key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert',
rarity: 'COMMON',
equipmentSlot: 'WEAPON',
requiredLevel: 1,
weaponDamage: 8,
bonusAttack: 0,
bonusHp: 0,
bonusArmor: 0,
iconPath: '/images/items/worn-short-sword.png',
},
},
{
id: 'item-2',
quantity: 3,
equipped: false,
item: expect.objectContaining({ key: 'worn-short-sword' }),
},
]);
});
it('returns an empty list when the character owns nothing', async () => {
const characterItems = {
find: jest.fn().mockResolvedValue([]),
} as unknown as Repository<CharacterItem>;
const equipment = {
find: jest.fn().mockResolvedValue([]),
} as unknown as Repository<CharacterEquipment>;
const service = new InventoryService(characterItems, equipment);
await expect(service.getInventory(CHARACTER_ID)).resolves.toEqual({ items: [] });
});
});

View File

@@ -0,0 +1,71 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { EquipmentSlot } from '../items/equipment-slot.enum';
import { ItemRarity } from '../items/item-rarity.enum';
export interface InventoryItemDto {
id: string;
quantity: number;
equipped: boolean;
item: {
key: string;
name: string;
rarity: ItemRarity;
equipmentSlot: EquipmentSlot | null;
requiredLevel: number;
weaponDamage: number;
bonusAttack: number;
bonusHp: number;
bonusArmor: number;
iconPath: string;
};
}
export interface InventoryResponseDto {
items: InventoryItemDto[];
}
@Injectable()
export class InventoryService {
constructor(
@InjectRepository(CharacterItem)
private readonly characterItems: Repository<CharacterItem>,
@InjectRepository(CharacterEquipment)
private readonly equipment: Repository<CharacterEquipment>,
) {}
async getInventory(characterId: string): Promise<InventoryResponseDto> {
const [items, equipped] = await Promise.all([
this.characterItems.find({
where: { characterId },
relations: { itemDefinition: true },
order: { createdAt: 'ASC' },
}),
this.equipment.find({ where: { characterId } }),
]);
const equippedIds = new Set(equipped.map((row) => row.characterItemId));
return {
items: items.map((characterItem) => ({
id: characterItem.id,
quantity: characterItem.quantity,
equipped: equippedIds.has(characterItem.id),
item: {
key: characterItem.itemDefinition.key,
name: characterItem.itemDefinition.name,
rarity: characterItem.itemDefinition.rarity,
equipmentSlot: characterItem.itemDefinition.equipmentSlot,
requiredLevel: characterItem.itemDefinition.requiredLevel,
weaponDamage: characterItem.itemDefinition.weaponDamage,
bonusAttack: characterItem.itemDefinition.bonusAttack,
bonusHp: characterItem.itemDefinition.bonusHp,
bonusArmor: characterItem.itemDefinition.bonusArmor,
iconPath: characterItem.itemDefinition.iconPath,
},
})),
};
}
}