Compare commits
74 Commits
d31d064d36
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
221819880c | ||
|
|
ab6227881e | ||
|
|
848f9d9195 | ||
|
|
c27b6c5026 | ||
|
|
caf63b34a4 | ||
|
|
cfe1866956 | ||
|
|
52178ba79d | ||
|
|
dd204db4b3 | ||
|
|
b070bf2b0d | ||
|
|
ac1329be46 | ||
|
|
432483e958 | ||
|
|
909f793eb6 | ||
|
|
02037b2917 | ||
|
|
47494abf50 | ||
|
|
71f58bc1b6 | ||
|
|
ea26e4b844 | ||
|
|
19e08f0b16 | ||
|
|
b55518b451 | ||
|
|
2579b23a5c | ||
|
|
a75a670509 | ||
|
|
dd90e05446 | ||
|
|
987242541d | ||
|
|
a376fb7128 | ||
|
|
65dfb466cd | ||
|
|
62d6677298 | ||
|
|
1aac3416fa | ||
|
|
e2f29d5eb6 | ||
|
|
0126d1dea1 | ||
|
|
b8d00278b8 | ||
|
|
c7b9601eb4 | ||
|
|
43d2085d2c | ||
|
|
6eb0f21360 | ||
|
|
b3dac8f61c | ||
|
|
fc1873e41f | ||
|
|
922c12f20f | ||
|
|
6f1afaf5a3 | ||
|
|
eed247d318 | ||
|
|
96d04d8ba3 | ||
|
|
67df86a818 | ||
|
|
9d396b7d96 | ||
|
|
49a0008a2b | ||
|
|
0ce3b420e6 | ||
|
|
7f031ac1ce | ||
|
|
b1968da754 | ||
|
|
724443de1e | ||
|
|
fdd8ae4e41 | ||
|
|
9cb0158d80 | ||
|
|
1015505f38 | ||
|
|
973d4e3ab4 | ||
|
|
10dd838465 | ||
|
|
c994a4c46f | ||
|
|
90094ba1ae | ||
|
|
6178b88573 | ||
|
|
2526ac230d | ||
|
|
2859a00597 | ||
|
|
820c69704f | ||
|
|
d1c7ffea86 | ||
|
|
9df740e7d4 | ||
|
|
3ee107694c | ||
|
|
f45f4b03cb | ||
|
|
e5746dec5c | ||
|
|
67c29e783f | ||
|
|
8a5f57fa1c | ||
|
|
8debb6f550 | ||
|
|
59e88bcf9c | ||
|
|
2cbb7cb235 | ||
|
|
c158260623 | ||
|
|
60e1734f59 | ||
|
|
ec91f3ac7b | ||
|
|
9b839623ce | ||
|
|
6f0020137b | ||
|
|
c7e7e97252 | ||
|
|
2664495ef4 | ||
|
|
c85a70484f |
2
.gitignore
vendored
@@ -13,3 +13,5 @@ coverage/
|
|||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
.worktrees/
|
||||||
@@ -2,8 +2,10 @@ import { Module } from '@nestjs/common';
|
|||||||
import { CharactersModule } from './characters/characters.module';
|
import { CharactersModule } from './characters/characters.module';
|
||||||
import { CombatModule } from './combat/combat.module';
|
import { CombatModule } from './combat/combat.module';
|
||||||
import { DatabaseModule } from './database/database.module';
|
import { DatabaseModule } from './database/database.module';
|
||||||
|
import { EquipmentModule } from './equipment/equipment.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { HuntingModule } from './hunting/hunting.module';
|
import { HuntingModule } from './hunting/hunting.module';
|
||||||
|
import { InventoryModule } from './inventory/inventory.module';
|
||||||
import { TravelModule } from './travel/travel.module';
|
import { TravelModule } from './travel/travel.module';
|
||||||
import { WorldModule } from './world/world.module';
|
import { WorldModule } from './world/world.module';
|
||||||
|
|
||||||
@@ -16,6 +18,8 @@ import { WorldModule } from './world/world.module';
|
|||||||
WorldModule,
|
WorldModule,
|
||||||
HuntingModule,
|
HuntingModule,
|
||||||
CombatModule,
|
CombatModule,
|
||||||
|
EquipmentModule,
|
||||||
|
InventoryModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
155
apps/api/src/characters/character-stats.service.spec.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
// 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';
|
||||||
|
import { CharacterVitalsService } from './character-vitals.service';
|
||||||
|
|
||||||
|
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,
|
||||||
|
hpRegenSince: null,
|
||||||
|
...overrides,
|
||||||
|
} as Character;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CharacterStatsService', () => {
|
||||||
|
const characterVitals = new CharacterVitalsService({
|
||||||
|
now: () => new Date('2026-08-21T12:00:00.000Z'),
|
||||||
|
});
|
||||||
|
const service = new CharacterStatsService({} as DataSource, characterVitals);
|
||||||
|
|
||||||
|
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('returns the raw current HP unchanged while regeneration is paused', async () => {
|
||||||
|
const scope = fakeScope([]);
|
||||||
|
|
||||||
|
const stats = await service.calculate(
|
||||||
|
character({ currentHp: 42, hpRegenSince: null }),
|
||||||
|
scope,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(stats.currentHp).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds elapsed regeneration, clamped to maxHp, when a regen anchor is set', async () => {
|
||||||
|
const scope = fakeScope([]);
|
||||||
|
|
||||||
|
const regenerating = await service.calculate(
|
||||||
|
character({
|
||||||
|
currentHp: 40,
|
||||||
|
hpRegenSince: new Date('2026-08-21T11:59:30.000Z'),
|
||||||
|
}),
|
||||||
|
scope,
|
||||||
|
);
|
||||||
|
expect(regenerating.currentHp).toBe(70);
|
||||||
|
|
||||||
|
const clamped = await service.calculate(
|
||||||
|
character({
|
||||||
|
currentHp: 40,
|
||||||
|
hpRegenSince: new Date('2026-08-21T11:40:00.000Z'),
|
||||||
|
}),
|
||||||
|
scope,
|
||||||
|
);
|
||||||
|
expect(clamped.currentHp).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the regeneration rate and anchor alongside the effective stats', async () => {
|
||||||
|
const scope = fakeScope([]);
|
||||||
|
const anchor = new Date('2026-08-21T11:59:30.000Z');
|
||||||
|
|
||||||
|
const stats = await service.calculate(character({ hpRegenSince: anchor }), scope);
|
||||||
|
|
||||||
|
expect(stats.hpRegenPerSecond).toBe(1);
|
||||||
|
expect(stats.hpRegenSince).toEqual(anchor);
|
||||||
|
});
|
||||||
|
});
|
||||||
73
apps/api/src/characters/character-stats.service.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
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 { HP_REGEN_PER_SECOND } from './character-vitals.constants';
|
||||||
|
import { CharacterVitalsService } from './character-vitals.service';
|
||||||
|
import { Character } from './entities/character.entity';
|
||||||
|
|
||||||
|
export interface EffectiveCharacterStats {
|
||||||
|
maxHp: number;
|
||||||
|
currentHp: number;
|
||||||
|
attack: number;
|
||||||
|
weaponDamage: number;
|
||||||
|
armor: number;
|
||||||
|
combatPower: number;
|
||||||
|
hpRegenPerSecond: number;
|
||||||
|
hpRegenSince: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
private readonly characterVitals: CharacterVitalsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
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: this.characterVitals.effectiveHp(character, maxHp),
|
||||||
|
attack,
|
||||||
|
weaponDamage,
|
||||||
|
armor,
|
||||||
|
combatPower: maxHp / 10 + attack * 2 + weaponDamage * 2 + armor * 1.5,
|
||||||
|
hpRegenPerSecond: HP_REGEN_PER_SECOND,
|
||||||
|
hpRegenSince: character.hpRegenSince,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/api/src/characters/character-vitals.constants.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const HP_REGEN_PER_SECOND = 1;
|
||||||
136
apps/api/src/characters/character-vitals.service.spec.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { Clock } from '../shared/clock';
|
||||||
|
import { CharacterVitalsService } from './character-vitals.service';
|
||||||
|
import { Character } from './entities/character.entity';
|
||||||
|
|
||||||
|
function fakeClock(initialIso: string): {
|
||||||
|
clock: Clock;
|
||||||
|
advanceSeconds: (seconds: number) => void;
|
||||||
|
} {
|
||||||
|
let current = Date.parse(initialIso);
|
||||||
|
return {
|
||||||
|
clock: { now: () => new Date(current) },
|
||||||
|
advanceSeconds: (seconds: number) => {
|
||||||
|
current += seconds * 1000;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function character(overrides: Partial<Character> = {}): Character {
|
||||||
|
return {
|
||||||
|
id: 'character-1',
|
||||||
|
currentHp: 50,
|
||||||
|
hpRegenSince: null,
|
||||||
|
...overrides,
|
||||||
|
} as Character;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CharacterVitalsService', () => {
|
||||||
|
describe('effectiveHp', () => {
|
||||||
|
it('returns the raw current HP when regeneration is paused', () => {
|
||||||
|
const { clock } = fakeClock('2026-08-21T12:00:00.000Z');
|
||||||
|
const service = new CharacterVitalsService(clock);
|
||||||
|
|
||||||
|
const hp = service.effectiveHp(character({ currentHp: 37, hpRegenSince: null }), 100);
|
||||||
|
|
||||||
|
expect(hp).toBe(37);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds one HP per elapsed second since the anchor', () => {
|
||||||
|
const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z');
|
||||||
|
const service = new CharacterVitalsService(clock);
|
||||||
|
const anchor = new Date('2026-08-21T12:00:00.000Z');
|
||||||
|
const target = character({ currentHp: 40, hpRegenSince: anchor });
|
||||||
|
|
||||||
|
advanceSeconds(25);
|
||||||
|
|
||||||
|
expect(service.effectiveHp(target, 100)).toBe(65);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('floors partial seconds instead of rounding up', () => {
|
||||||
|
const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z');
|
||||||
|
const service = new CharacterVitalsService(clock);
|
||||||
|
const anchor = new Date('2026-08-21T12:00:00.000Z');
|
||||||
|
const target = character({ currentHp: 40, hpRegenSince: anchor });
|
||||||
|
|
||||||
|
advanceSeconds(1.9);
|
||||||
|
|
||||||
|
expect(service.effectiveHp(target, 100)).toBe(41);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps regeneration at maxHp', () => {
|
||||||
|
const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z');
|
||||||
|
const service = new CharacterVitalsService(clock);
|
||||||
|
const anchor = new Date('2026-08-21T12:00:00.000Z');
|
||||||
|
const target = character({ currentHp: 90, hpRegenSince: anchor });
|
||||||
|
|
||||||
|
advanceSeconds(50);
|
||||||
|
|
||||||
|
expect(service.effectiveHp(target, 100)).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never lets HP fall if the clock moves backwards', () => {
|
||||||
|
const clock: Clock = { now: () => new Date('2026-08-21T11:59:00.000Z') };
|
||||||
|
const service = new CharacterVitalsService(clock);
|
||||||
|
const anchor = new Date('2026-08-21T12:00:00.000Z');
|
||||||
|
const target = character({ currentHp: 40, hpRegenSince: anchor });
|
||||||
|
|
||||||
|
expect(service.effectiveHp(target, 100)).toBe(40);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pause', () => {
|
||||||
|
it('freezes current HP at the given value and clears the anchor', () => {
|
||||||
|
const { clock } = fakeClock('2026-08-21T12:00:00.000Z');
|
||||||
|
const service = new CharacterVitalsService(clock);
|
||||||
|
const target = character({
|
||||||
|
currentHp: 100,
|
||||||
|
hpRegenSince: new Date('2026-08-21T11:00:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
service.pause(target, 62);
|
||||||
|
|
||||||
|
expect(target.currentHp).toBe(62);
|
||||||
|
expect(target.hpRegenSince).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resume', () => {
|
||||||
|
it('sets current HP and anchors regeneration at now', () => {
|
||||||
|
const { clock } = fakeClock('2026-08-21T12:00:00.000Z');
|
||||||
|
const service = new CharacterVitalsService(clock);
|
||||||
|
const target = character({ currentHp: 0, hpRegenSince: null });
|
||||||
|
|
||||||
|
service.resume(target, 15);
|
||||||
|
|
||||||
|
expect(target.currentHp).toBe(15);
|
||||||
|
expect(target.hpRegenSince).toEqual(new Date('2026-08-21T12:00:00.000Z'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('settle', () => {
|
||||||
|
it('re-anchors at the current effective value without changing it', () => {
|
||||||
|
const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z');
|
||||||
|
const service = new CharacterVitalsService(clock);
|
||||||
|
const anchor = new Date('2026-08-21T12:00:00.000Z');
|
||||||
|
const target = character({ currentHp: 40, hpRegenSince: anchor });
|
||||||
|
advanceSeconds(10);
|
||||||
|
|
||||||
|
service.settle(target, 100);
|
||||||
|
|
||||||
|
expect(target.currentHp).toBe(50);
|
||||||
|
expect(target.hpRegenSince).toEqual(new Date('2026-08-21T12:00:10.000Z'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not gift overflow past the pre-change maxHp when re-anchoring', () => {
|
||||||
|
const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z');
|
||||||
|
const service = new CharacterVitalsService(clock);
|
||||||
|
const anchor = new Date('2026-08-21T12:00:00.000Z');
|
||||||
|
const target = character({ currentHp: 100, hpRegenSince: anchor });
|
||||||
|
advanceSeconds(600);
|
||||||
|
|
||||||
|
service.settle(target, 100);
|
||||||
|
|
||||||
|
expect(target.currentHp).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
46
apps/api/src/characters/character-vitals.service.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { CLOCK } from '../shared/clock';
|
||||||
|
import type { Clock } from '../shared/clock';
|
||||||
|
import { HP_REGEN_PER_SECOND } from './character-vitals.constants';
|
||||||
|
import { Character } from './entities/character.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The only place that turns (current_hp, hp_regen_since) into an effective
|
||||||
|
* HP value, or moves that pair. `current_hp` is exact only while the anchor
|
||||||
|
* is null; everything else must go through here (persistent-hp-and-
|
||||||
|
* regeneration design, R3).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CharacterVitalsService {
|
||||||
|
constructor(@Inject(CLOCK) private readonly clock: Clock) {}
|
||||||
|
|
||||||
|
effectiveHp(
|
||||||
|
character: Pick<Character, 'currentHp' | 'hpRegenSince'>,
|
||||||
|
maxHp: number,
|
||||||
|
): number {
|
||||||
|
if (character.hpRegenSince === null) {
|
||||||
|
return Math.min(maxHp, character.currentHp);
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsedSeconds = Math.max(
|
||||||
|
0,
|
||||||
|
(this.clock.now().getTime() - character.hpRegenSince.getTime()) / 1000,
|
||||||
|
);
|
||||||
|
const regenerated = Math.floor(elapsedSeconds * HP_REGEN_PER_SECOND);
|
||||||
|
return Math.min(maxHp, character.currentHp + regenerated);
|
||||||
|
}
|
||||||
|
|
||||||
|
pause(character: Character, value: number): void {
|
||||||
|
character.currentHp = value;
|
||||||
|
character.hpRegenSince = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
resume(character: Character, value: number): void {
|
||||||
|
character.currentHp = value;
|
||||||
|
character.hpRegenSince = this.clock.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
settle(character: Character, maxHp: number): void {
|
||||||
|
this.resume(character, this.effectiveHp(character, maxHp));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { CharacterCombatStatsService } from './character-combat-stats.service';
|
import { CLOCK, systemClock } from '../shared/clock';
|
||||||
|
import { CharacterStatsService } from './character-stats.service';
|
||||||
|
import { CharacterVitalsService } from './character-vitals.service';
|
||||||
import { CharactersController } from './characters.controller';
|
import { CharactersController } from './characters.controller';
|
||||||
import { CharactersService } from './characters.service';
|
import { CharactersService } from './characters.service';
|
||||||
import { Character } from './entities/character.entity';
|
import { Character } from './entities/character.entity';
|
||||||
@@ -8,7 +10,12 @@ import { Character } from './entities/character.entity';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Character])],
|
imports: [TypeOrmModule.forFeature([Character])],
|
||||||
controllers: [CharactersController],
|
controllers: [CharactersController],
|
||||||
providers: [CharactersService, CharacterCombatStatsService],
|
providers: [
|
||||||
exports: [CharacterCombatStatsService],
|
CharactersService,
|
||||||
|
CharacterStatsService,
|
||||||
|
CharacterVitalsService,
|
||||||
|
{ provide: CLOCK, useValue: systemClock },
|
||||||
|
],
|
||||||
|
exports: [CharacterStatsService, CharacterVitalsService],
|
||||||
})
|
})
|
||||||
export class CharactersModule {}
|
export class CharactersModule {}
|
||||||
|
|||||||
@@ -2,11 +2,29 @@ import { NotFoundException } from '@nestjs/common';
|
|||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
|
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
|
||||||
import { SOUTH_GATE_ID } from '../database/seeds/vertical-slice.constants';
|
import { SOUTH_GATE_ID } from '../database/seeds/vertical-slice.constants';
|
||||||
|
import { CharacterStatsService } from './character-stats.service';
|
||||||
import { Character } from './entities/character.entity';
|
import { Character } from './entities/character.entity';
|
||||||
import { CharactersService } from './characters.service';
|
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,
|
||||||
|
hpRegenPerSecond: 1,
|
||||||
|
hpRegenSince: new Date('2026-08-18T09:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
} as unknown as CharacterStatsService;
|
||||||
|
}
|
||||||
|
|
||||||
describe('CharactersService', () => {
|
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 = {
|
const repository = {
|
||||||
findOne: jest.fn().mockResolvedValue({
|
findOne: jest.fn().mockResolvedValue({
|
||||||
id: DEMO_CHARACTER_ID,
|
id: DEMO_CHARACTER_ID,
|
||||||
@@ -20,11 +38,12 @@ describe('CharactersService', () => {
|
|||||||
currentLocation: {
|
currentLocation: {
|
||||||
id: SOUTH_GATE_ID,
|
id: SOUTH_GATE_ID,
|
||||||
key: 'south-gate',
|
key: 'south-gate',
|
||||||
name: 'S\u00fcdtor von Graufurt',
|
name: 'Südtor von Graufurt',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
} as unknown as Repository<Character>;
|
} 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({
|
await expect(service.getDemoCharacter()).resolves.toEqual({
|
||||||
id: DEMO_CHARACTER_ID,
|
id: DEMO_CHARACTER_ID,
|
||||||
@@ -33,18 +52,19 @@ describe('CharactersService', () => {
|
|||||||
experience: 0,
|
experience: 0,
|
||||||
silver: 0,
|
silver: 0,
|
||||||
currentHp: 100,
|
currentHp: 100,
|
||||||
maxHp: 100,
|
maxHp: 115,
|
||||||
attack: 6,
|
attack: 7,
|
||||||
|
hpRegenPerSecond: 1,
|
||||||
|
hpRegenSince: '2026-08-18T09:00:00.000Z',
|
||||||
currentLocation: {
|
currentLocation: {
|
||||||
id: SOUTH_GATE_ID,
|
id: SOUTH_GATE_ID,
|
||||||
key: 'south-gate',
|
key: 'south-gate',
|
||||||
name: 'S\u00fcdtor von Graufurt',
|
name: 'Südtor von Graufurt',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(repository.findOne).toHaveBeenCalledWith({
|
expect(characterStats.calculate).toHaveBeenCalledWith(
|
||||||
where: { id: DEMO_CHARACTER_ID },
|
expect.objectContaining({ id: DEMO_CHARACTER_ID }),
|
||||||
relations: { currentLocation: true },
|
);
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exposes the persisted silver so the HUD never has to guess', async () => {
|
it('exposes the persisted silver so the HUD never has to guess', async () => {
|
||||||
@@ -65,7 +85,7 @@ describe('CharactersService', () => {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
} as unknown as Repository<Character>;
|
} as unknown as Repository<Character>;
|
||||||
const service = new CharactersService(repository);
|
const service = new CharactersService(repository, fakeCharacterStats());
|
||||||
|
|
||||||
await expect(service.getDemoCharacter()).resolves.toEqual(
|
await expect(service.getDemoCharacter()).resolves.toEqual(
|
||||||
expect.objectContaining({ experience: 24, silver: 18 }),
|
expect.objectContaining({ experience: 24, silver: 18 }),
|
||||||
@@ -76,7 +96,7 @@ describe('CharactersService', () => {
|
|||||||
const repository = {
|
const repository = {
|
||||||
findOne: jest.fn().mockResolvedValue(null),
|
findOne: jest.fn().mockResolvedValue(null),
|
||||||
} as unknown as Repository<Character>;
|
} as unknown as Repository<Character>;
|
||||||
const service = new CharactersService(repository);
|
const service = new CharactersService(repository, fakeCharacterStats());
|
||||||
|
|
||||||
await expect(service.getDemoCharacter()).rejects.toBeInstanceOf(
|
await expect(service.getDemoCharacter()).rejects.toBeInstanceOf(
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
|||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
|
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
|
||||||
|
import { CharacterStatsService } from './character-stats.service';
|
||||||
import { Character } from './entities/character.entity';
|
import { Character } from './entities/character.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -9,6 +10,7 @@ export class CharactersService {
|
|||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Character)
|
@InjectRepository(Character)
|
||||||
private readonly characters: Repository<Character>,
|
private readonly characters: Repository<Character>,
|
||||||
|
private readonly characterStats: CharacterStatsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getDemoCharacter() {
|
async getDemoCharacter() {
|
||||||
@@ -21,15 +23,19 @@ export class CharactersService {
|
|||||||
throw new NotFoundException('Demo character has not been seeded');
|
throw new NotFoundException('Demo character has not been seeded');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stats = await this.characterStats.calculate(character);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: character.id,
|
id: character.id,
|
||||||
name: character.name,
|
name: character.name,
|
||||||
level: character.level,
|
level: character.level,
|
||||||
experience: character.experience,
|
experience: character.experience,
|
||||||
silver: character.silver,
|
silver: character.silver,
|
||||||
currentHp: character.currentHp,
|
currentHp: stats.currentHp,
|
||||||
maxHp: character.baseHp,
|
maxHp: stats.maxHp,
|
||||||
attack: character.baseAttack,
|
attack: stats.attack,
|
||||||
|
hpRegenPerSecond: stats.hpRegenPerSecond,
|
||||||
|
hpRegenSince: stats.hpRegenSince ? stats.hpRegenSince.toISOString() : null,
|
||||||
currentLocation: {
|
currentLocation: {
|
||||||
id: character.currentLocation.id,
|
id: character.currentLocation.id,
|
||||||
key: character.currentLocation.key,
|
key: character.currentLocation.key,
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ export class Character {
|
|||||||
@Column({ name: 'current_hp', type: 'integer' })
|
@Column({ name: 'current_hp', type: 'integer' })
|
||||||
currentHp!: number;
|
currentHp!: number;
|
||||||
|
|
||||||
|
// `current_hp` is only exact while this is null (regeneration paused, e.g.
|
||||||
|
// mid-combat). Otherwise it's the HP as of this timestamp -- read it
|
||||||
|
// through CharacterVitalsService.effectiveHp(), never directly.
|
||||||
|
@Column({ name: 'hp_regen_since', type: 'timestamptz', nullable: true })
|
||||||
|
hpRegenSince!: Date | null;
|
||||||
|
|
||||||
@Column({ name: 'current_location_id', type: 'uuid' })
|
@Column({ name: 'current_location_id', type: 'uuid' })
|
||||||
currentLocationId!: string;
|
currentLocationId!: string;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Only ATTACK is implemented in Slice 0.3. Future slices add HEAVY_STRIKE,
|
|
||||||
// SHIELD_BASH, DEFEND, POTION, FLEE as real members with their own
|
|
||||||
// CombatEngineService cases — do not add them here until their behavior ships.
|
|
||||||
export enum CombatAction {
|
export enum CombatAction {
|
||||||
ATTACK = 'ATTACK',
|
ATTACK = 'ATTACK',
|
||||||
|
HEAVY_STRIKE = 'HEAVY_STRIKE',
|
||||||
|
SHIELD_BASH = 'SHIELD_BASH',
|
||||||
|
DEFEND = 'DEFEND',
|
||||||
|
POTION = 'POTION',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,4 +14,13 @@ describe('calculateDamage', () => {
|
|||||||
// raw = 9; 9 * 60 / (60 + 5) = 8.307... -> rounds to 8
|
// raw = 9; 9 * 60 / (60 + 5) = 8.307... -> rounds to 8
|
||||||
expect(calculateDamage({ attack: 9 }, 5)).toBe(8);
|
expect(calculateDamage({ attack: 9 }, 5)).toBe(8);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('applies a damage multiplier before the minimum-1 floor', () => {
|
||||||
|
// raw = 27; mitigated = 20.25; *1.6 = 32.4 -> rounds to 32
|
||||||
|
expect(calculateDamage({ attack: 12, weaponDamage: 15 }, 20, 1.6)).toBe(32);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still floors at 1 damage even with a small multiplier', () => {
|
||||||
|
expect(calculateDamage({ attack: 1, weaponDamage: 0 }, 100_000, 0.5)).toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,9 +5,13 @@ export interface DamageAttacker {
|
|||||||
|
|
||||||
const ARMOR_MITIGATION_CONSTANT = 60;
|
const ARMOR_MITIGATION_CONSTANT = 60;
|
||||||
|
|
||||||
export function calculateDamage(attacker: DamageAttacker, targetArmor: number): number {
|
export function calculateDamage(
|
||||||
|
attacker: DamageAttacker,
|
||||||
|
targetArmor: number,
|
||||||
|
multiplier = 1,
|
||||||
|
): number {
|
||||||
const rawDamage = attacker.attack + (attacker.weaponDamage ?? 0);
|
const rawDamage = attacker.attack + (attacker.weaponDamage ?? 0);
|
||||||
const mitigatedDamage =
|
const mitigatedDamage =
|
||||||
(rawDamage * ARMOR_MITIGATION_CONSTANT) / (ARMOR_MITIGATION_CONSTANT + targetArmor);
|
(rawDamage * ARMOR_MITIGATION_CONSTANT) / (ARMOR_MITIGATION_CONSTANT + targetArmor);
|
||||||
return Math.max(1, Math.round(mitigatedDamage));
|
return Math.max(1, Math.round(mitigatedDamage * multiplier));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,7 +101,190 @@ describe('CombatEngineService', () => {
|
|||||||
|
|
||||||
it('throws UnsupportedCombatActionError for an action it does not implement', () => {
|
it('throws UnsupportedCombatActionError for an action it does not implement', () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
engine.resolveAction(baseState(), { action: 'HEAVY_STRIKE' as CombatAction }),
|
engine.resolveAction(baseState(), { action: 'FLEE' as CombatAction }),
|
||||||
).toThrow(UnsupportedCombatActionError);
|
).toThrow(UnsupportedCombatActionError);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('HEAVY_STRIKE deals 160% damage to the monster', () => {
|
||||||
|
const result = engine.resolveAction(baseState(), { action: CombatAction.HEAVY_STRIKE });
|
||||||
|
|
||||||
|
// raw = 14; 14 * 1.6 = 22.4 -> rounds to 22
|
||||||
|
expect(result.events[0]).toEqual({
|
||||||
|
source: Combatant.PLAYER,
|
||||||
|
target: Combatant.MONSTER,
|
||||||
|
type: CombatEventType.DAMAGE,
|
||||||
|
amount: 22,
|
||||||
|
});
|
||||||
|
expect(result.state.monster.currentHp).toBe(45 - 22);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SHIELD_BASH deals 70% damage and does not emit INTERRUPT when nothing is pending', () => {
|
||||||
|
const result = engine.resolveAction(baseState(), { action: CombatAction.SHIELD_BASH });
|
||||||
|
|
||||||
|
// raw = 14; 14 * 0.7 = 9.8 -> rounds to 10
|
||||||
|
expect(result.events[0]).toEqual({
|
||||||
|
source: Combatant.PLAYER,
|
||||||
|
target: Combatant.MONSTER,
|
||||||
|
type: CombatEventType.DAMAGE,
|
||||||
|
amount: 10,
|
||||||
|
});
|
||||||
|
expect(result.events.some((event) => event.type === CombatEventType.INTERRUPT)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SHIELD_BASH interrupts a pending Heavy Attack and the monster does not act this round', () => {
|
||||||
|
const state = baseState({
|
||||||
|
monster: {
|
||||||
|
currentHp: 45,
|
||||||
|
maxHp: 45,
|
||||||
|
stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = engine.resolveAction(state, { action: CombatAction.SHIELD_BASH });
|
||||||
|
|
||||||
|
expect(result.events).toEqual([
|
||||||
|
{ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.DAMAGE, amount: 10 },
|
||||||
|
{ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.INTERRUPT },
|
||||||
|
]);
|
||||||
|
expect(result.state.monster.stats.pendingAction).toBeUndefined();
|
||||||
|
expect(result.state.player.currentHp).toBe(100);
|
||||||
|
expect(result.state.round).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DEFEND deals no damage and halves the monster normal attack this round', () => {
|
||||||
|
const result = engine.resolveAction(baseState(), { action: CombatAction.DEFEND });
|
||||||
|
|
||||||
|
expect(result.state.monster.currentHp).toBe(45);
|
||||||
|
// raw = 5; mitigated = 4.545...; * 0.5 = 2.27 -> rounds to 2
|
||||||
|
expect(result.events).toEqual([
|
||||||
|
{ source: Combatant.PLAYER, target: Combatant.PLAYER, type: CombatEventType.DEFEND },
|
||||||
|
{ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.DAMAGE, amount: 2 },
|
||||||
|
]);
|
||||||
|
expect(result.state.player.currentHp).toBe(98);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POTION heals 35% of max HP, decrements potionsRemaining, and the monster still acts', () => {
|
||||||
|
const state = baseState({
|
||||||
|
player: {
|
||||||
|
currentHp: 60,
|
||||||
|
maxHp: 100,
|
||||||
|
stats: { attack: 6, weaponDamage: 8, armor: 6, potionsRemaining: 2 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = engine.resolveAction(state, { action: CombatAction.POTION });
|
||||||
|
|
||||||
|
// 35% of 100 = 35
|
||||||
|
expect(result.events[0]).toEqual({
|
||||||
|
source: Combatant.PLAYER,
|
||||||
|
target: Combatant.PLAYER,
|
||||||
|
type: CombatEventType.HEAL,
|
||||||
|
amount: 35,
|
||||||
|
});
|
||||||
|
expect(result.events[1]).toEqual({
|
||||||
|
source: Combatant.MONSTER,
|
||||||
|
target: Combatant.PLAYER,
|
||||||
|
type: CombatEventType.DAMAGE,
|
||||||
|
amount: 5,
|
||||||
|
});
|
||||||
|
// 60 + 35 healed - 5 monster hit = 90
|
||||||
|
expect(result.state.player.currentHp).toBe(90);
|
||||||
|
expect(result.state.player.stats.potionsRemaining).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps POTION healing at the maximum HP', () => {
|
||||||
|
const state = baseState({
|
||||||
|
player: {
|
||||||
|
currentHp: 90,
|
||||||
|
maxHp: 100,
|
||||||
|
stats: { attack: 6, weaponDamage: 8, armor: 6, potionsRemaining: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = engine.resolveAction(state, { action: CombatAction.POTION });
|
||||||
|
|
||||||
|
// 35% of 100 = 35, but only 10 HP is missing
|
||||||
|
expect(result.events[0]).toEqual({
|
||||||
|
source: Combatant.PLAYER,
|
||||||
|
target: Combatant.PLAYER,
|
||||||
|
type: CombatEventType.HEAL,
|
||||||
|
amount: 10,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('telegraphs a Heavy Attack instead of attacking on the third round, and resolves it the round after', () => {
|
||||||
|
const round3State = baseState({ round: 3 });
|
||||||
|
|
||||||
|
const telegraphResult = engine.resolveAction(round3State, { action: CombatAction.ATTACK });
|
||||||
|
|
||||||
|
expect(telegraphResult.state.player.currentHp).toBe(100);
|
||||||
|
expect(telegraphResult.state.monster.stats.pendingAction).toBe('HEAVY_ATTACK');
|
||||||
|
expect(telegraphResult.events[1]).toEqual({
|
||||||
|
source: Combatant.MONSTER,
|
||||||
|
target: Combatant.PLAYER,
|
||||||
|
type: CombatEventType.TELEGRAPH,
|
||||||
|
});
|
||||||
|
expect(telegraphResult.state.round).toBe(4);
|
||||||
|
|
||||||
|
const resolveResult = engine.resolveAction(telegraphResult.state, { action: CombatAction.ATTACK });
|
||||||
|
|
||||||
|
// raw = 5; mitigated = 4.545...; heavy * 1.6 = 7.27 -> rounds to 7
|
||||||
|
expect(resolveResult.events[1]).toEqual({
|
||||||
|
source: Combatant.MONSTER,
|
||||||
|
target: Combatant.PLAYER,
|
||||||
|
type: CombatEventType.DAMAGE,
|
||||||
|
amount: 7,
|
||||||
|
});
|
||||||
|
expect(resolveResult.state.monster.stats.pendingAction).toBeUndefined();
|
||||||
|
expect(resolveResult.state.player.currentHp).toBe(100 - 7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not resolve a pending Heavy Attack when the monster is killed this round', () => {
|
||||||
|
const state = baseState({
|
||||||
|
monster: {
|
||||||
|
currentHp: 10,
|
||||||
|
maxHp: 45,
|
||||||
|
stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = engine.resolveAction(state, { action: CombatAction.ATTACK });
|
||||||
|
|
||||||
|
expect(result.state.status).toBe(CombatStatus.WON);
|
||||||
|
expect(result.state.player.currentHp).toBe(100);
|
||||||
|
expect(result.state.monster.stats.pendingAction).toBeUndefined();
|
||||||
|
expect(result.events).toEqual([
|
||||||
|
{ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.DAMAGE, amount: 14 },
|
||||||
|
{ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.COMBAT_WON },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is deterministic for HEAVY_STRIKE as well', () => {
|
||||||
|
const state = baseState();
|
||||||
|
|
||||||
|
const first = engine.resolveAction(state, { action: CombatAction.HEAVY_STRIKE });
|
||||||
|
const second = engine.resolveAction(state, { action: CombatAction.HEAVY_STRIKE });
|
||||||
|
|
||||||
|
expect(first).toEqual(second);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DEFEND mitigates a resolving Heavy Attack by half', () => {
|
||||||
|
const state = baseState({
|
||||||
|
monster: {
|
||||||
|
currentHp: 45,
|
||||||
|
maxHp: 45,
|
||||||
|
stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = engine.resolveAction(state, { action: CombatAction.DEFEND });
|
||||||
|
|
||||||
|
// raw = 5; mitigated = 4.545...; heavy * 1.6 = 7.27; * defend 0.5 = 3.636 -> rounds to 4
|
||||||
|
expect(result.events).toEqual([
|
||||||
|
{ source: Combatant.PLAYER, target: Combatant.PLAYER, type: CombatEventType.DEFEND },
|
||||||
|
{ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.DAMAGE, amount: 4 },
|
||||||
|
]);
|
||||||
|
expect(result.state.monster.stats.pendingAction).toBeUndefined();
|
||||||
|
expect(result.state.player.currentHp).toBe(100 - 4);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { calculateDamage } from './combat-damage';
|
|||||||
import { Combatant } from './combatant.enum';
|
import { Combatant } from './combatant.enum';
|
||||||
import {
|
import {
|
||||||
CombatActionInput,
|
CombatActionInput,
|
||||||
|
CombatEngineCombatant,
|
||||||
CombatEngineEvent,
|
CombatEngineEvent,
|
||||||
CombatEngineResult,
|
CombatEngineResult,
|
||||||
CombatEngineState,
|
CombatEngineState,
|
||||||
@@ -17,62 +18,123 @@ export class UnsupportedCombatActionError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The monster telegraphs a Heavy Attack instead of striking every third
|
||||||
|
// round it acts, then resolves it the round after unless SHIELD_BASH
|
||||||
|
// interrupts it. A fixed cadence (not RNG) keeps combat deterministic
|
||||||
|
// (Playable Slice 0.6 spec §10/§11).
|
||||||
|
const TELEGRAPH_ROUND_INTERVAL = 3;
|
||||||
|
const HEAVY_ATTACK_MULTIPLIER = 1.6;
|
||||||
|
const SHIELD_BASH_MULTIPLIER = 0.7;
|
||||||
|
const DEFEND_MITIGATION_MULTIPLIER = 0.5;
|
||||||
|
const POTION_HEAL_FRACTION = 0.35;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CombatEngineService {
|
export class CombatEngineService {
|
||||||
resolveAction(state: CombatEngineState, input: CombatActionInput): CombatEngineResult {
|
resolveAction(state: CombatEngineState, input: CombatActionInput): CombatEngineResult {
|
||||||
switch (input.action) {
|
switch (input.action) {
|
||||||
case CombatAction.ATTACK:
|
case CombatAction.ATTACK:
|
||||||
return this.resolveAttack(state);
|
return this.resolvePlayerStrike(state, 1);
|
||||||
|
case CombatAction.HEAVY_STRIKE:
|
||||||
|
return this.resolvePlayerStrike(state, HEAVY_ATTACK_MULTIPLIER);
|
||||||
|
case CombatAction.SHIELD_BASH:
|
||||||
|
return this.resolveShieldBash(state);
|
||||||
|
case CombatAction.DEFEND:
|
||||||
|
return this.resolveDefend(state);
|
||||||
|
case CombatAction.POTION:
|
||||||
|
return this.resolvePotion(state);
|
||||||
default:
|
default:
|
||||||
throw new UnsupportedCombatActionError(input.action);
|
throw new UnsupportedCombatActionError(input.action);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveAttack(state: CombatEngineState): CombatEngineResult {
|
private resolvePlayerStrike(state: CombatEngineState, multiplier: number): CombatEngineResult {
|
||||||
|
const player = this.cloneCombatant(state.player);
|
||||||
|
const monster = this.cloneCombatant(state.monster);
|
||||||
const events: CombatEngineEvent[] = [];
|
const events: CombatEngineEvent[] = [];
|
||||||
const player = { ...state.player };
|
|
||||||
const monster = { ...state.monster };
|
|
||||||
|
|
||||||
const playerDamage = calculateDamage(player.stats, monster.stats.armor);
|
const damage = calculateDamage(player.stats, monster.stats.armor, multiplier);
|
||||||
monster.currentHp = Math.max(0, monster.currentHp - playerDamage);
|
monster.currentHp = Math.max(0, monster.currentHp - damage);
|
||||||
events.push({
|
events.push({
|
||||||
source: Combatant.PLAYER,
|
source: Combatant.PLAYER,
|
||||||
target: Combatant.MONSTER,
|
target: Combatant.MONSTER,
|
||||||
type: CombatEventType.DAMAGE,
|
type: CombatEventType.DAMAGE,
|
||||||
amount: playerDamage,
|
amount: damage,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (monster.currentHp <= 0) {
|
return this.finishRound(state, player, monster, events, false);
|
||||||
events.push({
|
|
||||||
source: Combatant.PLAYER,
|
|
||||||
target: Combatant.MONSTER,
|
|
||||||
type: CombatEventType.COMBAT_WON,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
state: { ...state, player, monster, status: CombatStatus.WON },
|
|
||||||
events,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const monsterDamage = calculateDamage(monster.stats, player.stats.armor);
|
private resolveShieldBash(state: CombatEngineState): CombatEngineResult {
|
||||||
player.currentHp = Math.max(0, player.currentHp - monsterDamage);
|
const player = this.cloneCombatant(state.player);
|
||||||
|
const monster = this.cloneCombatant(state.monster);
|
||||||
|
const events: CombatEngineEvent[] = [];
|
||||||
|
|
||||||
|
const damage = calculateDamage(player.stats, monster.stats.armor, SHIELD_BASH_MULTIPLIER);
|
||||||
|
monster.currentHp = Math.max(0, monster.currentHp - damage);
|
||||||
events.push({
|
events.push({
|
||||||
source: Combatant.MONSTER,
|
source: Combatant.PLAYER,
|
||||||
target: Combatant.PLAYER,
|
target: Combatant.MONSTER,
|
||||||
type: CombatEventType.DAMAGE,
|
type: CombatEventType.DAMAGE,
|
||||||
amount: monsterDamage,
|
amount: damage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let interrupted = false;
|
||||||
|
if (monster.stats.pendingAction) {
|
||||||
|
monster.stats.pendingAction = undefined;
|
||||||
|
interrupted = true;
|
||||||
|
events.push({ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.INTERRUPT });
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.finishRound(state, player, monster, events, false, interrupted);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveDefend(state: CombatEngineState): CombatEngineResult {
|
||||||
|
const player = this.cloneCombatant(state.player);
|
||||||
|
const monster = this.cloneCombatant(state.monster);
|
||||||
|
const events: CombatEngineEvent[] = [
|
||||||
|
{ source: Combatant.PLAYER, target: Combatant.PLAYER, type: CombatEventType.DEFEND },
|
||||||
|
];
|
||||||
|
|
||||||
|
return this.finishRound(state, player, monster, events, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolvePotion(state: CombatEngineState): CombatEngineResult {
|
||||||
|
const player = this.cloneCombatant(state.player);
|
||||||
|
const monster = this.cloneCombatant(state.monster);
|
||||||
|
|
||||||
|
const rawHeal = Math.round(player.maxHp * POTION_HEAL_FRACTION);
|
||||||
|
const healed = Math.min(rawHeal, player.maxHp - player.currentHp);
|
||||||
|
player.currentHp += healed;
|
||||||
|
player.stats.potionsRemaining = (player.stats.potionsRemaining ?? 0) - 1;
|
||||||
|
|
||||||
|
const events: CombatEngineEvent[] = [
|
||||||
|
{ source: Combatant.PLAYER, target: Combatant.PLAYER, type: CombatEventType.HEAL, amount: healed },
|
||||||
|
];
|
||||||
|
|
||||||
|
return this.finishRound(state, player, monster, events, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private finishRound(
|
||||||
|
state: CombatEngineState,
|
||||||
|
player: CombatEngineCombatant,
|
||||||
|
monster: CombatEngineCombatant,
|
||||||
|
events: CombatEngineEvent[],
|
||||||
|
defended: boolean,
|
||||||
|
interrupted = false,
|
||||||
|
): CombatEngineResult {
|
||||||
|
if (monster.currentHp <= 0) {
|
||||||
|
events.push({ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.COMBAT_WON });
|
||||||
|
const defeatedMonster = { ...monster, stats: { ...monster.stats, pendingAction: undefined } };
|
||||||
|
return { state: { ...state, player, monster: defeatedMonster, status: CombatStatus.WON }, events };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!interrupted) {
|
||||||
|
this.resolveMonsterTurn(state.round, monster, player, defended, events);
|
||||||
|
|
||||||
if (player.currentHp <= 0) {
|
if (player.currentHp <= 0) {
|
||||||
events.push({
|
events.push({ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.COMBAT_LOST });
|
||||||
source: Combatant.MONSTER,
|
return { state: { ...state, player, monster, status: CombatStatus.LOST }, events };
|
||||||
target: Combatant.PLAYER,
|
}
|
||||||
type: CombatEventType.COMBAT_LOST,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
state: { ...state, player, monster, status: CombatStatus.LOST },
|
|
||||||
events,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -80,4 +142,46 @@ export class CombatEngineService {
|
|||||||
events,
|
events,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveMonsterTurn(
|
||||||
|
round: number,
|
||||||
|
monster: CombatEngineCombatant,
|
||||||
|
player: CombatEngineCombatant,
|
||||||
|
defended: boolean,
|
||||||
|
events: CombatEngineEvent[],
|
||||||
|
): void {
|
||||||
|
const defendMultiplier = defended ? DEFEND_MITIGATION_MULTIPLIER : 1;
|
||||||
|
|
||||||
|
if (monster.stats.pendingAction === 'HEAVY_ATTACK') {
|
||||||
|
monster.stats.pendingAction = undefined;
|
||||||
|
const damage = calculateDamage(monster.stats, player.stats.armor, HEAVY_ATTACK_MULTIPLIER * defendMultiplier);
|
||||||
|
player.currentHp = Math.max(0, player.currentHp - damage);
|
||||||
|
events.push({
|
||||||
|
source: Combatant.MONSTER,
|
||||||
|
target: Combatant.PLAYER,
|
||||||
|
type: CombatEventType.DAMAGE,
|
||||||
|
amount: damage,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (round % TELEGRAPH_ROUND_INTERVAL === 0) {
|
||||||
|
monster.stats.pendingAction = 'HEAVY_ATTACK';
|
||||||
|
events.push({ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.TELEGRAPH });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const damage = calculateDamage(monster.stats, player.stats.armor, defendMultiplier);
|
||||||
|
player.currentHp = Math.max(0, player.currentHp - damage);
|
||||||
|
events.push({
|
||||||
|
source: Combatant.MONSTER,
|
||||||
|
target: Combatant.PLAYER,
|
||||||
|
type: CombatEventType.DAMAGE,
|
||||||
|
amount: damage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private cloneCombatant(combatant: CombatEngineCombatant): CombatEngineCombatant {
|
||||||
|
return { ...combatant, stats: { ...combatant.stats } };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,21 @@ import { CombatAction } from './combat-action.enum';
|
|||||||
import { CombatEventType } from './combat-event-type.enum';
|
import { CombatEventType } from './combat-event-type.enum';
|
||||||
import { CombatStatus } from './combat-status.enum';
|
import { CombatStatus } from './combat-status.enum';
|
||||||
|
|
||||||
|
// Only HEAVY_ATTACK needs telegraphing today; NORMAL_ATTACK resolves
|
||||||
|
// immediately and is never held as a pending intent (Playable Slice 0.6
|
||||||
|
// spec §5). Add members here as future slices add more prepared actions.
|
||||||
|
export type CombatIntent = 'HEAVY_ATTACK';
|
||||||
|
|
||||||
export interface CombatEngineCombatantStats {
|
export interface CombatEngineCombatantStats {
|
||||||
attack: number;
|
attack: number;
|
||||||
weaponDamage?: number;
|
weaponDamage?: number;
|
||||||
armor: number;
|
armor: number;
|
||||||
|
// Player-only: seeded at combat start, decremented by POTION. Optional
|
||||||
|
// because the monster's stats never carry it.
|
||||||
|
potionsRemaining?: number;
|
||||||
|
// Monster-only: set when it telegraphs, cleared when the action resolves
|
||||||
|
// or is interrupted. Optional because the player's stats never carry it.
|
||||||
|
pendingAction?: CombatIntent;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CombatEngineCombatant {
|
export interface CombatEngineCombatant {
|
||||||
|
|||||||
396
apps/api/src/combat/combat-equipment-integration.spec.ts
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
|
||||||
|
import { CharacterStatsService } from '../characters/character-stats.service';
|
||||||
|
import { CharacterVitalsService } from '../characters/character-vitals.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,
|
||||||
|
hpRegenSince: null,
|
||||||
|
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 characterVitals = new CharacterVitalsService({
|
||||||
|
now: () => new Date('2026-08-18T09:00:00.000Z'),
|
||||||
|
});
|
||||||
|
const characterStats = new CharacterStatsService(
|
||||||
|
dataSource as unknown as DataSource,
|
||||||
|
characterVitals,
|
||||||
|
);
|
||||||
|
const equipmentService = new EquipmentService(
|
||||||
|
dataSource as unknown as DataSource,
|
||||||
|
characterStats,
|
||||||
|
characterVitals,
|
||||||
|
);
|
||||||
|
const combatService = new CombatService(
|
||||||
|
dataSource as unknown as DataSource,
|
||||||
|
fakeTravelService(),
|
||||||
|
new CombatEngineService(),
|
||||||
|
characterStats,
|
||||||
|
characterVitals,
|
||||||
|
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,
|
||||||
|
potionsRemaining: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const reloaded = await combatService.getCombat(CHARACTER_ID, combat.id);
|
||||||
|
expect(reloaded.status).toBe(result.status);
|
||||||
|
expect(reloaded.round).toBe(result.round);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
export enum CombatEventType {
|
export enum CombatEventType {
|
||||||
DAMAGE = 'DAMAGE',
|
DAMAGE = 'DAMAGE',
|
||||||
|
HEAL = 'HEAL',
|
||||||
|
DEFEND = 'DEFEND',
|
||||||
|
TELEGRAPH = 'TELEGRAPH',
|
||||||
|
INTERRUPT = 'INTERRUPT',
|
||||||
COMBAT_WON = 'COMBAT_WON',
|
COMBAT_WON = 'COMBAT_WON',
|
||||||
COMBAT_LOST = 'COMBAT_LOST',
|
COMBAT_LOST = 'COMBAT_LOST',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ describe('CombatController', () => {
|
|||||||
it('rejects an unknown action value', async () => {
|
it('rejects an unknown action value', async () => {
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.post('/api/combats/combat-1/actions')
|
.post('/api/combats/combat-1/actions')
|
||||||
.send({ action: 'HEAVY_STRIKE' })
|
.send({ action: 'FLEE' })
|
||||||
.expect(400);
|
.expect(400);
|
||||||
|
|
||||||
expect(performAction).not.toHaveBeenCalled();
|
expect(performAction).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ export type CombatErrorCode =
|
|||||||
| 'HUNT_ENCOUNTER_ALREADY_CONSUMED'
|
| 'HUNT_ENCOUNTER_ALREADY_CONSUMED'
|
||||||
| 'INVALID_HUNT_ENCOUNTER'
|
| 'INVALID_HUNT_ENCOUNTER'
|
||||||
| 'CHARACTER_TRAVELLING'
|
| 'CHARACTER_TRAVELLING'
|
||||||
|
| 'CHARACTER_TOO_WOUNDED'
|
||||||
| 'COMBAT_ALREADY_ACTIVE'
|
| 'COMBAT_ALREADY_ACTIVE'
|
||||||
| 'COMBAT_NOT_FOUND'
|
| 'COMBAT_NOT_FOUND'
|
||||||
| 'COMBAT_ALREADY_FINISHED'
|
| 'COMBAT_ALREADY_FINISHED'
|
||||||
| 'COMBAT_STATE_INVALID';
|
| 'COMBAT_STATE_INVALID'
|
||||||
|
| 'COMBAT_NO_POTIONS_REMAINING';
|
||||||
|
|
||||||
export class CombatDomainError extends HttpException {
|
export class CombatDomainError extends HttpException {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -52,6 +54,14 @@ export function characterTravelling(): CombatDomainError {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function characterTooWounded(): CombatDomainError {
|
||||||
|
return new CombatDomainError(
|
||||||
|
'CHARACTER_TOO_WOUNDED',
|
||||||
|
HttpStatus.CONFLICT,
|
||||||
|
'The character is too wounded to fight.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function combatAlreadyActive(): CombatDomainError {
|
export function combatAlreadyActive(): CombatDomainError {
|
||||||
return new CombatDomainError(
|
return new CombatDomainError(
|
||||||
'COMBAT_ALREADY_ACTIVE',
|
'COMBAT_ALREADY_ACTIVE',
|
||||||
@@ -84,4 +94,12 @@ export function combatStateInvalid(): CombatDomainError {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function combatNoPotionsRemaining(): CombatDomainError {
|
||||||
|
return new CombatDomainError(
|
||||||
|
'COMBAT_NO_POTIONS_REMAINING',
|
||||||
|
HttpStatus.CONFLICT,
|
||||||
|
'No potions remain in this combat.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export { characterNotFound } from '../travel/travel.errors';
|
export { characterNotFound } from '../travel/travel.errors';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
|
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
|
||||||
import { CharacterCombatStatsService } from '../characters/character-combat-stats.service';
|
import { CharacterStatsService } from '../characters/character-stats.service';
|
||||||
|
import { CharacterVitalsService } from '../characters/character-vitals.service';
|
||||||
import { Character } from '../characters/entities/character.entity';
|
import { Character } from '../characters/entities/character.entity';
|
||||||
import { Hunt } from '../hunting/entities/hunt.entity';
|
import { Hunt } from '../hunting/entities/hunt.entity';
|
||||||
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
|
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
|
||||||
@@ -179,6 +180,7 @@ function character(overrides: Partial<Character> = {}): Character {
|
|||||||
baseHp: 100,
|
baseHp: 100,
|
||||||
baseAttack: 6,
|
baseAttack: 6,
|
||||||
currentHp: 100,
|
currentHp: 100,
|
||||||
|
hpRegenSince: null,
|
||||||
currentLocationId: 'location-1',
|
currentLocationId: 'location-1',
|
||||||
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||||
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||||
@@ -242,6 +244,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(
|
function fakeTravelService(
|
||||||
status: 'IDLE' | 'TRAVELLING' = 'IDLE',
|
status: 'IDLE' | 'TRAVELLING' = 'IDLE',
|
||||||
): TravelService {
|
): TravelService {
|
||||||
@@ -271,12 +286,16 @@ function createService(
|
|||||||
const dataSource = new FakeDataSource(state);
|
const dataSource = new FakeDataSource(state);
|
||||||
const travelService = options.travelService ?? fakeTravelService();
|
const travelService = options.travelService ?? fakeTravelService();
|
||||||
const combatEngine = new CombatEngineService();
|
const combatEngine = new CombatEngineService();
|
||||||
const characterCombatStats = new CharacterCombatStatsService();
|
const characterCombatStats = fakeCharacterStats();
|
||||||
|
const characterVitals = new CharacterVitalsService({
|
||||||
|
now: () => new Date('2026-08-18T09:00:00.000Z'),
|
||||||
|
});
|
||||||
const service = new CombatService(
|
const service = new CombatService(
|
||||||
dataSource as unknown as DataSource,
|
dataSource as unknown as DataSource,
|
||||||
travelService,
|
travelService,
|
||||||
combatEngine,
|
combatEngine,
|
||||||
characterCombatStats,
|
characterCombatStats,
|
||||||
|
characterVitals,
|
||||||
fakeRewardService(),
|
fakeRewardService(),
|
||||||
);
|
);
|
||||||
return { dataSource, service, travelService };
|
return { dataSource, service, travelService };
|
||||||
@@ -312,6 +331,8 @@ describe('CombatService', () => {
|
|||||||
name: 'Aric Duskwalker',
|
name: 'Aric Duskwalker',
|
||||||
maxHp: 100,
|
maxHp: 100,
|
||||||
currentHp: 100,
|
currentHp: 100,
|
||||||
|
potionsRemaining: 2,
|
||||||
|
potionsMax: 2,
|
||||||
});
|
});
|
||||||
expect(combat.monster).toEqual({
|
expect(combat.monster).toEqual({
|
||||||
key: 'ash-rat',
|
key: 'ash-rat',
|
||||||
@@ -320,6 +341,7 @@ describe('CombatService', () => {
|
|||||||
maxHp: 45,
|
maxHp: 45,
|
||||||
currentHp: 45,
|
currentHp: 45,
|
||||||
artworkPath: '/images/monsters/ash-rat.png',
|
artworkPath: '/images/monsters/ash-rat.png',
|
||||||
|
pendingIntent: null,
|
||||||
});
|
});
|
||||||
expect(combat.events).toEqual([]);
|
expect(combat.events).toEqual([]);
|
||||||
expect(dataSource.state.combats).toHaveLength(1);
|
expect(dataSource.state.combats).toHaveLength(1);
|
||||||
@@ -333,6 +355,49 @@ describe('CombatService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('seeds player HP from the character, carrying HP from a previous fight rather than starting full', async () => {
|
||||||
|
const state = createState({ characters: [character({ currentHp: 63 })] });
|
||||||
|
const { dataSource, service } = createService({ state });
|
||||||
|
|
||||||
|
const combat = await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
|
||||||
|
|
||||||
|
expect(combat.player.currentHp).toBe(63);
|
||||||
|
expect(dataSource.state.combats[0].playerCurrentHp).toBe(63);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pauses regeneration on the character once a combat starts', async () => {
|
||||||
|
const state = createState({
|
||||||
|
characters: [
|
||||||
|
character({ currentHp: 63, hpRegenSince: new Date('2026-08-18T08:00:00.000Z') }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const { dataSource, service } = createService({ state });
|
||||||
|
|
||||||
|
await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
|
||||||
|
|
||||||
|
expect(dataSource.state.characters[0].currentHp).toBe(63);
|
||||||
|
expect(dataSource.state.characters[0].hpRegenSince).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects starting a combat when the character has 0 effective HP', async () => {
|
||||||
|
const state = createState({ characters: [character({ currentHp: 0 })] });
|
||||||
|
const { service } = createService({ state });
|
||||||
|
|
||||||
|
await expectCombatDomainError(
|
||||||
|
service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
|
||||||
|
'CHARACTER_TOO_WOUNDED',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows starting a combat at exactly 1 effective HP', async () => {
|
||||||
|
const state = createState({ characters: [character({ currentHp: 1 })] });
|
||||||
|
const { service } = createService({ state });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
|
||||||
|
).resolves.toMatchObject({ status: 'ACTIVE' });
|
||||||
|
});
|
||||||
|
|
||||||
it('marks the encounter as IN_PROGRESS', async () => {
|
it('marks the encounter as IN_PROGRESS', async () => {
|
||||||
const { dataSource, service } = createService();
|
const { dataSource, service } = createService();
|
||||||
|
|
||||||
@@ -514,6 +579,41 @@ describe('CombatService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('mirrors the player HP onto the character each round while the fight continues', async () => {
|
||||||
|
const { dataSource, service, combatId } = await startedCombat();
|
||||||
|
|
||||||
|
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
|
||||||
|
|
||||||
|
expect(dataSource.state.characters[0].currentHp).toBe(95);
|
||||||
|
expect(dataSource.state.characters[0].hpRegenSince).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restarts regeneration on the character once the fight is won', async () => {
|
||||||
|
const state = createState({ monsters: [monster({ maxHp: 10 })] });
|
||||||
|
const { dataSource, service, combatId } = await startedCombat(state);
|
||||||
|
|
||||||
|
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
|
||||||
|
|
||||||
|
expect(dataSource.state.characters[0].currentHp).toBe(
|
||||||
|
dataSource.state.combats[0].playerCurrentHp,
|
||||||
|
);
|
||||||
|
expect(dataSource.state.characters[0].hpRegenSince).toEqual(
|
||||||
|
new Date('2026-08-18T09:00:00.000Z'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restarts regeneration from 0 HP once the fight is lost', async () => {
|
||||||
|
const state = createState({ characters: [character({ baseHp: 1, currentHp: 1 })] });
|
||||||
|
const { dataSource, service, combatId } = await startedCombat(state);
|
||||||
|
|
||||||
|
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
|
||||||
|
|
||||||
|
expect(dataSource.state.characters[0].currentHp).toBe(0);
|
||||||
|
expect(dataSource.state.characters[0].hpRegenSince).toEqual(
|
||||||
|
new Date('2026-08-18T09:00:00.000Z'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('ends the combat as WON, stops persisting new rounds, and rejects further actions', async () => {
|
it('ends the combat as WON, stops persisting new rounds, and rejects further actions', async () => {
|
||||||
const state = createState({ monsters: [monster({ maxHp: 10 })] });
|
const state = createState({ monsters: [monster({ maxHp: 10 })] });
|
||||||
const { dataSource, service, combatId } = await startedCombat(state);
|
const { dataSource, service, combatId } = await startedCombat(state);
|
||||||
@@ -534,7 +634,7 @@ describe('CombatService', () => {
|
|||||||
|
|
||||||
it('ends the combat as LOST, stops persisting new rounds, and rejects further actions', async () => {
|
it('ends the combat as LOST, stops persisting new rounds, and rejects further actions', async () => {
|
||||||
const state = createState({
|
const state = createState({
|
||||||
characters: [character({ baseHp: 1 })],
|
characters: [character({ baseHp: 1, currentHp: 1 })],
|
||||||
});
|
});
|
||||||
const { dataSource, service, combatId } = await startedCombat(state);
|
const { dataSource, service, combatId } = await startedCombat(state);
|
||||||
|
|
||||||
@@ -565,7 +665,7 @@ describe('CombatService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('frees the encounter for another attempt when the fight is lost', async () => {
|
it('frees the encounter for another attempt when the fight is lost', async () => {
|
||||||
const state = createState({ characters: [character({ baseHp: 1 })] });
|
const state = createState({ characters: [character({ baseHp: 1, currentHp: 1 })] });
|
||||||
const { dataSource, service, combatId } = await startedCombat(state);
|
const { dataSource, service, combatId } = await startedCombat(state);
|
||||||
|
|
||||||
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
|
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
|
||||||
@@ -585,11 +685,19 @@ describe('CombatService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lets a lost encounter be fought again as a fresh combat', async () => {
|
it('lets a lost encounter be fought again once the character has recovered HP', async () => {
|
||||||
const state = createState({ characters: [character({ baseHp: 1 })] });
|
const state = createState({ characters: [character({ baseHp: 1, currentHp: 1 })] });
|
||||||
const { dataSource, service, combatId } = await startedCombat(state);
|
const { dataSource, service, combatId } = await startedCombat(state);
|
||||||
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
|
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
|
||||||
|
|
||||||
|
// The loss now mirrors 0 HP onto the character (this task); simulate
|
||||||
|
// that regeneration has since restored it before retrying. This test
|
||||||
|
// is about the encounter itself being retryable once the character
|
||||||
|
// can fight again, not about regen math (covered by
|
||||||
|
// CharacterVitalsService's own tests).
|
||||||
|
dataSource.state.characters[0].currentHp = 1;
|
||||||
|
dataSource.state.characters[0].hpRegenSince = null;
|
||||||
|
|
||||||
const retry = await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
|
const retry = await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
|
||||||
|
|
||||||
expect(retry.id).not.toBe(combatId);
|
expect(retry.id).not.toBe(combatId);
|
||||||
@@ -640,6 +748,43 @@ describe('CombatService', () => {
|
|||||||
expect.arrayContaining([{ target: Combat, mode: 'pessimistic_write' }]),
|
expect.arrayContaining([{ target: Combat, mode: 'pessimistic_write' }]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resolves POTION, heals the player, and persists the reduced potion count', async () => {
|
||||||
|
const { service, combatId } = await startedCombat();
|
||||||
|
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
|
||||||
|
|
||||||
|
const result = await service.performAction(CHARACTER_ID, combatId, CombatAction.POTION);
|
||||||
|
|
||||||
|
expect(result.player.potionsRemaining).toBe(1);
|
||||||
|
expect(result.player.currentHp).toBe(95);
|
||||||
|
|
||||||
|
const reloaded = await service.getCombat(CHARACTER_ID, combatId);
|
||||||
|
expect(reloaded.player.potionsRemaining).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects POTION once both potions have been used', async () => {
|
||||||
|
const { service, combatId } = await startedCombat();
|
||||||
|
|
||||||
|
await service.performAction(CHARACTER_ID, combatId, CombatAction.POTION);
|
||||||
|
await service.performAction(CHARACTER_ID, combatId, CombatAction.POTION);
|
||||||
|
|
||||||
|
await expectCombatDomainError(
|
||||||
|
service.performAction(CHARACTER_ID, combatId, CombatAction.POTION),
|
||||||
|
'COMBAT_NO_POTIONS_REMAINING',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists the telegraphed Heavy Attack across a reload', async () => {
|
||||||
|
const { service, combatId } = await startedCombat();
|
||||||
|
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
|
||||||
|
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
|
||||||
|
const telegraphed = await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
|
||||||
|
|
||||||
|
expect(telegraphed.monster.pendingIntent).toBe('HEAVY_ATTACK');
|
||||||
|
|
||||||
|
const reloaded = await service.getCombat(CHARACTER_ID, combatId);
|
||||||
|
expect(reloaded.monster.pendingIntent).toBe('HEAVY_ATTACK');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getCombat', () => {
|
describe('getCombat', () => {
|
||||||
@@ -728,7 +873,7 @@ describe('CombatService', () => {
|
|||||||
|
|
||||||
it('keeps returning LOST after the combat has ended', async () => {
|
it('keeps returning LOST after the combat has ended', async () => {
|
||||||
const state = createState({
|
const state = createState({
|
||||||
characters: [character({ baseHp: 1 })],
|
characters: [character({ baseHp: 1, currentHp: 1 })],
|
||||||
});
|
});
|
||||||
const context = createService({ state });
|
const context = createService({ state });
|
||||||
const started = await context.service.startCombat(
|
const started = await context.service.startCombat(
|
||||||
@@ -785,7 +930,10 @@ describe('CombatService', () => {
|
|||||||
dataSource as unknown as DataSource,
|
dataSource as unknown as DataSource,
|
||||||
fakeTravelService(),
|
fakeTravelService(),
|
||||||
new CombatEngineService(),
|
new CombatEngineService(),
|
||||||
new CharacterCombatStatsService(),
|
fakeCharacterStats(),
|
||||||
|
new CharacterVitalsService({
|
||||||
|
now: () => new Date('2026-08-18T09:00:00.000Z'),
|
||||||
|
}),
|
||||||
rewards,
|
rewards,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -816,7 +964,7 @@ describe('CombatService', () => {
|
|||||||
huntEncounterId: ENCOUNTER_ID,
|
huntEncounterId: ENCOUNTER_ID,
|
||||||
monsterDefinitionId: MONSTER_ID,
|
monsterDefinitionId: MONSTER_ID,
|
||||||
status: CombatStatus.ACTIVE,
|
status: CombatStatus.ACTIVE,
|
||||||
round: 3,
|
round: 2,
|
||||||
playerMaxHp: 100,
|
playerMaxHp: 100,
|
||||||
playerCurrentHp: 1,
|
playerCurrentHp: 1,
|
||||||
monsterMaxHp: 45,
|
monsterMaxHp: 45,
|
||||||
@@ -833,7 +981,10 @@ describe('CombatService', () => {
|
|||||||
dataSource as unknown as DataSource,
|
dataSource as unknown as DataSource,
|
||||||
fakeTravelService(),
|
fakeTravelService(),
|
||||||
new CombatEngineService(),
|
new CombatEngineService(),
|
||||||
new CharacterCombatStatsService(),
|
fakeCharacterStats(),
|
||||||
|
new CharacterVitalsService({
|
||||||
|
now: () => new Date('2026-08-18T09:00:00.000Z'),
|
||||||
|
}),
|
||||||
rewards,
|
rewards,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -890,7 +1041,10 @@ describe('CombatService', () => {
|
|||||||
dataSource as unknown as DataSource,
|
dataSource as unknown as DataSource,
|
||||||
fakeTravelService(),
|
fakeTravelService(),
|
||||||
new CombatEngineService(),
|
new CombatEngineService(),
|
||||||
new CharacterCombatStatsService(),
|
fakeCharacterStats(),
|
||||||
|
new CharacterVitalsService({
|
||||||
|
now: () => new Date('2026-08-18T09:00:00.000Z'),
|
||||||
|
}),
|
||||||
rewards,
|
rewards,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -927,7 +1081,10 @@ describe('CombatService', () => {
|
|||||||
dataSource as unknown as DataSource,
|
dataSource as unknown as DataSource,
|
||||||
fakeTravelService(),
|
fakeTravelService(),
|
||||||
new CombatEngineService(),
|
new CombatEngineService(),
|
||||||
new CharacterCombatStatsService(),
|
fakeCharacterStats(),
|
||||||
|
new CharacterVitalsService({
|
||||||
|
now: () => new Date('2026-08-18T09:00:00.000Z'),
|
||||||
|
}),
|
||||||
fakeRewardService({
|
fakeRewardService({
|
||||||
// Genuinely write XP/silver through the transaction's manager
|
// Genuinely write XP/silver through the transaction's manager
|
||||||
// before failing, so the assertions below prove the rollback
|
// before failing, so the assertions below prove the rollback
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource, Repository } from 'typeorm';
|
import { DataSource, Repository } from 'typeorm';
|
||||||
import { CharacterCombatStatsService } from '../characters/character-combat-stats.service';
|
import { CharacterStatsService } from '../characters/character-stats.service';
|
||||||
|
import { CharacterVitalsService } from '../characters/character-vitals.service';
|
||||||
import { Character } from '../characters/entities/character.entity';
|
import { Character } from '../characters/entities/character.entity';
|
||||||
import { Hunt } from '../hunting/entities/hunt.entity';
|
import { Hunt } from '../hunting/entities/hunt.entity';
|
||||||
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
|
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
|
||||||
@@ -13,12 +14,14 @@ import { TravelService } from '../travel/travel.service';
|
|||||||
import { TravelStatus } from '../travel/travel-status.enum';
|
import { TravelStatus } from '../travel/travel-status.enum';
|
||||||
import { CombatAction } from './combat-action.enum';
|
import { CombatAction } from './combat-action.enum';
|
||||||
import { CombatEngineService } from './combat-engine.service';
|
import { CombatEngineService } from './combat-engine.service';
|
||||||
import { CombatEngineState } from './combat-engine.types';
|
import { CombatEngineState, CombatIntent } from './combat-engine.types';
|
||||||
import {
|
import {
|
||||||
characterNotFound,
|
characterNotFound,
|
||||||
|
characterTooWounded,
|
||||||
characterTravelling,
|
characterTravelling,
|
||||||
combatAlreadyActive,
|
combatAlreadyActive,
|
||||||
combatAlreadyFinished,
|
combatAlreadyFinished,
|
||||||
|
combatNoPotionsRemaining,
|
||||||
combatNotFound,
|
combatNotFound,
|
||||||
combatStateInvalid,
|
combatStateInvalid,
|
||||||
huntEncounterAlreadyConsumed,
|
huntEncounterAlreadyConsumed,
|
||||||
@@ -27,12 +30,18 @@ import {
|
|||||||
} from './combat.errors';
|
} from './combat.errors';
|
||||||
import { CombatStatus } from './combat-status.enum';
|
import { CombatStatus } from './combat-status.enum';
|
||||||
import { CombatEvent } from './entities/combat-event.entity';
|
import { CombatEvent } from './entities/combat-event.entity';
|
||||||
import { Combat } from './entities/combat.entity';
|
import { Combat, CombatPlayerState } from './entities/combat.entity';
|
||||||
|
|
||||||
|
// Playable Slice 0.6 spec §3: fixed at 2 for V1, not yet backed by the
|
||||||
|
// persistent consumable inventory.
|
||||||
|
const STARTING_POTION_COUNT = 2;
|
||||||
|
|
||||||
export interface CombatPlayerDto {
|
export interface CombatPlayerDto {
|
||||||
name: string;
|
name: string;
|
||||||
maxHp: number;
|
maxHp: number;
|
||||||
currentHp: number;
|
currentHp: number;
|
||||||
|
potionsRemaining: number;
|
||||||
|
potionsMax: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CombatMonsterDto {
|
export interface CombatMonsterDto {
|
||||||
@@ -42,6 +51,7 @@ export interface CombatMonsterDto {
|
|||||||
maxHp: number;
|
maxHp: number;
|
||||||
currentHp: number;
|
currentHp: number;
|
||||||
artworkPath: string;
|
artworkPath: string;
|
||||||
|
pendingIntent: CombatIntent | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CombatEventDto {
|
export interface CombatEventDto {
|
||||||
@@ -69,7 +79,8 @@ export class CombatService {
|
|||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
private readonly travelService: TravelService,
|
private readonly travelService: TravelService,
|
||||||
private readonly combatEngine: CombatEngineService,
|
private readonly combatEngine: CombatEngineService,
|
||||||
private readonly characterCombatStats: CharacterCombatStatsService,
|
private readonly characterStats: CharacterStatsService,
|
||||||
|
private readonly characterVitals: CharacterVitalsService,
|
||||||
private readonly combatRewards: CombatRewardService,
|
private readonly combatRewards: CombatRewardService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -126,7 +137,12 @@ export class CombatService {
|
|||||||
throw invalidHuntEncounter();
|
throw invalidHuntEncounter();
|
||||||
}
|
}
|
||||||
|
|
||||||
const playerStats = this.characterCombatStats.getStats(character);
|
const playerStats = await this.characterStats.calculate(character, manager);
|
||||||
|
if (playerStats.currentHp < 1) {
|
||||||
|
throw characterTooWounded();
|
||||||
|
}
|
||||||
|
this.characterVitals.pause(character, playerStats.currentHp);
|
||||||
|
await characters.save(character);
|
||||||
|
|
||||||
const combat = combats.create({
|
const combat = combats.create({
|
||||||
characterId,
|
characterId,
|
||||||
@@ -135,13 +151,14 @@ export class CombatService {
|
|||||||
status: CombatStatus.ACTIVE,
|
status: CombatStatus.ACTIVE,
|
||||||
round: 1,
|
round: 1,
|
||||||
playerMaxHp: playerStats.maxHp,
|
playerMaxHp: playerStats.maxHp,
|
||||||
playerCurrentHp: playerStats.maxHp,
|
playerCurrentHp: playerStats.currentHp,
|
||||||
monsterMaxHp: monster.maxHp,
|
monsterMaxHp: monster.maxHp,
|
||||||
monsterCurrentHp: monster.maxHp,
|
monsterCurrentHp: monster.maxHp,
|
||||||
playerState: {
|
playerState: {
|
||||||
attack: playerStats.attack,
|
attack: playerStats.attack,
|
||||||
weaponDamage: playerStats.weaponDamage,
|
weaponDamage: playerStats.weaponDamage,
|
||||||
armor: playerStats.armor,
|
armor: playerStats.armor,
|
||||||
|
potionsRemaining: STARTING_POTION_COUNT,
|
||||||
},
|
},
|
||||||
monsterState: { attack: monster.attack, armor: monster.armor },
|
monsterState: { attack: monster.attack, armor: monster.armor },
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
@@ -208,7 +225,7 @@ export class CombatService {
|
|||||||
// no-op re-lock — but locking it first here keeps both code paths
|
// no-op re-lock — but locking it first here keeps both code paths
|
||||||
// consistent and avoids a lock-order inversion that could deadlock two
|
// consistent and avoids a lock-order inversion that could deadlock two
|
||||||
// concurrent requests against the same character. Do not reorder this.
|
// concurrent requests against the same character. Do not reorder this.
|
||||||
await this.lockCharacter(characters, characterId);
|
const character = await this.lockCharacter(characters, characterId);
|
||||||
|
|
||||||
const combat = await combats.findOne({
|
const combat = await combats.findOne({
|
||||||
where: { id: combatId, characterId },
|
where: { id: combatId, characterId },
|
||||||
@@ -220,6 +237,9 @@ export class CombatService {
|
|||||||
if (combat.status !== CombatStatus.ACTIVE) {
|
if (combat.status !== CombatStatus.ACTIVE) {
|
||||||
throw combatAlreadyFinished();
|
throw combatAlreadyFinished();
|
||||||
}
|
}
|
||||||
|
if (action === CombatAction.POTION && (combat.playerState.potionsRemaining ?? 0) <= 0) {
|
||||||
|
throw combatNoPotionsRemaining();
|
||||||
|
}
|
||||||
|
|
||||||
const actionRound = combat.round;
|
const actionRound = combat.round;
|
||||||
const engineState = this.toEngineState(combat);
|
const engineState = this.toEngineState(combat);
|
||||||
@@ -229,14 +249,20 @@ export class CombatService {
|
|||||||
combat.status = result.state.status;
|
combat.status = result.state.status;
|
||||||
combat.playerCurrentHp = result.state.player.currentHp;
|
combat.playerCurrentHp = result.state.player.currentHp;
|
||||||
combat.monsterCurrentHp = result.state.monster.currentHp;
|
combat.monsterCurrentHp = result.state.monster.currentHp;
|
||||||
|
combat.playerState = result.state.player.stats as CombatPlayerState;
|
||||||
|
combat.monsterState = result.state.monster.stats;
|
||||||
if (combat.status !== CombatStatus.ACTIVE) {
|
if (combat.status !== CombatStatus.ACTIVE) {
|
||||||
combat.completedAt = new Date();
|
combat.completedAt = new Date();
|
||||||
|
this.characterVitals.resume(character, combat.playerCurrentHp);
|
||||||
await this.settleEncounter(
|
await this.settleEncounter(
|
||||||
manager.getRepository(HuntEncounter),
|
manager.getRepository(HuntEncounter),
|
||||||
combat.huntEncounterId,
|
combat.huntEncounterId,
|
||||||
combat.status,
|
combat.status,
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
this.characterVitals.pause(character, combat.playerCurrentHp);
|
||||||
}
|
}
|
||||||
|
await characters.save(character);
|
||||||
await combats.save(combat);
|
await combats.save(combat);
|
||||||
|
|
||||||
const startingSequence = await combatEvents.count({
|
const startingSequence = await combatEvents.count({
|
||||||
@@ -264,7 +290,7 @@ export class CombatService {
|
|||||||
? await this.combatRewards.grantVictoryRewards(manager, combat)
|
? await this.combatRewards.grantVictoryRewards(manager, combat)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const [character, monster, events] = await Promise.all([
|
const [reloadedCharacter, monster, events] = await Promise.all([
|
||||||
this.loadCharacter(
|
this.loadCharacter(
|
||||||
combat.characterId,
|
combat.characterId,
|
||||||
manager.getRepository(Character),
|
manager.getRepository(Character),
|
||||||
@@ -276,7 +302,7 @@ export class CombatService {
|
|||||||
this.loadEvents(combat.id, combatEvents),
|
this.loadEvents(combat.id, combatEvents),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return this.toCombatDto(combat, character.name, monster, events, rewards);
|
return this.toCombatDto(combat, reloadedCharacter.name, monster, events, rewards);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,6 +412,8 @@ export class CombatService {
|
|||||||
name: playerName,
|
name: playerName,
|
||||||
maxHp: combat.playerMaxHp,
|
maxHp: combat.playerMaxHp,
|
||||||
currentHp: combat.playerCurrentHp,
|
currentHp: combat.playerCurrentHp,
|
||||||
|
potionsRemaining: combat.playerState.potionsRemaining,
|
||||||
|
potionsMax: STARTING_POTION_COUNT,
|
||||||
},
|
},
|
||||||
monster: {
|
monster: {
|
||||||
key: monster.key,
|
key: monster.key,
|
||||||
@@ -394,6 +422,7 @@ export class CombatService {
|
|||||||
maxHp: combat.monsterMaxHp,
|
maxHp: combat.monsterMaxHp,
|
||||||
currentHp: combat.monsterCurrentHp,
|
currentHp: combat.monsterCurrentHp,
|
||||||
artworkPath: monster.artworkPath,
|
artworkPath: monster.artworkPath,
|
||||||
|
pendingIntent: combat.monsterState.pendingAction ?? null,
|
||||||
},
|
},
|
||||||
events: events.map((event) => ({
|
events: events.map((event) => ({
|
||||||
round: event.round,
|
round: event.round,
|
||||||
|
|||||||
@@ -11,15 +11,18 @@ import {
|
|||||||
import { Character } from '../../characters/entities/character.entity';
|
import { Character } from '../../characters/entities/character.entity';
|
||||||
import { HuntEncounter } from '../../hunting/entities/hunt-encounter.entity';
|
import { HuntEncounter } from '../../hunting/entities/hunt-encounter.entity';
|
||||||
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
|
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
|
||||||
|
import { CombatIntent } from '../combat-engine.types';
|
||||||
import { CombatStatus } from '../combat-status.enum';
|
import { CombatStatus } from '../combat-status.enum';
|
||||||
|
|
||||||
export interface CombatCombatantState {
|
export interface CombatCombatantState {
|
||||||
attack: number;
|
attack: number;
|
||||||
armor: number;
|
armor: number;
|
||||||
|
pendingAction?: CombatIntent;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CombatPlayerState extends CombatCombatantState {
|
export interface CombatPlayerState extends CombatCombatantState {
|
||||||
weaponDamage: number;
|
weaponDamage: number;
|
||||||
|
potionsRemaining: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Entity({ name: 'combats' })
|
@Entity({ name: 'combats' })
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the local location view content to `location_definitions`.
|
||||||
|
*
|
||||||
|
* The existing `description`/`artwork_path` columns are deliberately left
|
||||||
|
* alone — the map and hunt screens still render them. Backfill defaults keep
|
||||||
|
* existing rows valid; the seed replaces them with authored content.
|
||||||
|
*/
|
||||||
|
export class CreateLocalLocationView1788700000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "location_definitions" ADD COLUMN "region_name" character varying(150) NOT NULL DEFAULT \'\'',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "location_definitions" ADD COLUMN "region_tier_label" character varying(50) NOT NULL DEFAULT \'\'',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "location_definitions" ADD COLUMN "location_type" character varying(50) NOT NULL DEFAULT \'TRANSITION\'',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "location_definitions" ADD COLUMN "local_description" text NOT NULL DEFAULT \'\'',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "location_definitions" ADD COLUMN "local_artwork_path" character varying(255) NOT NULL DEFAULT \'\'',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "location_definitions" ADD COLUMN "local_points_of_interest" jsonb NOT NULL DEFAULT \'[]\'',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "location_definitions" ADD COLUMN "local_primary_actions" jsonb NOT NULL DEFAULT \'[]\'',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "location_definitions" ADD COLUMN "local_reward_preview" jsonb NOT NULL DEFAULT \'[]\'',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Existing rows fall back to their map-level content until the seed runs,
|
||||||
|
// so the view never renders an empty breadcrumb or a missing artwork.
|
||||||
|
await queryRunner.query(
|
||||||
|
'UPDATE "location_definitions" SET "region_name" = "region_key", "local_description" = "description", "local_artwork_path" = "artwork_path" WHERE "region_name" = \'\'',
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "monster_definitions" ADD COLUMN "icon_path" character varying(255) NOT NULL DEFAULT \'\'',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'UPDATE "monster_definitions" SET "icon_path" = "artwork_path" WHERE "icon_path" = \'\'',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "monster_definitions" DROP COLUMN "icon_path"',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "location_definitions" DROP COLUMN "local_reward_preview"',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "location_definitions" DROP COLUMN "local_primary_actions"',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "location_definitions" DROP COLUMN "local_points_of_interest"',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "location_definitions" DROP COLUMN "local_artwork_path"',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "location_definitions" DROP COLUMN "local_description"',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "location_definitions" DROP COLUMN "location_type"',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "location_definitions" DROP COLUMN "region_tier_label"',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "location_definitions" DROP COLUMN "region_name"',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class ExtendCombatEventTypes1790000000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TYPE "combat_event_type_enum" ADD VALUE 'HEAL'`);
|
||||||
|
await queryRunner.query(`ALTER TYPE "combat_event_type_enum" ADD VALUE 'DEFEND'`);
|
||||||
|
await queryRunner.query(`ALTER TYPE "combat_event_type_enum" ADD VALUE 'TELEGRAPH'`);
|
||||||
|
await queryRunner.query(`ALTER TYPE "combat_event_type_enum" ADD VALUE 'INTERRUPT'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Postgres has no "DROP VALUE"; rebuild the type from scratch instead.
|
||||||
|
// This fails if any row already uses one of the new values -- expected
|
||||||
|
// for a dev rollback, same tradeoff Postgres migrations always make here.
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "combat_events" ALTER COLUMN "type" TYPE varchar USING "type"::text`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TYPE "combat_event_type_enum"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TYPE "combat_event_type_enum" AS ENUM ('DAMAGE', 'COMBAT_WON', 'COMBAT_LOST')`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "combat_events" ALTER COLUMN "type" TYPE "combat_event_type_enum" USING "type"::"combat_event_type_enum"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddHpRegeneration1792000000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "characters" ADD COLUMN "hp_regen_since" TIMESTAMP WITH TIME ZONE',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Existing characters start regenerating immediately from their current
|
||||||
|
// HP. A character whose fight is still ACTIVE keeps regeneration paused
|
||||||
|
// until that fight resolves, matching the "no combat-time regen" rule
|
||||||
|
// (persistent-hp-and-regeneration design, R4) -- this migration must not
|
||||||
|
// gift them free healing mid-fight.
|
||||||
|
await queryRunner.query('UPDATE "characters" SET "hp_regen_since" = now()');
|
||||||
|
await queryRunner.query(`UPDATE "characters" AS "character"
|
||||||
|
SET "hp_regen_since" = NULL
|
||||||
|
FROM "combats" AS "combat"
|
||||||
|
WHERE "combat"."character_id" = "character"."id"
|
||||||
|
AND "combat"."status" = 'ACTIVE'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query('ALTER TABLE "characters" DROP COLUMN "hp_regen_since"');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { getMetadataArgsStorage } from 'typeorm';
|
||||||
|
import { Character } from '../../characters/entities/character.entity';
|
||||||
|
|
||||||
|
describe('characters.hp_regen_since schema', () => {
|
||||||
|
it('stores the regeneration anchor as a nullable timestamptz', () => {
|
||||||
|
const metadata = getMetadataArgsStorage();
|
||||||
|
const column = metadata.columns.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.target === Character && candidate.propertyName === 'hpRegenSince',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(column).toBeDefined();
|
||||||
|
expect(column?.options.type).toBe('timestamptz');
|
||||||
|
expect(column?.options.nullable).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
52
apps/api/src/database/migrations/equipment.migration.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { getMetadataArgsStorage } from 'typeorm';
|
||||||
|
import { CombatEvent } from '../../combat/entities/combat-event.entity';
|
||||||
|
import { CombatEventType } from '../../combat/combat-event-type.enum';
|
||||||
|
|
||||||
|
describe('combat_events.type enum', () => {
|
||||||
|
it('includes the Playable Slice 0.6 event types', () => {
|
||||||
|
const metadata = getMetadataArgsStorage();
|
||||||
|
const column = metadata.columns.find(
|
||||||
|
(candidate) => candidate.target === CombatEvent && candidate.propertyName === 'type',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(column).toBeDefined();
|
||||||
|
expect(column?.options.enum).toBe(CombatEventType);
|
||||||
|
expect(Object.values(CombatEventType)).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
'DAMAGE',
|
||||||
|
'HEAL',
|
||||||
|
'DEFEND',
|
||||||
|
'TELEGRAPH',
|
||||||
|
'INTERRUPT',
|
||||||
|
'COMBAT_WON',
|
||||||
|
'COMBAT_LOST',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { getMetadataArgsStorage } from 'typeorm';
|
||||||
|
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
|
||||||
|
import { LocationDefinition } from '../../world/entities/location-definition.entity';
|
||||||
|
|
||||||
|
const MIGRATION_SQL = readFileSync(
|
||||||
|
join(__dirname, '1788700000000-CreateLocalLocationView.ts'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
function columnNames(target: unknown): string[] {
|
||||||
|
return getMetadataArgsStorage()
|
||||||
|
.columns.filter((column) => column.target === target)
|
||||||
|
.map((column) => column.options.name)
|
||||||
|
.filter((name): name is string => typeof name === 'string');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('local location view schema', () => {
|
||||||
|
const newLocationColumns = [
|
||||||
|
'region_name',
|
||||||
|
'region_tier_label',
|
||||||
|
'location_type',
|
||||||
|
'local_description',
|
||||||
|
'local_artwork_path',
|
||||||
|
'local_points_of_interest',
|
||||||
|
'local_primary_actions',
|
||||||
|
'local_reward_preview',
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each(newLocationColumns)(
|
||||||
|
'maps %s on LocationDefinition',
|
||||||
|
(name: string) => {
|
||||||
|
expect(columnNames(LocationDefinition)).toContain(name);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(newLocationColumns)('adds %s in the migration', (name: string) => {
|
||||||
|
expect(MIGRATION_SQL).toContain(
|
||||||
|
`ALTER TABLE "location_definitions" ADD COLUMN "${name}"`,
|
||||||
|
);
|
||||||
|
expect(MIGRATION_SQL).toContain(
|
||||||
|
`ALTER TABLE "location_definitions" DROP COLUMN "${name}"`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds the monster icon path in both the entity and the migration', () => {
|
||||||
|
expect(columnNames(MonsterDefinition)).toContain('icon_path');
|
||||||
|
expect(MIGRATION_SQL).toContain(
|
||||||
|
'ALTER TABLE "monster_definitions" ADD COLUMN "icon_path"',
|
||||||
|
);
|
||||||
|
expect(MIGRATION_SQL).toContain(
|
||||||
|
'ALTER TABLE "monster_definitions" DROP COLUMN "icon_path"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the map-level columns untouched so the world screen is unaffected', () => {
|
||||||
|
expect(MIGRATION_SQL).not.toContain('DROP COLUMN "description"');
|
||||||
|
expect(MIGRATION_SQL).not.toContain('DROP COLUMN "artwork_path"');
|
||||||
|
expect(columnNames(LocationDefinition)).toEqual(
|
||||||
|
expect.arrayContaining(['description', 'artwork_path', 'region_key']),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('backfills existing rows so no location renders an empty breadcrumb', () => {
|
||||||
|
expect(MIGRATION_SQL).toContain(
|
||||||
|
'UPDATE "location_definitions" SET "region_name" = "region_key"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
210
apps/api/src/database/seeds/local-location.content.ts
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import type {
|
||||||
|
LocationPointOfInterestContent,
|
||||||
|
LocationPrimaryActionContent,
|
||||||
|
LocationRewardPreviewContent,
|
||||||
|
LocationType,
|
||||||
|
} from '../../world/local-location.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authored local-view content per location (plan §7–§11).
|
||||||
|
*
|
||||||
|
* Coordinates are percentages of the artwork box, so a hotspot stays on the
|
||||||
|
* same painted detail at every viewport width. They are tuned against the real
|
||||||
|
* artwork in `apps/web/public/images/backgrounds/`, not against the
|
||||||
|
* composition mockup in `docs/references/`.
|
||||||
|
*/
|
||||||
|
export interface LocalLocationContent {
|
||||||
|
regionName: string;
|
||||||
|
regionTierLabel: string;
|
||||||
|
locationType: LocationType;
|
||||||
|
localDescription: string;
|
||||||
|
localArtworkPath: string;
|
||||||
|
localPointsOfInterest: LocationPointOfInterestContent[];
|
||||||
|
localPrimaryActions: LocationPrimaryActionContent[];
|
||||||
|
localRewardPreview: LocationRewardPreviewContent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BURNED_ROAD_LOCAL_CONTENT: LocalLocationContent = {
|
||||||
|
regionName: 'Aschenfelder',
|
||||||
|
regionTierLabel: 'Gebiet 1',
|
||||||
|
locationType: 'HUNTING_GROUND',
|
||||||
|
localDescription:
|
||||||
|
'Ein alter Handelsweg, der durch Feuer und Krieg in Asche gelegt wurde. Verbrannte Karren, zerbrochene Waffen und verstummte Schreie säumen den Pfad in die Aschenfelder.',
|
||||||
|
localArtworkPath: '/images/backgrounds/Aschestrasse.png',
|
||||||
|
// Anchored to painted detail in `Aschestrasse.png`: the burning horizon down
|
||||||
|
// the road, the standing gravestone on the left verge, the cracked stones in
|
||||||
|
// the near foreground, and the broken cart wheel on the right.
|
||||||
|
localPointsOfInterest: [
|
||||||
|
{
|
||||||
|
key: 'hunt-area',
|
||||||
|
title: 'Jagdgebiet',
|
||||||
|
actionLabel: 'Jagd beginnen',
|
||||||
|
type: 'HUNT',
|
||||||
|
iconKey: 'hunt',
|
||||||
|
xPercent: 57,
|
||||||
|
yPercent: 33,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'inspect-tracks',
|
||||||
|
title: 'Verdächtige Spuren',
|
||||||
|
actionLabel: 'Untersuchen',
|
||||||
|
type: 'INVESTIGATE',
|
||||||
|
iconKey: 'investigate',
|
||||||
|
xPercent: 40,
|
||||||
|
yPercent: 82,
|
||||||
|
enabled: true,
|
||||||
|
resultTitle: 'Verdächtige Spuren',
|
||||||
|
resultText:
|
||||||
|
'Zwischen Asche und zerbrochenen Steinen erkennst du mehrere frische Stiefelabdrücke. Sie führen nach Osten, in Richtung des verlassenen Wachtpostens.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'search-abandoned-wagon',
|
||||||
|
title: 'Verlassener Wagen',
|
||||||
|
actionLabel: 'Durchsuchen',
|
||||||
|
type: 'SEARCH',
|
||||||
|
iconKey: 'search',
|
||||||
|
xPercent: 86,
|
||||||
|
yPercent: 70,
|
||||||
|
enabled: true,
|
||||||
|
resultTitle: 'Verlassener Wagen',
|
||||||
|
resultText:
|
||||||
|
'Der Wagen wurde gründlich geplündert. Zwischen verbrannten Brettern findest du nur leere Kisten und Spuren eines hastigen Aufbruchs.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'wounded-scout',
|
||||||
|
title: 'Verwundeter Kundschafter',
|
||||||
|
actionLabel: 'Sprechen',
|
||||||
|
type: 'NPC',
|
||||||
|
iconKey: 'speak',
|
||||||
|
xPercent: 17,
|
||||||
|
yPercent: 62,
|
||||||
|
enabled: true,
|
||||||
|
resultTitle: 'Verwundeter Kundschafter',
|
||||||
|
resultText:
|
||||||
|
'„Die Straße ist nicht mehr sicher. Die Plünderer kommen aus Richtung des alten Wachtpostens. Wenn du weitergehst, halte die Augen offen."',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
localPrimaryActions: [
|
||||||
|
{
|
||||||
|
key: 'start-hunt',
|
||||||
|
label: 'Jagd beginnen',
|
||||||
|
description: 'Im Gebiet jagen',
|
||||||
|
type: 'HUNT',
|
||||||
|
iconKey: 'hunt',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'investigate-tracks',
|
||||||
|
label: 'Spuren untersuchen',
|
||||||
|
description: 'Hinweise finden',
|
||||||
|
type: 'INVESTIGATE',
|
||||||
|
iconKey: 'investigate',
|
||||||
|
enabled: true,
|
||||||
|
poiKey: 'inspect-tracks',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'search-surroundings',
|
||||||
|
label: 'Umgebung durchsuchen',
|
||||||
|
description: 'Beute finden',
|
||||||
|
type: 'SEARCH',
|
||||||
|
iconKey: 'search',
|
||||||
|
enabled: true,
|
||||||
|
poiKey: 'search-abandoned-wagon',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'open-map',
|
||||||
|
label: 'Zur Karte',
|
||||||
|
description: 'Gebiet wechseln',
|
||||||
|
type: 'MAP',
|
||||||
|
iconKey: 'map',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// Only categories the loot tables on this road actually back: silver and
|
||||||
|
// experience from every kill, gear from the raiders, pelts from the beasts.
|
||||||
|
// Named items stay out — the view may not promise a drop the roll does not
|
||||||
|
// guarantee (spec §8, "Mögliche Belohnungen").
|
||||||
|
localRewardPreview: [
|
||||||
|
{ key: 'silver', label: 'Silber', iconKey: 'silver' },
|
||||||
|
{ key: 'experience', label: 'Erfahrung', iconKey: 'experience' },
|
||||||
|
{ key: 'equipment', label: 'Ausrüstung', iconKey: 'equipment' },
|
||||||
|
{ key: 'material', label: 'Material', iconKey: 'material' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SOUTH_GATE_LOCAL_CONTENT: LocalLocationContent = {
|
||||||
|
regionName: 'Aschenfelder',
|
||||||
|
regionTierLabel: 'Gebiet 1',
|
||||||
|
locationType: 'TRANSITION',
|
||||||
|
localDescription:
|
||||||
|
'Am schwarzen Südtor endet der Schutz Graufurts. Hinter den Wachtfeuern beginnt die stille Weite der Aschenfelder.',
|
||||||
|
localArtworkPath: '/images/backgrounds/Suedtor.png',
|
||||||
|
localPointsOfInterest: [
|
||||||
|
{
|
||||||
|
key: 'gate-notice',
|
||||||
|
title: 'Aushangtafel',
|
||||||
|
actionLabel: 'Lesen',
|
||||||
|
type: 'INVESTIGATE',
|
||||||
|
iconKey: 'investigate',
|
||||||
|
xPercent: 22,
|
||||||
|
yPercent: 42,
|
||||||
|
enabled: true,
|
||||||
|
resultTitle: 'Aushangtafel',
|
||||||
|
resultText:
|
||||||
|
'Verwitterte Anschläge flattern im Wind. Ein frischer Zettel warnt vor Plünderern auf der Verbrannten Straße und verspricht Silber für jeden erlegten Räuber.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'gate-watch',
|
||||||
|
title: 'Torwache',
|
||||||
|
actionLabel: 'Sprechen',
|
||||||
|
type: 'NPC',
|
||||||
|
iconKey: 'speak',
|
||||||
|
xPercent: 45,
|
||||||
|
yPercent: 52,
|
||||||
|
enabled: true,
|
||||||
|
resultTitle: 'Torwache',
|
||||||
|
resultText:
|
||||||
|
'„Hinter dem Tor endet Graufurts Schutz. Wer nach Süden geht, geht auf eigene Gefahr — und kommt selten so zurück, wie er gegangen ist."',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'south-road',
|
||||||
|
title: 'Straße nach Süden',
|
||||||
|
actionLabel: 'Zur Karte',
|
||||||
|
type: 'MAP',
|
||||||
|
iconKey: 'map',
|
||||||
|
xPercent: 66,
|
||||||
|
yPercent: 72,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
localPrimaryActions: [
|
||||||
|
{
|
||||||
|
key: 'talk-to-watch',
|
||||||
|
label: 'Wache ansprechen',
|
||||||
|
description: 'Lage erfragen',
|
||||||
|
type: 'NPC',
|
||||||
|
iconKey: 'speak',
|
||||||
|
enabled: true,
|
||||||
|
poiKey: 'gate-watch',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'read-notice',
|
||||||
|
label: 'Aushang lesen',
|
||||||
|
description: 'Hinweise finden',
|
||||||
|
type: 'INVESTIGATE',
|
||||||
|
iconKey: 'investigate',
|
||||||
|
enabled: true,
|
||||||
|
poiKey: 'gate-notice',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'open-map',
|
||||||
|
label: 'Zur Karte',
|
||||||
|
description: 'Gebiet wechseln',
|
||||||
|
type: 'MAP',
|
||||||
|
iconKey: 'map',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
localRewardPreview: [],
|
||||||
|
};
|
||||||
@@ -2,3 +2,5 @@ export const SOUTH_GATE_ID = '20000000-0000-4000-8000-000000000001';
|
|||||||
export const BURNED_ROAD_ID = '20000000-0000-4000-8000-000000000002';
|
export const BURNED_ROAD_ID = '20000000-0000-4000-8000-000000000002';
|
||||||
export const ASH_RAT_MONSTER_ID = '30000000-0000-4000-8000-000000000001';
|
export const ASH_RAT_MONSTER_ID = '30000000-0000-4000-8000-000000000001';
|
||||||
export const ROAD_BANDIT_MONSTER_ID = '30000000-0000-4000-8000-000000000002';
|
export const ROAD_BANDIT_MONSTER_ID = '30000000-0000-4000-8000-000000000002';
|
||||||
|
export const WILD_ROAD_DOG_MONSTER_ID = '30000000-0000-4000-8000-000000000003';
|
||||||
|
export const CHARRED_LOOTER_MONSTER_ID = '30000000-0000-4000-8000-000000000004';
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { Character } from '../../characters/entities/character.entity';
|
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 { ItemDefinition } from '../../items/entities/item-definition.entity';
|
||||||
import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity';
|
import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity';
|
||||||
import { LootTable } from '../../loot/entities/loot-table.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 { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
|
||||||
import { LocationConnection } from '../../world/entities/location-connection.entity';
|
import { LocationConnection } from '../../world/entities/location-connection.entity';
|
||||||
import { LocationDefinition } from '../../world/entities/location-definition.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 { ASH_RAT_LOOT_TABLE_ID, ITEM_IDS, ROAD_BANDIT_LOOT_TABLE_ID } from './item.constants';
|
||||||
import { seedVisibleVerticalSlice } from './vertical-slice.seed';
|
import { seedVisibleVerticalSlice } from './vertical-slice.seed';
|
||||||
|
|
||||||
@@ -17,6 +20,8 @@ const SOUTH_GATE_ID = '20000000-0000-4000-8000-000000000001';
|
|||||||
const BURNED_ROAD_ID = '20000000-0000-4000-8000-000000000002';
|
const BURNED_ROAD_ID = '20000000-0000-4000-8000-000000000002';
|
||||||
const ASH_RAT_MONSTER_ID = '30000000-0000-4000-8000-000000000001';
|
const ASH_RAT_MONSTER_ID = '30000000-0000-4000-8000-000000000001';
|
||||||
const ROAD_BANDIT_MONSTER_ID = '30000000-0000-4000-8000-000000000002';
|
const ROAD_BANDIT_MONSTER_ID = '30000000-0000-4000-8000-000000000002';
|
||||||
|
const WILD_ROAD_DOG_MONSTER_ID = '30000000-0000-4000-8000-000000000003';
|
||||||
|
const CHARRED_LOOTER_MONSTER_ID = '30000000-0000-4000-8000-000000000004';
|
||||||
|
|
||||||
class InMemoryRepository {
|
class InMemoryRepository {
|
||||||
readonly rows: Row[] = [];
|
readonly rows: Row[] = [];
|
||||||
@@ -74,6 +79,8 @@ function createDataSource(
|
|||||||
itemRepository: InMemoryRepository = new InMemoryRepository(),
|
itemRepository: InMemoryRepository = new InMemoryRepository(),
|
||||||
lootTableRepository: InMemoryRepository = new InMemoryRepository(),
|
lootTableRepository: InMemoryRepository = new InMemoryRepository(),
|
||||||
lootEntryRepository: InMemoryRepository = new InMemoryRepository(),
|
lootEntryRepository: InMemoryRepository = new InMemoryRepository(),
|
||||||
|
characterItemRepository: InMemoryRepository = new InMemoryRepository(),
|
||||||
|
characterEquipmentRepository: InMemoryRepository = new InMemoryRepository(),
|
||||||
): DataSource {
|
): DataSource {
|
||||||
return {
|
return {
|
||||||
getRepository: jest.fn((entity: unknown) => {
|
getRepository: jest.fn((entity: unknown) => {
|
||||||
@@ -85,6 +92,8 @@ function createDataSource(
|
|||||||
if (entity === ItemDefinition) return itemRepository;
|
if (entity === ItemDefinition) return itemRepository;
|
||||||
if (entity === LootTable) return lootTableRepository;
|
if (entity === LootTable) return lootTableRepository;
|
||||||
if (entity === LootTableEntry) return lootEntryRepository;
|
if (entity === LootTableEntry) return lootEntryRepository;
|
||||||
|
if (entity === CharacterItem) return characterItemRepository;
|
||||||
|
if (entity === CharacterEquipment) return characterEquipmentRepository;
|
||||||
|
|
||||||
throw new Error('Unexpected repository');
|
throw new Error('Unexpected repository');
|
||||||
}),
|
}),
|
||||||
@@ -153,8 +162,8 @@ describe('seedVisibleVerticalSlice', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(monsterRepository.insert).toHaveBeenCalledTimes(2);
|
expect(monsterRepository.insert).toHaveBeenCalledTimes(4);
|
||||||
expect(monsterRepository.rows).toHaveLength(2);
|
expect(monsterRepository.rows).toHaveLength(4);
|
||||||
expect(monsterRepository.rows).toEqual(
|
expect(monsterRepository.rows).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -181,6 +190,20 @@ describe('seedVisibleVerticalSlice', () => {
|
|||||||
silverMax: 15,
|
silverMax: 15,
|
||||||
artworkPath: '/images/monsters/road-bandit.png',
|
artworkPath: '/images/monsters/road-bandit.png',
|
||||||
}),
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
key: 'wild-road-dog',
|
||||||
|
name: 'Verwilderter Straßenhund',
|
||||||
|
level: 1,
|
||||||
|
artworkPath: '/images/monsters/wild-road-dog.png',
|
||||||
|
iconPath: '/images/combat/icons/wild-road-dog-128.png',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
key: 'charred-looter',
|
||||||
|
name: 'Verkohlter Plünderer',
|
||||||
|
level: 2,
|
||||||
|
artworkPath: '/images/monsters/charred-looter.png',
|
||||||
|
iconPath: '/images/combat/icons/charred-looter-128.png',
|
||||||
|
}),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -189,17 +212,114 @@ describe('seedVisibleVerticalSlice', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
locationId: BURNED_ROAD_ID,
|
locationId: BURNED_ROAD_ID,
|
||||||
monsterId: ASH_RAT_MONSTER_ID,
|
monsterId: ASH_RAT_MONSTER_ID,
|
||||||
weight: 70,
|
weight: 40,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
locationId: BURNED_ROAD_ID,
|
||||||
|
monsterId: WILD_ROAD_DOG_MONSTER_ID,
|
||||||
|
weight: 30,
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
locationId: BURNED_ROAD_ID,
|
locationId: BURNED_ROAD_ID,
|
||||||
monsterId: ROAD_BANDIT_MONSTER_ID,
|
monsterId: ROAD_BANDIT_MONSTER_ID,
|
||||||
weight: 30,
|
weight: 20,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
locationId: BURNED_ROAD_ID,
|
||||||
|
monsterId: CHARRED_LOOTER_MONSTER_ID,
|
||||||
|
weight: 10,
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
['locationId', 'monsterId'],
|
['locationId', 'monsterId'],
|
||||||
);
|
);
|
||||||
expect(locationMonsterRepository.rows).toHaveLength(2);
|
expect(locationMonsterRepository.rows).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds the local view content of the Verbrannte Straße with four points of interest', async () => {
|
||||||
|
const locationRepository = new InMemoryRepository();
|
||||||
|
const dataSource = createDataSource(
|
||||||
|
locationRepository,
|
||||||
|
new InMemoryRepository(),
|
||||||
|
new InMemoryRepository(),
|
||||||
|
new InMemoryRepository(),
|
||||||
|
new InMemoryRepository(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await seedVisibleVerticalSlice(dataSource);
|
||||||
|
|
||||||
|
const burnedRoad = locationRepository.rows.find(
|
||||||
|
(row) => row.key === 'burned-road',
|
||||||
|
) as Row;
|
||||||
|
|
||||||
|
expect(burnedRoad).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
regionName: 'Aschenfelder',
|
||||||
|
regionTierLabel: 'Gebiet 1',
|
||||||
|
locationType: 'HUNTING_GROUND',
|
||||||
|
localArtworkPath: '/images/backgrounds/Aschestrasse.png',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(burnedRoad.localDescription).toContain(
|
||||||
|
'Ein alter Handelsweg, der durch Feuer und Krieg in Asche gelegt wurde.',
|
||||||
|
);
|
||||||
|
|
||||||
|
const pointsOfInterest = burnedRoad.localPointsOfInterest as {
|
||||||
|
key: string;
|
||||||
|
type: string;
|
||||||
|
}[];
|
||||||
|
expect(pointsOfInterest.map((poi) => poi.key)).toEqual([
|
||||||
|
'hunt-area',
|
||||||
|
'inspect-tracks',
|
||||||
|
'search-abandoned-wagon',
|
||||||
|
'wounded-scout',
|
||||||
|
]);
|
||||||
|
expect(pointsOfInterest.map((poi) => poi.type)).toEqual([
|
||||||
|
'HUNT',
|
||||||
|
'INVESTIGATE',
|
||||||
|
'SEARCH',
|
||||||
|
'NPC',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const primaryActions = burnedRoad.localPrimaryActions as {
|
||||||
|
label: string;
|
||||||
|
}[];
|
||||||
|
expect(primaryActions.map((action) => action.label)).toEqual([
|
||||||
|
'Jagd beginnen',
|
||||||
|
'Spuren untersuchen',
|
||||||
|
'Umgebung durchsuchen',
|
||||||
|
'Zur Karte',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the Südtor its own local content so a second location needs no new component', async () => {
|
||||||
|
const locationRepository = new InMemoryRepository();
|
||||||
|
const dataSource = createDataSource(
|
||||||
|
locationRepository,
|
||||||
|
new InMemoryRepository(),
|
||||||
|
new InMemoryRepository(),
|
||||||
|
new InMemoryRepository(),
|
||||||
|
new InMemoryRepository(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await seedVisibleVerticalSlice(dataSource);
|
||||||
|
|
||||||
|
const southGate = locationRepository.rows.find(
|
||||||
|
(row) => row.key === 'south-gate',
|
||||||
|
) as Row;
|
||||||
|
|
||||||
|
expect(southGate).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
locationType: 'TRANSITION',
|
||||||
|
localArtworkPath: '/images/backgrounds/Suedtor.png',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(southGate.localPointsOfInterest).toHaveLength(3);
|
||||||
|
// A transition location offers no hunt, so no HUNT hotspot may appear.
|
||||||
|
expect(
|
||||||
|
(southGate.localPointsOfInterest as { type: string }[]).some(
|
||||||
|
(poi) => poi.type === 'HUNT',
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves existing location IDs and uses them for the directed connections', async () => {
|
it('preserves existing location IDs and uses them for the directed connections', async () => {
|
||||||
@@ -336,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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { DataSource } from 'typeorm';
|
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 { 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 { ItemDefinition } from '../../items/entities/item-definition.entity';
|
||||||
import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity';
|
import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity';
|
||||||
import { LootTable } from '../../loot/entities/loot-table.entity';
|
import { LootTable } from '../../loot/entities/loot-table.entity';
|
||||||
@@ -9,16 +16,23 @@ import { LocationMonster } from '../../monsters/entities/location-monster.entity
|
|||||||
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
|
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
|
||||||
import { LocationConnection } from '../../world/entities/location-connection.entity';
|
import { LocationConnection } from '../../world/entities/location-connection.entity';
|
||||||
import { LocationDefinition } from '../../world/entities/location-definition.entity';
|
import { LocationDefinition } from '../../world/entities/location-definition.entity';
|
||||||
|
import { ITEM_DEFINITIONS, LOOT_TABLES, LOOT_TABLE_ENTRIES } from './item-content';
|
||||||
import {
|
import {
|
||||||
ASH_RAT_LOOT_TABLE_ID,
|
ASH_RAT_LOOT_TABLE_ID,
|
||||||
|
ITEM_IDS,
|
||||||
ROAD_BANDIT_LOOT_TABLE_ID,
|
ROAD_BANDIT_LOOT_TABLE_ID,
|
||||||
} from './item.constants';
|
} from './item.constants';
|
||||||
import { ITEM_DEFINITIONS, LOOT_TABLES, LOOT_TABLE_ENTRIES } from './item-content';
|
import {
|
||||||
|
BURNED_ROAD_LOCAL_CONTENT,
|
||||||
|
SOUTH_GATE_LOCAL_CONTENT,
|
||||||
|
} from './local-location.content';
|
||||||
import {
|
import {
|
||||||
ASH_RAT_MONSTER_ID,
|
ASH_RAT_MONSTER_ID,
|
||||||
BURNED_ROAD_ID,
|
BURNED_ROAD_ID,
|
||||||
|
CHARRED_LOOTER_MONSTER_ID,
|
||||||
ROAD_BANDIT_MONSTER_ID,
|
ROAD_BANDIT_MONSTER_ID,
|
||||||
SOUTH_GATE_ID,
|
SOUTH_GATE_ID,
|
||||||
|
WILD_ROAD_DOG_MONSTER_ID,
|
||||||
} from './vertical-slice.constants';
|
} from './vertical-slice.constants';
|
||||||
|
|
||||||
export async function seedVisibleVerticalSlice(
|
export async function seedVisibleVerticalSlice(
|
||||||
@@ -47,6 +61,7 @@ export async function seedVisibleVerticalSlice(
|
|||||||
isSafe: true,
|
isSafe: true,
|
||||||
huntingEnabled: false,
|
huntingEnabled: false,
|
||||||
artworkPath: '/images/backgrounds/Suedtor.png',
|
artworkPath: '/images/backgrounds/Suedtor.png',
|
||||||
|
...SOUTH_GATE_LOCAL_CONTENT,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: BURNED_ROAD_ID,
|
id: BURNED_ROAD_ID,
|
||||||
@@ -61,17 +76,14 @@ export async function seedVisibleVerticalSlice(
|
|||||||
isSafe: false,
|
isSafe: false,
|
||||||
huntingEnabled: true,
|
huntingEnabled: true,
|
||||||
artworkPath: '/images/backgrounds/Aschestrasse.png',
|
artworkPath: '/images/backgrounds/Aschestrasse.png',
|
||||||
|
...BURNED_ROAD_LOCAL_CONTENT,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
let southGateId = SOUTH_GATE_ID;
|
|
||||||
let burnedRoadId = BURNED_ROAD_ID;
|
|
||||||
|
|
||||||
|
const locationIds = new Map<string, string>();
|
||||||
for (const location of locations) {
|
for (const location of locations) {
|
||||||
const existing = await locationRepository.findOneBy({
|
const existing = await locationRepository.findOneBy({ key: location.key });
|
||||||
key: location.key,
|
|
||||||
});
|
|
||||||
const { id, key, ...definition } = location;
|
const { id, key, ...definition } = location;
|
||||||
const persistedId = existing?.id ?? id;
|
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await locationRepository.update(existing.id, definition);
|
await locationRepository.update(existing.id, definition);
|
||||||
@@ -79,13 +91,12 @@ export async function seedVisibleVerticalSlice(
|
|||||||
await locationRepository.insert(location);
|
await locationRepository.insert(location);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === 'south-gate') {
|
locationIds.set(key, existing?.id ?? id);
|
||||||
southGateId = persistedId;
|
|
||||||
} else {
|
|
||||||
burnedRoadId = persistedId;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const southGateId = locationIds.get('south-gate') ?? SOUTH_GATE_ID;
|
||||||
|
const burnedRoadId = locationIds.get('burned-road') ?? BURNED_ROAD_ID;
|
||||||
|
|
||||||
await connectionRepository.upsert(
|
await connectionRepository.upsert(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -128,6 +139,24 @@ export async function seedVisibleVerticalSlice(
|
|||||||
silverMin: 4,
|
silverMin: 4,
|
||||||
silverMax: 7,
|
silverMax: 7,
|
||||||
artworkPath: '/images/monsters/ash-rat.png',
|
artworkPath: '/images/monsters/ash-rat.png',
|
||||||
|
iconPath: '/images/combat/icons/ash-rat-128.png',
|
||||||
|
lootTableId: ASH_RAT_LOOT_TABLE_ID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: WILD_ROAD_DOG_MONSTER_ID,
|
||||||
|
key: 'wild-road-dog',
|
||||||
|
name: 'Verwilderter Straßenhund',
|
||||||
|
level: 1,
|
||||||
|
maxHp: 55,
|
||||||
|
attack: 7,
|
||||||
|
armor: 0,
|
||||||
|
experienceReward: 11,
|
||||||
|
silverMin: 5,
|
||||||
|
silverMax: 9,
|
||||||
|
artworkPath: '/images/monsters/wild-road-dog.png',
|
||||||
|
iconPath: '/images/combat/icons/wild-road-dog-128.png',
|
||||||
|
// Shares the beast table: both are scorched road animals that leave a
|
||||||
|
// pelt behind. A table of its own waits for content that differs.
|
||||||
lootTableId: ASH_RAT_LOOT_TABLE_ID,
|
lootTableId: ASH_RAT_LOOT_TABLE_ID,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -142,18 +171,33 @@ export async function seedVisibleVerticalSlice(
|
|||||||
silverMin: 9,
|
silverMin: 9,
|
||||||
silverMax: 15,
|
silverMax: 15,
|
||||||
artworkPath: '/images/monsters/road-bandit.png',
|
artworkPath: '/images/monsters/road-bandit.png',
|
||||||
|
iconPath: '/images/combat/icons/road-bandit-128.png',
|
||||||
|
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: CHARRED_LOOTER_MONSTER_ID,
|
||||||
|
key: 'charred-looter',
|
||||||
|
name: 'Verkohlter Plünderer',
|
||||||
|
level: 2,
|
||||||
|
maxHp: 85,
|
||||||
|
attack: 11,
|
||||||
|
armor: 6,
|
||||||
|
experienceReward: 20,
|
||||||
|
silverMin: 12,
|
||||||
|
silverMax: 19,
|
||||||
|
artworkPath: '/images/monsters/charred-looter.png',
|
||||||
|
iconPath: '/images/combat/icons/charred-looter-128.png',
|
||||||
|
// Shares the raider table: same gear, taken from the same caravans.
|
||||||
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
|
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
let ashRatId = ASH_RAT_MONSTER_ID;
|
|
||||||
let roadBanditId = ROAD_BANDIT_MONSTER_ID;
|
|
||||||
|
|
||||||
|
const monsterIds = new Map<string, string>();
|
||||||
for (const monster of monsters) {
|
for (const monster of monsters) {
|
||||||
const existingMonster = await monsterRepository.findOneBy({
|
const existingMonster = await monsterRepository.findOneBy({
|
||||||
key: monster.key,
|
key: monster.key,
|
||||||
});
|
});
|
||||||
const { id, key, ...definition } = monster;
|
const { id, key, ...definition } = monster;
|
||||||
const persistedId = existingMonster?.id ?? id;
|
|
||||||
|
|
||||||
if (existingMonster) {
|
if (existingMonster) {
|
||||||
await monsterRepository.update(existingMonster.id, definition);
|
await monsterRepository.update(existingMonster.id, definition);
|
||||||
@@ -161,30 +205,27 @@ export async function seedVisibleVerticalSlice(
|
|||||||
await monsterRepository.insert(monster);
|
await monsterRepository.insert(monster);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === 'ash-rat') {
|
monsterIds.set(key, existingMonster?.id ?? id);
|
||||||
ashRatId = persistedId;
|
|
||||||
} else {
|
|
||||||
roadBanditId = persistedId;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Weights read as "how often you meet this on the road". They also drive the
|
||||||
|
// location's danger rating, which is computed from the weighted average of
|
||||||
|
// the pool rather than from its single worst entry.
|
||||||
|
const encounterWeights: Readonly<Record<string, number>> = {
|
||||||
|
'ash-rat': 40,
|
||||||
|
'wild-road-dog': 30,
|
||||||
|
'road-bandit': 20,
|
||||||
|
'charred-looter': 10,
|
||||||
|
};
|
||||||
|
|
||||||
await locationMonsterRepository.upsert(
|
await locationMonsterRepository.upsert(
|
||||||
[
|
Object.entries(encounterWeights).map(([key, weight]) => ({
|
||||||
{
|
|
||||||
locationId: burnedRoadId,
|
locationId: burnedRoadId,
|
||||||
monsterId: ashRatId,
|
monsterId: monsterIds.get(key) as string,
|
||||||
weight: 70,
|
weight,
|
||||||
encounterType: EncounterType.NORMAL,
|
encounterType: EncounterType.NORMAL,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
})),
|
||||||
{
|
|
||||||
locationId: burnedRoadId,
|
|
||||||
monsterId: roadBanditId,
|
|
||||||
weight: 30,
|
|
||||||
encounterType: EncounterType.NORMAL,
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
['locationId', 'monsterId'],
|
['locationId', 'monsterId'],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -202,7 +243,46 @@ export async function seedVisibleVerticalSlice(
|
|||||||
baseHp: 100,
|
baseHp: 100,
|
||||||
baseAttack: 6,
|
baseAttack: 6,
|
||||||
currentHp: 100,
|
currentHp: 100,
|
||||||
|
hpRegenSince: new Date(),
|
||||||
currentLocationId: southGateId,
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,5 @@
|
|||||||
export const DEMO_CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
|
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';
|
||||||
|
|||||||
6
apps/api/src/equipment/dto/equip-item.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class EquipItemDto {
|
||||||
|
@IsUUID()
|
||||||
|
characterItemId!: string;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
19
apps/api/src/equipment/equipment.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
71
apps/api/src/equipment/equipment.errors.ts
Normal 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';
|
||||||
21
apps/api/src/equipment/equipment.module.ts
Normal 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 {}
|
||||||
475
apps/api/src/equipment/equipment.service.spec.ts
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
|
||||||
|
import { CharacterStatsService } from '../characters/character-stats.service';
|
||||||
|
import { CharacterVitalsService } from '../characters/character-vitals.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,
|
||||||
|
hpRegenSince: null,
|
||||||
|
...overrides,
|
||||||
|
} as Character;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHarness(state: Partial<State> = {}) {
|
||||||
|
const fullState: State = {
|
||||||
|
characters: [character()],
|
||||||
|
itemDefinitions: [],
|
||||||
|
characterItems: [],
|
||||||
|
characterEquipment: [],
|
||||||
|
combats: [],
|
||||||
|
...state,
|
||||||
|
};
|
||||||
|
const dataSource = new FakeDataSource(fullState);
|
||||||
|
const characterVitals = new CharacterVitalsService({
|
||||||
|
now: () => new Date('2026-08-18T09:00:00.000Z'),
|
||||||
|
});
|
||||||
|
const characterStats = new CharacterStatsService(
|
||||||
|
dataSource as unknown as DataSource,
|
||||||
|
characterVitals,
|
||||||
|
);
|
||||||
|
const service = new EquipmentService(
|
||||||
|
dataSource as unknown as DataSource,
|
||||||
|
characterStats,
|
||||||
|
characterVitals,
|
||||||
|
);
|
||||||
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-anchors HP regeneration so a later max-HP increase does not gift accumulated overflow', async () => {
|
||||||
|
const bonusHpHelm = itemDefinition({
|
||||||
|
id: 'def-bonus-hp-helm',
|
||||||
|
key: 'bonus-hp-helm',
|
||||||
|
name: 'Gepolsterter Helm',
|
||||||
|
equipmentSlot: EquipmentSlot.HEAD,
|
||||||
|
bonusHp: 20,
|
||||||
|
weaponDamage: 0,
|
||||||
|
});
|
||||||
|
const { state, service } = createHarness({
|
||||||
|
characters: [
|
||||||
|
character({
|
||||||
|
currentHp: 90,
|
||||||
|
hpRegenSince: new Date('2026-08-18T08:50:00.000Z'),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
itemDefinitions: [bonusHpHelm],
|
||||||
|
characterItems: [
|
||||||
|
{
|
||||||
|
id: BANDIT_HOOD_ITEM_ID,
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
itemDefinitionId: bonusHpHelm.id,
|
||||||
|
quantity: 1,
|
||||||
|
} as CharacterItem,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.equip(CHARACTER_ID, BANDIT_HOOD_ITEM_ID);
|
||||||
|
|
||||||
|
expect(state.characters[0].currentHp).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
174
apps/api/src/equipment/equipment.service.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { CharacterStatsService } from '../characters/character-stats.service';
|
||||||
|
import { CharacterVitalsService } from '../characters/character-vitals.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,
|
||||||
|
private readonly characterVitals: CharacterVitalsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
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 statsBeforeChange = await this.characterStats.calculate(character, manager);
|
||||||
|
this.characterVitals.settle(character, statsBeforeChange.maxHp);
|
||||||
|
await characters.save(character);
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/api/src/inventory/inventory.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/api/src/inventory/inventory.module.ts
Normal 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 {}
|
||||||
99
apps/api/src/inventory/inventory.service.spec.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
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',
|
||||||
|
description: 'Die Klinge eines Rekruten, öfter geschliffen als geführt.',
|
||||||
|
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',
|
||||||
|
description: 'Die Klinge eines Rekruten, öfter geschliffen als geführt.',
|
||||||
|
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: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
73
apps/api/src/inventory/inventory.service.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
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;
|
||||||
|
description: 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,
|
||||||
|
description: characterItem.itemDefinition.description,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,12 @@ export class MonsterDefinition {
|
|||||||
@Column({ name: 'artwork_path', type: 'varchar', length: 255 })
|
@Column({ name: 'artwork_path', type: 'varchar', length: 255 })
|
||||||
artworkPath!: string;
|
artworkPath!: string;
|
||||||
|
|
||||||
|
// Round medallion portrait used wherever a monster appears at icon size
|
||||||
|
// (encounter preview in the local location view). `artworkPath` stays the
|
||||||
|
// wide combat/hunt portrait.
|
||||||
|
@Column({ name: 'icon_path', type: 'varchar', length: 255 })
|
||||||
|
iconPath!: string;
|
||||||
|
|
||||||
@Column({ name: 'loot_table_id', type: 'uuid', nullable: true })
|
@Column({ name: 'loot_table_id', type: 'uuid', nullable: true })
|
||||||
lootTableId!: string | null;
|
lootTableId!: string | null;
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import { Character } from '../characters/entities/character.entity';
|
import { Character } from '../characters/entities/character.entity';
|
||||||
import { LocationConnection } from '../world/entities/location-connection.entity';
|
import { LocationConnection } from '../world/entities/location-connection.entity';
|
||||||
import { LocationDefinition } from '../world/entities/location-definition.entity';
|
import { LocationDefinition } from '../world/entities/location-definition.entity';
|
||||||
import { CLOCK, systemClock } from './clock';
|
import { CLOCK, systemClock } from '../shared/clock';
|
||||||
import { Travel } from './entities/travel.entity';
|
import { Travel } from './entities/travel.entity';
|
||||||
import { TravelController } from './travel.controller';
|
import { TravelController } from './travel.controller';
|
||||||
import { TravelService } from './travel.service';
|
import { TravelService } from './travel.service';
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
} from '../database/seeds/vertical-slice.constants';
|
} from '../database/seeds/vertical-slice.constants';
|
||||||
import { LocationConnection } from '../world/entities/location-connection.entity';
|
import { LocationConnection } from '../world/entities/location-connection.entity';
|
||||||
import { LocationDefinition } from '../world/entities/location-definition.entity';
|
import { LocationDefinition } from '../world/entities/location-definition.entity';
|
||||||
import { Clock } from './clock';
|
import { Clock } from '../shared/clock';
|
||||||
import { Travel } from './entities/travel.entity';
|
import { Travel } from './entities/travel.entity';
|
||||||
import { TravelDomainError } from './travel.errors';
|
import { TravelDomainError } from './travel.errors';
|
||||||
import { TravelService } from './travel.service';
|
import { TravelService } from './travel.service';
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { DataSource, Repository } from 'typeorm';
|
|||||||
import { Character } from '../characters/entities/character.entity';
|
import { Character } from '../characters/entities/character.entity';
|
||||||
import { LocationConnection } from '../world/entities/location-connection.entity';
|
import { LocationConnection } from '../world/entities/location-connection.entity';
|
||||||
import { LocationDefinition } from '../world/entities/location-definition.entity';
|
import { LocationDefinition } from '../world/entities/location-definition.entity';
|
||||||
import { CLOCK } from './clock';
|
import { CLOCK } from '../shared/clock';
|
||||||
import type { Clock } from './clock';
|
import type { Clock } from '../shared/clock';
|
||||||
import { Travel } from './entities/travel.entity';
|
import { Travel } from './entities/travel.entity';
|
||||||
import {
|
import {
|
||||||
characterNotFound,
|
characterNotFound,
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ import {
|
|||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { Character } from '../../characters/entities/character.entity';
|
import { Character } from '../../characters/entities/character.entity';
|
||||||
|
import type {
|
||||||
|
LocationPointOfInterestContent,
|
||||||
|
LocationPrimaryActionContent,
|
||||||
|
LocationRewardPreviewContent,
|
||||||
|
LocationType,
|
||||||
|
} from '../local-location.types';
|
||||||
import { LocationConnection } from './location-connection.entity';
|
import { LocationConnection } from './location-connection.entity';
|
||||||
|
|
||||||
@Entity({ name: 'location_definitions' })
|
@Entity({ name: 'location_definitions' })
|
||||||
@@ -46,6 +52,35 @@ export class LocationDefinition {
|
|||||||
@Column({ name: 'artwork_path', type: 'varchar', length: 255 })
|
@Column({ name: 'artwork_path', type: 'varchar', length: 255 })
|
||||||
artworkPath!: string;
|
artworkPath!: string;
|
||||||
|
|
||||||
|
// --- Local location view (spec §4, §10) -----------------------------------
|
||||||
|
// `description`/`artworkPath` above stay untouched: the map and the hunt
|
||||||
|
// screen keep rendering them. The `local*` columns below feed the local
|
||||||
|
// location view, which needs a longer scene description and a wide artwork.
|
||||||
|
|
||||||
|
@Column({ name: 'region_name', type: 'varchar', length: 150 })
|
||||||
|
regionName!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'region_tier_label', type: 'varchar', length: 50 })
|
||||||
|
regionTierLabel!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'location_type', type: 'varchar', length: 50 })
|
||||||
|
locationType!: LocationType;
|
||||||
|
|
||||||
|
@Column({ name: 'local_description', type: 'text' })
|
||||||
|
localDescription!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'local_artwork_path', type: 'varchar', length: 255 })
|
||||||
|
localArtworkPath!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'local_points_of_interest', type: 'jsonb' })
|
||||||
|
localPointsOfInterest!: LocationPointOfInterestContent[];
|
||||||
|
|
||||||
|
@Column({ name: 'local_primary_actions', type: 'jsonb' })
|
||||||
|
localPrimaryActions!: LocationPrimaryActionContent[];
|
||||||
|
|
||||||
|
@Column({ name: 'local_reward_preview', type: 'jsonb' })
|
||||||
|
localRewardPreview!: LocationRewardPreviewContent[];
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
|
||||||
|
|||||||
160
apps/api/src/world/local-location-interaction.spec.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Character } from '../characters/entities/character.entity';
|
||||||
|
import {
|
||||||
|
BURNED_ROAD_ID,
|
||||||
|
SOUTH_GATE_ID,
|
||||||
|
} from '../database/seeds/vertical-slice.constants';
|
||||||
|
import { LocationMonster } from '../monsters/entities/location-monster.entity';
|
||||||
|
import { TravelService } from '../travel/travel.service';
|
||||||
|
import { LocationConnection } from './entities/location-connection.entity';
|
||||||
|
import { LocationDefinition } from './entities/location-definition.entity';
|
||||||
|
import type { LocationPointOfInterestContent } from './local-location.types';
|
||||||
|
import { WorldDomainError } from './world.errors';
|
||||||
|
import { WorldService } from './world.service';
|
||||||
|
|
||||||
|
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
|
||||||
|
|
||||||
|
const BURNED_ROAD_POIS: LocationPointOfInterestContent[] = [
|
||||||
|
{
|
||||||
|
key: 'hunt-area',
|
||||||
|
title: 'Jagdgebiet',
|
||||||
|
actionLabel: 'Jagd beginnen',
|
||||||
|
type: 'HUNT',
|
||||||
|
iconKey: 'hunt',
|
||||||
|
xPercent: 52,
|
||||||
|
yPercent: 44,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'inspect-tracks',
|
||||||
|
title: 'Verdächtige Spuren',
|
||||||
|
actionLabel: 'Untersuchen',
|
||||||
|
type: 'INVESTIGATE',
|
||||||
|
iconKey: 'investigate',
|
||||||
|
xPercent: 32,
|
||||||
|
yPercent: 78,
|
||||||
|
enabled: true,
|
||||||
|
resultTitle: 'Verdächtige Spuren',
|
||||||
|
resultText:
|
||||||
|
'Zwischen Asche und zerbrochenen Steinen erkennst du mehrere frische Stiefelabdrücke.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sealed-crypt',
|
||||||
|
title: 'Versiegelte Krypta',
|
||||||
|
type: 'DUNGEON',
|
||||||
|
iconKey: 'search',
|
||||||
|
xPercent: 90,
|
||||||
|
yPercent: 20,
|
||||||
|
enabled: false,
|
||||||
|
resultTitle: 'Versiegelte Krypta',
|
||||||
|
resultText: 'Noch verschlossen.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const SOUTH_GATE_POIS: LocationPointOfInterestContent[] = [
|
||||||
|
{
|
||||||
|
key: 'gate-watch',
|
||||||
|
title: 'Torwache',
|
||||||
|
actionLabel: 'Sprechen',
|
||||||
|
type: 'NPC',
|
||||||
|
iconKey: 'speak',
|
||||||
|
xPercent: 45,
|
||||||
|
yPercent: 52,
|
||||||
|
enabled: true,
|
||||||
|
resultTitle: 'Torwache',
|
||||||
|
resultText: 'Nur am Südtor zu hören.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function location(
|
||||||
|
id: string,
|
||||||
|
pointsOfInterest: LocationPointOfInterestContent[],
|
||||||
|
): LocationDefinition {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
localPointsOfInterest: pointsOfInterest,
|
||||||
|
} as LocationDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createService(
|
||||||
|
currentLocation: LocationDefinition,
|
||||||
|
completeTravelIfDue = jest.fn().mockResolvedValue({ status: 'IDLE' }),
|
||||||
|
) {
|
||||||
|
return new WorldService(
|
||||||
|
{ completeTravelIfDue } as unknown as TravelService,
|
||||||
|
{
|
||||||
|
findOne: jest.fn().mockResolvedValue({
|
||||||
|
id: CHARACTER_ID,
|
||||||
|
currentLocationId: currentLocation.id,
|
||||||
|
currentLocation,
|
||||||
|
}),
|
||||||
|
} as unknown as Repository<Character>,
|
||||||
|
{ find: jest.fn() } as unknown as Repository<LocationConnection>,
|
||||||
|
{ find: jest.fn() } as unknown as Repository<LocationMonster>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectRejected(promise: Promise<unknown>): Promise<void> {
|
||||||
|
await expect(promise).rejects.toBeInstanceOf(WorldDomainError);
|
||||||
|
await expect(promise).rejects.toMatchObject({
|
||||||
|
code: 'LOCATION_INTERACTION_UNAVAILABLE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WorldService.runLocalInteraction', () => {
|
||||||
|
it('returns the authored result of an enabled interaction at the current location', async () => {
|
||||||
|
const service = createService(location(BURNED_ROAD_ID, BURNED_ROAD_POIS));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.runLocalInteraction(CHARACTER_ID, 'inspect-tracks'),
|
||||||
|
).resolves.toEqual({
|
||||||
|
interactionKey: 'inspect-tracks',
|
||||||
|
title: 'Verdächtige Spuren',
|
||||||
|
text: 'Zwischen Asche und zerbrochenen Steinen erkennst du mehrere frische Stiefelabdrücke.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('settles travel before resolving which location the character stands at', async () => {
|
||||||
|
const completeTravelIfDue = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ status: 'IDLE' });
|
||||||
|
const service = createService(
|
||||||
|
location(BURNED_ROAD_ID, BURNED_ROAD_POIS),
|
||||||
|
completeTravelIfDue,
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.runLocalInteraction(CHARACTER_ID, 'inspect-tracks');
|
||||||
|
|
||||||
|
expect(completeTravelIfDue).toHaveBeenCalledWith(CHARACTER_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an interaction that belongs to a different location', async () => {
|
||||||
|
const service = createService(location(SOUTH_GATE_ID, SOUTH_GATE_POIS));
|
||||||
|
|
||||||
|
await expectRejected(
|
||||||
|
service.runLocalInteraction(CHARACTER_ID, 'inspect-tracks'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown interaction key', async () => {
|
||||||
|
const service = createService(location(BURNED_ROAD_ID, BURNED_ROAD_POIS));
|
||||||
|
|
||||||
|
await expectRejected(
|
||||||
|
service.runLocalInteraction(CHARACTER_ID, 'open-vault'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a disabled interaction', async () => {
|
||||||
|
const service = createService(location(BURNED_ROAD_ID, BURNED_ROAD_POIS));
|
||||||
|
|
||||||
|
await expectRejected(
|
||||||
|
service.runLocalInteraction(CHARACTER_ID, 'sealed-crypt'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a navigation hotspot that has no result to reveal', async () => {
|
||||||
|
const service = createService(location(BURNED_ROAD_ID, BURNED_ROAD_POIS));
|
||||||
|
|
||||||
|
await expectRejected(service.runLocalInteraction(CHARACTER_ID, 'hunt-area'));
|
||||||
|
});
|
||||||
|
});
|
||||||
126
apps/api/src/world/local-location.types.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* Content and transport types for the local location view (spec §10).
|
||||||
|
*
|
||||||
|
* A location's local presentation is content, not code: points of interest,
|
||||||
|
* primary actions and the reward preview are stored as JSONB on
|
||||||
|
* `LocationDefinition` so a new location renders through the same components
|
||||||
|
* by supplying different data.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type LocationInteractionType =
|
||||||
|
| 'HUNT'
|
||||||
|
| 'INVESTIGATE'
|
||||||
|
| 'SEARCH'
|
||||||
|
| 'NPC'
|
||||||
|
| 'MAP'
|
||||||
|
| 'TRAVEL'
|
||||||
|
| 'SHOP'
|
||||||
|
| 'QUEST'
|
||||||
|
| 'BOSS'
|
||||||
|
| 'DUNGEON';
|
||||||
|
|
||||||
|
export type LocationType =
|
||||||
|
| 'SAFE_HUB'
|
||||||
|
| 'TRANSITION'
|
||||||
|
| 'HUNTING_GROUND'
|
||||||
|
| 'QUEST_LOCATION'
|
||||||
|
| 'OUTPOST'
|
||||||
|
| 'ELITE_ZONE'
|
||||||
|
| 'BOSS_LOCATION'
|
||||||
|
| 'DUNGEON_ENTRANCE';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stored shape of a point of interest. `resultTitle`/`resultText` never leave
|
||||||
|
* the server through the location payload — they are revealed only by the
|
||||||
|
* interaction endpoint, which first verifies the character actually stands
|
||||||
|
* here (plan §5).
|
||||||
|
*/
|
||||||
|
export interface LocationPointOfInterestContent {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
actionLabel?: string;
|
||||||
|
type: LocationInteractionType;
|
||||||
|
iconKey: string;
|
||||||
|
xPercent: number;
|
||||||
|
yPercent: number;
|
||||||
|
enabled: boolean;
|
||||||
|
resultTitle?: string;
|
||||||
|
resultText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stored shape of a primary action. Interaction-backed actions carry `poiKey`
|
||||||
|
* instead of their own result text, so a POI and the action bar entry that
|
||||||
|
* duplicates it can never drift apart.
|
||||||
|
*/
|
||||||
|
export interface LocationPrimaryActionContent {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
type: LocationInteractionType;
|
||||||
|
iconKey: string;
|
||||||
|
enabled: boolean;
|
||||||
|
poiKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocationRewardPreviewContent {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
iconKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalLocationPointOfInterestDto {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
actionLabel?: string;
|
||||||
|
type: LocationInteractionType;
|
||||||
|
iconKey: string;
|
||||||
|
xPercent: number;
|
||||||
|
yPercent: number;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalLocationPrimaryActionDto {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
type: LocationInteractionType;
|
||||||
|
iconKey: string;
|
||||||
|
enabled: boolean;
|
||||||
|
poiKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EncounterPreviewDto {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
level: number;
|
||||||
|
iconPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RewardPreviewDto {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
iconKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocationInteractionResultDto {
|
||||||
|
interactionKey: string;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strips server-only result text before a POI is sent to the client. */
|
||||||
|
export function toPointOfInterestDto(
|
||||||
|
poi: LocationPointOfInterestContent,
|
||||||
|
): LocalLocationPointOfInterestDto {
|
||||||
|
return {
|
||||||
|
key: poi.key,
|
||||||
|
title: poi.title,
|
||||||
|
...(poi.actionLabel === undefined ? {} : { actionLabel: poi.actionLabel }),
|
||||||
|
type: poi.type,
|
||||||
|
iconKey: poi.iconKey,
|
||||||
|
xPercent: poi.xPercent,
|
||||||
|
yPercent: poi.yPercent,
|
||||||
|
enabled: poi.enabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
35
apps/api/src/world/world.controller.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
|
||||||
|
import { WorldController } from './world.controller';
|
||||||
|
import { WorldService } from './world.service';
|
||||||
|
|
||||||
|
describe('WorldController', () => {
|
||||||
|
it('resolves the current location for the acting character', () => {
|
||||||
|
const getCurrentLocation = jest.fn().mockResolvedValue({ key: 'burned-road' });
|
||||||
|
const controller = new WorldController({
|
||||||
|
getCurrentLocation,
|
||||||
|
} as unknown as WorldService);
|
||||||
|
|
||||||
|
void controller.getCurrentLocation();
|
||||||
|
|
||||||
|
expect(getCurrentLocation).toHaveBeenCalledWith(DEMO_CHARACTER_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards only the interaction key, never a caller-supplied location', () => {
|
||||||
|
const runLocalInteraction = jest.fn().mockResolvedValue({
|
||||||
|
interactionKey: 'inspect-tracks',
|
||||||
|
title: 'Verdächtige Spuren',
|
||||||
|
text: 'Frische Stiefelabdrücke.',
|
||||||
|
});
|
||||||
|
const controller = new WorldController({
|
||||||
|
runLocalInteraction,
|
||||||
|
} as unknown as WorldService);
|
||||||
|
|
||||||
|
void controller.runLocalInteraction('inspect-tracks');
|
||||||
|
|
||||||
|
expect(runLocalInteraction).toHaveBeenCalledWith(
|
||||||
|
DEMO_CHARACTER_ID,
|
||||||
|
'inspect-tracks',
|
||||||
|
);
|
||||||
|
expect(runLocalInteraction).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get, Param, Post } from '@nestjs/common';
|
||||||
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
|
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
|
||||||
|
import { LocationInteractionResultDto } from './local-location.types';
|
||||||
import { WorldService } from './world.service';
|
import { WorldService } from './world.service';
|
||||||
|
|
||||||
@Controller('world')
|
@Controller('world')
|
||||||
@@ -10,4 +11,19 @@ export class WorldController {
|
|||||||
getCurrentLocation() {
|
getCurrentLocation() {
|
||||||
return this.worldService.getCurrentLocation(DEMO_CHARACTER_ID);
|
return this.worldService.getCurrentLocation(DEMO_CHARACTER_ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The interaction is addressed by key alone. There is deliberately no
|
||||||
|
* location parameter: the server resolves the location from the character,
|
||||||
|
* so the route cannot be pointed at somewhere the player is not.
|
||||||
|
*/
|
||||||
|
@Post('current-location/interactions/:interactionKey')
|
||||||
|
runLocalInteraction(
|
||||||
|
@Param('interactionKey') interactionKey: string,
|
||||||
|
): Promise<LocationInteractionResultDto> {
|
||||||
|
return this.worldService.runLocalInteraction(
|
||||||
|
DEMO_CHARACTER_ID,
|
||||||
|
interactionKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
25
apps/api/src/world/world.errors.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { HttpException } from '@nestjs/common';
|
||||||
|
|
||||||
|
export class WorldDomainError extends HttpException {
|
||||||
|
constructor(
|
||||||
|
public readonly code: string,
|
||||||
|
status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super({ statusCode: status, code, message }, status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raised for any interaction key the current location does not offer — an
|
||||||
|
* unknown key, a key belonging to another location, or one that is disabled.
|
||||||
|
* They share a code on purpose: the client learns "not here", not which of the
|
||||||
|
* three it was.
|
||||||
|
*/
|
||||||
|
export function locationInteractionUnavailable(): WorldDomainError {
|
||||||
|
return new WorldDomainError(
|
||||||
|
'LOCATION_INTERACTION_UNAVAILABLE',
|
||||||
|
400,
|
||||||
|
'This interaction is not available at the current location.',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,10 +9,122 @@ import { LocationMonster } from '../monsters/entities/location-monster.entity';
|
|||||||
import { TravelService } from '../travel/travel.service';
|
import { TravelService } from '../travel/travel.service';
|
||||||
import { LocationConnection } from './entities/location-connection.entity';
|
import { LocationConnection } from './entities/location-connection.entity';
|
||||||
import { LocationDefinition } from './entities/location-definition.entity';
|
import { LocationDefinition } from './entities/location-definition.entity';
|
||||||
|
import type {
|
||||||
|
LocationPointOfInterestContent,
|
||||||
|
LocationPrimaryActionContent,
|
||||||
|
} from './local-location.types';
|
||||||
import { WorldService } from './world.service';
|
import { WorldService } from './world.service';
|
||||||
|
|
||||||
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
|
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
|
||||||
|
|
||||||
|
const SOUTH_GATE_POIS: LocationPointOfInterestContent[] = [
|
||||||
|
{
|
||||||
|
key: 'gate-watch',
|
||||||
|
title: 'Torwache',
|
||||||
|
actionLabel: 'Sprechen',
|
||||||
|
type: 'NPC',
|
||||||
|
iconKey: 'speak',
|
||||||
|
xPercent: 45,
|
||||||
|
yPercent: 52,
|
||||||
|
enabled: true,
|
||||||
|
resultTitle: 'Torwache',
|
||||||
|
resultText: 'Geheimer Servertext.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const BURNED_ROAD_POIS: LocationPointOfInterestContent[] = [
|
||||||
|
{
|
||||||
|
key: 'hunt-area',
|
||||||
|
title: 'Jagdgebiet',
|
||||||
|
actionLabel: 'Jagd beginnen',
|
||||||
|
type: 'HUNT',
|
||||||
|
iconKey: 'hunt',
|
||||||
|
xPercent: 52,
|
||||||
|
yPercent: 44,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'inspect-tracks',
|
||||||
|
title: 'Verdächtige Spuren',
|
||||||
|
actionLabel: 'Untersuchen',
|
||||||
|
type: 'INVESTIGATE',
|
||||||
|
iconKey: 'investigate',
|
||||||
|
xPercent: 32,
|
||||||
|
yPercent: 78,
|
||||||
|
enabled: true,
|
||||||
|
resultTitle: 'Verdächtige Spuren',
|
||||||
|
resultText: 'Frische Stiefelabdrücke führen nach Osten.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sealed-crypt',
|
||||||
|
title: 'Versiegelte Krypta',
|
||||||
|
actionLabel: 'Öffnen',
|
||||||
|
type: 'DUNGEON',
|
||||||
|
iconKey: 'search',
|
||||||
|
xPercent: 90,
|
||||||
|
yPercent: 20,
|
||||||
|
enabled: false,
|
||||||
|
resultTitle: 'Versiegelte Krypta',
|
||||||
|
resultText: 'Noch verschlossen.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const BURNED_ROAD_ACTIONS: LocationPrimaryActionContent[] = [
|
||||||
|
{
|
||||||
|
key: 'start-hunt',
|
||||||
|
label: 'Jagd beginnen',
|
||||||
|
description: 'Im Gebiet jagen',
|
||||||
|
type: 'HUNT',
|
||||||
|
iconKey: 'hunt',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'investigate-tracks',
|
||||||
|
label: 'Spuren untersuchen',
|
||||||
|
type: 'INVESTIGATE',
|
||||||
|
iconKey: 'investigate',
|
||||||
|
enabled: true,
|
||||||
|
poiKey: 'inspect-tracks',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function character(locationId: string, location: LocationDefinition) {
|
||||||
|
return {
|
||||||
|
id: CHARACTER_ID,
|
||||||
|
baseAttack: 6,
|
||||||
|
baseHp: 100,
|
||||||
|
currentLocationId: locationId,
|
||||||
|
currentLocation: location,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function poolEntry(
|
||||||
|
name: string,
|
||||||
|
weight: number,
|
||||||
|
stats: { level: number; attack: number; armor: number; maxHp: number },
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
weight,
|
||||||
|
monster: {
|
||||||
|
key: name.toLowerCase(),
|
||||||
|
name,
|
||||||
|
level: stats.level,
|
||||||
|
attack: stats.attack,
|
||||||
|
armor: stats.armor,
|
||||||
|
maxHp: stats.maxHp,
|
||||||
|
iconPath: `/images/monsters/icons/${name.toLowerCase()}-128.png`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ash rats are common and harmless, bandits rare and dangerous. Judged by its
|
||||||
|
// worst entry this pool reads STRONG; judged by what a traveller actually
|
||||||
|
// meets it reads MATCH.
|
||||||
|
const BURNED_ROAD_POOL = [
|
||||||
|
poolEntry('Aschenratte', 70, { level: 1, attack: 5, armor: 0, maxHp: 45 }),
|
||||||
|
poolEntry('Straßenräuber', 30, { level: 2, attack: 9, armor: 5, maxHp: 75 }),
|
||||||
|
];
|
||||||
|
|
||||||
function currentLocation(): LocationDefinition {
|
function currentLocation(): LocationDefinition {
|
||||||
return {
|
return {
|
||||||
id: SOUTH_GATE_ID,
|
id: SOUTH_GATE_ID,
|
||||||
@@ -27,6 +139,14 @@ function currentLocation(): LocationDefinition {
|
|||||||
isSafe: true,
|
isSafe: true,
|
||||||
huntingEnabled: false,
|
huntingEnabled: false,
|
||||||
artworkPath: '/assets/locations/south-gate.webp',
|
artworkPath: '/assets/locations/south-gate.webp',
|
||||||
|
regionName: 'Aschenfelder',
|
||||||
|
regionTierLabel: 'Gebiet 1',
|
||||||
|
locationType: 'TRANSITION',
|
||||||
|
localDescription: 'Hinter den Wachtfeuern beginnen die Aschenfelder.',
|
||||||
|
localArtworkPath: '/images/backgrounds/Suedtor.png',
|
||||||
|
localPointsOfInterest: SOUTH_GATE_POIS,
|
||||||
|
localPrimaryActions: [],
|
||||||
|
localRewardPreview: [],
|
||||||
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||||
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||||
characters: [],
|
characters: [],
|
||||||
@@ -48,6 +168,14 @@ function burnedRoad(): LocationDefinition {
|
|||||||
isSafe: false,
|
isSafe: false,
|
||||||
huntingEnabled: true,
|
huntingEnabled: true,
|
||||||
artworkPath: '/assets/locations/burned-road.webp',
|
artworkPath: '/assets/locations/burned-road.webp',
|
||||||
|
regionName: 'Aschenfelder',
|
||||||
|
regionTierLabel: 'Gebiet 1',
|
||||||
|
locationType: 'HUNTING_GROUND',
|
||||||
|
localDescription: 'Ein alter Handelsweg, in Asche gelegt.',
|
||||||
|
localArtworkPath: '/images/backgrounds/Aschestrasse.png',
|
||||||
|
localPointsOfInterest: BURNED_ROAD_POIS,
|
||||||
|
localPrimaryActions: BURNED_ROAD_ACTIONS,
|
||||||
|
localRewardPreview: [{ key: 'silver', label: 'Silber', iconKey: 'silver' }],
|
||||||
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||||
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||||
characters: [],
|
characters: [],
|
||||||
@@ -69,11 +197,7 @@ describe('WorldService', () => {
|
|||||||
} as unknown as TravelService;
|
} as unknown as TravelService;
|
||||||
const findCharacter = jest.fn().mockImplementation(() => {
|
const findCharacter = jest.fn().mockImplementation(() => {
|
||||||
callOrder.push('findCharacter');
|
callOrder.push('findCharacter');
|
||||||
return Promise.resolve({
|
return Promise.resolve(character(SOUTH_GATE_ID, location));
|
||||||
id: CHARACTER_ID,
|
|
||||||
currentLocationId: SOUTH_GATE_ID,
|
|
||||||
currentLocation: location,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
const characters = {
|
const characters = {
|
||||||
findOne: findCharacter,
|
findOne: findCharacter,
|
||||||
@@ -130,6 +254,28 @@ describe('WorldService', () => {
|
|||||||
isSafe: true,
|
isSafe: true,
|
||||||
huntingEnabled: false,
|
huntingEnabled: false,
|
||||||
artworkPath: '/assets/locations/south-gate.webp',
|
artworkPath: '/assets/locations/south-gate.webp',
|
||||||
|
regionName: 'Aschenfelder',
|
||||||
|
regionTierLabel: 'Gebiet 1',
|
||||||
|
locationType: 'TRANSITION',
|
||||||
|
localDescription: 'Hinter den Wachtfeuern beginnen die Aschenfelder.',
|
||||||
|
localArtworkPath: '/images/backgrounds/Suedtor.png',
|
||||||
|
dangerRating: null,
|
||||||
|
recommendationLabel: '1',
|
||||||
|
pointsOfInterest: [
|
||||||
|
{
|
||||||
|
key: 'gate-watch',
|
||||||
|
title: 'Torwache',
|
||||||
|
actionLabel: 'Sprechen',
|
||||||
|
type: 'NPC',
|
||||||
|
iconKey: 'speak',
|
||||||
|
xPercent: 45,
|
||||||
|
yPercent: 52,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
primaryActions: [],
|
||||||
|
encounterPreview: [],
|
||||||
|
rewardPreview: [],
|
||||||
connections: [
|
connections: [
|
||||||
{
|
{
|
||||||
targetLocation: {
|
targetLocation: {
|
||||||
@@ -160,21 +306,12 @@ describe('WorldService', () => {
|
|||||||
completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }),
|
completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }),
|
||||||
} as unknown as TravelService;
|
} as unknown as TravelService;
|
||||||
const characters = {
|
const characters = {
|
||||||
findOne: jest.fn().mockResolvedValue({
|
findOne: jest.fn().mockResolvedValue(character(BURNED_ROAD_ID, location)),
|
||||||
id: CHARACTER_ID,
|
|
||||||
currentLocationId: BURNED_ROAD_ID,
|
|
||||||
currentLocation: location,
|
|
||||||
}),
|
|
||||||
} as unknown as Repository<Character>;
|
} as unknown as Repository<Character>;
|
||||||
const connections = {
|
const connections = {
|
||||||
find: jest.fn().mockResolvedValue([]),
|
find: jest.fn().mockResolvedValue([]),
|
||||||
} as unknown as Repository<LocationConnection>;
|
} as unknown as Repository<LocationConnection>;
|
||||||
const findLocationMonsters = jest
|
const findLocationMonsters = jest.fn().mockResolvedValue(BURNED_ROAD_POOL);
|
||||||
.fn()
|
|
||||||
.mockResolvedValue([
|
|
||||||
{ monster: { name: 'Aschenratte' } },
|
|
||||||
{ monster: { name: 'Stra\u00dfenr\u00e4uber' } },
|
|
||||||
]);
|
|
||||||
const locationMonsters = {
|
const locationMonsters = {
|
||||||
find: findLocationMonsters,
|
find: findLocationMonsters,
|
||||||
} as unknown as Repository<LocationMonster>;
|
} as unknown as Repository<LocationMonster>;
|
||||||
@@ -230,4 +367,131 @@ describe('WorldService', () => {
|
|||||||
expect(findConnections).not.toHaveBeenCalled();
|
expect(findConnections).not.toHaveBeenCalled();
|
||||||
expect(findLocationMonsters).not.toHaveBeenCalled();
|
expect(findLocationMonsters).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('exposes the authored local view content of the current location', async () => {
|
||||||
|
const result = await loadBurnedRoad();
|
||||||
|
|
||||||
|
expect(result).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
regionName: 'Aschenfelder',
|
||||||
|
regionTierLabel: 'Gebiet 1',
|
||||||
|
locationType: 'HUNTING_GROUND',
|
||||||
|
localDescription: 'Ein alter Handelsweg, in Asche gelegt.',
|
||||||
|
localArtworkPath: '/images/backgrounds/Aschestrasse.png',
|
||||||
|
recommendationLabel: '1–2',
|
||||||
|
rewardPreview: [{ key: 'silver', label: 'Silber', iconKey: 'silver' }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.primaryActions).toEqual(BURNED_ROAD_ACTIONS);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders a single recommended level without a range', async () => {
|
||||||
|
const result = await loadSouthGate();
|
||||||
|
|
||||||
|
expect(result.recommendationLabel).toBe('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves every point of interest, including disabled ones, without leaking its result text', async () => {
|
||||||
|
const result = await loadBurnedRoad();
|
||||||
|
|
||||||
|
expect(result.pointsOfInterest).toEqual([
|
||||||
|
{
|
||||||
|
key: 'hunt-area',
|
||||||
|
title: 'Jagdgebiet',
|
||||||
|
actionLabel: 'Jagd beginnen',
|
||||||
|
type: 'HUNT',
|
||||||
|
iconKey: 'hunt',
|
||||||
|
xPercent: 52,
|
||||||
|
yPercent: 44,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'inspect-tracks',
|
||||||
|
title: 'Verdächtige Spuren',
|
||||||
|
actionLabel: 'Untersuchen',
|
||||||
|
type: 'INVESTIGATE',
|
||||||
|
iconKey: 'investigate',
|
||||||
|
xPercent: 32,
|
||||||
|
yPercent: 78,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sealed-crypt',
|
||||||
|
title: 'Versiegelte Krypta',
|
||||||
|
actionLabel: 'Öffnen',
|
||||||
|
type: 'DUNGEON',
|
||||||
|
iconKey: 'search',
|
||||||
|
xPercent: 90,
|
||||||
|
yPercent: 20,
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(JSON.stringify(result)).not.toContain('Frische Stiefelabdrücke');
|
||||||
|
expect(JSON.stringify(result)).not.toContain('Noch verschlossen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives the encounter preview from the location monster pool', async () => {
|
||||||
|
const result = await loadBurnedRoad();
|
||||||
|
|
||||||
|
expect(result.encounterPreview).toEqual([
|
||||||
|
{
|
||||||
|
key: 'aschenratte',
|
||||||
|
name: 'Aschenratte',
|
||||||
|
level: 1,
|
||||||
|
iconPath: '/images/monsters/icons/aschenratte-128.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'straßenräuber',
|
||||||
|
name: 'Straßenräuber',
|
||||||
|
level: 2,
|
||||||
|
iconPath: '/images/monsters/icons/straßenräuber-128.png',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rates local danger from the weighted pool average rather than its worst entry', async () => {
|
||||||
|
const result = await loadBurnedRoad();
|
||||||
|
|
||||||
|
// The lone bandit rates STRONG against this character; weighted by how
|
||||||
|
// rarely it appears, the road as a whole is a fair match.
|
||||||
|
expect(result.dangerRating).toBe('MATCH');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports no danger rating where nothing can be hunted', async () => {
|
||||||
|
const result = await loadSouthGate();
|
||||||
|
|
||||||
|
expect(result.dangerRating).toBeNull();
|
||||||
|
expect(result.encounterPreview).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadBurnedRoad() {
|
||||||
|
return loadLocation(burnedRoad(), BURNED_ROAD_ID, BURNED_ROAD_POOL);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSouthGate() {
|
||||||
|
return loadLocation(currentLocation(), SOUTH_GATE_ID, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLocation(
|
||||||
|
location: LocationDefinition,
|
||||||
|
locationId: string,
|
||||||
|
pool: ReturnType<typeof poolEntry>[],
|
||||||
|
) {
|
||||||
|
const service = new WorldService(
|
||||||
|
{
|
||||||
|
completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }),
|
||||||
|
} as unknown as TravelService,
|
||||||
|
{
|
||||||
|
findOne: jest.fn().mockResolvedValue(character(locationId, location)),
|
||||||
|
} as unknown as Repository<Character>,
|
||||||
|
{
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
} as unknown as Repository<LocationConnection>,
|
||||||
|
{
|
||||||
|
find: jest.fn().mockResolvedValue(pool),
|
||||||
|
} as unknown as Repository<LocationMonster>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return service.getCurrentLocation(CHARACTER_ID);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,9 +2,24 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
|||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { Character } from '../characters/entities/character.entity';
|
import { Character } from '../characters/entities/character.entity';
|
||||||
|
import {
|
||||||
|
calculateDangerRating,
|
||||||
|
DangerRating,
|
||||||
|
} from '../hunting/danger-rating';
|
||||||
import { LocationMonster } from '../monsters/entities/location-monster.entity';
|
import { LocationMonster } from '../monsters/entities/location-monster.entity';
|
||||||
import { TravelService } from '../travel/travel.service';
|
import { TravelService } from '../travel/travel.service';
|
||||||
import { LocationConnection } from './entities/location-connection.entity';
|
import { LocationConnection } from './entities/location-connection.entity';
|
||||||
|
import { LocationDefinition } from './entities/location-definition.entity';
|
||||||
|
import {
|
||||||
|
EncounterPreviewDto,
|
||||||
|
LocalLocationPointOfInterestDto,
|
||||||
|
LocalLocationPrimaryActionDto,
|
||||||
|
LocationInteractionResultDto,
|
||||||
|
LocationType,
|
||||||
|
RewardPreviewDto,
|
||||||
|
toPointOfInterestDto,
|
||||||
|
} from './local-location.types';
|
||||||
|
import { locationInteractionUnavailable } from './world.errors';
|
||||||
|
|
||||||
export interface LocationSummary {
|
export interface LocationSummary {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -30,6 +45,18 @@ export interface CurrentLocationResponse {
|
|||||||
isSafe: boolean;
|
isSafe: boolean;
|
||||||
huntingEnabled: boolean;
|
huntingEnabled: boolean;
|
||||||
artworkPath: string;
|
artworkPath: string;
|
||||||
|
regionName: string;
|
||||||
|
regionTierLabel: string;
|
||||||
|
locationType: LocationType;
|
||||||
|
localDescription: string;
|
||||||
|
localArtworkPath: string;
|
||||||
|
/** `null` where nothing hostile can be met — the view reads that as safe. */
|
||||||
|
dangerRating: DangerRating | null;
|
||||||
|
recommendationLabel: string;
|
||||||
|
pointsOfInterest: LocalLocationPointOfInterestDto[];
|
||||||
|
primaryActions: LocalLocationPrimaryActionDto[];
|
||||||
|
encounterPreview: EncounterPreviewDto[];
|
||||||
|
rewardPreview: RewardPreviewDto[];
|
||||||
connections: CurrentLocationConnection[];
|
connections: CurrentLocationConnection[];
|
||||||
possibleMonsters: string[];
|
possibleMonsters: string[];
|
||||||
}
|
}
|
||||||
@@ -49,15 +76,7 @@ export class WorldService {
|
|||||||
async getCurrentLocation(
|
async getCurrentLocation(
|
||||||
characterId: string,
|
characterId: string,
|
||||||
): Promise<CurrentLocationResponse> {
|
): Promise<CurrentLocationResponse> {
|
||||||
await this.travelService.completeTravelIfDue(characterId);
|
const character = await this.loadCharacterAtCurrentLocation(characterId);
|
||||||
|
|
||||||
const character = await this.characters.findOne({
|
|
||||||
where: { id: characterId },
|
|
||||||
relations: { currentLocation: true },
|
|
||||||
});
|
|
||||||
if (!character) {
|
|
||||||
throw new NotFoundException('Character not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const connections = await this.connections.find({
|
const connections = await this.connections.find({
|
||||||
where: { fromLocationId: character.currentLocationId, enabled: true },
|
where: { fromLocationId: character.currentLocationId, enabled: true },
|
||||||
@@ -65,8 +84,8 @@ export class WorldService {
|
|||||||
});
|
});
|
||||||
const location = character.currentLocation;
|
const location = character.currentLocation;
|
||||||
|
|
||||||
const possibleMonsters = location.huntingEnabled
|
const pool = location.huntingEnabled
|
||||||
? await this.getPossibleMonsters(location.id)
|
? await this.getEncounterPool(location.id)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -81,6 +100,24 @@ export class WorldService {
|
|||||||
isSafe: location.isSafe,
|
isSafe: location.isSafe,
|
||||||
huntingEnabled: location.huntingEnabled,
|
huntingEnabled: location.huntingEnabled,
|
||||||
artworkPath: location.artworkPath,
|
artworkPath: location.artworkPath,
|
||||||
|
regionName: location.regionName,
|
||||||
|
regionTierLabel: location.regionTierLabel,
|
||||||
|
locationType: location.locationType,
|
||||||
|
localDescription: location.localDescription,
|
||||||
|
localArtworkPath: location.localArtworkPath,
|
||||||
|
dangerRating: this.toLocalDangerRating(character, pool),
|
||||||
|
recommendationLabel: this.toRecommendationLabel(location),
|
||||||
|
pointsOfInterest: location.localPointsOfInterest.map(
|
||||||
|
toPointOfInterestDto,
|
||||||
|
),
|
||||||
|
primaryActions: location.localPrimaryActions,
|
||||||
|
encounterPreview: pool.map((entry) => ({
|
||||||
|
key: entry.monster.key,
|
||||||
|
name: entry.monster.name,
|
||||||
|
level: entry.monster.level,
|
||||||
|
iconPath: entry.monster.iconPath,
|
||||||
|
})),
|
||||||
|
rewardPreview: location.localRewardPreview,
|
||||||
connections: connections
|
connections: connections
|
||||||
.filter((connection) => connection.enabled)
|
.filter((connection) => connection.enabled)
|
||||||
.map((connection) => ({
|
.map((connection) => ({
|
||||||
@@ -92,17 +129,102 @@ export class WorldService {
|
|||||||
travelDurationSeconds: connection.travelDurationSeconds,
|
travelDurationSeconds: connection.travelDurationSeconds,
|
||||||
danger: this.toDangerRating(connection.ambushChance),
|
danger: this.toDangerRating(connection.ambushChance),
|
||||||
})),
|
})),
|
||||||
possibleMonsters,
|
possibleMonsters: pool.map((entry) => entry.monster.name),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getPossibleMonsters(locationId: string): Promise<string[]> {
|
/**
|
||||||
const pool = await this.locationMonsters.find({
|
* Runs a short local interaction (investigate, search, talk) and reveals its
|
||||||
|
* authored result.
|
||||||
|
*
|
||||||
|
* The location is taken from the character, never from the request, so a
|
||||||
|
* caller cannot reach a hotspot it has not travelled to. Hotspots that only
|
||||||
|
* navigate (HUNT, MAP) carry no result text and are rejected here — the
|
||||||
|
* client routes those itself.
|
||||||
|
*/
|
||||||
|
async runLocalInteraction(
|
||||||
|
characterId: string,
|
||||||
|
interactionKey: string,
|
||||||
|
): Promise<LocationInteractionResultDto> {
|
||||||
|
const character = await this.loadCharacterAtCurrentLocation(characterId);
|
||||||
|
|
||||||
|
const poi = character.currentLocation.localPointsOfInterest.find(
|
||||||
|
(candidate) => candidate.key === interactionKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!poi?.enabled || !poi.resultText) {
|
||||||
|
throw locationInteractionUnavailable();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
interactionKey: poi.key,
|
||||||
|
title: poi.resultTitle ?? poi.title,
|
||||||
|
text: poi.resultText,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the character together with the location it actually stands at.
|
||||||
|
*
|
||||||
|
* Every local interaction resolves through here, so a request can never name
|
||||||
|
* the location it wants to act on (plan §5).
|
||||||
|
*/
|
||||||
|
async loadCharacterAtCurrentLocation(
|
||||||
|
characterId: string,
|
||||||
|
): Promise<Character> {
|
||||||
|
await this.travelService.completeTravelIfDue(characterId);
|
||||||
|
|
||||||
|
const character = await this.characters.findOne({
|
||||||
|
where: { id: characterId },
|
||||||
|
relations: { currentLocation: true },
|
||||||
|
});
|
||||||
|
if (!character) {
|
||||||
|
throw new NotFoundException('Character not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return character;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getEncounterPool(locationId: string): Promise<LocationMonster[]> {
|
||||||
|
return this.locationMonsters.find({
|
||||||
where: { locationId, enabled: true },
|
where: { locationId, enabled: true },
|
||||||
relations: { monster: true },
|
relations: { monster: true },
|
||||||
order: { weight: 'DESC' },
|
order: { weight: 'DESC' },
|
||||||
});
|
});
|
||||||
return pool.map((entry) => entry.monster.name);
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rates the location by the encounter a traveller can typically expect: the
|
||||||
|
* pool's weight-averaged stats, not its single worst entry. A rare elite
|
||||||
|
* would otherwise make a beginner road read as lethal.
|
||||||
|
*/
|
||||||
|
private toLocalDangerRating(
|
||||||
|
character: Character,
|
||||||
|
pool: LocationMonster[],
|
||||||
|
): DangerRating | null {
|
||||||
|
const totalWeight = pool.reduce((sum, entry) => sum + entry.weight, 0);
|
||||||
|
if (totalWeight === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const average = (pick: (entry: LocationMonster) => number) =>
|
||||||
|
pool.reduce((sum, entry) => sum + entry.weight * pick(entry), 0) /
|
||||||
|
totalWeight;
|
||||||
|
|
||||||
|
return calculateDangerRating(
|
||||||
|
{ attack: character.baseAttack, armor: 0, hp: character.baseHp },
|
||||||
|
{
|
||||||
|
attack: average((entry) => entry.monster.attack),
|
||||||
|
armor: average((entry) => entry.monster.armor),
|
||||||
|
hp: average((entry) => entry.monster.maxHp),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toRecommendationLabel(location: LocationDefinition): string {
|
||||||
|
return location.minRecommendedLevel === location.maxRecommendedLevel
|
||||||
|
? `${location.minRecommendedLevel}`
|
||||||
|
: `${location.minRecommendedLevel}–${location.maxRecommendedLevel}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private toDangerRating(ambushChance: string): 'LOW' | 'HIGH' {
|
private toDangerRating(ambushChance: string): 'LOW' | 'HIGH' {
|
||||||
|
|||||||
BIN
apps/web/public/assets/hud-elements/panel-frame.png
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
apps/web/public/assets/hud-elements/panel-ornament.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
apps/web/public/images/character/female-320.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
apps/web/public/images/character/female-portrait-256.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
apps/web/public/images/character/male-320.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
apps/web/public/images/character/male-portrait-256.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
apps/web/public/images/hud/runtime/LocationIcon-128.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
apps/web/public/images/monsters/runtime/charred-looter-560.jpg
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
apps/web/public/images/monsters/runtime/wild-road-dog-560.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
@@ -2,11 +2,18 @@ import { Routes } from '@angular/router';
|
|||||||
import { AppShellComponent } from './layout/app-shell/app-shell.component';
|
import { AppShellComponent } from './layout/app-shell/app-shell.component';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: '', pathMatch: 'full', redirectTo: 'world' },
|
{ path: '', pathMatch: 'full', redirectTo: 'location' },
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
component: AppShellComponent,
|
component: AppShellComponent,
|
||||||
children: [
|
children: [
|
||||||
|
{
|
||||||
|
path: 'location',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/world/location-page/location-page.component').then(
|
||||||
|
(module) => module.LocationPageComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'world',
|
path: 'world',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
@@ -28,7 +35,14 @@ export const routes: Routes = [
|
|||||||
(module) => module.CombatPageComponent,
|
(module) => module.CombatPageComponent,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'inventory',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/inventory/inventory-page.component').then(
|
||||||
|
(module) => module.InventoryPageComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ path: '**', redirectTo: 'world' },
|
{ path: '**', redirectTo: 'location' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import { routes } from './app.routes';
|
|||||||
|
|
||||||
describe('App', () => {
|
describe('App', () => {
|
||||||
let character: WritableSignal<CharacterResponse | null>;
|
let character: WritableSignal<CharacterResponse | null>;
|
||||||
|
let displayedCharacter: WritableSignal<CharacterResponse | null>;
|
||||||
let currentLocation: WritableSignal<null>;
|
let currentLocation: WritableSignal<null>;
|
||||||
let selectedConnection: WritableSignal<null>;
|
let selectedConnection: WritableSignal<null>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
character = signal<CharacterResponse | null>(null);
|
character = signal<CharacterResponse | null>(null);
|
||||||
|
displayedCharacter = signal<CharacterResponse | null>(null);
|
||||||
currentLocation = signal(null);
|
currentLocation = signal(null);
|
||||||
selectedConnection = signal(null);
|
selectedConnection = signal(null);
|
||||||
|
|
||||||
@@ -20,12 +22,14 @@ describe('App', () => {
|
|||||||
imports: [AppShellComponent],
|
imports: [AppShellComponent],
|
||||||
providers: [
|
providers: [
|
||||||
provideRouter([
|
provideRouter([
|
||||||
|
{ path: 'location', children: [] },
|
||||||
{ path: 'world', children: [] },
|
{ path: 'world', children: [] },
|
||||||
{ path: 'hunt', children: [] },
|
{ path: 'hunt', children: [] },
|
||||||
|
{ path: 'inventory', children: [] },
|
||||||
]),
|
]),
|
||||||
{
|
{
|
||||||
provide: WorldStore,
|
provide: WorldStore,
|
||||||
useValue: { character, currentLocation, selectedConnection },
|
useValue: { character, displayedCharacter, currentLocation, selectedConnection },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
@@ -56,7 +60,14 @@ describe('App', () => {
|
|||||||
expect(huntButton?.getAttribute('aria-current')).toBeNull();
|
expect(huntButton?.getAttribute('aria-current')).toBeNull();
|
||||||
expect(huntButton?.getAttribute('aria-label')).toBe('Jagd');
|
expect(huntButton?.getAttribute('aria-label')).toBe('Jagd');
|
||||||
|
|
||||||
for (const destination of ['quests', 'inventory', 'character']) {
|
const inventoryButton = element.querySelector<HTMLButtonElement>(
|
||||||
|
'[data-navigation="inventory"]',
|
||||||
|
);
|
||||||
|
expect(inventoryButton).not.toBeNull();
|
||||||
|
expect(inventoryButton?.disabled).toBe(false);
|
||||||
|
expect(inventoryButton?.getAttribute('aria-label')).toBe('Inventar');
|
||||||
|
|
||||||
|
for (const destination of ['quests', 'character']) {
|
||||||
expect(
|
expect(
|
||||||
element.querySelector<HTMLButtonElement>(`[data-navigation="${destination}"]`)?.disabled,
|
element.querySelector<HTMLButtonElement>(`[data-navigation="${destination}"]`)?.disabled,
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
@@ -65,6 +76,65 @@ describe('App', () => {
|
|||||||
expect(element.textContent).not.toContain('Shop');
|
expect(element.textContent).not.toContain('Shop');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('offers Ort as the first navigation entry, marked active on /location', async () => {
|
||||||
|
const fixture = TestBed.createComponent(AppShellComponent);
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
await router.navigateByUrl('/location');
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
const entries = element.querySelectorAll<HTMLButtonElement>('[data-navigation]');
|
||||||
|
expect([...entries].map((entry) => entry.dataset['navigation'])).toEqual([
|
||||||
|
'location',
|
||||||
|
'world',
|
||||||
|
'hunt',
|
||||||
|
'quests',
|
||||||
|
'inventory',
|
||||||
|
'character',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const locationButton = element.querySelector<HTMLButtonElement>(
|
||||||
|
'[data-navigation="location"]',
|
||||||
|
);
|
||||||
|
expect(locationButton?.disabled).toBe(false);
|
||||||
|
expect(locationButton?.getAttribute('aria-label')).toBe('Ort');
|
||||||
|
expect(locationButton?.getAttribute('aria-current')).toBe('page');
|
||||||
|
expect(
|
||||||
|
element.querySelector('[data-navigation="world"]')?.getAttribute('aria-current'),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops the shell context rail on /location, where the screen brings its own', async () => {
|
||||||
|
const fixture = TestBed.createComponent(AppShellComponent);
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
await router.navigateByUrl('/location');
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.querySelector('app-context-panel')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops the shell context rail on /inventory, which needs all three of its own columns', async () => {
|
||||||
|
const fixture = TestBed.createComponent(AppShellComponent);
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
await router.navigateByUrl('/inventory');
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.querySelector('app-context-panel')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the shell context rail on the map', async () => {
|
||||||
|
const fixture = TestBed.createComponent(AppShellComponent);
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
await router.navigateByUrl('/world');
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.querySelector('app-context-panel')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('marks Jagd as the active navigation entry while on /hunt', async () => {
|
it('marks Jagd as the active navigation entry while on /hunt', async () => {
|
||||||
const fixture = TestBed.createComponent(AppShellComponent);
|
const fixture = TestBed.createComponent(AppShellComponent);
|
||||||
const router = TestBed.inject(Router);
|
const router = TestBed.inject(Router);
|
||||||
@@ -81,7 +151,20 @@ describe('App', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders loaded character values supplied by the WorldStore', () => {
|
it('renders loaded character values supplied by the WorldStore', () => {
|
||||||
character.set({
|
const decoy: CharacterResponse = {
|
||||||
|
id: 'stale-id',
|
||||||
|
name: 'Stale Decoy',
|
||||||
|
level: 1,
|
||||||
|
experience: 0,
|
||||||
|
silver: 0,
|
||||||
|
currentHp: 1,
|
||||||
|
maxHp: 1,
|
||||||
|
attack: 1,
|
||||||
|
hpRegenPerSecond: 1,
|
||||||
|
hpRegenSince: null,
|
||||||
|
currentLocation: { id: 'location-id', key: 'south-gate', name: 'Südtor von Graufurt' },
|
||||||
|
};
|
||||||
|
const value: CharacterResponse = {
|
||||||
id: 'character-id',
|
id: 'character-id',
|
||||||
name: 'Mara Ashfall',
|
name: 'Mara Ashfall',
|
||||||
level: 7,
|
level: 7,
|
||||||
@@ -90,8 +173,12 @@ describe('App', () => {
|
|||||||
currentHp: 52,
|
currentHp: 52,
|
||||||
maxHp: 80,
|
maxHp: 80,
|
||||||
attack: 12,
|
attack: 12,
|
||||||
|
hpRegenPerSecond: 1,
|
||||||
|
hpRegenSince: null,
|
||||||
currentLocation: { id: 'location-id', key: 'south-gate', name: 'Südtor von Graufurt' },
|
currentLocation: { id: 'location-id', key: 'south-gate', name: 'Südtor von Graufurt' },
|
||||||
});
|
};
|
||||||
|
character.set(decoy);
|
||||||
|
displayedCharacter.set(value);
|
||||||
const fixture = TestBed.createComponent(AppShellComponent);
|
const fixture = TestBed.createComponent(AppShellComponent);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
@@ -104,11 +191,19 @@ describe('App', () => {
|
|||||||
expect(fixture.nativeElement.querySelector('app-top-bar')?.textContent).toContain('320');
|
expect(fixture.nativeElement.querySelector('app-top-bar')?.textContent).toContain('320');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('redirects root and unknown routes to the world shell', () => {
|
it('lands root and unknown routes on the place the character is standing in', () => {
|
||||||
expect(routes.find((route) => route.path === '')).toMatchObject({
|
expect(routes.find((route) => route.path === '')).toMatchObject({
|
||||||
pathMatch: 'full',
|
pathMatch: 'full',
|
||||||
redirectTo: 'world',
|
redirectTo: 'location',
|
||||||
});
|
});
|
||||||
expect(routes.find((route) => route.path === '**')).toMatchObject({ redirectTo: 'world' });
|
expect(routes.find((route) => route.path === '**')).toMatchObject({ redirectTo: 'location' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the map and hunt routes where they were', () => {
|
||||||
|
const shellChildren = routes.find((route) => route.children)?.children ?? [];
|
||||||
|
|
||||||
|
expect(shellChildren.map((route) => route.path)).toEqual(
|
||||||
|
expect.arrayContaining(['location', 'world', 'hunt']),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export interface CharacterResponse {
|
|||||||
currentHp: number;
|
currentHp: number;
|
||||||
maxHp: number;
|
maxHp: number;
|
||||||
attack: number;
|
attack: number;
|
||||||
|
hpRegenPerSecond: number;
|
||||||
|
hpRegenSince: string | null;
|
||||||
currentLocation: LocationSummary;
|
currentLocation: LocationSummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,6 +24,69 @@ export interface CurrentLocationConnection {
|
|||||||
danger: 'LOW' | 'HIGH';
|
danger: 'LOW' | 'HIGH';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type LocationInteractionType =
|
||||||
|
| 'HUNT'
|
||||||
|
| 'INVESTIGATE'
|
||||||
|
| 'SEARCH'
|
||||||
|
| 'NPC'
|
||||||
|
| 'MAP'
|
||||||
|
| 'TRAVEL'
|
||||||
|
| 'SHOP'
|
||||||
|
| 'QUEST'
|
||||||
|
| 'BOSS'
|
||||||
|
| 'DUNGEON';
|
||||||
|
|
||||||
|
export type LocationType =
|
||||||
|
| 'SAFE_HUB'
|
||||||
|
| 'TRANSITION'
|
||||||
|
| 'HUNTING_GROUND'
|
||||||
|
| 'QUEST_LOCATION'
|
||||||
|
| 'OUTPOST'
|
||||||
|
| 'ELITE_ZONE'
|
||||||
|
| 'BOSS_LOCATION'
|
||||||
|
| 'DUNGEON_ENTRANCE';
|
||||||
|
|
||||||
|
export interface LocationPointOfInterest {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
actionLabel?: string;
|
||||||
|
type: LocationInteractionType;
|
||||||
|
iconKey: string;
|
||||||
|
xPercent: number;
|
||||||
|
yPercent: number;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocationPrimaryAction {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
type: LocationInteractionType;
|
||||||
|
iconKey: string;
|
||||||
|
enabled: boolean;
|
||||||
|
/** Set when the action reveals the same result as a hotspot on the artwork. */
|
||||||
|
poiKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EncounterPreview {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
level: number;
|
||||||
|
iconPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RewardPreview {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
iconKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocationInteractionResult {
|
||||||
|
interactionKey: string;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CurrentLocationResponse {
|
export interface CurrentLocationResponse {
|
||||||
id: string;
|
id: string;
|
||||||
key: string;
|
key: string;
|
||||||
@@ -34,6 +99,18 @@ export interface CurrentLocationResponse {
|
|||||||
isSafe: boolean;
|
isSafe: boolean;
|
||||||
huntingEnabled: boolean;
|
huntingEnabled: boolean;
|
||||||
artworkPath: string;
|
artworkPath: string;
|
||||||
|
regionName: string;
|
||||||
|
regionTierLabel: string;
|
||||||
|
locationType: LocationType;
|
||||||
|
localDescription: string;
|
||||||
|
localArtworkPath: string;
|
||||||
|
/** `null` where nothing hostile can be met — the view reads that as safe. */
|
||||||
|
dangerRating: DangerRating | null;
|
||||||
|
recommendationLabel: string;
|
||||||
|
pointsOfInterest: LocationPointOfInterest[];
|
||||||
|
primaryActions: LocationPrimaryAction[];
|
||||||
|
encounterPreview: EncounterPreview[];
|
||||||
|
rewardPreview: RewardPreview[];
|
||||||
connections: CurrentLocationConnection[];
|
connections: CurrentLocationConnection[];
|
||||||
possibleMonsters: string[];
|
possibleMonsters: string[];
|
||||||
}
|
}
|
||||||
@@ -74,9 +151,10 @@ export interface HuntResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type CombatStatus = 'ACTIVE' | 'WON' | 'LOST';
|
export type CombatStatus = 'ACTIVE' | 'WON' | 'LOST';
|
||||||
export type CombatEventType = 'DAMAGE' | 'COMBAT_WON' | 'COMBAT_LOST';
|
export type CombatEventType = 'DAMAGE' | 'HEAL' | 'DEFEND' | 'TELEGRAPH' | 'INTERRUPT' | 'COMBAT_WON' | 'COMBAT_LOST';
|
||||||
export type CombatSide = 'PLAYER' | 'MONSTER';
|
export type CombatSide = 'PLAYER' | 'MONSTER';
|
||||||
export type CombatAction = 'ATTACK';
|
export type CombatAction = 'ATTACK' | 'HEAVY_STRIKE' | 'SHIELD_BASH' | 'DEFEND' | 'POTION';
|
||||||
|
export type CombatMonsterIntent = 'HEAVY_ATTACK';
|
||||||
|
|
||||||
export interface CombatEvent {
|
export interface CombatEvent {
|
||||||
round: number;
|
round: number;
|
||||||
@@ -91,6 +169,8 @@ export interface CombatPlayer {
|
|||||||
name: string;
|
name: string;
|
||||||
maxHp: number;
|
maxHp: number;
|
||||||
currentHp: number;
|
currentHp: number;
|
||||||
|
potionsRemaining: number;
|
||||||
|
potionsMax: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CombatMonster {
|
export interface CombatMonster {
|
||||||
@@ -100,6 +180,7 @@ export interface CombatMonster {
|
|||||||
maxHp: number;
|
maxHp: number;
|
||||||
currentHp: number;
|
currentHp: number;
|
||||||
artworkPath: string;
|
artworkPath: string;
|
||||||
|
pendingIntent: CombatMonsterIntent | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Combat {
|
export interface Combat {
|
||||||
@@ -132,3 +213,59 @@ export interface CombatReward {
|
|||||||
silver: number;
|
silver: number;
|
||||||
items: CombatRewardItem[];
|
items: CombatRewardItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EquipmentSlot =
|
||||||
|
| 'WEAPON'
|
||||||
|
| 'HEAD'
|
||||||
|
| 'CHEST'
|
||||||
|
| 'HANDS'
|
||||||
|
| 'LEGS'
|
||||||
|
| 'FEET'
|
||||||
|
| 'AMULET';
|
||||||
|
|
||||||
|
export interface InventoryItem {
|
||||||
|
id: string;
|
||||||
|
quantity: number;
|
||||||
|
equipped: boolean;
|
||||||
|
item: {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
rarity: ItemRarity;
|
||||||
|
equipmentSlot: EquipmentSlot | null;
|
||||||
|
requiredLevel: number;
|
||||||
|
weaponDamage: number;
|
||||||
|
bonusAttack: number;
|
||||||
|
bonusHp: number;
|
||||||
|
bonusArmor: number;
|
||||||
|
iconPath: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryResponse {
|
||||||
|
items: InventoryItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EquipmentSlotItem {
|
||||||
|
characterItemId: string;
|
||||||
|
item: {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
rarity: ItemRarity;
|
||||||
|
iconPath: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EquipmentSlots = Record<EquipmentSlot, EquipmentSlotItem | null>;
|
||||||
|
|
||||||
|
export interface EquipmentStats {
|
||||||
|
maxHp: number;
|
||||||
|
attack: number;
|
||||||
|
weaponDamage: number;
|
||||||
|
armor: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EquipmentResponse {
|
||||||
|
slots: EquipmentSlots;
|
||||||
|
stats: EquipmentStats;
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,6 +48,24 @@ describe('GameApiService', () => {
|
|||||||
request.flush({ status: 'IDLE' });
|
request.flush({ status: 'IDLE' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('posts a local interaction by key alone, never naming a location', () => {
|
||||||
|
service.runLocationInteraction('inspect-tracks').subscribe();
|
||||||
|
|
||||||
|
const request = http.expectOne('/api/world/current-location/interactions/inspect-tracks');
|
||||||
|
expect(request.request.method).toBe('POST');
|
||||||
|
expect(request.request.body).toEqual({});
|
||||||
|
request.flush({ interactionKey: 'inspect-tracks', title: 'Spuren', text: '…' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes an interaction key so it cannot break out of its path segment', () => {
|
||||||
|
service.runLocationInteraction('a/../b').subscribe();
|
||||||
|
|
||||||
|
const request = http.expectOne(
|
||||||
|
'/api/world/current-location/interactions/a%2F..%2Fb',
|
||||||
|
);
|
||||||
|
request.flush({ interactionKey: 'a/../b', title: '', text: '' });
|
||||||
|
});
|
||||||
|
|
||||||
it('posts to the encounter-scoped attack endpoint with an empty body to start a combat', () => {
|
it('posts to the encounter-scoped attack endpoint with an empty body to start a combat', () => {
|
||||||
service.startCombat('encounter-uuid').subscribe();
|
service.startCombat('encounter-uuid').subscribe();
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import {
|
|||||||
CombatAction,
|
CombatAction,
|
||||||
CurrentLocationResponse,
|
CurrentLocationResponse,
|
||||||
CurrentTravel,
|
CurrentTravel,
|
||||||
|
EquipmentResponse,
|
||||||
HuntResult,
|
HuntResult,
|
||||||
|
InventoryResponse,
|
||||||
|
LocationInteractionResult,
|
||||||
} from './game-api.models';
|
} from './game-api.models';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
@@ -22,6 +25,17 @@ export class GameApiService {
|
|||||||
return this.http.get<CurrentLocationResponse>('/api/world/current-location');
|
return this.http.get<CurrentLocationResponse>('/api/world/current-location');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a local hotspot interaction. Only the key travels: the server pairs
|
||||||
|
* it with the character's actual location.
|
||||||
|
*/
|
||||||
|
runLocationInteraction(interactionKey: string): Observable<LocationInteractionResult> {
|
||||||
|
return this.http.post<LocationInteractionResult>(
|
||||||
|
`/api/world/current-location/interactions/${encodeURIComponent(interactionKey)}`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
startTravel(targetLocationId: string): Observable<CurrentTravel> {
|
startTravel(targetLocationId: string): Observable<CurrentTravel> {
|
||||||
return this.http.post<CurrentTravel>('/api/travel', { targetLocationId });
|
return this.http.post<CurrentTravel>('/api/travel', { targetLocationId });
|
||||||
}
|
}
|
||||||
@@ -53,4 +67,16 @@ export class GameApiService {
|
|||||||
performCombatAction(combatId: string, action: CombatAction): Observable<Combat> {
|
performCombatAction(combatId: string, action: CombatAction): Observable<Combat> {
|
||||||
return this.http.post<Combat>(`/api/combats/${combatId}/actions`, { action });
|
return this.http.post<Combat>(`/api/combats/${combatId}/actions`, { action });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getInventory(): Observable<InventoryResponse> {
|
||||||
|
return this.http.get<InventoryResponse>('/api/inventory');
|
||||||
|
}
|
||||||
|
|
||||||
|
getEquipment(): Observable<EquipmentResponse> {
|
||||||
|
return this.http.get<EquipmentResponse>('/api/equipment');
|
||||||
|
}
|
||||||
|
|
||||||
|
equipItem(characterItemId: string): Observable<EquipmentResponse> {
|
||||||
|
return this.http.post<EquipmentResponse>('/api/equipment', { characterItemId });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const runningCombat: Combat = {
|
|||||||
id: 'combat-running',
|
id: 'combat-running',
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
round: 4,
|
round: 4,
|
||||||
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 62 },
|
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 62, potionsRemaining: 2, potionsMax: 2 },
|
||||||
monster: {
|
monster: {
|
||||||
key: 'road-bandit',
|
key: 'road-bandit',
|
||||||
name: 'Straßenräuber',
|
name: 'Straßenräuber',
|
||||||
@@ -17,6 +17,7 @@ const runningCombat: Combat = {
|
|||||||
maxHp: 75,
|
maxHp: 75,
|
||||||
currentHp: 30,
|
currentHp: 30,
|
||||||
artworkPath: '/images/enemies/RoadBandit.png',
|
artworkPath: '/images/enemies/RoadBandit.png',
|
||||||
|
pendingIntent: null,
|
||||||
},
|
},
|
||||||
events: [],
|
events: [],
|
||||||
rewards: null,
|
rewards: null,
|
||||||
|
|||||||
@@ -48,6 +48,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
@if (monsterIntentLabel(); as intent) {
|
||||||
|
<p class="combat__telegraph" data-combat-telegraph role="status">{{ intent }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="combat__field">
|
<div class="combat__field">
|
||||||
<div
|
<div
|
||||||
class="sprite sprite--player"
|
class="sprite sprite--player"
|
||||||
@@ -73,12 +77,56 @@
|
|||||||
class="action"
|
class="action"
|
||||||
data-combat-attack
|
data-combat-attack
|
||||||
[disabled]="busy()"
|
[disabled]="busy()"
|
||||||
(click)="attack()"
|
(click)="performAction('ATTACK')"
|
||||||
>
|
>
|
||||||
<img class="action__icon" src="/images/hud/runtime/AttackIcon-96.png" alt="" />
|
<img class="action__icon" src="/images/hud/runtime/AttackIcon-96.png" alt="" />
|
||||||
<span class="action__label">Angriff</span>
|
<span class="action__label">Angriff</span>
|
||||||
<span class="action__key">1</span>
|
<span class="action__key">1</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action"
|
||||||
|
data-combat-heavy-strike
|
||||||
|
[disabled]="busy()"
|
||||||
|
(click)="performAction('HEAVY_STRIKE')"
|
||||||
|
>
|
||||||
|
<img class="action__icon" src="/images/hud/runtime/AttackIcon-96.png" alt="" />
|
||||||
|
<span class="action__label">Schwerer Hieb</span>
|
||||||
|
<span class="action__key">2</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action"
|
||||||
|
data-combat-shield-bash
|
||||||
|
[disabled]="busy()"
|
||||||
|
(click)="performAction('SHIELD_BASH')"
|
||||||
|
>
|
||||||
|
<img class="action__icon" src="/images/hud/runtime/CharacterIcon-128.png" alt="" />
|
||||||
|
<span class="action__label">Schildstoß</span>
|
||||||
|
<span class="action__key">3</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action"
|
||||||
|
data-combat-defend
|
||||||
|
[disabled]="busy()"
|
||||||
|
(click)="performAction('DEFEND')"
|
||||||
|
>
|
||||||
|
<img class="action__icon" src="/images/hud/runtime/CharacterIcon-128.png" alt="" />
|
||||||
|
<span class="action__label">Verteidigen</span>
|
||||||
|
<span class="action__key">4</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action"
|
||||||
|
data-combat-potion
|
||||||
|
[disabled]="busy() || combat.player.potionsRemaining <= 0"
|
||||||
|
(click)="performAction('POTION')"
|
||||||
|
>
|
||||||
|
<img class="action__icon" src="/images/items/small-healing-potion.png" alt="" />
|
||||||
|
<span class="action__label">Trank {{ combat.player.potionsRemaining }}/{{ combat.player.potionsMax }}</span>
|
||||||
|
<span class="action__key">5</span>
|
||||||
|
</button>
|
||||||
}
|
}
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
@@ -117,17 +165,45 @@
|
|||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<div class="outcome__buttons">
|
||||||
<button type="button" class="outcome__button" data-combat-to-hunt (click)="goToHunt()">
|
<button type="button" class="outcome__button" data-combat-to-hunt (click)="goToHunt()">
|
||||||
Zur Jagd
|
Weiter jagen
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="outcome__button outcome__button--secondary"
|
||||||
|
data-combat-to-location
|
||||||
|
(click)="goToLocation()"
|
||||||
|
>
|
||||||
|
Zum Ort
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="outcome__button outcome__button--secondary"
|
||||||
|
data-combat-to-inventory
|
||||||
|
(click)="goToInventory()"
|
||||||
|
>
|
||||||
|
Inventar öffnen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} @else if (combat.status === 'LOST') {
|
} @else if (combat.status === 'LOST') {
|
||||||
<div class="outcome outcome--lost" data-combat-result="LOST">
|
<div class="outcome outcome--lost" data-combat-result="LOST">
|
||||||
<h2 class="outcome__title">Niederlage</h2>
|
<h2 class="outcome__title">Niederlage</h2>
|
||||||
<p>{{ combat.player.name }} wurde im Kampf besiegt.</p>
|
<p>{{ combat.player.name }} wurde im Kampf besiegt.</p>
|
||||||
|
<div class="outcome__buttons">
|
||||||
<button type="button" class="outcome__button" data-combat-to-hunt (click)="goToHunt()">
|
<button type="button" class="outcome__button" data-combat-to-hunt (click)="goToHunt()">
|
||||||
Zur Jagd
|
Weiter jagen
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="outcome__button outcome__button--secondary"
|
||||||
|
data-combat-to-location
|
||||||
|
(click)="goToLocation()"
|
||||||
|
>
|
||||||
|
Zum Ort
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -363,10 +363,27 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
gap: var(--ar-space-3);
|
gap: var(--ar-space-3);
|
||||||
min-block-size: clamp(6.5rem, 11vw, 8.5rem);
|
min-block-size: clamp(6.5rem, 11vw, 8.5rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.combat__telegraph {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--ar-space-2) var(--ar-space-4);
|
||||||
|
border: 1px solid var(--ar-gold);
|
||||||
|
border-radius: var(--ar-radius-sm);
|
||||||
|
background: rgb(9 11 13 / 0.85);
|
||||||
|
color: var(--ar-gold);
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
font-size: clamp(1rem, 1.6vw, 1.15rem);
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.action {
|
.action {
|
||||||
position: relative;
|
position: relative;
|
||||||
inline-size: clamp(6.5rem, 11vw, 8.5rem);
|
inline-size: clamp(6.5rem, 11vw, 8.5rem);
|
||||||
@@ -403,7 +420,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.action__key {
|
.action__key {
|
||||||
inset-block-start: 90%;
|
inset-block-start: 85%;
|
||||||
color: var(--ar-text-muted);
|
color: var(--ar-text-muted);
|
||||||
font-size: var(--ar-font-sm);
|
font-size: var(--ar-font-sm);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
@@ -490,6 +507,19 @@
|
|||||||
margin-block-start: var(--ar-space-2);
|
margin-block-start: var(--ar-space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.outcome__buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--ar-space-3);
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outcome__button--secondary {
|
||||||
|
border-color: var(--ar-border);
|
||||||
|
color: var(--ar-text-muted);
|
||||||
|
background: var(--ar-panel);
|
||||||
|
}
|
||||||
|
|
||||||
.outcome__button:hover,
|
.outcome__button:hover,
|
||||||
.combat__notice--error button:hover {
|
.combat__notice--error button:hover {
|
||||||
border-color: var(--ar-gold);
|
border-color: var(--ar-gold);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const activeCombat: Combat = {
|
|||||||
id: 'combat-1',
|
id: 'combat-1',
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
round: 2,
|
round: 2,
|
||||||
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 95 },
|
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 95, potionsRemaining: 2, potionsMax: 2 },
|
||||||
monster: {
|
monster: {
|
||||||
key: 'ash-rat',
|
key: 'ash-rat',
|
||||||
name: 'Aschenratte',
|
name: 'Aschenratte',
|
||||||
@@ -19,6 +19,7 @@ const activeCombat: Combat = {
|
|||||||
maxHp: 45,
|
maxHp: 45,
|
||||||
currentHp: 31,
|
currentHp: 31,
|
||||||
artworkPath: '/images/monsters/ash-rat.png',
|
artworkPath: '/images/monsters/ash-rat.png',
|
||||||
|
pendingIntent: null,
|
||||||
},
|
},
|
||||||
events: [
|
events: [
|
||||||
{ round: 1, sequence: 1, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 14 },
|
{ round: 1, sequence: 1, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 14 },
|
||||||
@@ -40,7 +41,7 @@ describe('CombatPageComponent', () => {
|
|||||||
actionPending: ReturnType<typeof signal<boolean>>;
|
actionPending: ReturnType<typeof signal<boolean>>;
|
||||||
error: ReturnType<typeof signal<string | null>>;
|
error: ReturnType<typeof signal<string | null>>;
|
||||||
loadCombat: ReturnType<typeof vi.fn>;
|
loadCombat: ReturnType<typeof vi.fn>;
|
||||||
attack: ReturnType<typeof vi.fn>;
|
performAction: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
let worldStore: { refreshCharacter: ReturnType<typeof vi.fn> };
|
let worldStore: { refreshCharacter: ReturnType<typeof vi.fn> };
|
||||||
let router: Router;
|
let router: Router;
|
||||||
@@ -52,7 +53,7 @@ describe('CombatPageComponent', () => {
|
|||||||
actionPending: signal(false),
|
actionPending: signal(false),
|
||||||
error: signal<string | null>(null),
|
error: signal<string | null>(null),
|
||||||
loadCombat: vi.fn(() => Promise.resolve()),
|
loadCombat: vi.fn(() => Promise.resolve()),
|
||||||
attack: vi.fn(() => Promise.resolve()),
|
performAction: vi.fn(() => Promise.resolve()),
|
||||||
};
|
};
|
||||||
worldStore = { refreshCharacter: vi.fn(() => Promise.resolve()) };
|
worldStore = { refreshCharacter: vi.fn(() => Promise.resolve()) };
|
||||||
|
|
||||||
@@ -102,6 +103,101 @@ describe('CombatPageComponent', () => {
|
|||||||
expect(element.querySelector('[data-combat-attack]')).toBeTruthy();
|
expect(element.querySelector('[data-combat-attack]')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows all five combat actions with their German labels', async () => {
|
||||||
|
const fixture = await setup(activeCombat);
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
expect(element.querySelector('[data-combat-attack]')?.textContent).toContain('Angriff');
|
||||||
|
expect(element.querySelector('[data-combat-heavy-strike]')?.textContent).toContain('Schwerer Hieb');
|
||||||
|
expect(element.querySelector('[data-combat-shield-bash]')?.textContent).toContain('Schildstoß');
|
||||||
|
expect(element.querySelector('[data-combat-defend]')?.textContent).toContain('Verteidigen');
|
||||||
|
expect(element.querySelector('[data-combat-potion]')?.textContent).toContain('Trank 2/2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends HEAVY_STRIKE when Schwerer Hieb is clicked', async () => {
|
||||||
|
const fixture = await setup(activeCombat);
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
element.querySelector<HTMLButtonElement>('[data-combat-heavy-strike]')?.click();
|
||||||
|
|
||||||
|
expect(combatStore.performAction).toHaveBeenCalledWith('HEAVY_STRIKE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends SHIELD_BASH when Schildstoß is clicked', async () => {
|
||||||
|
const fixture = await setup(activeCombat);
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
element.querySelector<HTMLButtonElement>('[data-combat-shield-bash]')?.click();
|
||||||
|
|
||||||
|
expect(combatStore.performAction).toHaveBeenCalledWith('SHIELD_BASH');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends DEFEND when Verteidigen is clicked', async () => {
|
||||||
|
const fixture = await setup(activeCombat);
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
element.querySelector<HTMLButtonElement>('[data-combat-defend]')?.click();
|
||||||
|
|
||||||
|
expect(combatStore.performAction).toHaveBeenCalledWith('DEFEND');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends POTION when Trank is clicked', async () => {
|
||||||
|
const fixture = await setup(activeCombat);
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
element.querySelector<HTMLButtonElement>('[data-combat-potion]')?.click();
|
||||||
|
|
||||||
|
expect(combatStore.performAction).toHaveBeenCalledWith('POTION');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables the potion button once both potions are used', async () => {
|
||||||
|
const fixture = await setup({
|
||||||
|
...activeCombat,
|
||||||
|
player: { ...activeCombat.player, potionsRemaining: 0 },
|
||||||
|
});
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
expect(element.querySelector<HTMLButtonElement>('[data-combat-potion]')?.disabled).toBe(true);
|
||||||
|
expect(element.querySelector<HTMLButtonElement>('[data-combat-attack]')?.disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a prominent telegraph banner when the monster has a pending Heavy Attack', async () => {
|
||||||
|
const fixture = await setup({
|
||||||
|
...activeCombat,
|
||||||
|
monster: { ...activeCombat.monster, pendingIntent: 'HEAVY_ATTACK' },
|
||||||
|
});
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
expect(element.querySelector('[data-combat-telegraph]')?.textContent).toContain(
|
||||||
|
'Aschenratte bereitet Schweren Hieb vor.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no telegraph banner when nothing is pending', async () => {
|
||||||
|
const fixture = await setup(activeCombat);
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
expect(element.querySelector('[data-combat-telegraph]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders HEAL, DEFEND, TELEGRAPH, and INTERRUPT log lines', async () => {
|
||||||
|
const fixture = await setup({
|
||||||
|
...activeCombat,
|
||||||
|
events: [
|
||||||
|
{ round: 1, sequence: 1, type: 'HEAL', source: 'PLAYER', target: 'PLAYER', amount: 35 },
|
||||||
|
{ round: 1, sequence: 2, type: 'DEFEND', source: 'PLAYER', target: 'PLAYER' },
|
||||||
|
{ round: 1, sequence: 3, type: 'TELEGRAPH', source: 'MONSTER', target: 'PLAYER' },
|
||||||
|
{ round: 1, sequence: 4, type: 'INTERRUPT', source: 'PLAYER', target: 'MONSTER' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
expect(element.textContent).toContain('Aric Duskwalker trinkt einen Trank und heilt 35 Lebenspunkte.');
|
||||||
|
expect(element.textContent).toContain('Aric Duskwalker geht in die Verteidigung.');
|
||||||
|
expect(element.textContent).toContain('Aschenratte bereitet Schweren Hieb vor.');
|
||||||
|
expect(element.textContent).toContain('Aric Duskwalker unterbricht den vorbereiteten Angriff von Aschenratte.');
|
||||||
|
});
|
||||||
|
|
||||||
it('renders the structured events as readable German combat-log entries', async () => {
|
it('renders the structured events as readable German combat-log entries', async () => {
|
||||||
const fixture = await setup(activeCombat);
|
const fixture = await setup(activeCombat);
|
||||||
const element = fixture.nativeElement as HTMLElement;
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
@@ -110,13 +206,13 @@ describe('CombatPageComponent', () => {
|
|||||||
expect(element.textContent).toContain('Aschenratte trifft Aric Duskwalker für 5 Schaden.');
|
expect(element.textContent).toContain('Aschenratte trifft Aric Duskwalker für 5 Schaden.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls combatStore.attack() when Angriff is clicked', async () => {
|
it('calls combatStore.performAction("ATTACK") when Angriff is clicked', async () => {
|
||||||
const fixture = await setup(activeCombat);
|
const fixture = await setup(activeCombat);
|
||||||
const element = fixture.nativeElement as HTMLElement;
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
element.querySelector<HTMLButtonElement>('[data-combat-attack]')?.click();
|
element.querySelector<HTMLButtonElement>('[data-combat-attack]')?.click();
|
||||||
|
|
||||||
expect(combatStore.attack).toHaveBeenCalledOnce();
|
expect(combatStore.performAction).toHaveBeenCalledWith('ATTACK');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('plays the swing, reveals the monster damage, then the recoil a beat later', async () => {
|
it('plays the swing, reveals the monster damage, then the recoil a beat later', async () => {
|
||||||
@@ -132,7 +228,7 @@ describe('CombatPageComponent', () => {
|
|||||||
{ round: 2, sequence: 4, type: 'DAMAGE', source: 'MONSTER', target: 'PLAYER', amount: 5 },
|
{ round: 2, sequence: 4, type: 'DAMAGE', source: 'MONSTER', target: 'PLAYER', amount: 5 },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
combatStore.attack.mockImplementation(async () => {
|
combatStore.performAction.mockImplementation(async () => {
|
||||||
combatStore.combat.set(resolvedRound);
|
combatStore.combat.set(resolvedRound);
|
||||||
});
|
});
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
@@ -162,7 +258,7 @@ describe('CombatPageComponent', () => {
|
|||||||
expect(countOccurrences(element.textContent, monsterHitLine)).toBe(1);
|
expect(countOccurrences(element.textContent, monsterHitLine)).toBe(1);
|
||||||
|
|
||||||
// The monster strikes back after the beat.
|
// The monster strikes back after the beat.
|
||||||
await vi.advanceTimersByTimeAsync(260);
|
await vi.advanceTimersByTimeAsync(1260);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
expect(sprite?.classList.contains('sprite--hit')).toBe(true);
|
expect(sprite?.classList.contains('sprite--hit')).toBe(true);
|
||||||
expect(monster?.classList.contains('sprite--lunge')).toBe(true);
|
expect(monster?.classList.contains('sprite--lunge')).toBe(true);
|
||||||
@@ -179,6 +275,115 @@ describe('CombatPageComponent', () => {
|
|||||||
expect(stage?.classList.contains('combat__stage--shaken')).toBe(false);
|
expect(stage?.classList.contains('combat__stage--shaken')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reveals the telegraph banner only after the reply beat, and never lunges for it', async () => {
|
||||||
|
const fixture = await setup(activeCombat);
|
||||||
|
const telegraphed: Combat = {
|
||||||
|
...activeCombat,
|
||||||
|
round: 3,
|
||||||
|
events: [
|
||||||
|
...activeCombat.events,
|
||||||
|
{ round: 2, sequence: 3, type: 'DEFEND', source: 'PLAYER', target: 'PLAYER' },
|
||||||
|
{ round: 2, sequence: 4, type: 'TELEGRAPH', source: 'MONSTER', target: 'PLAYER' },
|
||||||
|
],
|
||||||
|
monster: { ...activeCombat.monster, pendingIntent: 'HEAVY_ATTACK' },
|
||||||
|
};
|
||||||
|
combatStore.performAction.mockImplementation(async () => {
|
||||||
|
combatStore.combat.set(telegraphed);
|
||||||
|
});
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
const monster = element.querySelector('.sprite--monster');
|
||||||
|
element.querySelector<HTMLButtonElement>('[data-combat-defend]')?.click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(540);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(element.querySelector('[data-combat-telegraph]')).toBeNull();
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(1260);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(element.querySelector('[data-combat-telegraph]')?.textContent).toContain('bereitet Schweren Hieb vor');
|
||||||
|
expect(monster?.classList.contains('sprite--lunge')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the INTERRUPT log line immediately and skips the lunge when SHIELD_BASH interrupts', async () => {
|
||||||
|
const fixture = await setup({
|
||||||
|
...activeCombat,
|
||||||
|
monster: { ...activeCombat.monster, pendingIntent: 'HEAVY_ATTACK' },
|
||||||
|
});
|
||||||
|
const interrupted: Combat = {
|
||||||
|
...activeCombat,
|
||||||
|
round: 3,
|
||||||
|
monster: { ...activeCombat.monster, currentHp: 21, pendingIntent: null },
|
||||||
|
events: [
|
||||||
|
...activeCombat.events,
|
||||||
|
{ round: 2, sequence: 3, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 10 },
|
||||||
|
{ round: 2, sequence: 4, type: 'INTERRUPT', source: 'PLAYER', target: 'MONSTER' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
combatStore.performAction.mockImplementation(async () => {
|
||||||
|
combatStore.combat.set(interrupted);
|
||||||
|
});
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
const monster = element.querySelector('.sprite--monster');
|
||||||
|
element.querySelector<HTMLButtonElement>('[data-combat-shield-bash]')?.click();
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(540);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(element.textContent).toContain('Aric Duskwalker unterbricht den vorbereiteten Angriff von Aschenratte.');
|
||||||
|
expect(element.querySelector('[data-combat-telegraph]')).toBeNull();
|
||||||
|
expect(monster?.classList.contains('sprite--lunge')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the potion heal at the first checkpoint instead of waiting for the riposte reveal', async () => {
|
||||||
|
const fixture = await setup({
|
||||||
|
...activeCombat,
|
||||||
|
player: { ...activeCombat.player, currentHp: 70 },
|
||||||
|
});
|
||||||
|
const healed: Combat = {
|
||||||
|
...activeCombat,
|
||||||
|
round: 3,
|
||||||
|
player: { ...activeCombat.player, currentHp: 80, potionsRemaining: 1 },
|
||||||
|
events: [
|
||||||
|
...activeCombat.events,
|
||||||
|
{ round: 2, sequence: 3, type: 'HEAL', source: 'PLAYER', target: 'PLAYER', amount: 15 },
|
||||||
|
{ round: 2, sequence: 4, type: 'DAMAGE', source: 'MONSTER', target: 'PLAYER', amount: 5 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
combatStore.performAction.mockImplementation(async () => {
|
||||||
|
combatStore.combat.set(healed);
|
||||||
|
});
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
element.querySelector<HTMLButtonElement>('[data-combat-potion]')?.click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
// Before the checkpoint: the pre-heal HP and potion count still show.
|
||||||
|
expect(element.textContent).toContain('70 / 100');
|
||||||
|
expect(element.querySelector('[data-combat-potion]')?.textContent).toContain('Trank 2/2');
|
||||||
|
|
||||||
|
// First checkpoint: the heal already landed from the player's own action,
|
||||||
|
// so the HP bar and potion count update here -- well before the monster's
|
||||||
|
// held-back reply resolves.
|
||||||
|
await vi.advanceTimersByTimeAsync(540);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(element.textContent).toContain('85 / 100');
|
||||||
|
expect(element.querySelector('[data-combat-potion]')?.textContent).toContain('Trank 1/2');
|
||||||
|
expect(element.textContent).toContain('Aric Duskwalker trinkt einen Trank und heilt 15 Lebenspunkte.');
|
||||||
|
|
||||||
|
// The monster's reply is still held back at this point.
|
||||||
|
expect(countOccurrences(element.textContent, monsterHitLine)).toBe(1);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(1260);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(element.textContent).toContain('80 / 100');
|
||||||
|
});
|
||||||
|
|
||||||
it('skips the recoil when the round ends without the monster striking back', async () => {
|
it('skips the recoil when the round ends without the monster striking back', async () => {
|
||||||
const fixture = await setup(activeCombat);
|
const fixture = await setup(activeCombat);
|
||||||
const won: Combat = {
|
const won: Combat = {
|
||||||
@@ -191,7 +396,7 @@ describe('CombatPageComponent', () => {
|
|||||||
{ round: 2, sequence: 4, type: 'COMBAT_WON', source: 'PLAYER', target: 'MONSTER' },
|
{ round: 2, sequence: 4, type: 'COMBAT_WON', source: 'PLAYER', target: 'MONSTER' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
combatStore.attack.mockImplementation(async () => {
|
combatStore.performAction.mockImplementation(async () => {
|
||||||
combatStore.combat.set(won);
|
combatStore.combat.set(won);
|
||||||
});
|
});
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
@@ -216,7 +421,7 @@ describe('CombatPageComponent', () => {
|
|||||||
|
|
||||||
it('refreshes the character from the server once a combat is won', async () => {
|
it('refreshes the character from the server once a combat is won', async () => {
|
||||||
const fixture = await setup(activeCombat);
|
const fixture = await setup(activeCombat);
|
||||||
combatStore.attack.mockImplementation(async () => {
|
combatStore.performAction.mockImplementation(async () => {
|
||||||
combatStore.combat.set({
|
combatStore.combat.set({
|
||||||
...activeCombat,
|
...activeCombat,
|
||||||
status: 'WON',
|
status: 'WON',
|
||||||
@@ -266,7 +471,7 @@ describe('CombatPageComponent', () => {
|
|||||||
expect(element.querySelector('[data-combat-attack]')).toBeNull();
|
expect(element.querySelector('[data-combat-attack]')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('navigates to /hunt from the victory screen', async () => {
|
it('keeps the one-click hunt loop from the victory screen', async () => {
|
||||||
const fixture = await setup({ ...activeCombat, status: 'WON' });
|
const fixture = await setup({ ...activeCombat, status: 'WON' });
|
||||||
const element = fixture.nativeElement as HTMLElement;
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
@@ -275,6 +480,34 @@ describe('CombatPageComponent', () => {
|
|||||||
expect(router.navigate).toHaveBeenCalledWith(['/hunt']);
|
expect(router.navigate).toHaveBeenCalledWith(['/hunt']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('also offers the way back to the location from the victory screen', async () => {
|
||||||
|
const fixture = await setup({ ...activeCombat, status: 'WON' });
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
element.querySelector<HTMLButtonElement>('[data-combat-to-location]')?.click();
|
||||||
|
|
||||||
|
expect(router.navigate).toHaveBeenCalledWith(['/location']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers the same two ways out after a defeat', async () => {
|
||||||
|
const fixture = await setup({ ...activeCombat, status: 'LOST' });
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
expect(element.querySelector('[data-combat-to-hunt]')).not.toBeNull();
|
||||||
|
element.querySelector<HTMLButtonElement>('[data-combat-to-location]')?.click();
|
||||||
|
|
||||||
|
expect(router.navigate).toHaveBeenCalledWith(['/location']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates to /inventory from the victory screen', async () => {
|
||||||
|
const fixture = await setup({ ...activeCombat, status: 'WON' });
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
element.querySelector<HTMLButtonElement>('[data-combat-to-inventory]')?.click();
|
||||||
|
|
||||||
|
expect(router.navigate).toHaveBeenCalledWith(['/inventory']);
|
||||||
|
});
|
||||||
|
|
||||||
it('shows an error and retries loading the combat', async () => {
|
it('shows an error and retries loading the combat', async () => {
|
||||||
const fixture = await setup(null);
|
const fixture = await setup(null);
|
||||||
combatStore.error.set('Dieser Kampf wurde nicht gefunden.');
|
combatStore.error.set('Dieser Kampf wurde nicht gefunden.');
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Component, DestroyRef, OnInit, computed, inject, signal } from '@angular/core';
|
import { Component, DestroyRef, OnInit, computed, inject, signal } from '@angular/core';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import type { Combat, CombatEvent } from '../../../core/api/game-api.models';
|
import type { Combat, CombatAction, CombatEvent } from '../../../core/api/game-api.models';
|
||||||
import {
|
import {
|
||||||
combatMonsterSpriteScale,
|
combatMonsterSpriteScale,
|
||||||
monsterCutoutPath,
|
monsterCutoutPath,
|
||||||
@@ -29,10 +29,15 @@ const PLAYER_ICON = '/images/hud/runtime/CharacterIcon-128.png';
|
|||||||
const SWING_MS = 540;
|
const SWING_MS = 540;
|
||||||
const RECOIL_MS = 540;
|
const RECOIL_MS = 540;
|
||||||
// Beat between the player's blow landing and the monster striking back.
|
// Beat between the player's blow landing and the monster striking back.
|
||||||
const RIPOSTE_DELAY_MS = 260;
|
const RIPOSTE_DELAY_MS = 1260;
|
||||||
// Length of the stage jolt keyframes, see `stage-shake` in the stylesheet.
|
// Length of the stage jolt keyframes, see `stage-shake` in the stylesheet.
|
||||||
const STAGE_SHAKE_MS = 200;
|
const STAGE_SHAKE_MS = 200;
|
||||||
|
|
||||||
|
// Actions that land a blow on the monster this round -- everything else
|
||||||
|
// (DEFEND, POTION) skips the swing wind-up so the player sprite doesn't
|
||||||
|
// mime an attack it didn't make.
|
||||||
|
const DAMAGING_ACTIONS: ReadonlySet<CombatAction> = new Set(['ATTACK', 'HEAVY_STRIKE', 'SHIELD_BASH']);
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-combat-page',
|
selector: 'app-combat-page',
|
||||||
templateUrl: './combat-page.component.html',
|
templateUrl: './combat-page.component.html',
|
||||||
@@ -75,7 +80,7 @@ export class CombatPageComponent implements OnInit {
|
|||||||
void this.loadFromRoute();
|
void this.loadFromRoute();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async attack(): Promise<void> {
|
protected async performAction(action: CombatAction): Promise<void> {
|
||||||
const before = this.displayed();
|
const before = this.displayed();
|
||||||
if (!before || this.busy()) {
|
if (!before || this.busy()) {
|
||||||
return;
|
return;
|
||||||
@@ -83,16 +88,16 @@ export class CombatPageComponent implements OnInit {
|
|||||||
|
|
||||||
this.replaying.set(true);
|
this.replaying.set(true);
|
||||||
try {
|
try {
|
||||||
this.phase.set('attacking');
|
const damaging = DAMAGING_ACTIONS.has(action);
|
||||||
|
this.phase.set(damaging ? 'attacking' : 'idle');
|
||||||
this.monsterPhase.set('idle');
|
this.monsterPhase.set('idle');
|
||||||
const swing = this.wait(SWING_MS);
|
const swing = this.wait(SWING_MS);
|
||||||
await this.combatStore.attack();
|
await this.combatStore.performAction(action);
|
||||||
await swing;
|
await swing;
|
||||||
if (this.destroyed) {
|
if (this.destroyed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.phase.set('idle');
|
this.phase.set('idle');
|
||||||
this.monsterPhase.set('flinch');
|
|
||||||
|
|
||||||
const after = this.combatStore.combat();
|
const after = this.combatStore.combat();
|
||||||
if (!after) {
|
if (!after) {
|
||||||
@@ -105,21 +110,44 @@ export class CombatPageComponent implements OnInit {
|
|||||||
void this.worldStore.refreshCharacter();
|
void this.worldStore.refreshCharacter();
|
||||||
}
|
}
|
||||||
|
|
||||||
const riposte = after.events.find(
|
const roundEvents = after.events.filter((event) => event.round === before.round);
|
||||||
(event) =>
|
const dealtDamage = roundEvents.some(
|
||||||
event.round === before.round && event.type === 'DAMAGE' && event.source === 'MONSTER',
|
(event) => event.source === 'PLAYER' && event.target === 'MONSTER' && event.type === 'DAMAGE',
|
||||||
);
|
);
|
||||||
|
this.monsterPhase.set(dealtDamage ? 'flinch' : 'idle');
|
||||||
|
|
||||||
if (!riposte) {
|
const monsterEvent = roundEvents.find((event) => event.source === 'MONSTER');
|
||||||
|
if (!monsterEvent) {
|
||||||
|
// No reply this round: either the fight just ended, or SHIELD_BASH
|
||||||
|
// interrupted the monster's turn outright.
|
||||||
this.displayed.set(after);
|
this.displayed.set(after);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show the blow the player just landed, holding back the monster's reply.
|
// Show what the player's own action produced, holding back the
|
||||||
|
// monster's reply -- including whether it just started telegraphing.
|
||||||
|
// A POTION heal lands from the player's own action, before the
|
||||||
|
// monster's reply, so it must show up here rather than being folded
|
||||||
|
// into the delayed riposte reveal.
|
||||||
|
const healEvent = roundEvents.find(
|
||||||
|
(event) => event.type === 'HEAL' && event.sequence < monsterEvent.sequence,
|
||||||
|
);
|
||||||
|
const intermediatePlayer = healEvent
|
||||||
|
? {
|
||||||
|
...before.player,
|
||||||
|
currentHp: Math.min(before.player.maxHp, before.player.currentHp + (healEvent.amount ?? 0)),
|
||||||
|
potionsRemaining: after.player.potionsRemaining,
|
||||||
|
}
|
||||||
|
: before.player;
|
||||||
|
|
||||||
this.displayed.set({
|
this.displayed.set({
|
||||||
...after,
|
...after,
|
||||||
player: before.player,
|
player: intermediatePlayer,
|
||||||
events: after.events.filter((event) => event.sequence < riposte.sequence),
|
monster: {
|
||||||
|
...(dealtDamage ? after.monster : before.monster),
|
||||||
|
pendingIntent: before.monster.pendingIntent,
|
||||||
|
},
|
||||||
|
events: after.events.filter((event) => event.sequence < monsterEvent.sequence),
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.wait(RIPOSTE_DELAY_MS);
|
await this.wait(RIPOSTE_DELAY_MS);
|
||||||
@@ -127,6 +155,11 @@ export class CombatPageComponent implements OnInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (monsterEvent.type === 'TELEGRAPH') {
|
||||||
|
this.displayed.set(after);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.phase.set('hit');
|
this.phase.set('hit');
|
||||||
this.monsterPhase.set('lunge');
|
this.monsterPhase.set('lunge');
|
||||||
this.displayed.set(after);
|
this.displayed.set(after);
|
||||||
@@ -161,6 +194,17 @@ export class CombatPageComponent implements OnInit {
|
|||||||
void this.router.navigate(['/hunt']);
|
void this.router.navigate(['/hunt']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The location is the screen a fight resolves back into. It sits beside
|
||||||
|
// "Weiter jagen" rather than replacing it, so the hunt loop keeps its
|
||||||
|
// one-click rhythm.
|
||||||
|
protected goToLocation(): void {
|
||||||
|
void this.router.navigate(['/location']);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected goToInventory(): void {
|
||||||
|
void this.router.navigate(['/inventory']);
|
||||||
|
}
|
||||||
|
|
||||||
protected monsterSprite(monsterKey: string, artworkPath: string): string {
|
protected monsterSprite(monsterKey: string, artworkPath: string): string {
|
||||||
return monsterCutoutPath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath;
|
return monsterCutoutPath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath;
|
||||||
}
|
}
|
||||||
@@ -183,6 +227,14 @@ export class CombatPageComponent implements OnInit {
|
|||||||
return combat ? (combat.monster.currentHp / combat.monster.maxHp) * 100 : 0;
|
return combat ? (combat.monster.currentHp / combat.monster.maxHp) * 100 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected monsterIntentLabel(): string | null {
|
||||||
|
const combat = this.displayed();
|
||||||
|
if (!combat || combat.monster.pendingIntent !== 'HEAVY_ATTACK') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return `${combat.monster.name} bereitet Schweren Hieb vor.`;
|
||||||
|
}
|
||||||
|
|
||||||
protected logRounds(): CombatLogRound[] {
|
protected logRounds(): CombatLogRound[] {
|
||||||
const combat = this.displayed();
|
const combat = this.displayed();
|
||||||
if (!combat) {
|
if (!combat) {
|
||||||
@@ -210,6 +262,22 @@ export class CombatPageComponent implements OnInit {
|
|||||||
return `${attacker} trifft ${defender} für ${event.amount} Schaden.`;
|
return `${attacker} trifft ${defender} für ${event.amount} Schaden.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.type === 'HEAL') {
|
||||||
|
return `${playerName} trinkt einen Trank und heilt ${event.amount} Lebenspunkte.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'DEFEND') {
|
||||||
|
return `${playerName} geht in die Verteidigung.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'TELEGRAPH') {
|
||||||
|
return `${monsterName} bereitet Schweren Hieb vor.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'INTERRUPT') {
|
||||||
|
return `${playerName} unterbricht den vorbereiteten Angriff von ${monsterName}.`;
|
||||||
|
}
|
||||||
|
|
||||||
if (event.type === 'COMBAT_WON') {
|
if (event.type === 'COMBAT_WON') {
|
||||||
return `${monsterName} wurde besiegt.`;
|
return `${monsterName} wurde besiegt.`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const startedCombat: Combat = {
|
|||||||
id: 'combat-1',
|
id: 'combat-1',
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
round: 1,
|
round: 1,
|
||||||
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100 },
|
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100, potionsRemaining: 2, potionsMax: 2 },
|
||||||
monster: {
|
monster: {
|
||||||
key: 'ash-rat',
|
key: 'ash-rat',
|
||||||
name: 'Aschenratte',
|
name: 'Aschenratte',
|
||||||
@@ -18,6 +18,7 @@ const startedCombat: Combat = {
|
|||||||
maxHp: 45,
|
maxHp: 45,
|
||||||
currentHp: 45,
|
currentHp: 45,
|
||||||
artworkPath: '/images/monsters/ash-rat.png',
|
artworkPath: '/images/monsters/ash-rat.png',
|
||||||
|
pendingIntent: null,
|
||||||
},
|
},
|
||||||
events: [],
|
events: [],
|
||||||
rewards: null,
|
rewards: null,
|
||||||
@@ -83,6 +84,22 @@ describe('CombatStore', () => {
|
|||||||
expect(store.errorCode()).toBe('COMBAT_ALREADY_ACTIVE');
|
expect(store.errorCode()).toBe('COMBAT_ALREADY_ACTIVE');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('maps CHARACTER_TOO_WOUNDED to its German message', async () => {
|
||||||
|
api.startCombat.mockReturnValue(
|
||||||
|
throwError(
|
||||||
|
() =>
|
||||||
|
new HttpErrorResponse({
|
||||||
|
status: 409,
|
||||||
|
error: { statusCode: 409, code: 'CHARACTER_TOO_WOUNDED', message: 'Too wounded.' },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await store.startCombat('encounter-1');
|
||||||
|
|
||||||
|
expect(store.error()).toBe('Du bist zu schwer verwundet, um zu kämpfen. Warte, bis du dich erholt hast.');
|
||||||
|
});
|
||||||
|
|
||||||
it('loads the running combat and clears the error that sent us looking for it', async () => {
|
it('loads the running combat and clears the error that sent us looking for it', async () => {
|
||||||
api.startCombat.mockReturnValue(
|
api.startCombat.mockReturnValue(
|
||||||
throwError(
|
throwError(
|
||||||
@@ -139,14 +156,22 @@ describe('CombatStore', () => {
|
|||||||
it('sends only the ATTACK action and replaces combat with the server response', async () => {
|
it('sends only the ATTACK action and replaces combat with the server response', async () => {
|
||||||
await store.startCombat('encounter-1');
|
await store.startCombat('encounter-1');
|
||||||
|
|
||||||
await store.attack();
|
await store.performAction('ATTACK');
|
||||||
|
|
||||||
expect(api.performCombatAction).toHaveBeenCalledWith('combat-1', 'ATTACK');
|
expect(api.performCombatAction).toHaveBeenCalledWith('combat-1', 'ATTACK');
|
||||||
expect(store.combat()).toEqual(afterAttack);
|
expect(store.combat()).toEqual(afterAttack);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sends whichever action is requested', async () => {
|
||||||
|
await store.startCombat('encounter-1');
|
||||||
|
|
||||||
|
await store.performAction('HEAVY_STRIKE');
|
||||||
|
|
||||||
|
expect(api.performCombatAction).toHaveBeenCalledWith('combat-1', 'HEAVY_STRIKE');
|
||||||
|
});
|
||||||
|
|
||||||
it('does nothing when attacking without a loaded combat', async () => {
|
it('does nothing when attacking without a loaded combat', async () => {
|
||||||
await store.attack();
|
await store.performAction('ATTACK');
|
||||||
|
|
||||||
expect(api.performCombatAction).not.toHaveBeenCalled();
|
expect(api.performCombatAction).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -162,9 +187,9 @@ describe('CombatStore', () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const first = store.attack();
|
const first = store.performAction('ATTACK');
|
||||||
expect(store.actionPending()).toBe(true);
|
expect(store.actionPending()).toBe(true);
|
||||||
const second = store.attack();
|
const second = store.performAction('ATTACK');
|
||||||
|
|
||||||
resolveAttack(afterAttack);
|
resolveAttack(afterAttack);
|
||||||
await Promise.all([first, second]);
|
await Promise.all([first, second]);
|
||||||
@@ -176,7 +201,7 @@ describe('CombatStore', () => {
|
|||||||
await store.startCombat('encounter-1');
|
await store.startCombat('encounter-1');
|
||||||
api.performCombatAction.mockReturnValue(throwError(() => new Error('Netzwerkfehler')));
|
api.performCombatAction.mockReturnValue(throwError(() => new Error('Netzwerkfehler')));
|
||||||
|
|
||||||
await store.attack();
|
await store.performAction('ATTACK');
|
||||||
|
|
||||||
expect(store.actionPending()).toBe(false);
|
expect(store.actionPending()).toBe(false);
|
||||||
expect(store.combat()).toEqual(startedCombat);
|
expect(store.combat()).toEqual(startedCombat);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { HttpErrorResponse } from '@angular/common/http';
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
import { Injectable, signal } from '@angular/core';
|
import { Injectable, signal } from '@angular/core';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import { Combat } from '../../core/api/game-api.models';
|
import { Combat, CombatAction } from '../../core/api/game-api.models';
|
||||||
import { GameApiService } from '../../core/api/game-api.service';
|
import { GameApiService } from '../../core/api/game-api.service';
|
||||||
|
|
||||||
const GENERIC_ERROR_MESSAGE = 'Der Kampf konnte nicht geladen werden.';
|
const GENERIC_ERROR_MESSAGE = 'Der Kampf konnte nicht geladen werden.';
|
||||||
@@ -13,9 +13,11 @@ const COMBAT_ERROR_MESSAGES: Readonly<Record<string, string>> = {
|
|||||||
HUNT_ENCOUNTER_ALREADY_CONSUMED: 'Diese Begegnung wurde bereits genutzt.',
|
HUNT_ENCOUNTER_ALREADY_CONSUMED: 'Diese Begegnung wurde bereits genutzt.',
|
||||||
INVALID_HUNT_ENCOUNTER: 'Diese Begegnung ist nicht mehr gültig.',
|
INVALID_HUNT_ENCOUNTER: 'Diese Begegnung ist nicht mehr gültig.',
|
||||||
CHARACTER_TRAVELLING: 'Du kannst nicht kämpfen, während du unterwegs bist.',
|
CHARACTER_TRAVELLING: 'Du kannst nicht kämpfen, während du unterwegs bist.',
|
||||||
|
CHARACTER_TOO_WOUNDED: 'Du bist zu schwer verwundet, um zu kämpfen. Warte, bis du dich erholt hast.',
|
||||||
COMBAT_ALREADY_ACTIVE: 'Du befindest dich bereits in einem Kampf.',
|
COMBAT_ALREADY_ACTIVE: 'Du befindest dich bereits in einem Kampf.',
|
||||||
COMBAT_NOT_FOUND: 'Dieser Kampf wurde nicht gefunden.',
|
COMBAT_NOT_FOUND: 'Dieser Kampf wurde nicht gefunden.',
|
||||||
COMBAT_ALREADY_FINISHED: 'Dieser Kampf ist bereits beendet.',
|
COMBAT_ALREADY_FINISHED: 'Dieser Kampf ist bereits beendet.',
|
||||||
|
COMBAT_NO_POTIONS_REMAINING: 'Du hast keine Tränke mehr.',
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
@@ -83,7 +85,7 @@ export class CombatStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async attack(): Promise<void> {
|
async performAction(action: CombatAction): Promise<void> {
|
||||||
const combat = this.combatState();
|
const combat = this.combatState();
|
||||||
if (!combat || this.actionPendingState()) {
|
if (!combat || this.actionPendingState()) {
|
||||||
return;
|
return;
|
||||||
@@ -93,7 +95,7 @@ export class CombatStore {
|
|||||||
this.clearError();
|
this.clearError();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updated = await firstValueFrom(this.api.performCombatAction(combat.id, 'ATTACK'));
|
const updated = await firstValueFrom(this.api.performCombatAction(combat.id, action));
|
||||||
this.combatState.set(updated);
|
this.combatState.set(updated);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setError(error);
|
this.setError(error);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
Am Südtor von Graufurt gibt es keine regulären Jagdgebiete. Reise in ein gefährlicheres
|
Am Südtor von Graufurt gibt es keine regulären Jagdgebiete. Reise in ein gefährlicheres
|
||||||
Gebiet, um nach Gegnern zu suchen.
|
Gebiet, um nach Gegnern zu suchen.
|
||||||
</p>
|
</p>
|
||||||
<button type="button" data-hunt-to-world (click)="goToWorld()">Zur Karte</button>
|
<button type="button" data-hunt-to-location (click)="goToLocation()">Zurück zum Ort</button>
|
||||||
</section>
|
</section>
|
||||||
} @else if (huntingStore.currentHunt(); as hunt) {
|
} @else if (huntingStore.currentHunt(); as hunt) {
|
||||||
<section class="hunt-page__results" [attr.aria-label]="'Begegnungen bei ' + location.name">
|
<section class="hunt-page__results" [attr.aria-label]="'Begegnungen bei ' + location.name">
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
>
|
>
|
||||||
Neu suchen
|
Neu suchen
|
||||||
</button>
|
</button>
|
||||||
<button type="button" data-hunt-to-world (click)="goToWorld()">Zur Karte</button>
|
<button type="button" data-hunt-to-location (click)="goToLocation()">Zurück zum Ort</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
} @else {
|
} @else {
|
||||||
|
|||||||
@@ -5,38 +5,17 @@ import { Router, provideRouter } from '@angular/router';
|
|||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
import type { Combat, CurrentLocationResponse, HuntResult } from '../../../core/api/game-api.models';
|
import type { Combat, CurrentLocationResponse, HuntResult } from '../../../core/api/game-api.models';
|
||||||
import { CombatStore } from '../../combat/combat.store';
|
import { CombatStore } from '../../combat/combat.store';
|
||||||
|
import {
|
||||||
|
burnedRoadFixture,
|
||||||
|
southGateFixture,
|
||||||
|
} from '../../world/current-location.fixture';
|
||||||
import { WorldStore } from '../../world/world.store';
|
import { WorldStore } from '../../world/world.store';
|
||||||
import { HuntingStore } from '../hunting.store';
|
import { HuntingStore } from '../hunting.store';
|
||||||
import { HuntPageComponent } from './hunt-page.component';
|
import { HuntPageComponent } from './hunt-page.component';
|
||||||
|
|
||||||
const southGate: CurrentLocationResponse = {
|
const southGate = southGateFixture({ connections: [] });
|
||||||
id: 'south-gate-id',
|
|
||||||
key: 'south-gate',
|
|
||||||
name: 'Südtor von Graufurt',
|
|
||||||
description: 'Der letzte sichere Schritt vor den Aschenfeldern.',
|
|
||||||
regionKey: 'ashen-fields',
|
|
||||||
minRecommendedLevel: 1,
|
|
||||||
maxRecommendedLevel: 1,
|
|
||||||
dangerLevel: 0,
|
|
||||||
isSafe: true,
|
|
||||||
huntingEnabled: false,
|
|
||||||
artworkPath: '/images/backgrounds/Suedtor.png',
|
|
||||||
connections: [],
|
|
||||||
possibleMonsters: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const burnedRoad: CurrentLocationResponse = {
|
const burnedRoad = burnedRoadFixture({ connections: [] });
|
||||||
...southGate,
|
|
||||||
id: 'burned-road-id',
|
|
||||||
key: 'burned-road',
|
|
||||||
name: 'Verbrannte Straße',
|
|
||||||
description: 'Die erste Jagdzone zwischen Asche und zerbrochenen Wagen.',
|
|
||||||
isSafe: false,
|
|
||||||
huntingEnabled: true,
|
|
||||||
artworkPath: '/images/backgrounds/Aschestrasse.png',
|
|
||||||
possibleMonsters: ['Aschenratte', 'Straßenräuber'],
|
|
||||||
connections: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const threeEncounterHunt: HuntResult = {
|
const threeEncounterHunt: HuntResult = {
|
||||||
id: 'hunt-id',
|
id: 'hunt-id',
|
||||||
@@ -72,7 +51,7 @@ const startedCombat: Combat = {
|
|||||||
id: 'combat-2',
|
id: 'combat-2',
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
round: 1,
|
round: 1,
|
||||||
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100 },
|
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100, potionsRemaining: 2, potionsMax: 2 },
|
||||||
monster: {
|
monster: {
|
||||||
key: 'road-bandit',
|
key: 'road-bandit',
|
||||||
name: 'Straßenräuber',
|
name: 'Straßenräuber',
|
||||||
@@ -80,6 +59,7 @@ const startedCombat: Combat = {
|
|||||||
maxHp: 75,
|
maxHp: 75,
|
||||||
currentHp: 75,
|
currentHp: 75,
|
||||||
artworkPath: '/images/enemies/RoadBandit.png',
|
artworkPath: '/images/enemies/RoadBandit.png',
|
||||||
|
pendingIntent: null,
|
||||||
},
|
},
|
||||||
events: [],
|
events: [],
|
||||||
rewards: null,
|
rewards: null,
|
||||||
@@ -150,7 +130,7 @@ describe('HuntPageComponent', () => {
|
|||||||
return fixture;
|
return fixture;
|
||||||
}
|
}
|
||||||
|
|
||||||
it('shows the hunting-unavailable state at the Südtor, with no Jagd beginnen button, and a working Zur Karte action', async () => {
|
it('shows the hunting-unavailable state at the Südtor, with no Jagd beginnen button, and a way back to the location', async () => {
|
||||||
const fixture = await setup(southGate);
|
const fixture = await setup(southGate);
|
||||||
const element = fixture.nativeElement as HTMLElement;
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
@@ -162,11 +142,11 @@ describe('HuntPageComponent', () => {
|
|||||||
),
|
),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
|
|
||||||
const toWorldButton = element.querySelector<HTMLButtonElement>('[data-hunt-to-world]');
|
const backButton = element.querySelector<HTMLButtonElement>('[data-hunt-to-location]');
|
||||||
expect(toWorldButton?.textContent?.trim()).toBe('Zur Karte');
|
expect(backButton?.textContent?.trim()).toBe('Zurück zum Ort');
|
||||||
toWorldButton?.click();
|
backButton?.click();
|
||||||
|
|
||||||
expect(router.navigate).toHaveBeenCalledWith(['/world']);
|
expect(router.navigate).toHaveBeenCalledWith(['/location']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls startHunt when Jagd beginnen is clicked at a hunting-enabled location', async () => {
|
it('calls startHunt when Jagd beginnen is clicked at a hunting-enabled location', async () => {
|
||||||
|
|||||||
@@ -44,8 +44,10 @@ export class HuntPageComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected goToWorld(): void {
|
// Back out of the hunt returns to the place the hunt happens in, not to the
|
||||||
void this.router.navigate(['/world']);
|
// map: the location is the screen the player left to get here.
|
||||||
|
protected goToLocation(): void {
|
||||||
|
void this.router.navigate(['/location']);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async onAttack(encounterId: string): Promise<void> {
|
protected async onAttack(encounterId: string): Promise<void> {
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<section class="ar-panel detail" aria-label="Gewählter Gegenstand">
|
||||||
|
<h2 class="ar-panel__title">Gewählter Gegenstand</h2>
|
||||||
|
|
||||||
|
@if (item(); as item) {
|
||||||
|
<article
|
||||||
|
class="detail__body"
|
||||||
|
[class.detail__body--rare]="item.item.rarity === 'RARE'"
|
||||||
|
[class.detail__body--epic]="item.item.rarity === 'EPIC'"
|
||||||
|
>
|
||||||
|
<header class="detail__head">
|
||||||
|
<span class="detail__portrait">
|
||||||
|
<img [src]="item.item.iconPath" [alt]="item.item.name" />
|
||||||
|
</span>
|
||||||
|
<div class="detail__ident">
|
||||||
|
<h3 class="detail__name" data-detail-name>{{ item.item.name }}</h3>
|
||||||
|
<p class="detail__rarity" data-detail-rarity>{{ rarityLabel() }}</p>
|
||||||
|
<p class="detail__slot">{{ slotLabel() ?? typeLabel() }}</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (statRows().length) {
|
||||||
|
<dl class="detail__stats" data-detail-stats>
|
||||||
|
@for (row of statRows(); track row.label) {
|
||||||
|
<div class="detail__stat">
|
||||||
|
<dt>{{ row.label }}</dt>
|
||||||
|
<dd>
|
||||||
|
<span class="detail__value">{{ row.value }}</span>
|
||||||
|
@if (row.diff !== null && row.diff !== 0) {
|
||||||
|
<span
|
||||||
|
class="detail__diff"
|
||||||
|
[class.detail__diff--positive]="row.diff > 0"
|
||||||
|
[class.detail__diff--negative]="row.diff < 0"
|
||||||
|
>
|
||||||
|
({{ row.diff > 0 ? '+' : '' }}{{ row.diff }})
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (isEquippable()) {
|
||||||
|
<dl class="detail__meta">
|
||||||
|
<div class="detail__stat">
|
||||||
|
<dt>Benötigte Stufe</dt>
|
||||||
|
<dd>
|
||||||
|
<span class="detail__value" [class.detail__value--unmet]="!meetsLevelRequirement()">
|
||||||
|
{{ item.item.requiredLevel }}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (item.item.description) {
|
||||||
|
<p class="detail__flavour">{{ item.item.description }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="detail__actions">
|
||||||
|
@if (item.equipped) {
|
||||||
|
<span class="detail__equipped" data-detail-equipped>Ausgerüstet</span>
|
||||||
|
} @else if (!isEquippable()) {
|
||||||
|
<span class="detail__note">Nicht ausrüstbar</span>
|
||||||
|
} @else if (!meetsLevelRequirement()) {
|
||||||
|
<button type="button" class="detail__equip" data-detail-equip disabled>
|
||||||
|
Benötigt Stufe {{ item.item.requiredLevel }}
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="detail__equip"
|
||||||
|
data-detail-equip
|
||||||
|
[disabled]="busy()"
|
||||||
|
(click)="onEquip()"
|
||||||
|
>
|
||||||
|
Ausrüsten
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
} @else {
|
||||||
|
<p class="detail__empty" data-detail-empty>Wähle einen Gegenstand aus deinem Inventar.</p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail {
|
||||||
|
block-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__body {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--ar-space-3);
|
||||||
|
|
||||||
|
/* Rarity tints the name and the icon frame, nothing else — the numbers stay
|
||||||
|
the loudest thing in the panel. */
|
||||||
|
--detail-rarity: var(--ar-text);
|
||||||
|
--detail-rarity-edge: var(--ar-border-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__body--rare {
|
||||||
|
--detail-rarity: var(--ar-blue);
|
||||||
|
--detail-rarity-edge: var(--ar-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__body--epic {
|
||||||
|
--detail-rarity: var(--ar-gold);
|
||||||
|
--detail-rarity-edge: var(--ar-gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- identity ---------- */
|
||||||
|
|
||||||
|
.detail__head {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--ar-space-3);
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__portrait {
|
||||||
|
display: grid;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
place-items: center;
|
||||||
|
inline-size: 4.25rem;
|
||||||
|
block-size: 4.25rem;
|
||||||
|
padding: 0.3rem;
|
||||||
|
border: 1px solid var(--detail-rarity-edge);
|
||||||
|
background: linear-gradient(180deg, #1b1f22, #0d1012);
|
||||||
|
box-shadow: inset 0 0.15rem 0.6rem rgb(0 0 0 / 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__portrait img {
|
||||||
|
inline-size: 100%;
|
||||||
|
block-size: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__ident {
|
||||||
|
min-inline-size: 0;
|
||||||
|
padding-block-start: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__name {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--detail-rarity);
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__rarity {
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
color: var(--detail-rarity);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__slot {
|
||||||
|
margin: 0.15rem 0 0;
|
||||||
|
color: var(--ar-text-muted);
|
||||||
|
font-size: var(--ar-font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- numbers ---------- */
|
||||||
|
|
||||||
|
.detail__stats,
|
||||||
|
.detail__meta {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin: 0;
|
||||||
|
padding-block-start: var(--ar-space-3);
|
||||||
|
border-block-start: 1px solid rgb(85 74 57 / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__stat {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--ar-space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__stat dt {
|
||||||
|
color: var(--ar-text-muted);
|
||||||
|
font-size: var(--ar-font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__stat dd {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: baseline;
|
||||||
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__value {
|
||||||
|
color: var(--ar-text);
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__value--unmet {
|
||||||
|
color: var(--ar-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The sign is carried by the text, never by colour alone (spec §52). */
|
||||||
|
.detail__diff {
|
||||||
|
font-size: var(--ar-font-sm);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__diff--positive {
|
||||||
|
color: var(--ar-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__diff--negative {
|
||||||
|
color: var(--ar-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- flavour ---------- */
|
||||||
|
|
||||||
|
.detail__flavour {
|
||||||
|
margin: 0;
|
||||||
|
padding-block-start: var(--ar-space-3);
|
||||||
|
border-block-start: 1px solid rgb(85 74 57 / 0.5);
|
||||||
|
color: var(--ar-text-muted);
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
font-size: var(--ar-font-sm);
|
||||||
|
font-style: italic;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- action ---------- */
|
||||||
|
|
||||||
|
.detail__actions {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
padding-block-start: var(--ar-space-3);
|
||||||
|
border-block-start: 1px solid rgb(85 74 57 / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__equipped {
|
||||||
|
color: var(--ar-gold);
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__note {
|
||||||
|
color: var(--ar-text-muted);
|
||||||
|
font-size: var(--ar-font-sm);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Same steel-blue plate as the travel button: the one committing action. */
|
||||||
|
.detail__equip {
|
||||||
|
inline-size: 100%;
|
||||||
|
padding: var(--ar-space-2) var(--ar-space-4);
|
||||||
|
border: 1px solid var(--ar-border-highlight);
|
||||||
|
border-radius: var(--ar-radius-sm);
|
||||||
|
color: var(--ar-text);
|
||||||
|
background: linear-gradient(180deg, #263b4b, #17232d);
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
font-size: 1rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__equip:hover:not(:disabled) {
|
||||||
|
border-color: #d6b26b;
|
||||||
|
background: linear-gradient(180deg, #315067, #1a2c3a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__equip:disabled {
|
||||||
|
border-color: var(--ar-border);
|
||||||
|
color: var(--ar-text-muted);
|
||||||
|
background: #1a1c1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__empty {
|
||||||
|
margin: 0;
|
||||||
|
padding-block: var(--ar-space-5);
|
||||||
|
color: var(--ar-text-muted);
|
||||||
|
font-size: var(--ar-font-sm);
|
||||||
|
font-style: italic;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import type { InventoryItem } from '../../core/api/game-api.models';
|
||||||
|
import { InventoryDetailPanelComponent } from './inventory-detail-panel.component';
|
||||||
|
|
||||||
|
const wornSword: InventoryItem = {
|
||||||
|
id: 'item-sword',
|
||||||
|
quantity: 1,
|
||||||
|
equipped: true,
|
||||||
|
item: {
|
||||||
|
key: 'worn-short-sword',
|
||||||
|
name: 'Abgenutztes Kurzschwert',
|
||||||
|
description: 'Beschreibung des Gegenstands.',
|
||||||
|
rarity: 'COMMON',
|
||||||
|
equipmentSlot: 'WEAPON',
|
||||||
|
requiredLevel: 1,
|
||||||
|
weaponDamage: 8,
|
||||||
|
bonusAttack: 0,
|
||||||
|
bonusHp: 0,
|
||||||
|
bonusArmor: 0,
|
||||||
|
iconPath: '/images/items/worn-short-sword.png',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const banditBlade: InventoryItem = {
|
||||||
|
id: 'item-blade',
|
||||||
|
quantity: 1,
|
||||||
|
equipped: false,
|
||||||
|
item: {
|
||||||
|
key: 'bandit-blade',
|
||||||
|
name: 'Räuberklinge',
|
||||||
|
description: 'Beschreibung des Gegenstands.',
|
||||||
|
rarity: 'COMMON',
|
||||||
|
equipmentSlot: 'WEAPON',
|
||||||
|
requiredLevel: 1,
|
||||||
|
weaponDamage: 11,
|
||||||
|
bonusAttack: 1,
|
||||||
|
bonusHp: 0,
|
||||||
|
bonusArmor: 0,
|
||||||
|
iconPath: '/images/items/bandit-blade.png',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function setup(overrides: {
|
||||||
|
item?: InventoryItem | null;
|
||||||
|
equippedItemInSlot?: InventoryItem | null;
|
||||||
|
characterLevel?: number;
|
||||||
|
busy?: boolean;
|
||||||
|
}) {
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
await TestBed.configureTestingModule({ imports: [InventoryDetailPanelComponent] }).compileComponents();
|
||||||
|
const fixture = TestBed.createComponent(InventoryDetailPanelComponent);
|
||||||
|
fixture.componentRef.setInput('item', overrides.item ?? null);
|
||||||
|
fixture.componentRef.setInput('equippedItemInSlot', overrides.equippedItemInSlot ?? null);
|
||||||
|
fixture.componentRef.setInput('characterLevel', overrides.characterLevel ?? 1);
|
||||||
|
fixture.componentRef.setInput('busy', overrides.busy ?? false);
|
||||||
|
fixture.detectChanges();
|
||||||
|
return fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('InventoryDetailPanelComponent', () => {
|
||||||
|
it('shows a placeholder when nothing is selected', async () => {
|
||||||
|
const fixture = await setup({ item: null });
|
||||||
|
expect((fixture.nativeElement as HTMLElement).querySelector('[data-detail-empty]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Ausgerüstet for the currently equipped item, with no equip button', async () => {
|
||||||
|
const fixture = await setup({ item: wornSword });
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
expect(element.querySelector('[data-detail-equipped]')?.textContent).toContain('Ausgerüstet');
|
||||||
|
expect(element.querySelector('[data-detail-equip]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the item flavour text from the server', async () => {
|
||||||
|
const fixture = await setup({
|
||||||
|
item: {
|
||||||
|
...banditBlade,
|
||||||
|
item: { ...banditBlade.item, description: 'Eine grob gezahnte Klinge.' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((fixture.nativeElement as HTMLElement).textContent).toContain(
|
||||||
|
'Eine grob gezahnte Klinge.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the stat comparison against the equipped item in the same slot', async () => {
|
||||||
|
const fixture = await setup({ item: banditBlade, equippedItemInSlot: wornSword });
|
||||||
|
const text = (fixture.nativeElement as HTMLElement).querySelector('[data-detail-stats]')?.textContent ?? '';
|
||||||
|
|
||||||
|
expect(text).toContain('11');
|
||||||
|
expect(text).toContain('+3');
|
||||||
|
expect(text).toContain('+1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a disabled Benötigt Stufe X button when the level requirement is not met', async () => {
|
||||||
|
const fixture = await setup({ item: { ...banditBlade, item: { ...banditBlade.item, requiredLevel: 5 } }, characterLevel: 1 });
|
||||||
|
const button = (fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>('[data-detail-equip]');
|
||||||
|
|
||||||
|
expect(button?.textContent).toContain('Benötigt Stufe 5');
|
||||||
|
expect(button?.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits equip with the characterItemId when Ausrüsten is clicked', async () => {
|
||||||
|
const fixture = await setup({ item: banditBlade });
|
||||||
|
const emitted: string[] = [];
|
||||||
|
fixture.componentInstance.equip.subscribe((id: string) => emitted.push(id));
|
||||||
|
|
||||||
|
(fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>('[data-detail-equip]')?.click();
|
||||||
|
|
||||||
|
expect(emitted).toEqual(['item-blade']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables the equip button while busy', async () => {
|
||||||
|
const fixture = await setup({ item: banditBlade, busy: true });
|
||||||
|
const button = (fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>('[data-detail-equip]');
|
||||||
|
|
||||||
|
expect(button?.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { Component, computed, input, output } from '@angular/core';
|
||||||
|
import type { InventoryItem } from '../../core/api/game-api.models';
|
||||||
|
import { RARITY_LABELS } from '../../shared/item-card/item-card.component';
|
||||||
|
import { SLOT_LABELS } from './inventory.labels';
|
||||||
|
|
||||||
|
interface StatRow {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
diff: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatKey = 'weaponDamage' | 'bonusAttack' | 'bonusHp' | 'bonusArmor';
|
||||||
|
const STAT_LABELS: ReadonlyArray<{ label: string; key: StatKey }> = [
|
||||||
|
{ label: 'Waffenschaden', key: 'weaponDamage' },
|
||||||
|
{ label: 'Angriff', key: 'bonusAttack' },
|
||||||
|
{ label: 'Leben', key: 'bonusHp' },
|
||||||
|
{ label: 'Rüstung', key: 'bonusArmor' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Selected-item details and equip comparison (spec §32–37). */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-inventory-detail-panel',
|
||||||
|
templateUrl: './inventory-detail-panel.component.html',
|
||||||
|
styleUrl: './inventory-detail-panel.component.scss',
|
||||||
|
})
|
||||||
|
export class InventoryDetailPanelComponent {
|
||||||
|
readonly item = input<InventoryItem | null>(null);
|
||||||
|
readonly equippedItemInSlot = input<InventoryItem | null>(null);
|
||||||
|
readonly characterLevel = input(1);
|
||||||
|
readonly busy = input(false);
|
||||||
|
readonly equip = output<string>();
|
||||||
|
|
||||||
|
protected readonly rarityLabel = computed(() => {
|
||||||
|
const item = this.item();
|
||||||
|
return item ? RARITY_LABELS[item.item.rarity] : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
protected readonly slotLabel = computed(() => {
|
||||||
|
const slot = this.item()?.item.equipmentSlot;
|
||||||
|
return slot ? SLOT_LABELS[slot] : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Stand-in for items with no slot: the API does not expose an item type. */
|
||||||
|
protected readonly typeLabel = computed(() => 'Gegenstand');
|
||||||
|
|
||||||
|
protected readonly statRows = computed<StatRow[]>(() => {
|
||||||
|
const item = this.item();
|
||||||
|
if (!item) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const compareTo = this.equippedItemInSlot();
|
||||||
|
const comparable = compareTo && compareTo.id !== item.id ? compareTo.item : null;
|
||||||
|
|
||||||
|
return STAT_LABELS.map(({ label, key }) => ({
|
||||||
|
label,
|
||||||
|
value: item.item[key],
|
||||||
|
diff: comparable ? item.item[key] - comparable[key] : null,
|
||||||
|
})).filter((row) => row.value > 0 || (row.diff ?? 0) !== 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
protected readonly isEquippable = computed(() => !!this.item()?.item.equipmentSlot);
|
||||||
|
|
||||||
|
protected readonly meetsLevelRequirement = computed(() => {
|
||||||
|
const item = this.item();
|
||||||
|
return item ? item.item.requiredLevel <= this.characterLevel() : true;
|
||||||
|
});
|
||||||
|
|
||||||
|
protected onEquip(): void {
|
||||||
|
const item = this.item();
|
||||||
|
if (item) {
|
||||||
|
this.equip.emit(item.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<section class="inventory-page" aria-label="Inventar">
|
||||||
|
<h1 class="inventory-page__title">Inventar</h1>
|
||||||
|
|
||||||
|
@if (inventoryStore.loading() && !inventoryStore.inventory()) {
|
||||||
|
<p class="inventory-page__notice" role="status">Inventar wird geladen…</p>
|
||||||
|
} @else if (inventoryStore.inventory(); as inventory) {
|
||||||
|
<div class="inventory-page__columns">
|
||||||
|
<!-- ---------- paper doll + derived stats ---------- -->
|
||||||
|
<div class="inventory-page__rail">
|
||||||
|
<section class="ar-panel panel--doll" aria-label="Ausrüstung">
|
||||||
|
<h2 class="ar-panel__title">Ausrüstung</h2>
|
||||||
|
|
||||||
|
@if (inventoryStore.equipment(); as equipment) {
|
||||||
|
<div class="doll inventory-page__equipment-list">
|
||||||
|
<div class="doll__column">
|
||||||
|
@for (slot of dollLeft; track slot) {
|
||||||
|
<ng-container
|
||||||
|
*ngTemplateOutlet="dollSlot; context: { label: slotLabels[slot], entry: equipment.slots[slot] }"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="doll__figure" role="img" aria-label="Ausgerüsteter Charakter"></div>
|
||||||
|
|
||||||
|
<div class="doll__column">
|
||||||
|
@for (slot of dollRight; track slot) {
|
||||||
|
<ng-container
|
||||||
|
*ngTemplateOutlet="dollSlot; context: { label: slotLabels[slot], entry: equipment.slots[slot] }"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="doll__below">
|
||||||
|
<ng-container
|
||||||
|
*ngTemplateOutlet="
|
||||||
|
dollSlot;
|
||||||
|
context: { label: slotLabels[dollBelow], entry: equipment.slots[dollBelow] }
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@if (inventoryStore.equipment(); as equipment) {
|
||||||
|
<section class="ar-panel panel--values" aria-label="Werte">
|
||||||
|
<h2 class="ar-panel__title">Werte</h2>
|
||||||
|
<dl class="values" data-inventory-stats>
|
||||||
|
<div class="values__row">
|
||||||
|
<dt><span class="values__glyph" aria-hidden="true">❤</span>Leben</dt>
|
||||||
|
<dd>{{ equipment.stats.maxHp }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="values__row">
|
||||||
|
<dt><span class="values__glyph" aria-hidden="true">⚔</span>Angriff</dt>
|
||||||
|
<dd>{{ equipment.stats.attack }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="values__row">
|
||||||
|
<dt><span class="values__glyph" aria-hidden="true">†</span>Waffenschaden</dt>
|
||||||
|
<dd>{{ equipment.stats.weaponDamage }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="values__row">
|
||||||
|
<dt><span class="values__glyph" aria-hidden="true">⛊</span>Rüstung</dt>
|
||||||
|
<dd>{{ equipment.stats.armor }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ---------- the bag ---------- -->
|
||||||
|
<section class="ar-panel panel--bag" aria-label="Gegenstände">
|
||||||
|
<h2 class="ar-panel__title">Beutel</h2>
|
||||||
|
|
||||||
|
<div class="bag">
|
||||||
|
@for (entry of bagCells(); track $index) {
|
||||||
|
@if (entry) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cell inventory-page__slot"
|
||||||
|
[class.cell--selected]="inventoryStore.selectedItemId() === entry.id"
|
||||||
|
[class.cell--rare]="entry.item.rarity === 'RARE'"
|
||||||
|
[class.cell--epic]="entry.item.rarity === 'EPIC'"
|
||||||
|
[attr.aria-pressed]="inventoryStore.selectedItemId() === entry.id"
|
||||||
|
[attr.aria-label]="entry.item.name"
|
||||||
|
(click)="selectItem(entry.id)"
|
||||||
|
>
|
||||||
|
<img class="cell__icon" [src]="entry.item.iconPath" alt="" />
|
||||||
|
@if (entry.quantity > 1) {
|
||||||
|
<span class="cell__quantity">{{ entry.quantity }}</span>
|
||||||
|
}
|
||||||
|
@if (entry.equipped) {
|
||||||
|
<span class="cell__equipped" data-slot-equipped>
|
||||||
|
<span class="visually-hidden">Ausgerüstet</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<div class="cell cell--empty" aria-hidden="true"></div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="bag__footer">
|
||||||
|
<span class="bag__count">{{ bagUsed() }} / {{ bagCapacity() }} Plätze belegt</span>
|
||||||
|
@if (bagUsed() === 0) {
|
||||||
|
<span class="bag__hint" data-inventory-empty>Noch keine Gegenstände gefunden.</span>
|
||||||
|
}
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ---------- selected item ---------- -->
|
||||||
|
<app-inventory-detail-panel
|
||||||
|
class="inventory-page__detail"
|
||||||
|
[item]="inventoryStore.selectedItem()"
|
||||||
|
[equippedItemInSlot]="equippedItemInSelectedSlot()"
|
||||||
|
[characterLevel]="characterLevel()"
|
||||||
|
[busy]="inventoryStore.equipping()"
|
||||||
|
(equip)="equipSelected($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (inventoryStore.error(); as error) {
|
||||||
|
<section class="inventory-page__notice inventory-page__notice--error" role="alert">
|
||||||
|
<p>{{ error }}</p>
|
||||||
|
<button type="button" data-inventory-retry (click)="retry()">Erneut versuchen</button>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- One equipment socket: label above, framed icon below. Filled sockets are
|
||||||
|
buttons so the equipped piece can be inspected like any other item. -->
|
||||||
|
<ng-template #dollSlot let-label="label" let-entry="entry">
|
||||||
|
<div class="socket">
|
||||||
|
<span class="socket__label">{{ label }}</span>
|
||||||
|
@if (entry) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="socket__frame socket__frame--filled"
|
||||||
|
[class.socket__frame--rare]="entry.item.rarity === 'RARE'"
|
||||||
|
[class.socket__frame--epic]="entry.item.rarity === 'EPIC'"
|
||||||
|
[class.socket__frame--selected]="inventoryStore.selectedItemId() === entry.characterItemId"
|
||||||
|
[attr.aria-label]="label + ': ' + entry.item.name"
|
||||||
|
(click)="selectItem(entry.characterItemId)"
|
||||||
|
>
|
||||||
|
<img [src]="entry.item.iconPath" alt="" />
|
||||||
|
</button>
|
||||||
|
<span class="visually-hidden">{{ entry.item.name }}</span>
|
||||||
|
} @else {
|
||||||
|
<span class="socket__frame socket__frame--empty">
|
||||||
|
<span class="visually-hidden">Leer</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
block-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fits the viewport instead of growing it, so the bag scrolls inside its own
|
||||||
|
panel rather than dragging the whole screen down. Same measure the combat
|
||||||
|
stage uses for the shell chrome above and below. */
|
||||||
|
.inventory-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
block-size: max(34rem, calc(100dvh - 12rem));
|
||||||
|
min-block-size: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-page__title {
|
||||||
|
margin: 0 0 var(--ar-space-4);
|
||||||
|
color: var(--ar-text);
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
font-size: clamp(1.5rem, 2.6vw, 2rem);
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-shadow: 0 0.1rem 0.6rem rgb(0 0 0 / 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Three rails: what you wear, what you carry, what you are looking at. */
|
||||||
|
.inventory-page__columns {
|
||||||
|
display: grid;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
grid-template-columns: minmax(18rem, 21rem) minmax(0, 1fr) minmax(16rem, 19rem);
|
||||||
|
gap: var(--ar-space-4);
|
||||||
|
align-items: stretch;
|
||||||
|
min-block-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The doll takes the slack; the derived values sit under it like a plate. */
|
||||||
|
.inventory-page__rail {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 1fr auto;
|
||||||
|
gap: var(--ar-space-4);
|
||||||
|
min-block-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Panel chrome (.ar-panel) lives in styles.scss — every screen shares it. */
|
||||||
|
|
||||||
|
.panel--doll {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
min-block-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Title, cells, then the count pinned to the bottom edge. */
|
||||||
|
.panel--bag {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto 1fr;
|
||||||
|
min-block-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- paper doll ---------- */
|
||||||
|
|
||||||
|
.doll {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(3.5rem, 1fr) auto;
|
||||||
|
grid-template-rows: minmax(0, 1fr) auto;
|
||||||
|
gap: var(--ar-space-2) var(--ar-space-3);
|
||||||
|
min-block-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doll__column {
|
||||||
|
display: grid;
|
||||||
|
grid-row: 1;
|
||||||
|
gap: var(--ar-space-3);
|
||||||
|
align-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The same warrior that fights on the combat screen stands here wearing the
|
||||||
|
result. Frame 0 of the six-frame attack sheet is the resting stance; the box
|
||||||
|
is kept square so that frame lands at its own proportions and never smears. */
|
||||||
|
.doll__figure {
|
||||||
|
grid-row: 1;
|
||||||
|
grid-column: 2;
|
||||||
|
z-index: 0;
|
||||||
|
place-self: center;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
/* Wider than its column: the character stands behind the sockets rather than
|
||||||
|
squeezed between them, as in the concept. */
|
||||||
|
inline-size: 165%;
|
||||||
|
max-block-size: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
background-image: url('/images/character/female-320.png');
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-size: contain;
|
||||||
|
filter: drop-shadow(0 0.4rem 0.9rem rgb(0 0 0 / 0.75));
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doll__below {
|
||||||
|
grid-row: 2;
|
||||||
|
grid-column: 2;
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.socket {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.15rem;
|
||||||
|
justify-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.socket__label {
|
||||||
|
color: var(--ar-text-muted);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.socket__frame {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
inline-size: 3.35rem;
|
||||||
|
block-size: 3.35rem;
|
||||||
|
padding: 0.2rem;
|
||||||
|
border: 1px solid var(--ar-border);
|
||||||
|
background: linear-gradient(180deg, #1b1f22, #0d1012);
|
||||||
|
box-shadow: inset 0 0.15rem 0.5rem rgb(0 0 0 / 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.socket__frame img {
|
||||||
|
inline-size: 100%;
|
||||||
|
block-size: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* An empty socket is a recess, not a pattern — quiet enough that a filled
|
||||||
|
one is what the eye lands on. */
|
||||||
|
.socket__frame--empty {
|
||||||
|
border-color: rgb(85 74 57 / 0.55);
|
||||||
|
background: linear-gradient(180deg, #141719, #0a0d0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.socket__frame--filled {
|
||||||
|
border-color: var(--ar-border-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.socket__frame--filled:hover {
|
||||||
|
border-color: var(--ar-gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.socket__frame--rare {
|
||||||
|
border-color: var(--ar-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.socket__frame--epic {
|
||||||
|
border-color: var(--ar-gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.socket__frame--selected {
|
||||||
|
outline: 1px solid var(--ar-blue);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- values ---------- */
|
||||||
|
|
||||||
|
.values {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.1rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.values__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--ar-space-3);
|
||||||
|
padding-block: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.values__row + .values__row {
|
||||||
|
border-block-start: 1px solid rgb(85 74 57 / 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.values dt {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--ar-space-2);
|
||||||
|
color: var(--ar-text-muted);
|
||||||
|
font-size: var(--ar-font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.values__glyph {
|
||||||
|
color: var(--ar-border-highlight);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.values dd {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ar-text);
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- the bag ---------- */
|
||||||
|
|
||||||
|
.bag {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||||
|
gap: var(--ar-space-2);
|
||||||
|
align-content: start;
|
||||||
|
min-block-size: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-color: var(--ar-border) transparent;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
/* Room for the scrollbar so cells never sit under it. */
|
||||||
|
padding-inline-end: var(--ar-space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
padding: 0.42rem;
|
||||||
|
border: 1px solid var(--ar-border);
|
||||||
|
background: linear-gradient(180deg, #1b1f22, #0d1012);
|
||||||
|
box-shadow: inset 0 0.15rem 0.5rem rgb(0 0 0 / 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell--empty {
|
||||||
|
border-color: rgb(85 74 57 / 0.45);
|
||||||
|
background: linear-gradient(180deg, #141719, #0a0d0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell.inventory-page__slot:hover {
|
||||||
|
border-color: var(--ar-border-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell--rare {
|
||||||
|
border-color: rgb(92 169 216 / 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell--epic {
|
||||||
|
border-color: rgb(201 164 95 / 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell--selected {
|
||||||
|
border-color: var(--ar-blue);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0.15rem 0.5rem rgb(0 0 0 / 0.7),
|
||||||
|
0 0 0 1px var(--ar-blue),
|
||||||
|
0 0 0.7rem rgb(92 169 216 / 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell__icon {
|
||||||
|
inline-size: 100%;
|
||||||
|
block-size: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell__quantity {
|
||||||
|
position: absolute;
|
||||||
|
inset-block-end: 0.1rem;
|
||||||
|
inset-inline-end: 0.25rem;
|
||||||
|
color: var(--ar-text);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-shadow: 0 0 0.3rem #000, 0 0 0.2rem #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Equipped pieces live on the doll; in the bag they carry a quiet gold notch. */
|
||||||
|
.cell__equipped {
|
||||||
|
position: absolute;
|
||||||
|
inset-block-start: 0;
|
||||||
|
inset-inline-start: 0;
|
||||||
|
inline-size: 0;
|
||||||
|
block-size: 0;
|
||||||
|
border-block-start: 0.55rem solid var(--ar-gold);
|
||||||
|
border-inline-end: 0.55rem solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bag__footer {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--ar-space-3);
|
||||||
|
align-items: end;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-self: end;
|
||||||
|
inline-size: 100%;
|
||||||
|
margin-block-start: var(--ar-space-4);
|
||||||
|
padding-block-start: var(--ar-space-3);
|
||||||
|
border-block-start: 1px solid rgb(85 74 57 / 0.5);
|
||||||
|
color: var(--ar-text-muted);
|
||||||
|
font-size: var(--ar-font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bag__count {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bag__hint {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- notices ---------- */
|
||||||
|
|
||||||
|
.inventory-page__notice {
|
||||||
|
padding: var(--ar-space-4);
|
||||||
|
color: var(--ar-text-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-page__notice--error {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--ar-space-4);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-block-start: var(--ar-space-4);
|
||||||
|
border: 1px solid var(--ar-danger);
|
||||||
|
background: var(--ar-panel);
|
||||||
|
color: var(--ar-danger);
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-page__notice--error p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-page__notice--error button {
|
||||||
|
padding: var(--ar-space-2) var(--ar-space-4);
|
||||||
|
border: 1px solid var(--ar-border-highlight);
|
||||||
|
border-radius: var(--ar-radius-sm);
|
||||||
|
color: var(--ar-text);
|
||||||
|
background: linear-gradient(180deg, #23282c, #14181b);
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-page__notice--error button:hover {
|
||||||
|
border-color: var(--ar-gold);
|
||||||
|
color: var(--ar-gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- responsive ---------- */
|
||||||
|
|
||||||
|
/* Once the columns start stacking the screen is taller than the viewport, so
|
||||||
|
the page goes back to scrolling as a whole and the bag stops scrolling
|
||||||
|
inside itself — two nested scrollers would fight each other. */
|
||||||
|
@media (width < 76rem) {
|
||||||
|
.inventory-page {
|
||||||
|
block-size: auto;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-page__columns {
|
||||||
|
grid-template-columns: minmax(16rem, 18rem) minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-page__detail {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bag {
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
overflow-y: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel--doll,
|
||||||
|
.panel--bag {
|
||||||
|
grid-template-rows: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doll {
|
||||||
|
grid-template-rows: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doll__figure {
|
||||||
|
inline-size: 100%;
|
||||||
|
min-block-size: 12rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width < 52rem) {
|
||||||
|
.inventory-page {
|
||||||
|
padding: var(--ar-space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-page__columns {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bag {
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { signal } from '@angular/core';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import type { CharacterResponse, EquipmentResponse, InventoryItem, InventoryResponse } from '../../core/api/game-api.models';
|
||||||
|
import { WorldStore } from '../world/world.store';
|
||||||
|
import { InventoryDetailPanelComponent } from './inventory-detail-panel.component';
|
||||||
|
import { InventoryPageComponent } from './inventory-page.component';
|
||||||
|
import { InventoryStore } from './inventory.store';
|
||||||
|
|
||||||
|
const inventory: InventoryResponse = {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'item-sword',
|
||||||
|
quantity: 1,
|
||||||
|
equipped: true,
|
||||||
|
item: {
|
||||||
|
key: 'worn-short-sword',
|
||||||
|
name: 'Abgenutztes Kurzschwert',
|
||||||
|
description: 'Beschreibung des Gegenstands.',
|
||||||
|
rarity: 'COMMON',
|
||||||
|
equipmentSlot: 'WEAPON',
|
||||||
|
requiredLevel: 1,
|
||||||
|
weaponDamage: 8,
|
||||||
|
bonusAttack: 0,
|
||||||
|
bonusHp: 0,
|
||||||
|
bonusArmor: 0,
|
||||||
|
iconPath: '/images/items/worn-short-sword.png',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'item-blade',
|
||||||
|
quantity: 1,
|
||||||
|
equipped: false,
|
||||||
|
item: {
|
||||||
|
key: 'bandit-blade',
|
||||||
|
name: 'Räuberklinge',
|
||||||
|
description: 'Beschreibung des Gegenstands.',
|
||||||
|
rarity: 'COMMON',
|
||||||
|
equipmentSlot: 'WEAPON',
|
||||||
|
requiredLevel: 1,
|
||||||
|
weaponDamage: 11,
|
||||||
|
bonusAttack: 1,
|
||||||
|
bonusHp: 0,
|
||||||
|
bonusArmor: 0,
|
||||||
|
iconPath: '/images/items/bandit-blade.png',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const equipment: EquipmentResponse = {
|
||||||
|
slots: {
|
||||||
|
WEAPON: { characterItemId: 'item-sword', item: { key: 'worn-short-sword', name: 'Abgenutztes Kurzschwert', rarity: 'COMMON', iconPath: '/images/items/worn-short-sword.png' } },
|
||||||
|
HEAD: null,
|
||||||
|
CHEST: null,
|
||||||
|
HANDS: null,
|
||||||
|
LEGS: null,
|
||||||
|
FEET: null,
|
||||||
|
AMULET: null,
|
||||||
|
},
|
||||||
|
stats: { maxHp: 100, attack: 6, weaponDamage: 8, armor: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const character: CharacterResponse = {
|
||||||
|
id: 'character-1',
|
||||||
|
name: 'Aric Duskwalker',
|
||||||
|
level: 1,
|
||||||
|
experience: 0,
|
||||||
|
silver: 0,
|
||||||
|
currentHp: 100,
|
||||||
|
maxHp: 100,
|
||||||
|
attack: 6,
|
||||||
|
hpRegenPerSecond: 1,
|
||||||
|
hpRegenSince: null,
|
||||||
|
currentLocation: { id: 'loc-1', key: 'south-gate', name: 'Südtor' },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SetupOptions {
|
||||||
|
inventoryData?: InventoryResponse;
|
||||||
|
selectedItemId?: string | null;
|
||||||
|
selectedItem?: InventoryItem | null;
|
||||||
|
character?: CharacterResponse | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setup(options: SetupOptions = {}) {
|
||||||
|
const inventoryStore = {
|
||||||
|
inventory: signal(options.inventoryData ?? inventory),
|
||||||
|
equipment: signal(equipment),
|
||||||
|
selectedItemId: signal<string | null>(options.selectedItemId ?? null),
|
||||||
|
loading: signal(false),
|
||||||
|
equipping: signal(false),
|
||||||
|
error: signal<string | null>(null),
|
||||||
|
load: vi.fn(() => Promise.resolve()),
|
||||||
|
selectItem: vi.fn(),
|
||||||
|
selectedItem: vi.fn(() => options.selectedItem ?? null),
|
||||||
|
equip: vi.fn(() => Promise.resolve()),
|
||||||
|
};
|
||||||
|
const worldStore = {
|
||||||
|
character: signal(options.character === undefined ? character : options.character),
|
||||||
|
load: vi.fn(() => Promise.resolve()),
|
||||||
|
};
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [InventoryPageComponent],
|
||||||
|
providers: [
|
||||||
|
{ provide: InventoryStore, useValue: inventoryStore },
|
||||||
|
{ provide: WorldStore, useValue: worldStore },
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(InventoryPageComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
return { fixture, inventoryStore, worldStore };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('InventoryPageComponent', () => {
|
||||||
|
it('loads the inventory on init', async () => {
|
||||||
|
const { inventoryStore } = await setup();
|
||||||
|
expect(inventoryStore.load).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads the world state on init when no character has been loaded yet (direct navigation/hard refresh)', async () => {
|
||||||
|
const { worldStore } = await setup({ character: null });
|
||||||
|
expect(worldStore.load).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call world load again when a character is already present', async () => {
|
||||||
|
const { worldStore } = await setup();
|
||||||
|
expect(worldStore.load).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders one tile per owned item', async () => {
|
||||||
|
const { fixture } = await setup();
|
||||||
|
const tiles = (fixture.nativeElement as HTMLElement).querySelectorAll('.inventory-page__slot');
|
||||||
|
expect(tiles.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the equipped item with a badge', async () => {
|
||||||
|
const { fixture } = await setup();
|
||||||
|
expect((fixture.nativeElement as HTMLElement).querySelector('[data-slot-equipped]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects an item when its tile is clicked', async () => {
|
||||||
|
const { fixture, inventoryStore } = await setup();
|
||||||
|
(fixture.nativeElement as HTMLElement).querySelectorAll<HTMLButtonElement>('.inventory-page__slot')[1].click();
|
||||||
|
|
||||||
|
expect(inventoryStore.selectItem).toHaveBeenCalledWith('item-blade');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the equipment overview with all seven slots and empty ones as Leer', async () => {
|
||||||
|
const { fixture } = await setup();
|
||||||
|
const text = (fixture.nativeElement as HTMLElement).querySelector('.inventory-page__equipment-list')?.textContent ?? '';
|
||||||
|
|
||||||
|
expect(text).toContain('Waffe');
|
||||||
|
expect(text).toContain('Abgenutztes Kurzschwert');
|
||||||
|
expect(text).toContain('Kopf');
|
||||||
|
expect(text).toContain('Leer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the effective stats summary from the equipment response', async () => {
|
||||||
|
const { fixture } = await setup();
|
||||||
|
const text = (fixture.nativeElement as HTMLElement).querySelector('[data-inventory-stats]')?.textContent ?? '';
|
||||||
|
|
||||||
|
expect(text).toContain('100');
|
||||||
|
expect(text).toContain('6');
|
||||||
|
expect(text).toContain('8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the equipped item from the SAME slot as the selection — not just any equipped item — to the detail panel', async () => {
|
||||||
|
// item-helm (HEAD, equipped) is placed before item-sword (WEAPON, equipped) so that a
|
||||||
|
// regression which drops the equipmentSlot match (i.e. "find the first equipped item")
|
||||||
|
// would surface item-helm instead of item-sword, and this test would fail.
|
||||||
|
const threeItemInventory: InventoryResponse = {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'item-helm',
|
||||||
|
quantity: 1,
|
||||||
|
equipped: true,
|
||||||
|
item: {
|
||||||
|
key: 'iron-helm',
|
||||||
|
name: 'Eiserner Helm',
|
||||||
|
description: 'Beschreibung des Gegenstands.',
|
||||||
|
rarity: 'COMMON',
|
||||||
|
equipmentSlot: 'HEAD',
|
||||||
|
requiredLevel: 1,
|
||||||
|
weaponDamage: 0,
|
||||||
|
bonusAttack: 0,
|
||||||
|
bonusHp: 5,
|
||||||
|
bonusArmor: 2,
|
||||||
|
iconPath: '/images/items/iron-helm.png',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
inventory.items[0], // item-sword, WEAPON, equipped
|
||||||
|
inventory.items[1], // item-blade, WEAPON, not equipped — this is the selection
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const { fixture } = await setup({
|
||||||
|
inventoryData: threeItemInventory,
|
||||||
|
selectedItemId: 'item-blade',
|
||||||
|
selectedItem: threeItemInventory.items[2],
|
||||||
|
});
|
||||||
|
|
||||||
|
const panel = fixture.debugElement.query(By.directive(InventoryDetailPanelComponent))
|
||||||
|
.componentInstance as InventoryDetailPanelComponent;
|
||||||
|
|
||||||
|
expect(panel.equippedItemInSlot()?.id).toBe('item-sword');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { NgTemplateOutlet } from '@angular/common';
|
||||||
|
import { Component, OnInit, computed, inject } from '@angular/core';
|
||||||
|
import type { EquipmentSlot, InventoryItem } from '../../core/api/game-api.models';
|
||||||
|
import { WorldStore } from '../world/world.store';
|
||||||
|
import { InventoryDetailPanelComponent } from './inventory-detail-panel.component';
|
||||||
|
import { SLOT_LABELS } from './inventory.labels';
|
||||||
|
import { InventoryStore } from './inventory.store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The paper doll reads as a body: weapon hand and worn trinkets down the left,
|
||||||
|
* armour down the right, legs beneath the figure. Only the seven slots the
|
||||||
|
* game actually models appear — no decorative rings or offhand (spec §38).
|
||||||
|
*/
|
||||||
|
const DOLL_LEFT: readonly EquipmentSlot[] = ['WEAPON', 'AMULET', 'HANDS'];
|
||||||
|
const DOLL_RIGHT: readonly EquipmentSlot[] = ['HEAD', 'CHEST', 'FEET'];
|
||||||
|
const DOLL_BELOW: EquipmentSlot = 'LEGS';
|
||||||
|
|
||||||
|
// Every slot, in the order the equipment list announces them.
|
||||||
|
const SLOT_ORDER: readonly EquipmentSlot[] = [...DOLL_LEFT, ...DOLL_RIGHT, DOLL_BELOW];
|
||||||
|
|
||||||
|
// The bag is drawn as a fixed grid so it reads as a container with room left,
|
||||||
|
// not as a list that happens to be short. Capacity is not enforced yet
|
||||||
|
// (spec §31): the empty cells are structure, not a limit.
|
||||||
|
const BAG_COLUMNS = 8;
|
||||||
|
const BAG_MIN_CELLS = 64;
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-inventory-page',
|
||||||
|
imports: [NgTemplateOutlet, InventoryDetailPanelComponent],
|
||||||
|
templateUrl: './inventory-page.component.html',
|
||||||
|
styleUrl: './inventory-page.component.scss',
|
||||||
|
})
|
||||||
|
export class InventoryPageComponent implements OnInit {
|
||||||
|
protected readonly inventoryStore = inject(InventoryStore);
|
||||||
|
private readonly worldStore = inject(WorldStore);
|
||||||
|
|
||||||
|
protected readonly dollLeft = DOLL_LEFT;
|
||||||
|
protected readonly dollRight = DOLL_RIGHT;
|
||||||
|
protected readonly dollBelow = DOLL_BELOW;
|
||||||
|
protected readonly slotOrder = SLOT_ORDER;
|
||||||
|
protected readonly slotLabels = SLOT_LABELS;
|
||||||
|
|
||||||
|
protected readonly characterLevel = computed(() => this.worldStore.character()?.level ?? 1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pads the owned items out to a full rectangle of cells. `null` is an empty
|
||||||
|
* slot; the grid never ends on a ragged row.
|
||||||
|
*/
|
||||||
|
protected readonly bagCells = computed<(InventoryItem | null)[]>(() => {
|
||||||
|
const items = this.inventoryStore.inventory()?.items ?? [];
|
||||||
|
const filled = Math.ceil(items.length / BAG_COLUMNS) * BAG_COLUMNS;
|
||||||
|
const total = Math.max(BAG_MIN_CELLS, filled);
|
||||||
|
return Array.from({ length: total }, (_, index) => items[index] ?? null);
|
||||||
|
});
|
||||||
|
|
||||||
|
protected readonly bagUsed = computed(() => this.inventoryStore.inventory()?.items.length ?? 0);
|
||||||
|
protected readonly bagCapacity = computed(() => this.bagCells().length);
|
||||||
|
|
||||||
|
protected readonly equippedItemInSelectedSlot = computed(() => {
|
||||||
|
const selected = this.inventoryStore.selectedItem();
|
||||||
|
if (!selected?.item.equipmentSlot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
this.inventoryStore.inventory()?.items.find(
|
||||||
|
(item) => item.equipped && item.item.equipmentSlot === selected.item.equipmentSlot,
|
||||||
|
) ?? null
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
if (this.worldStore.character() === null) {
|
||||||
|
void this.worldStore.load();
|
||||||
|
}
|
||||||
|
void this.inventoryStore.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected selectItem(itemId: string): void {
|
||||||
|
this.inventoryStore.selectItem(itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected retry(): void {
|
||||||
|
void this.inventoryStore.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async equipSelected(characterItemId: string): Promise<void> {
|
||||||
|
await this.inventoryStore.equip(characterItemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/web/src/app/features/inventory/inventory.labels.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { EquipmentSlot } from '../../core/api/game-api.models';
|
||||||
|
|
||||||
|
/** German slot names, shared by the paper doll and the item detail panel. */
|
||||||
|
export const SLOT_LABELS: Readonly<Record<EquipmentSlot, string>> = {
|
||||||
|
WEAPON: 'Waffe',
|
||||||
|
HEAD: 'Kopf',
|
||||||
|
CHEST: 'Brust',
|
||||||
|
HANDS: 'Handschuhe',
|
||||||
|
LEGS: 'Beine',
|
||||||
|
FEET: 'Stiefel',
|
||||||
|
AMULET: 'Amulett',
|
||||||
|
};
|
||||||
147
apps/web/src/app/features/inventory/inventory.store.spec.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { of, throwError } from 'rxjs';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import type { EquipmentResponse, InventoryResponse } from '../../core/api/game-api.models';
|
||||||
|
import { GameApiService } from '../../core/api/game-api.service';
|
||||||
|
import { WorldStore } from '../world/world.store';
|
||||||
|
import { InventoryStore } from './inventory.store';
|
||||||
|
|
||||||
|
const inventory: InventoryResponse = {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'item-sword',
|
||||||
|
quantity: 1,
|
||||||
|
equipped: true,
|
||||||
|
item: {
|
||||||
|
key: 'worn-short-sword',
|
||||||
|
name: 'Abgenutztes Kurzschwert',
|
||||||
|
description: 'Beschreibung des Gegenstands.',
|
||||||
|
rarity: 'COMMON',
|
||||||
|
equipmentSlot: 'WEAPON',
|
||||||
|
requiredLevel: 1,
|
||||||
|
weaponDamage: 8,
|
||||||
|
bonusAttack: 0,
|
||||||
|
bonusHp: 0,
|
||||||
|
bonusArmor: 0,
|
||||||
|
iconPath: '/images/items/worn-short-sword.png',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'item-blade',
|
||||||
|
quantity: 1,
|
||||||
|
equipped: false,
|
||||||
|
item: {
|
||||||
|
key: 'bandit-blade',
|
||||||
|
name: 'Räuberklinge',
|
||||||
|
description: 'Beschreibung des Gegenstands.',
|
||||||
|
rarity: 'COMMON',
|
||||||
|
equipmentSlot: 'WEAPON',
|
||||||
|
requiredLevel: 1,
|
||||||
|
weaponDamage: 11,
|
||||||
|
bonusAttack: 1,
|
||||||
|
bonusHp: 0,
|
||||||
|
bonusArmor: 0,
|
||||||
|
iconPath: '/images/items/bandit-blade.png',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const equipment: EquipmentResponse = {
|
||||||
|
slots: {
|
||||||
|
WEAPON: { characterItemId: 'item-sword', item: { key: 'worn-short-sword', name: 'Abgenutztes Kurzschwert', rarity: 'COMMON', iconPath: '/images/items/worn-short-sword.png' } },
|
||||||
|
HEAD: null,
|
||||||
|
CHEST: null,
|
||||||
|
HANDS: null,
|
||||||
|
LEGS: null,
|
||||||
|
FEET: null,
|
||||||
|
AMULET: null,
|
||||||
|
},
|
||||||
|
stats: { maxHp: 100, attack: 6, weaponDamage: 8, armor: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const equippedAfter: EquipmentResponse = {
|
||||||
|
...equipment,
|
||||||
|
slots: { ...equipment.slots, WEAPON: { characterItemId: 'item-blade', item: { key: 'bandit-blade', name: 'Räuberklinge', rarity: 'COMMON', iconPath: '/images/items/bandit-blade.png' } } },
|
||||||
|
stats: { maxHp: 100, attack: 7, weaponDamage: 11, armor: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const inventoryAfterEquip: InventoryResponse = {
|
||||||
|
items: [
|
||||||
|
{ ...inventory.items[0], equipped: false },
|
||||||
|
{ ...inventory.items[1], equipped: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('InventoryStore', () => {
|
||||||
|
let api: {
|
||||||
|
getInventory: ReturnType<typeof vi.fn>;
|
||||||
|
getEquipment: ReturnType<typeof vi.fn>;
|
||||||
|
equipItem: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
let worldStore: { refreshCharacter: ReturnType<typeof vi.fn> };
|
||||||
|
let store: InventoryStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
api = {
|
||||||
|
getInventory: vi.fn(() => of(inventory)),
|
||||||
|
getEquipment: vi.fn(() => of(equipment)),
|
||||||
|
equipItem: vi.fn(() => of(equippedAfter)),
|
||||||
|
};
|
||||||
|
worldStore = { refreshCharacter: vi.fn(() => Promise.resolve()) };
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
InventoryStore,
|
||||||
|
{ provide: GameApiService, useValue: api },
|
||||||
|
{ provide: WorldStore, useValue: worldStore },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
store = TestBed.inject(InventoryStore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads inventory and equipment together', async () => {
|
||||||
|
await store.load();
|
||||||
|
|
||||||
|
expect(store.inventory()).toEqual(inventory);
|
||||||
|
expect(store.equipment()).toEqual(equipment);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects an item by id', async () => {
|
||||||
|
await store.load();
|
||||||
|
|
||||||
|
store.selectItem('item-blade');
|
||||||
|
|
||||||
|
expect(store.selectedItemId()).toBe('item-blade');
|
||||||
|
expect(store.selectedItem()).toEqual(inventory.items[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('equips the selected item, refreshes inventory/equipment, and refreshes the character HUD', async () => {
|
||||||
|
api.getInventory
|
||||||
|
.mockReturnValueOnce(of(inventory)) // initial load()
|
||||||
|
.mockReturnValueOnce(of(inventoryAfterEquip)); // post-equip refetch
|
||||||
|
|
||||||
|
await store.load();
|
||||||
|
|
||||||
|
await store.equip('item-blade');
|
||||||
|
|
||||||
|
expect(api.equipItem).toHaveBeenCalledWith('item-blade');
|
||||||
|
expect(store.equipment()).toEqual(equippedAfter);
|
||||||
|
expect(store.inventory()).toEqual(inventoryAfterEquip);
|
||||||
|
expect(store.inventory()?.items.find((item) => item.id === 'item-sword')?.equipped).toBe(false);
|
||||||
|
expect(store.inventory()?.items.find((item) => item.id === 'item-blade')?.equipped).toBe(true);
|
||||||
|
expect(worldStore.refreshCharacter).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a German message for a known equip error', async () => {
|
||||||
|
await store.load();
|
||||||
|
api.equipItem.mockReturnValue(
|
||||||
|
throwError(() => new HttpErrorResponse({ error: { code: 'ITEM_LEVEL_REQUIREMENT_NOT_MET' }, status: 400 })),
|
||||||
|
);
|
||||||
|
|
||||||
|
await store.equip('item-blade');
|
||||||
|
|
||||||
|
expect(store.error()).toBe('Du erfüllst die Stufenanforderung nicht.');
|
||||||
|
});
|
||||||
|
});
|
||||||
96
apps/web/src/app/features/inventory/inventory.store.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { Injectable, signal } from '@angular/core';
|
||||||
|
import { firstValueFrom, forkJoin } from 'rxjs';
|
||||||
|
import { EquipmentResponse, InventoryItem, InventoryResponse } from '../../core/api/game-api.models';
|
||||||
|
import { GameApiService } from '../../core/api/game-api.service';
|
||||||
|
import { WorldStore } from '../world/world.store';
|
||||||
|
|
||||||
|
const GENERIC_ERROR_MESSAGE = 'Inventar konnte nicht geladen werden.';
|
||||||
|
|
||||||
|
// Mirrors `EquipmentErrorCode` in `apps/api/src/equipment/equipment.errors.ts`.
|
||||||
|
const EQUIPMENT_ERROR_MESSAGES: Readonly<Record<string, string>> = {
|
||||||
|
CHARACTER_ITEM_NOT_FOUND: 'Dieser Gegenstand konnte nicht gefunden werden.',
|
||||||
|
ITEM_NOT_OWNED: 'Dieser Gegenstand gehört dir nicht.',
|
||||||
|
ITEM_NOT_EQUIPPABLE: 'Dieser Gegenstand kann nicht ausgerüstet werden.',
|
||||||
|
ITEM_LEVEL_REQUIREMENT_NOT_MET: 'Du erfüllst die Stufenanforderung nicht.',
|
||||||
|
INVALID_EQUIPMENT_SLOT: 'Dieser Ausrüstungsplatz ist ungültig.',
|
||||||
|
CHARACTER_IN_COMBAT: 'Ausrüstung kann während eines Kampfes nicht geändert werden.',
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class InventoryStore {
|
||||||
|
private readonly inventoryState = signal<InventoryResponse | null>(null);
|
||||||
|
private readonly equipmentState = signal<EquipmentResponse | null>(null);
|
||||||
|
private readonly selectedItemIdState = signal<string | null>(null);
|
||||||
|
private readonly loadingState = signal(false);
|
||||||
|
private readonly equippingState = signal(false);
|
||||||
|
private readonly errorState = signal<string | null>(null);
|
||||||
|
|
||||||
|
readonly inventory = this.inventoryState.asReadonly();
|
||||||
|
readonly equipment = this.equipmentState.asReadonly();
|
||||||
|
readonly selectedItemId = this.selectedItemIdState.asReadonly();
|
||||||
|
readonly loading = this.loadingState.asReadonly();
|
||||||
|
readonly equipping = this.equippingState.asReadonly();
|
||||||
|
readonly error = this.errorState.asReadonly();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly api: GameApiService,
|
||||||
|
private readonly worldStore: WorldStore,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async load(): Promise<void> {
|
||||||
|
this.loadingState.set(true);
|
||||||
|
this.errorState.set(null);
|
||||||
|
try {
|
||||||
|
const { inventory, equipment } = await firstValueFrom(
|
||||||
|
forkJoin({ inventory: this.api.getInventory(), equipment: this.api.getEquipment() }),
|
||||||
|
);
|
||||||
|
this.inventoryState.set(inventory);
|
||||||
|
this.equipmentState.set(equipment);
|
||||||
|
} catch (error) {
|
||||||
|
this.errorState.set(this.toErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
this.loadingState.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectItem(characterItemId: string | null): void {
|
||||||
|
this.selectedItemIdState.set(characterItemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedItem(): InventoryItem | null {
|
||||||
|
const id = this.selectedItemIdState();
|
||||||
|
if (!id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.inventoryState()?.items.find((item) => item.id === id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Equips an item, then refreshes inventory/equipment and the character HUD (spec §35, §40). */
|
||||||
|
async equip(characterItemId: string): Promise<void> {
|
||||||
|
if (this.equippingState()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.equippingState.set(true);
|
||||||
|
this.errorState.set(null);
|
||||||
|
try {
|
||||||
|
const equipment = await firstValueFrom(this.api.equipItem(characterItemId));
|
||||||
|
this.equipmentState.set(equipment);
|
||||||
|
const inventory = await firstValueFrom(this.api.getInventory());
|
||||||
|
this.inventoryState.set(inventory);
|
||||||
|
await this.worldStore.refreshCharacter();
|
||||||
|
} catch (error) {
|
||||||
|
this.errorState.set(this.toErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
this.equippingState.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof HttpErrorResponse) {
|
||||||
|
const code = (error.error as { code?: string } | null)?.code;
|
||||||
|
return (code && EQUIPMENT_ERROR_MESSAGES[code]) || GENERIC_ERROR_MESSAGE;
|
||||||
|
}
|
||||||
|
return error instanceof Error ? error.message : GENERIC_ERROR_MESSAGE;
|
||||||
|
}
|
||||||
|
}
|
||||||
192
apps/web/src/app/features/world/current-location.fixture.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import type {
|
||||||
|
CurrentLocationResponse,
|
||||||
|
LocationPointOfInterest,
|
||||||
|
LocationPrimaryAction,
|
||||||
|
} from '../../core/api/game-api.models';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test fixtures for `GET /api/world/current-location`.
|
||||||
|
*
|
||||||
|
* Shared rather than re-declared per spec: the payload backs the map, the
|
||||||
|
* hunt screen and the local location view, so a field added to the contract
|
||||||
|
* needs to be answered in exactly one place.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const BURNED_ROAD_POIS: LocationPointOfInterest[] = [
|
||||||
|
{
|
||||||
|
key: 'hunt-area',
|
||||||
|
title: 'Jagdgebiet',
|
||||||
|
actionLabel: 'Jagd beginnen',
|
||||||
|
type: 'HUNT',
|
||||||
|
iconKey: 'hunt',
|
||||||
|
xPercent: 52,
|
||||||
|
yPercent: 44,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'inspect-tracks',
|
||||||
|
title: 'Verdächtige Spuren',
|
||||||
|
actionLabel: 'Untersuchen',
|
||||||
|
type: 'INVESTIGATE',
|
||||||
|
iconKey: 'investigate',
|
||||||
|
xPercent: 32,
|
||||||
|
yPercent: 78,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'search-abandoned-wagon',
|
||||||
|
title: 'Verlassener Wagen',
|
||||||
|
actionLabel: 'Durchsuchen',
|
||||||
|
type: 'SEARCH',
|
||||||
|
iconKey: 'search',
|
||||||
|
xPercent: 80,
|
||||||
|
yPercent: 68,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'wounded-scout',
|
||||||
|
title: 'Verwundeter Kundschafter',
|
||||||
|
actionLabel: 'Sprechen',
|
||||||
|
type: 'NPC',
|
||||||
|
iconKey: 'speak',
|
||||||
|
xPercent: 20,
|
||||||
|
yPercent: 60,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const BURNED_ROAD_ACTIONS: LocationPrimaryAction[] = [
|
||||||
|
{
|
||||||
|
key: 'start-hunt',
|
||||||
|
label: 'Jagd beginnen',
|
||||||
|
description: 'Im Gebiet jagen',
|
||||||
|
type: 'HUNT',
|
||||||
|
iconKey: 'hunt',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'investigate-tracks',
|
||||||
|
label: 'Spuren untersuchen',
|
||||||
|
description: 'Hinweise finden',
|
||||||
|
type: 'INVESTIGATE',
|
||||||
|
iconKey: 'investigate',
|
||||||
|
enabled: true,
|
||||||
|
poiKey: 'inspect-tracks',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'search-surroundings',
|
||||||
|
label: 'Umgebung durchsuchen',
|
||||||
|
description: 'Beute finden',
|
||||||
|
type: 'SEARCH',
|
||||||
|
iconKey: 'search',
|
||||||
|
enabled: true,
|
||||||
|
poiKey: 'search-abandoned-wagon',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'open-map',
|
||||||
|
label: 'Zur Karte',
|
||||||
|
description: 'Gebiet wechseln',
|
||||||
|
type: 'MAP',
|
||||||
|
iconKey: 'map',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function southGateFixture(
|
||||||
|
overrides: Partial<CurrentLocationResponse> = {},
|
||||||
|
): CurrentLocationResponse {
|
||||||
|
return {
|
||||||
|
id: 'south-gate-id',
|
||||||
|
key: 'south-gate',
|
||||||
|
name: 'Südtor von Graufurt',
|
||||||
|
description: 'Der letzte sichere Schritt vor den Aschenfeldern.',
|
||||||
|
regionKey: 'ashen-fields',
|
||||||
|
minRecommendedLevel: 1,
|
||||||
|
maxRecommendedLevel: 1,
|
||||||
|
dangerLevel: 0,
|
||||||
|
isSafe: true,
|
||||||
|
huntingEnabled: false,
|
||||||
|
artworkPath: '/images/backgrounds/Suedtor.png',
|
||||||
|
regionName: 'Aschenfelder',
|
||||||
|
regionTierLabel: 'Gebiet 1',
|
||||||
|
locationType: 'TRANSITION',
|
||||||
|
localDescription: 'Hinter den Wachtfeuern beginnen die Aschenfelder.',
|
||||||
|
localArtworkPath: '/images/backgrounds/Suedtor.png',
|
||||||
|
dangerRating: null,
|
||||||
|
recommendationLabel: '1',
|
||||||
|
pointsOfInterest: [],
|
||||||
|
primaryActions: [],
|
||||||
|
encounterPreview: [],
|
||||||
|
rewardPreview: [],
|
||||||
|
connections: [
|
||||||
|
{
|
||||||
|
targetLocation: {
|
||||||
|
id: 'burned-road-id',
|
||||||
|
key: 'burned-road',
|
||||||
|
name: 'Verbrannte Straße',
|
||||||
|
},
|
||||||
|
travelDurationSeconds: 10,
|
||||||
|
danger: 'LOW',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
possibleMonsters: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function burnedRoadFixture(
|
||||||
|
overrides: Partial<CurrentLocationResponse> = {},
|
||||||
|
): CurrentLocationResponse {
|
||||||
|
return southGateFixture({
|
||||||
|
id: 'burned-road-id',
|
||||||
|
key: 'burned-road',
|
||||||
|
name: 'Verbrannte Straße',
|
||||||
|
description: 'Die erste Jagdzone zwischen Asche und zerbrochenen Wagen.',
|
||||||
|
maxRecommendedLevel: 2,
|
||||||
|
dangerLevel: 1,
|
||||||
|
isSafe: false,
|
||||||
|
huntingEnabled: true,
|
||||||
|
artworkPath: '/images/backgrounds/Aschestrasse.png',
|
||||||
|
locationType: 'HUNTING_GROUND',
|
||||||
|
localDescription:
|
||||||
|
'Ein alter Handelsweg, der durch Feuer und Krieg in Asche gelegt wurde.',
|
||||||
|
localArtworkPath: '/images/backgrounds/Aschestrasse.png',
|
||||||
|
dangerRating: 'MATCH',
|
||||||
|
recommendationLabel: '1–2',
|
||||||
|
pointsOfInterest: BURNED_ROAD_POIS,
|
||||||
|
primaryActions: BURNED_ROAD_ACTIONS,
|
||||||
|
encounterPreview: [
|
||||||
|
{
|
||||||
|
key: 'ash-rat',
|
||||||
|
name: 'Aschenratte',
|
||||||
|
level: 1,
|
||||||
|
iconPath: '/images/combat/icons/ash-rat-128.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'road-bandit',
|
||||||
|
name: 'Straßenräuber',
|
||||||
|
level: 2,
|
||||||
|
iconPath: '/images/combat/icons/road-bandit-128.png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rewardPreview: [
|
||||||
|
{ key: 'silver', label: 'Silber', iconKey: 'silver' },
|
||||||
|
{ key: 'experience', label: 'Erfahrung', iconKey: 'experience' },
|
||||||
|
{ key: 'equipment', label: 'Ausrüstung', iconKey: 'equipment' },
|
||||||
|
{ key: 'material', label: 'Material', iconKey: 'material' },
|
||||||
|
],
|
||||||
|
connections: [
|
||||||
|
{
|
||||||
|
targetLocation: {
|
||||||
|
id: 'south-gate-id',
|
||||||
|
key: 'south-gate',
|
||||||
|
name: 'Südtor von Graufurt',
|
||||||
|
},
|
||||||
|
travelDurationSeconds: 10,
|
||||||
|
danger: 'LOW',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
possibleMonsters: ['Aschenratte', 'Straßenräuber'],
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||