test(api): prove equipping a weapon upgrade increases future combat damage
This commit is contained in:
374
apps/api/src/combat/combat-equipment-integration.spec.ts
Normal file
374
apps/api/src/combat/combat-equipment-integration.spec.ts
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
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);
|
||||||
|
|
||||||
|
const reloaded = await combatService.getCombat(CHARACTER_ID, combat.id);
|
||||||
|
expect(reloaded.status).toBe(result.status);
|
||||||
|
expect(reloaded.round).toBe(result.round);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user