Merge branch 'master' into worktree-encounter-status
@@ -13,6 +13,7 @@ describe('CharactersService', () => {
|
|||||||
name: 'Aric Duskwalker',
|
name: 'Aric Duskwalker',
|
||||||
level: 1,
|
level: 1,
|
||||||
experience: 0,
|
experience: 0,
|
||||||
|
silver: 0,
|
||||||
currentHp: 100,
|
currentHp: 100,
|
||||||
baseHp: 100,
|
baseHp: 100,
|
||||||
baseAttack: 6,
|
baseAttack: 6,
|
||||||
@@ -30,6 +31,7 @@ describe('CharactersService', () => {
|
|||||||
name: 'Aric Duskwalker',
|
name: 'Aric Duskwalker',
|
||||||
level: 1,
|
level: 1,
|
||||||
experience: 0,
|
experience: 0,
|
||||||
|
silver: 0,
|
||||||
currentHp: 100,
|
currentHp: 100,
|
||||||
maxHp: 100,
|
maxHp: 100,
|
||||||
attack: 6,
|
attack: 6,
|
||||||
@@ -45,6 +47,31 @@ describe('CharactersService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('exposes the persisted silver so the HUD never has to guess', async () => {
|
||||||
|
const repository = {
|
||||||
|
findOne: jest.fn().mockResolvedValue({
|
||||||
|
id: DEMO_CHARACTER_ID,
|
||||||
|
name: 'Aric Duskwalker',
|
||||||
|
level: 1,
|
||||||
|
experience: 24,
|
||||||
|
silver: 18,
|
||||||
|
currentHp: 100,
|
||||||
|
baseHp: 100,
|
||||||
|
baseAttack: 6,
|
||||||
|
currentLocation: {
|
||||||
|
id: SOUTH_GATE_ID,
|
||||||
|
key: 'south-gate',
|
||||||
|
name: 'Südtor von Graufurt',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
} as unknown as Repository<Character>;
|
||||||
|
const service = new CharactersService(repository);
|
||||||
|
|
||||||
|
await expect(service.getDemoCharacter()).resolves.toEqual(
|
||||||
|
expect.objectContaining({ experience: 24, silver: 18 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('reports a missing demo seed as not found', async () => {
|
it('reports a missing demo seed as not found', async () => {
|
||||||
const repository = {
|
const repository = {
|
||||||
findOne: jest.fn().mockResolvedValue(null),
|
findOne: jest.fn().mockResolvedValue(null),
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export class CharactersService {
|
|||||||
name: character.name,
|
name: character.name,
|
||||||
level: character.level,
|
level: character.level,
|
||||||
experience: character.experience,
|
experience: character.experience,
|
||||||
|
silver: character.silver,
|
||||||
currentHp: character.currentHp,
|
currentHp: character.currentHp,
|
||||||
maxHp: character.baseHp,
|
maxHp: character.baseHp,
|
||||||
attack: character.baseAttack,
|
attack: character.baseAttack,
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ export class Character {
|
|||||||
@Column({ name: 'experience', type: 'integer' })
|
@Column({ name: 'experience', type: 'integer' })
|
||||||
experience!: number;
|
experience!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'silver', type: 'integer' })
|
||||||
|
silver!: number;
|
||||||
|
|
||||||
@Column({ name: 'base_hp', type: 'integer' })
|
@Column({ name: 'base_hp', type: 'integer' })
|
||||||
baseHp!: number;
|
baseHp!: number;
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ describe('CombatController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('delegates GET /api/combats/:combatId to combatService.getCombat', async () => {
|
it('delegates GET /api/combats/:combatId to combatService.getCombat', async () => {
|
||||||
const combat = { id: 'combat-1', status: 'ACTIVE', round: 1, player: {}, monster: {}, events: [] };
|
const combat = { id: 'combat-1', status: 'ACTIVE', round: 1, player: {}, monster: {}, events: [], rewards: null };
|
||||||
getCombat.mockResolvedValue(combat);
|
getCombat.mockResolvedValue(combat);
|
||||||
|
|
||||||
const response = await request(app.getHttpServer()).get('/api/combats/combat-1').expect(200);
|
const response = await request(app.getHttpServer()).get('/api/combats/combat-1').expect(200);
|
||||||
@@ -44,7 +44,7 @@ describe('CombatController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('delegates GET /api/combats/active to combatService.getActiveCombat', async () => {
|
it('delegates GET /api/combats/active to combatService.getActiveCombat', async () => {
|
||||||
const combat = { id: 'combat-1', status: 'ACTIVE', round: 3, player: {}, monster: {}, events: [] };
|
const combat = { id: 'combat-1', status: 'ACTIVE', round: 3, player: {}, monster: {}, events: [], rewards: null };
|
||||||
getActiveCombat.mockResolvedValue(combat);
|
getActiveCombat.mockResolvedValue(combat);
|
||||||
|
|
||||||
const response = await request(app.getHttpServer()).get('/api/combats/active').expect(200);
|
const response = await request(app.getHttpServer()).get('/api/combats/active').expect(200);
|
||||||
@@ -65,7 +65,7 @@ describe('CombatController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('delegates POST /api/combats/:combatId/actions with only the action field', async () => {
|
it('delegates POST /api/combats/:combatId/actions with only the action field', async () => {
|
||||||
const combat = { id: 'combat-1', status: 'ACTIVE', round: 2, player: {}, monster: {}, events: [] };
|
const combat = { id: 'combat-1', status: 'ACTIVE', round: 2, player: {}, monster: {}, events: [], rewards: null };
|
||||||
performAction.mockResolvedValue(combat);
|
performAction.mockResolvedValue(combat);
|
||||||
|
|
||||||
const response = await request(app.getHttpServer())
|
const response = await request(app.getHttpServer())
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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';
|
||||||
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
|
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
|
||||||
|
import { RewardsModule } from '../rewards/rewards.module';
|
||||||
import { TravelModule } from '../travel/travel.module';
|
import { TravelModule } from '../travel/travel.module';
|
||||||
import { CombatEngineService } from './combat-engine.service';
|
import { CombatEngineService } from './combat-engine.service';
|
||||||
import { CombatController } from './combat.controller';
|
import { CombatController } from './combat.controller';
|
||||||
@@ -18,6 +19,7 @@ import { HuntEncounterAttackController } from './hunt-encounter-attack.controlle
|
|||||||
TypeOrmModule.forFeature([Character, Hunt, HuntEncounter, MonsterDefinition, Combat, CombatEvent]),
|
TypeOrmModule.forFeature([Character, Hunt, HuntEncounter, MonsterDefinition, Combat, CombatEvent]),
|
||||||
TravelModule,
|
TravelModule,
|
||||||
CharactersModule,
|
CharactersModule,
|
||||||
|
RewardsModule,
|
||||||
],
|
],
|
||||||
controllers: [CombatController, HuntEncounterAttackController],
|
controllers: [CombatController, HuntEncounterAttackController],
|
||||||
providers: [CombatService, CombatEngineService],
|
providers: [CombatService, CombatEngineService],
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
|
|||||||
import { HuntEncounterStatus } from '../hunting/hunt-encounter-status.enum';
|
import { HuntEncounterStatus } from '../hunting/hunt-encounter-status.enum';
|
||||||
import { HuntStatus } from '../hunting/hunt-status.enum';
|
import { HuntStatus } from '../hunting/hunt-status.enum';
|
||||||
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
|
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
|
||||||
|
import { CombatRewardService } from '../rewards/combat-reward.service';
|
||||||
import { TravelService } from '../travel/travel.service';
|
import { TravelService } from '../travel/travel.service';
|
||||||
import { CombatAction } from './combat-action.enum';
|
import { CombatAction } from './combat-action.enum';
|
||||||
import { CombatEngineService } from './combat-engine.service';
|
import { CombatEngineService } from './combat-engine.service';
|
||||||
@@ -174,6 +175,7 @@ function character(overrides: Partial<Character> = {}): Character {
|
|||||||
name: 'Aric Duskwalker',
|
name: 'Aric Duskwalker',
|
||||||
level: 1,
|
level: 1,
|
||||||
experience: 0,
|
experience: 0,
|
||||||
|
silver: 0,
|
||||||
baseHp: 100,
|
baseHp: 100,
|
||||||
baseAttack: 6,
|
baseAttack: 6,
|
||||||
currentHp: 100,
|
currentHp: 100,
|
||||||
@@ -248,6 +250,20 @@ function fakeTravelService(
|
|||||||
} as unknown as TravelService;
|
} as unknown as TravelService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fakeRewardService(
|
||||||
|
overrides: Partial<{
|
||||||
|
grantVictoryRewards: jest.Mock;
|
||||||
|
loadRewards: jest.Mock;
|
||||||
|
}> = {},
|
||||||
|
): CombatRewardService {
|
||||||
|
return {
|
||||||
|
grantVictoryRewards:
|
||||||
|
overrides.grantVictoryRewards ??
|
||||||
|
jest.fn().mockResolvedValue({ experience: 8, silver: 6, items: [] }),
|
||||||
|
loadRewards: overrides.loadRewards ?? jest.fn().mockResolvedValue(null),
|
||||||
|
} as unknown as CombatRewardService;
|
||||||
|
}
|
||||||
|
|
||||||
function createService(
|
function createService(
|
||||||
options: { state?: FakeState; travelService?: TravelService } = {},
|
options: { state?: FakeState; travelService?: TravelService } = {},
|
||||||
) {
|
) {
|
||||||
@@ -261,6 +277,7 @@ function createService(
|
|||||||
travelService,
|
travelService,
|
||||||
combatEngine,
|
combatEngine,
|
||||||
characterCombatStats,
|
characterCombatStats,
|
||||||
|
fakeRewardService(),
|
||||||
);
|
);
|
||||||
return { dataSource, service, travelService };
|
return { dataSource, service, travelService };
|
||||||
}
|
}
|
||||||
@@ -599,7 +616,10 @@ describe('CombatService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects actions from a character who does not own the combat', async () => {
|
it('rejects actions from a character who does not own the combat', async () => {
|
||||||
const { service, combatId } = await startedCombat();
|
const state = createState({
|
||||||
|
characters: [character(), character({ id: OTHER_CHARACTER_ID })],
|
||||||
|
});
|
||||||
|
const { service, combatId } = await startedCombat(state);
|
||||||
|
|
||||||
await expectCombatDomainError(
|
await expectCombatDomainError(
|
||||||
service.performAction(
|
service.performAction(
|
||||||
@@ -730,4 +750,215 @@ describe('CombatService', () => {
|
|||||||
expect(reloaded.player.currentHp).toBe(0);
|
expect(reloaded.player.currentHp).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('rewards', () => {
|
||||||
|
it('grants rewards inside the same transaction when the round ends in victory', async () => {
|
||||||
|
const dataSource = new FakeDataSource(
|
||||||
|
createState({
|
||||||
|
combats: [
|
||||||
|
{
|
||||||
|
id: 'combat-1',
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
huntEncounterId: ENCOUNTER_ID,
|
||||||
|
monsterDefinitionId: MONSTER_ID,
|
||||||
|
status: CombatStatus.ACTIVE,
|
||||||
|
round: 3,
|
||||||
|
playerMaxHp: 100,
|
||||||
|
playerCurrentHp: 80,
|
||||||
|
monsterMaxHp: 45,
|
||||||
|
monsterCurrentHp: 1,
|
||||||
|
playerState: { attack: 6, weaponDamage: 8, armor: 6 },
|
||||||
|
monsterState: { attack: 5, armor: 0 },
|
||||||
|
completedAt: null,
|
||||||
|
} as Combat,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const rewards = fakeRewardService({
|
||||||
|
grantVictoryRewards: jest.fn().mockResolvedValue({
|
||||||
|
experience: 8,
|
||||||
|
silver: 6,
|
||||||
|
items: [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const service = new CombatService(
|
||||||
|
dataSource as unknown as DataSource,
|
||||||
|
fakeTravelService(),
|
||||||
|
new CombatEngineService(),
|
||||||
|
new CharacterCombatStatsService(),
|
||||||
|
rewards,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.performAction(CHARACTER_ID, 'combat-1', CombatAction.ATTACK);
|
||||||
|
|
||||||
|
expect(result.status).toBe(CombatStatus.WON);
|
||||||
|
expect(result.rewards).toEqual({ experience: 8, silver: 6, items: [] });
|
||||||
|
expect(rewards.grantVictoryRewards).toHaveBeenCalledTimes(1);
|
||||||
|
// The reward service must receive the transaction manager, not the data
|
||||||
|
// source: `expect.anything()` would pass even if the code handed over
|
||||||
|
// `this.dataSource`, so assert on the captured argument's identity.
|
||||||
|
const passedManager = (rewards.grantVictoryRewards as jest.Mock).mock
|
||||||
|
.calls[0][0];
|
||||||
|
expect(passedManager).not.toBe(dataSource);
|
||||||
|
expect(rewards.grantVictoryRewards).toHaveBeenCalledWith(
|
||||||
|
passedManager,
|
||||||
|
expect.objectContaining({ id: 'combat-1', status: CombatStatus.WON }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grants no rewards when the round ends in defeat', async () => {
|
||||||
|
const dataSource = new FakeDataSource(
|
||||||
|
createState({
|
||||||
|
combats: [
|
||||||
|
{
|
||||||
|
id: 'combat-1',
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
huntEncounterId: ENCOUNTER_ID,
|
||||||
|
monsterDefinitionId: MONSTER_ID,
|
||||||
|
status: CombatStatus.ACTIVE,
|
||||||
|
round: 3,
|
||||||
|
playerMaxHp: 100,
|
||||||
|
playerCurrentHp: 1,
|
||||||
|
monsterMaxHp: 45,
|
||||||
|
monsterCurrentHp: 45,
|
||||||
|
playerState: { attack: 1, weaponDamage: 1, armor: 0 },
|
||||||
|
monsterState: { attack: 99, armor: 99 },
|
||||||
|
completedAt: null,
|
||||||
|
} as Combat,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const rewards = fakeRewardService();
|
||||||
|
const service = new CombatService(
|
||||||
|
dataSource as unknown as DataSource,
|
||||||
|
fakeTravelService(),
|
||||||
|
new CombatEngineService(),
|
||||||
|
new CharacterCombatStatsService(),
|
||||||
|
rewards,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.performAction(CHARACTER_ID, 'combat-1', CombatAction.ATTACK);
|
||||||
|
|
||||||
|
expect(result.status).toBe(CombatStatus.LOST);
|
||||||
|
expect(result.rewards).toBeNull();
|
||||||
|
expect(rewards.grantVictoryRewards).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replays the persisted reward when a finished combat is read again', async () => {
|
||||||
|
const persisted = {
|
||||||
|
experience: 16,
|
||||||
|
silver: 12,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
characterItemId: 'character-item-1',
|
||||||
|
item: {
|
||||||
|
key: 'bandit-blade',
|
||||||
|
name: 'Räuberklinge',
|
||||||
|
rarity: 'COMMON',
|
||||||
|
iconPath: '/images/items/bandit-blade.png',
|
||||||
|
},
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const dataSource = new FakeDataSource(
|
||||||
|
createState({
|
||||||
|
combats: [
|
||||||
|
{
|
||||||
|
id: 'combat-1',
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
huntEncounterId: ENCOUNTER_ID,
|
||||||
|
monsterDefinitionId: MONSTER_ID,
|
||||||
|
status: CombatStatus.WON,
|
||||||
|
round: 5,
|
||||||
|
playerMaxHp: 100,
|
||||||
|
playerCurrentHp: 62,
|
||||||
|
monsterMaxHp: 45,
|
||||||
|
monsterCurrentHp: 0,
|
||||||
|
playerState: { attack: 6, weaponDamage: 8, armor: 6 },
|
||||||
|
monsterState: { attack: 5, armor: 0 },
|
||||||
|
completedAt: new Date('2026-08-19T09:00:00.000Z'),
|
||||||
|
} as Combat,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const rewards = fakeRewardService({
|
||||||
|
loadRewards: jest.fn().mockResolvedValue(persisted),
|
||||||
|
grantVictoryRewards: jest.fn(),
|
||||||
|
});
|
||||||
|
const service = new CombatService(
|
||||||
|
dataSource as unknown as DataSource,
|
||||||
|
fakeTravelService(),
|
||||||
|
new CombatEngineService(),
|
||||||
|
new CharacterCombatStatsService(),
|
||||||
|
rewards,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.getCombat(CHARACTER_ID, 'combat-1');
|
||||||
|
|
||||||
|
expect(result.rewards).toEqual(persisted);
|
||||||
|
// Reading must never grant: only the ACTIVE -> WON transition does.
|
||||||
|
expect(rewards.grantVictoryRewards).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists nothing at all when reward resolution fails mid-transaction', async () => {
|
||||||
|
const dataSource = new FakeDataSource(
|
||||||
|
createState({
|
||||||
|
combats: [
|
||||||
|
{
|
||||||
|
id: 'combat-1',
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
huntEncounterId: ENCOUNTER_ID,
|
||||||
|
monsterDefinitionId: MONSTER_ID,
|
||||||
|
status: CombatStatus.ACTIVE,
|
||||||
|
round: 3,
|
||||||
|
playerMaxHp: 100,
|
||||||
|
playerCurrentHp: 80,
|
||||||
|
monsterMaxHp: 45,
|
||||||
|
monsterCurrentHp: 1,
|
||||||
|
playerState: { attack: 6, weaponDamage: 8, armor: 6 },
|
||||||
|
monsterState: { attack: 5, armor: 0 },
|
||||||
|
completedAt: null,
|
||||||
|
} as Combat,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const service = new CombatService(
|
||||||
|
dataSource as unknown as DataSource,
|
||||||
|
fakeTravelService(),
|
||||||
|
new CombatEngineService(),
|
||||||
|
new CharacterCombatStatsService(),
|
||||||
|
fakeRewardService({
|
||||||
|
// Genuinely write XP/silver through the transaction's manager
|
||||||
|
// before failing, so the assertions below prove the rollback
|
||||||
|
// discards those writes rather than passing vacuously because
|
||||||
|
// nothing was ever written.
|
||||||
|
grantVictoryRewards: jest.fn().mockImplementation(async (manager: EntityManager) => {
|
||||||
|
const characters = manager.getRepository(Character);
|
||||||
|
const combatCharacter = await characters.findOneBy({
|
||||||
|
id: CHARACTER_ID,
|
||||||
|
});
|
||||||
|
if (combatCharacter) {
|
||||||
|
combatCharacter.experience += 8;
|
||||||
|
combatCharacter.silver += 6;
|
||||||
|
await characters.save(combatCharacter);
|
||||||
|
}
|
||||||
|
throw new Error('reward persistence failed');
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.performAction(CHARACTER_ID, 'combat-1', CombatAction.ATTACK),
|
||||||
|
).rejects.toThrow('reward persistence failed');
|
||||||
|
|
||||||
|
// The whole round rolled back: the combat is still ACTIVE and unmodified,
|
||||||
|
// so no half-granted state can survive.
|
||||||
|
expect(dataSource.state.combats[0].status).toBe(CombatStatus.ACTIVE);
|
||||||
|
expect(dataSource.state.combats[0].monsterCurrentHp).toBe(1);
|
||||||
|
expect(dataSource.state.combatEvents).toHaveLength(0);
|
||||||
|
expect(dataSource.state.characters[0].experience).toBe(0);
|
||||||
|
expect(dataSource.state.characters[0].silver).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
|
|||||||
import { HuntEncounterStatus } from '../hunting/hunt-encounter-status.enum';
|
import { HuntEncounterStatus } from '../hunting/hunt-encounter-status.enum';
|
||||||
import { HuntStatus } from '../hunting/hunt-status.enum';
|
import { HuntStatus } from '../hunting/hunt-status.enum';
|
||||||
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
|
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
|
||||||
|
import { CombatRewardService } from '../rewards/combat-reward.service';
|
||||||
|
import type { CombatRewardDto } from '../rewards/combat-reward.service';
|
||||||
import { TravelService } from '../travel/travel.service';
|
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';
|
||||||
@@ -58,6 +60,7 @@ export interface CombatDto {
|
|||||||
player: CombatPlayerDto;
|
player: CombatPlayerDto;
|
||||||
monster: CombatMonsterDto;
|
monster: CombatMonsterDto;
|
||||||
events: CombatEventDto[];
|
events: CombatEventDto[];
|
||||||
|
rewards: CombatRewardDto | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -67,6 +70,7 @@ export class CombatService {
|
|||||||
private readonly travelService: TravelService,
|
private readonly travelService: TravelService,
|
||||||
private readonly combatEngine: CombatEngineService,
|
private readonly combatEngine: CombatEngineService,
|
||||||
private readonly characterCombatStats: CharacterCombatStatsService,
|
private readonly characterCombatStats: CharacterCombatStatsService,
|
||||||
|
private readonly combatRewards: CombatRewardService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async startCombat(
|
async startCombat(
|
||||||
@@ -147,7 +151,7 @@ export class CombatService {
|
|||||||
encounter.status = HuntEncounterStatus.IN_PROGRESS;
|
encounter.status = HuntEncounterStatus.IN_PROGRESS;
|
||||||
await encounters.save(encounter);
|
await encounters.save(encounter);
|
||||||
|
|
||||||
return this.toCombatDto(combat, character.name, monster, []);
|
return this.toCombatDto(combat, character.name, monster, [], null);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,13 +164,14 @@ export class CombatService {
|
|||||||
throw combatNotFound();
|
throw combatNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const [character, monster, events] = await Promise.all([
|
const [character, monster, events, rewards] = await Promise.all([
|
||||||
this.loadCharacter(combat.characterId),
|
this.loadCharacter(combat.characterId),
|
||||||
this.loadMonster(combat.monsterDefinitionId),
|
this.loadMonster(combat.monsterDefinitionId),
|
||||||
this.loadEvents(combat.id),
|
this.loadEvents(combat.id),
|
||||||
|
this.combatRewards.loadRewards(combat.id),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return this.toCombatDto(combat, character.name, monster, events);
|
return this.toCombatDto(combat, character.name, monster, events, rewards);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getActiveCombat(characterId: string): Promise<CombatDto | null> {
|
async getActiveCombat(characterId: string): Promise<CombatDto | null> {
|
||||||
@@ -184,7 +189,7 @@ export class CombatService {
|
|||||||
this.loadEvents(combat.id),
|
this.loadEvents(combat.id),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return this.toCombatDto(combat, character.name, monster, events);
|
return this.toCombatDto(combat, character.name, monster, events, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async performAction(
|
async performAction(
|
||||||
@@ -193,9 +198,18 @@ export class CombatService {
|
|||||||
action: CombatAction,
|
action: CombatAction,
|
||||||
): Promise<CombatDto> {
|
): Promise<CombatDto> {
|
||||||
return this.dataSource.transaction(async (manager) => {
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
const characters = manager.getRepository(Character);
|
||||||
const combats = manager.getRepository(Combat);
|
const combats = manager.getRepository(Combat);
|
||||||
const combatEvents = manager.getRepository(CombatEvent);
|
const combatEvents = manager.getRepository(CombatEvent);
|
||||||
|
|
||||||
|
// Lock the character before the combat row here, matching the order
|
||||||
|
// startCombat already uses (character, then combat). grantVictoryRewards
|
||||||
|
// locks the character again later in this same transaction, which is a
|
||||||
|
// no-op re-lock — but locking it first here keeps both code paths
|
||||||
|
// consistent and avoids a lock-order inversion that could deadlock two
|
||||||
|
// concurrent requests against the same character. Do not reorder this.
|
||||||
|
await this.lockCharacter(characters, characterId);
|
||||||
|
|
||||||
const combat = await combats.findOne({
|
const combat = await combats.findOne({
|
||||||
where: { id: combatId, characterId },
|
where: { id: combatId, characterId },
|
||||||
lock: { mode: 'pessimistic_write' },
|
lock: { mode: 'pessimistic_write' },
|
||||||
@@ -242,6 +256,14 @@ export class CombatService {
|
|||||||
await combatEvents.save(entity);
|
await combatEvents.save(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The engine decided the outcome; rewards are resolved here, outside it
|
||||||
|
// (spec §30). Running inside this transaction means a reward failure
|
||||||
|
// rolls the whole round back rather than leaving a half-granted victory.
|
||||||
|
const rewards =
|
||||||
|
combat.status === CombatStatus.WON
|
||||||
|
? await this.combatRewards.grantVictoryRewards(manager, combat)
|
||||||
|
: null;
|
||||||
|
|
||||||
const [character, monster, events] = await Promise.all([
|
const [character, monster, events] = await Promise.all([
|
||||||
this.loadCharacter(
|
this.loadCharacter(
|
||||||
combat.characterId,
|
combat.characterId,
|
||||||
@@ -254,7 +276,7 @@ export class CombatService {
|
|||||||
this.loadEvents(combat.id, combatEvents),
|
this.loadEvents(combat.id, combatEvents),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return this.toCombatDto(combat, character.name, monster, events);
|
return this.toCombatDto(combat, character.name, monster, events, rewards);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,6 +376,7 @@ export class CombatService {
|
|||||||
playerName: string,
|
playerName: string,
|
||||||
monster: MonsterDefinition,
|
monster: MonsterDefinition,
|
||||||
events: CombatEvent[],
|
events: CombatEvent[],
|
||||||
|
rewards: CombatRewardDto | null,
|
||||||
): CombatDto {
|
): CombatDto {
|
||||||
return {
|
return {
|
||||||
id: combat.id,
|
id: combat.id,
|
||||||
@@ -380,6 +403,7 @@ export class CombatService {
|
|||||||
target: event.target,
|
target: event.target,
|
||||||
amount: event.amount ?? undefined,
|
amount: event.amount ?? undefined,
|
||||||
})),
|
})),
|
||||||
|
rewards,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ describe('HuntEncounterAttackController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('delegates to combatService.startCombat with the demo character id and the encounter id', async () => {
|
it('delegates to combatService.startCombat with the demo character id and the encounter id', async () => {
|
||||||
const combat = { id: 'combat-1', status: 'ACTIVE', round: 1, player: {}, monster: {}, events: [] };
|
const combat = { id: 'combat-1', status: 'ACTIVE', round: 1, player: {}, monster: {}, events: [], rewards: null };
|
||||||
startCombat.mockResolvedValue(combat);
|
startCombat.mockResolvedValue(combat);
|
||||||
|
|
||||||
const response = await request(app.getHttpServer())
|
const response = await request(app.getHttpServer())
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateLootAndRewards1788600000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Existing characters keep their progression; silver simply starts at 0.
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "characters" ADD COLUMN "silver" integer NOT NULL DEFAULT 0',
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
"CREATE TYPE \"item_type_enum\" AS ENUM ('WEAPON', 'ARMOR', 'MATERIAL', 'CONSUMABLE')",
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
"CREATE TYPE \"equipment_slot_enum\" AS ENUM ('WEAPON', 'HEAD', 'CHEST', 'HANDS', 'LEGS', 'FEET', 'AMULET')",
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
"CREATE TYPE \"item_rarity_enum\" AS ENUM ('COMMON', 'RARE', 'EPIC')",
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.query(`CREATE TABLE "item_definitions" (
|
||||||
|
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"key" character varying(100) NOT NULL,
|
||||||
|
"name" character varying(150) NOT NULL,
|
||||||
|
"description" text NOT NULL,
|
||||||
|
"type" "item_type_enum" NOT NULL,
|
||||||
|
"equipment_slot" "equipment_slot_enum",
|
||||||
|
"rarity" "item_rarity_enum" NOT NULL,
|
||||||
|
"tier" integer NOT NULL,
|
||||||
|
"required_level" integer NOT NULL,
|
||||||
|
"weapon_damage" integer NOT NULL DEFAULT 0,
|
||||||
|
"bonus_hp" integer NOT NULL DEFAULT 0,
|
||||||
|
"bonus_attack" integer NOT NULL DEFAULT 0,
|
||||||
|
"bonus_armor" integer NOT NULL DEFAULT 0,
|
||||||
|
"sell_price" integer NOT NULL DEFAULT 0,
|
||||||
|
"icon_path" character varying(255) NOT NULL,
|
||||||
|
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||||
|
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT "PK_item_definitions" PRIMARY KEY ("id")
|
||||||
|
)`);
|
||||||
|
await queryRunner.query(
|
||||||
|
'CREATE UNIQUE INDEX "IDX_item_definitions_key" ON "item_definitions" ("key")',
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.query(`CREATE TABLE "loot_tables" (
|
||||||
|
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"key" character varying(100) NOT NULL,
|
||||||
|
"name" character varying(150) NOT NULL,
|
||||||
|
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||||
|
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT "PK_loot_tables" PRIMARY KEY ("id")
|
||||||
|
)`);
|
||||||
|
await queryRunner.query(
|
||||||
|
'CREATE UNIQUE INDEX "IDX_loot_tables_key" ON "loot_tables" ("key")',
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.query(`CREATE TABLE "loot_table_entries" (
|
||||||
|
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"loot_table_id" uuid NOT NULL,
|
||||||
|
"item_definition_id" uuid NOT NULL,
|
||||||
|
"position" integer NOT NULL,
|
||||||
|
"drop_chance" numeric(5,4) NOT NULL,
|
||||||
|
"min_quantity" integer NOT NULL DEFAULT 1,
|
||||||
|
"max_quantity" integer NOT NULL DEFAULT 1,
|
||||||
|
"enabled" boolean NOT NULL DEFAULT true,
|
||||||
|
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||||
|
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT "PK_loot_table_entries" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "CHK_loot_table_entries_drop_chance" CHECK ("drop_chance" >= 0 AND "drop_chance" <= 1),
|
||||||
|
CONSTRAINT "CHK_loot_table_entries_quantity" CHECK ("min_quantity" >= 1 AND "max_quantity" >= "min_quantity"),
|
||||||
|
CONSTRAINT "FK_loot_table_entries_loot_table" FOREIGN KEY ("loot_table_id") REFERENCES "loot_tables"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||||
|
CONSTRAINT "FK_loot_table_entries_item_definition" FOREIGN KEY ("item_definition_id") REFERENCES "item_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
|
||||||
|
)`);
|
||||||
|
await queryRunner.query(
|
||||||
|
'CREATE UNIQUE INDEX "IDX_loot_table_entries_table_position" ON "loot_table_entries" ("loot_table_id", "position")',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'CREATE UNIQUE INDEX "IDX_loot_table_entries_table_item" ON "loot_table_entries" ("loot_table_id", "item_definition_id")',
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "monster_definitions" ADD COLUMN "loot_table_id" uuid',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "monster_definitions" ADD CONSTRAINT "FK_monster_definitions_loot_table" FOREIGN KEY ("loot_table_id") REFERENCES "loot_tables"("id") ON DELETE RESTRICT ON UPDATE NO ACTION',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'CREATE INDEX "IDX_monster_definitions_loot_table" ON "monster_definitions" ("loot_table_id")',
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.query(`CREATE TABLE "character_items" (
|
||||||
|
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"character_id" uuid NOT NULL,
|
||||||
|
"item_definition_id" uuid NOT NULL,
|
||||||
|
"quantity" integer 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_items" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "CHK_character_items_quantity" CHECK ("quantity" >= 1),
|
||||||
|
CONSTRAINT "FK_character_items_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||||
|
CONSTRAINT "FK_character_items_item_definition" FOREIGN KEY ("item_definition_id") REFERENCES "item_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
|
||||||
|
)`);
|
||||||
|
await queryRunner.query(
|
||||||
|
'CREATE UNIQUE INDEX "IDX_character_items_character_item" ON "character_items" ("character_id", "item_definition_id")',
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.query(`CREATE TABLE "combat_rewards" (
|
||||||
|
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"combat_id" uuid NOT NULL,
|
||||||
|
"character_id" uuid NOT NULL,
|
||||||
|
"experience_granted" integer NOT NULL,
|
||||||
|
"silver_granted" integer NOT NULL,
|
||||||
|
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT "PK_combat_rewards" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "FK_combat_rewards_combat" FOREIGN KEY ("combat_id") REFERENCES "combats"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||||
|
CONSTRAINT "FK_combat_rewards_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
|
||||||
|
)`);
|
||||||
|
// The database half of the "one reward per combat" invariant (spec §7).
|
||||||
|
await queryRunner.query(
|
||||||
|
'CREATE UNIQUE INDEX "IDX_combat_rewards_combat" ON "combat_rewards" ("combat_id")',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'CREATE INDEX "IDX_combat_rewards_character" ON "combat_rewards" ("character_id")',
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.query(`CREATE TABLE "combat_reward_items" (
|
||||||
|
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"combat_reward_id" uuid NOT NULL,
|
||||||
|
"character_item_id" uuid NOT NULL,
|
||||||
|
"item_definition_id" uuid NOT NULL,
|
||||||
|
"quantity" integer NOT NULL,
|
||||||
|
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT "PK_combat_reward_items" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "CHK_combat_reward_items_quantity" CHECK ("quantity" >= 1),
|
||||||
|
CONSTRAINT "FK_combat_reward_items_reward" FOREIGN KEY ("combat_reward_id") REFERENCES "combat_rewards"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||||
|
CONSTRAINT "FK_combat_reward_items_character_item" FOREIGN KEY ("character_item_id") REFERENCES "character_items"("id") ON DELETE RESTRICT ON UPDATE NO ACTION,
|
||||||
|
CONSTRAINT "FK_combat_reward_items_item_definition" FOREIGN KEY ("item_definition_id") REFERENCES "item_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
|
||||||
|
)`);
|
||||||
|
await queryRunner.query(
|
||||||
|
'CREATE INDEX "IDX_combat_reward_items_reward" ON "combat_reward_items" ("combat_reward_id")',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'CREATE UNIQUE INDEX "IDX_combat_reward_items_reward_item" ON "combat_reward_items" ("combat_reward_id", "item_definition_id")',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query('DROP INDEX "IDX_combat_reward_items_reward_item"');
|
||||||
|
await queryRunner.query('DROP INDEX "IDX_combat_reward_items_reward"');
|
||||||
|
await queryRunner.query('DROP TABLE "combat_reward_items"');
|
||||||
|
await queryRunner.query('DROP INDEX "IDX_combat_rewards_character"');
|
||||||
|
await queryRunner.query('DROP INDEX "IDX_combat_rewards_combat"');
|
||||||
|
await queryRunner.query('DROP TABLE "combat_rewards"');
|
||||||
|
await queryRunner.query('DROP INDEX "IDX_character_items_character_item"');
|
||||||
|
await queryRunner.query('DROP TABLE "character_items"');
|
||||||
|
await queryRunner.query('DROP INDEX "IDX_monster_definitions_loot_table"');
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "monster_definitions" DROP CONSTRAINT "FK_monster_definitions_loot_table"',
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE "monster_definitions" DROP COLUMN "loot_table_id"',
|
||||||
|
);
|
||||||
|
await queryRunner.query('DROP INDEX "IDX_loot_table_entries_table_item"');
|
||||||
|
await queryRunner.query('DROP INDEX "IDX_loot_table_entries_table_position"');
|
||||||
|
await queryRunner.query('DROP TABLE "loot_table_entries"');
|
||||||
|
await queryRunner.query('DROP INDEX "IDX_loot_tables_key"');
|
||||||
|
await queryRunner.query('DROP TABLE "loot_tables"');
|
||||||
|
await queryRunner.query('DROP INDEX "IDX_item_definitions_key"');
|
||||||
|
await queryRunner.query('DROP TABLE "item_definitions"');
|
||||||
|
await queryRunner.query('DROP TYPE "item_rarity_enum"');
|
||||||
|
await queryRunner.query('DROP TYPE "equipment_slot_enum"');
|
||||||
|
await queryRunner.query('DROP TYPE "item_type_enum"');
|
||||||
|
await queryRunner.query('ALTER TABLE "characters" DROP COLUMN "silver"');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { getMetadataArgsStorage, QueryRunner } from 'typeorm';
|
||||||
|
import { CreateLootAndRewards1788600000000 } from './1788600000000-CreateLootAndRewards';
|
||||||
|
import { Character } from '../../characters/entities/character.entity';
|
||||||
|
import { CharacterItem } from '../../items/entities/character-item.entity';
|
||||||
|
import { ItemDefinition } from '../../items/entities/item-definition.entity';
|
||||||
|
import { LootTable } from '../../loot/entities/loot-table.entity';
|
||||||
|
import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity';
|
||||||
|
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
|
||||||
|
import { CombatReward } from '../../rewards/entities/combat-reward.entity';
|
||||||
|
import { CombatRewardItem } from '../../rewards/entities/combat-reward-item.entity';
|
||||||
|
|
||||||
|
function uniqueIndexFor(target: unknown, columns: string[]) {
|
||||||
|
const index = getMetadataArgsStorage().indices.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.target === target &&
|
||||||
|
columns.every((column) => candidate.columns?.includes(column)),
|
||||||
|
);
|
||||||
|
const indexMetadata = index as typeof index & {
|
||||||
|
options?: { unique?: boolean };
|
||||||
|
unique?: boolean;
|
||||||
|
};
|
||||||
|
return indexMetadata?.options?.unique ?? indexMetadata?.unique;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('loot and rewards schema', () => {
|
||||||
|
it('gives every combat at most one reward record', () => {
|
||||||
|
expect(uniqueIndexFor(CombatReward, ['combatId'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps one stack per character per item definition', () => {
|
||||||
|
expect(uniqueIndexFor(CharacterItem, ['characterId', 'itemDefinitionId'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps loot-table content keys and entry positions unique', () => {
|
||||||
|
expect(uniqueIndexFor(ItemDefinition, ['key'])).toBe(true);
|
||||||
|
expect(uniqueIndexFor(LootTable, ['key'])).toBe(true);
|
||||||
|
expect(uniqueIndexFor(LootTableEntry, ['lootTableId', 'position'])).toBe(true);
|
||||||
|
expect(uniqueIndexFor(LootTableEntry, ['lootTableId', 'itemDefinitionId'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps reward and loot relations with the documented onDelete behavior', () => {
|
||||||
|
const relations = getMetadataArgsStorage().relations.filter((relation) =>
|
||||||
|
[CombatReward, CombatRewardItem, CharacterItem, LootTableEntry, MonsterDefinition].includes(
|
||||||
|
relation.target as never,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
relations.map((relation) => ({
|
||||||
|
onDelete: relation.options.onDelete,
|
||||||
|
propertyName: relation.propertyName,
|
||||||
|
target: relation.target,
|
||||||
|
})),
|
||||||
|
).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'combat', target: CombatReward }),
|
||||||
|
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'character', target: CombatReward }),
|
||||||
|
expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'combatReward', target: CombatRewardItem }),
|
||||||
|
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'characterItem', target: CombatRewardItem }),
|
||||||
|
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'itemDefinition', target: CombatRewardItem }),
|
||||||
|
expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'character', target: CharacterItem }),
|
||||||
|
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'itemDefinition', target: CharacterItem }),
|
||||||
|
expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'lootTable', target: LootTableEntry }),
|
||||||
|
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'itemDefinition', target: LootTableEntry }),
|
||||||
|
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'lootTable', target: MonsterDefinition }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds the character silver column and the nullable monster loot table link', () => {
|
||||||
|
const columns = getMetadataArgsStorage().columns;
|
||||||
|
|
||||||
|
const silver = columns.find(
|
||||||
|
(candidate) => candidate.target === Character && candidate.propertyName === 'silver',
|
||||||
|
);
|
||||||
|
expect(silver).toBeDefined();
|
||||||
|
expect(silver?.options.type).toBe('integer');
|
||||||
|
|
||||||
|
const lootTableId = columns.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.target === MonsterDefinition && candidate.propertyName === 'lootTableId',
|
||||||
|
);
|
||||||
|
expect(lootTableId).toBeDefined();
|
||||||
|
expect(lootTableId?.options.nullable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores drop chance as a numeric column so probabilities stay data-driven', () => {
|
||||||
|
const dropChance = getMetadataArgsStorage().columns.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.target === LootTableEntry && candidate.propertyName === 'dropChance',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(dropChance?.options.type).toBe('numeric');
|
||||||
|
expect(dropChance?.options.precision).toBe(5);
|
||||||
|
expect(dropChance?.options.scale).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits the real SQL that enforces the schema invariants, not just entity decorators', async () => {
|
||||||
|
// synchronize: false means entity decorators never touch the real database -
|
||||||
|
// only the raw SQL emitted by the migration itself does. Assert on that SQL
|
||||||
|
// directly so deleting a constraint here would fail this test.
|
||||||
|
const query = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const queryRunner = { query } as unknown as QueryRunner;
|
||||||
|
const migration = new CreateLootAndRewards1788600000000();
|
||||||
|
|
||||||
|
await migration.up(queryRunner);
|
||||||
|
|
||||||
|
const upQueries = query.mock.calls.map(([sql]) => sql as string);
|
||||||
|
|
||||||
|
expect(upQueries).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
// The database half of the "one reward per combat" invariant (spec §7, §37).
|
||||||
|
expect.stringContaining('CREATE UNIQUE INDEX "IDX_combat_rewards_combat"'),
|
||||||
|
expect.stringContaining('CREATE UNIQUE INDEX "IDX_character_items_character_item"'),
|
||||||
|
expect.stringContaining('CREATE UNIQUE INDEX "IDX_combat_reward_items_reward_item"'),
|
||||||
|
expect.stringContaining('ALTER TABLE "characters" ADD COLUMN "silver"'),
|
||||||
|
expect.stringContaining('ALTER TABLE "monster_definitions" ADD COLUMN "loot_table_id"'),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkConstraints = upQueries.filter((sql) => sql.includes('CHECK ('));
|
||||||
|
expect(checkConstraints.length).toBeGreaterThan(0);
|
||||||
|
expect(
|
||||||
|
checkConstraints.some(
|
||||||
|
(sql) =>
|
||||||
|
sql.includes('CHK_loot_table_entries_drop_chance') ||
|
||||||
|
sql.includes('CHK_character_items_quantity'),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
await migration.down(queryRunner);
|
||||||
|
|
||||||
|
const downQueries = query.mock.calls
|
||||||
|
.slice(upQueries.length)
|
||||||
|
.map(([sql]) => sql as string);
|
||||||
|
|
||||||
|
// Proves down() is real and reverses the up() migration, not a no-op.
|
||||||
|
expect(downQueries).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.stringContaining('DROP TABLE "combat_rewards"'),
|
||||||
|
expect.stringContaining('DROP TABLE "character_items"'),
|
||||||
|
'ALTER TABLE "characters" DROP COLUMN "silver"',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
216
apps/api/src/database/seeds/item-content.ts
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import { EquipmentSlot } from '../../items/equipment-slot.enum';
|
||||||
|
import { ItemRarity } from '../../items/item-rarity.enum';
|
||||||
|
import { ItemType } from '../../items/item-type.enum';
|
||||||
|
import {
|
||||||
|
ASH_RAT_LOOT_TABLE_ID,
|
||||||
|
ITEM_IDS,
|
||||||
|
ItemKey,
|
||||||
|
ROAD_BANDIT_LOOT_TABLE_ID,
|
||||||
|
} from './item.constants';
|
||||||
|
|
||||||
|
export interface SeedItemDefinition {
|
||||||
|
id: string;
|
||||||
|
key: ItemKey;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
type: ItemType;
|
||||||
|
equipmentSlot: EquipmentSlot | null;
|
||||||
|
rarity: ItemRarity;
|
||||||
|
tier: number;
|
||||||
|
requiredLevel: number;
|
||||||
|
weaponDamage: number;
|
||||||
|
bonusHp: number;
|
||||||
|
bonusAttack: number;
|
||||||
|
bonusArmor: number;
|
||||||
|
sellPrice: number;
|
||||||
|
iconPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function item(
|
||||||
|
key: ItemKey,
|
||||||
|
name: string,
|
||||||
|
description: string,
|
||||||
|
type: ItemType,
|
||||||
|
equipmentSlot: EquipmentSlot | null,
|
||||||
|
rarity: ItemRarity,
|
||||||
|
stats: Partial<Pick<SeedItemDefinition, 'weaponDamage' | 'bonusHp' | 'bonusAttack' | 'bonusArmor'>> = {},
|
||||||
|
): SeedItemDefinition {
|
||||||
|
return {
|
||||||
|
id: ITEM_IDS[key],
|
||||||
|
key,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
type,
|
||||||
|
equipmentSlot,
|
||||||
|
rarity,
|
||||||
|
tier: 1,
|
||||||
|
requiredLevel: 1,
|
||||||
|
weaponDamage: stats.weaponDamage ?? 0,
|
||||||
|
bonusHp: stats.bonusHp ?? 0,
|
||||||
|
bonusAttack: stats.bonusAttack ?? 0,
|
||||||
|
bonusArmor: stats.bonusArmor ?? 0,
|
||||||
|
// Always 0: no merchants exist in Slice 0.4, and the balancing doc's
|
||||||
|
// Grenzmarken table lists purchase prices, not sell prices.
|
||||||
|
sellPrice: 0,
|
||||||
|
iconPath: `/images/items/${key}.png`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats from docs/Ashen_Realms_Balancing_Items_Loot_Design_V1.md §19.
|
||||||
|
export const ITEM_DEFINITIONS: SeedItemDefinition[] = [
|
||||||
|
item(
|
||||||
|
'worn-short-sword',
|
||||||
|
'Abgenutztes Kurzschwert',
|
||||||
|
'Die Klinge eines Rekruten, öfter geschliffen als geführt.',
|
||||||
|
ItemType.WEAPON,
|
||||||
|
EquipmentSlot.WEAPON,
|
||||||
|
ItemRarity.COMMON,
|
||||||
|
{ weaponDamage: 8 },
|
||||||
|
),
|
||||||
|
item(
|
||||||
|
'bandit-blade',
|
||||||
|
'Räuberklinge',
|
||||||
|
'Eine grob gezahnte Klinge, geschmiedet für schnelle Überfälle.',
|
||||||
|
ItemType.WEAPON,
|
||||||
|
EquipmentSlot.WEAPON,
|
||||||
|
ItemRarity.COMMON,
|
||||||
|
{ weaponDamage: 11, bonusAttack: 1 },
|
||||||
|
),
|
||||||
|
item(
|
||||||
|
'ash-blade',
|
||||||
|
'Aschenklinge',
|
||||||
|
'In der Glut der Aschenfelder gehärtet; die Schneide glimmt noch.',
|
||||||
|
ItemType.WEAPON,
|
||||||
|
EquipmentSlot.WEAPON,
|
||||||
|
ItemRarity.RARE,
|
||||||
|
{ weaponDamage: 15, bonusAttack: 2 },
|
||||||
|
),
|
||||||
|
item(
|
||||||
|
'bandit-hood',
|
||||||
|
'Räuberhaube',
|
||||||
|
'Vernarbtes Leder, das Gesicht und Absicht des Trägers verbirgt.',
|
||||||
|
ItemType.ARMOR,
|
||||||
|
EquipmentSlot.HEAD,
|
||||||
|
ItemRarity.COMMON,
|
||||||
|
{ bonusArmor: 3, bonusHp: 5 },
|
||||||
|
),
|
||||||
|
item(
|
||||||
|
'reinforced-leather-jacket',
|
||||||
|
'Verstärkte Lederjacke',
|
||||||
|
'Mit Eisenplatten benähtes Leder, schwer und verlässlich.',
|
||||||
|
ItemType.ARMOR,
|
||||||
|
EquipmentSlot.CHEST,
|
||||||
|
ItemRarity.RARE,
|
||||||
|
{ bonusArmor: 7, bonusHp: 10 },
|
||||||
|
),
|
||||||
|
item(
|
||||||
|
'raider-gloves',
|
||||||
|
'Plündererhandschuhe',
|
||||||
|
'Beschlagene Handschuhe, abgegriffen von fremdem Gut.',
|
||||||
|
ItemType.ARMOR,
|
||||||
|
EquipmentSlot.HANDS,
|
||||||
|
ItemRarity.COMMON,
|
||||||
|
{ bonusArmor: 3, bonusAttack: 1 },
|
||||||
|
),
|
||||||
|
item(
|
||||||
|
'guardsman-legs',
|
||||||
|
'Wachmannsbeinkleid',
|
||||||
|
'Beinzeug der Grenzwacht, an den Knien geflickt.',
|
||||||
|
ItemType.ARMOR,
|
||||||
|
EquipmentSlot.LEGS,
|
||||||
|
ItemRarity.RARE,
|
||||||
|
{ bonusArmor: 5, bonusHp: 5 },
|
||||||
|
),
|
||||||
|
item(
|
||||||
|
'ash-boots',
|
||||||
|
'Aschenstiefel',
|
||||||
|
'Stiefel, die durch glimmende Felder getragen wurden und blieben.',
|
||||||
|
ItemType.ARMOR,
|
||||||
|
EquipmentSlot.FEET,
|
||||||
|
ItemRarity.RARE,
|
||||||
|
{ bonusArmor: 4, bonusHp: 5 },
|
||||||
|
),
|
||||||
|
item(
|
||||||
|
'borderwatch-sigil',
|
||||||
|
'Zeichen der Grenzwacht',
|
||||||
|
'Das Wappen eines Turms, den es nicht mehr gibt.',
|
||||||
|
ItemType.ARMOR,
|
||||||
|
EquipmentSlot.AMULET,
|
||||||
|
ItemRarity.RARE,
|
||||||
|
{ bonusAttack: 3, bonusHp: 10 },
|
||||||
|
),
|
||||||
|
item(
|
||||||
|
'burned-captain-pendant',
|
||||||
|
'Anhänger des verbrannten Hauptmanns',
|
||||||
|
'Ein Schädel aus Schlacke, in dem die Glut nie erlosch.',
|
||||||
|
ItemType.ARMOR,
|
||||||
|
EquipmentSlot.AMULET,
|
||||||
|
ItemRarity.EPIC,
|
||||||
|
{ bonusAttack: 3, bonusHp: 15, bonusArmor: 2 },
|
||||||
|
),
|
||||||
|
// Seeded as content only. Slice 0.4 implements no consumable use, and the
|
||||||
|
// Straßenräuber loot entry for it is deliberately deferred (spec §16).
|
||||||
|
item(
|
||||||
|
'small-healing-potion',
|
||||||
|
'Kleiner Heiltrank',
|
||||||
|
'Ein bitterer Sud, der Wunden für einen Atemzug vergessen lässt.',
|
||||||
|
ItemType.CONSUMABLE,
|
||||||
|
null,
|
||||||
|
ItemRarity.COMMON,
|
||||||
|
),
|
||||||
|
item(
|
||||||
|
'ash-pelt',
|
||||||
|
'Aschenfell',
|
||||||
|
'Versengtes Fell, zäh wie Leder und grau von Ascheflug.',
|
||||||
|
ItemType.MATERIAL,
|
||||||
|
null,
|
||||||
|
ItemRarity.COMMON,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const LOOT_TABLES = [
|
||||||
|
{ id: ASH_RAT_LOOT_TABLE_ID, key: 'ash-rat-loot', name: 'Aschenratte Beute' },
|
||||||
|
{ id: ROAD_BANDIT_LOOT_TABLE_ID, key: 'road-bandit-loot', name: 'Straßenräuber Beute' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface SeedLootTableEntry {
|
||||||
|
lootTableId: string;
|
||||||
|
itemDefinitionId: string;
|
||||||
|
position: number;
|
||||||
|
dropChance: string;
|
||||||
|
minQuantity: number;
|
||||||
|
maxQuantity: number;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function entry(
|
||||||
|
lootTableId: string,
|
||||||
|
key: ItemKey,
|
||||||
|
position: number,
|
||||||
|
dropChance: string,
|
||||||
|
): SeedLootTableEntry {
|
||||||
|
return {
|
||||||
|
lootTableId,
|
||||||
|
itemDefinitionId: ITEM_IDS[key],
|
||||||
|
position,
|
||||||
|
dropChance,
|
||||||
|
minQuantity: 1,
|
||||||
|
maxQuantity: 1,
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop chances from docs/Ashen_Realms_Balancing_Items_Loot_Design_V1.md §27–28.
|
||||||
|
* Every entry is an independent roll (spec §17), rolled in `position` order.
|
||||||
|
*
|
||||||
|
* DEFERRED: the Straßenräuber table also lists 10 % Kleiner Heiltrank. It is
|
||||||
|
* omitted here because Slice 0.4 implements no consumables (spec §16).
|
||||||
|
*/
|
||||||
|
export const LOOT_TABLE_ENTRIES: SeedLootTableEntry[] = [
|
||||||
|
entry(ASH_RAT_LOOT_TABLE_ID, 'ash-pelt', 1, '0.6000'),
|
||||||
|
entry(ASH_RAT_LOOT_TABLE_ID, 'worn-short-sword', 2, '0.0800'),
|
||||||
|
entry(ROAD_BANDIT_LOOT_TABLE_ID, 'bandit-blade', 1, '0.1800'),
|
||||||
|
entry(ROAD_BANDIT_LOOT_TABLE_ID, 'bandit-hood', 2, '0.1200'),
|
||||||
|
entry(ROAD_BANDIT_LOOT_TABLE_ID, 'raider-gloves', 3, '0.0800'),
|
||||||
|
];
|
||||||
20
apps/api/src/database/seeds/item.constants.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// Stable content ids, in art-sheet order. Aschenfell (no sheet entry) is last.
|
||||||
|
export const ITEM_IDS = {
|
||||||
|
'worn-short-sword': '50000000-0000-4000-8000-000000000001',
|
||||||
|
'bandit-blade': '50000000-0000-4000-8000-000000000002',
|
||||||
|
'ash-blade': '50000000-0000-4000-8000-000000000003',
|
||||||
|
'bandit-hood': '50000000-0000-4000-8000-000000000004',
|
||||||
|
'reinforced-leather-jacket': '50000000-0000-4000-8000-000000000005',
|
||||||
|
'raider-gloves': '50000000-0000-4000-8000-000000000006',
|
||||||
|
'guardsman-legs': '50000000-0000-4000-8000-000000000007',
|
||||||
|
'ash-boots': '50000000-0000-4000-8000-000000000008',
|
||||||
|
'borderwatch-sigil': '50000000-0000-4000-8000-000000000009',
|
||||||
|
'burned-captain-pendant': '50000000-0000-4000-8000-00000000000a',
|
||||||
|
'small-healing-potion': '50000000-0000-4000-8000-00000000000b',
|
||||||
|
'ash-pelt': '50000000-0000-4000-8000-00000000000c',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ItemKey = keyof typeof ITEM_IDS;
|
||||||
|
|
||||||
|
export const ASH_RAT_LOOT_TABLE_ID = '60000000-0000-4000-8000-000000000001';
|
||||||
|
export const ROAD_BANDIT_LOOT_TABLE_ID = '60000000-0000-4000-8000-000000000002';
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { Character } from '../../characters/entities/character.entity';
|
import { Character } from '../../characters/entities/character.entity';
|
||||||
|
import { ItemDefinition } from '../../items/entities/item-definition.entity';
|
||||||
|
import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity';
|
||||||
|
import { LootTable } from '../../loot/entities/loot-table.entity';
|
||||||
import { LocationMonster } from '../../monsters/entities/location-monster.entity';
|
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 { 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';
|
||||||
|
|
||||||
type Row = Record<string, unknown>;
|
type Row = Record<string, unknown>;
|
||||||
@@ -67,24 +71,20 @@ function createDataSource(
|
|||||||
characterRepository: InMemoryRepository,
|
characterRepository: InMemoryRepository,
|
||||||
monsterRepository: InMemoryRepository,
|
monsterRepository: InMemoryRepository,
|
||||||
locationMonsterRepository: InMemoryRepository,
|
locationMonsterRepository: InMemoryRepository,
|
||||||
|
itemRepository: InMemoryRepository = new InMemoryRepository(),
|
||||||
|
lootTableRepository: InMemoryRepository = new InMemoryRepository(),
|
||||||
|
lootEntryRepository: InMemoryRepository = new InMemoryRepository(),
|
||||||
): DataSource {
|
): DataSource {
|
||||||
return {
|
return {
|
||||||
getRepository: jest.fn((entity: unknown) => {
|
getRepository: jest.fn((entity: unknown) => {
|
||||||
if (entity === LocationDefinition) {
|
if (entity === LocationDefinition) return locationRepository;
|
||||||
return locationRepository;
|
if (entity === LocationConnection) return connectionRepository;
|
||||||
}
|
if (entity === Character) return characterRepository;
|
||||||
if (entity === LocationConnection) {
|
if (entity === MonsterDefinition) return monsterRepository;
|
||||||
return connectionRepository;
|
if (entity === LocationMonster) return locationMonsterRepository;
|
||||||
}
|
if (entity === ItemDefinition) return itemRepository;
|
||||||
if (entity === Character) {
|
if (entity === LootTable) return lootTableRepository;
|
||||||
return characterRepository;
|
if (entity === LootTableEntry) return lootEntryRepository;
|
||||||
}
|
|
||||||
if (entity === MonsterDefinition) {
|
|
||||||
return monsterRepository;
|
|
||||||
}
|
|
||||||
if (entity === LocationMonster) {
|
|
||||||
return locationMonsterRepository;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Unexpected repository');
|
throw new Error('Unexpected repository');
|
||||||
}),
|
}),
|
||||||
@@ -256,4 +256,84 @@ describe('seedVisibleVerticalSlice', () => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('seeds the tier-1 items and both loot tables idempotently and wires them to the monsters', async () => {
|
||||||
|
const locationRepository = new InMemoryRepository();
|
||||||
|
const connectionRepository = new InMemoryRepository();
|
||||||
|
const characterRepository = new InMemoryRepository();
|
||||||
|
const monsterRepository = new InMemoryRepository();
|
||||||
|
const locationMonsterRepository = new InMemoryRepository();
|
||||||
|
const itemRepository = new InMemoryRepository();
|
||||||
|
const lootTableRepository = new InMemoryRepository();
|
||||||
|
const lootEntryRepository = new InMemoryRepository();
|
||||||
|
const dataSource = createDataSource(
|
||||||
|
locationRepository,
|
||||||
|
connectionRepository,
|
||||||
|
characterRepository,
|
||||||
|
monsterRepository,
|
||||||
|
locationMonsterRepository,
|
||||||
|
itemRepository,
|
||||||
|
lootTableRepository,
|
||||||
|
lootEntryRepository,
|
||||||
|
);
|
||||||
|
|
||||||
|
await seedVisibleVerticalSlice(dataSource);
|
||||||
|
await seedVisibleVerticalSlice(dataSource);
|
||||||
|
|
||||||
|
expect(itemRepository.rows).toHaveLength(12);
|
||||||
|
expect(itemRepository.rows).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
key: 'bandit-blade',
|
||||||
|
name: 'Räuberklinge',
|
||||||
|
type: 'WEAPON',
|
||||||
|
equipmentSlot: 'WEAPON',
|
||||||
|
rarity: 'COMMON',
|
||||||
|
weaponDamage: 11,
|
||||||
|
bonusAttack: 1,
|
||||||
|
sellPrice: 0,
|
||||||
|
iconPath: '/images/items/bandit-blade.png',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
key: 'ash-pelt',
|
||||||
|
name: 'Aschenfell',
|
||||||
|
type: 'MATERIAL',
|
||||||
|
equipmentSlot: null,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(lootTableRepository.rows).toHaveLength(2);
|
||||||
|
expect(lootEntryRepository.rows).toHaveLength(5);
|
||||||
|
expect(lootEntryRepository.rows).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
lootTableId: ASH_RAT_LOOT_TABLE_ID,
|
||||||
|
itemDefinitionId: ITEM_IDS['ash-pelt'],
|
||||||
|
position: 1,
|
||||||
|
dropChance: '0.6000',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
|
||||||
|
itemDefinitionId: ITEM_IDS['bandit-blade'],
|
||||||
|
position: 1,
|
||||||
|
dropChance: '0.1800',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The Kleiner Heiltrank entry is deliberately deferred (spec §16).
|
||||||
|
expect(
|
||||||
|
lootEntryRepository.rows.some(
|
||||||
|
(row) => row.itemDefinitionId === ITEM_IDS['small-healing-potion'],
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
expect(monsterRepository.rows).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ key: 'ash-rat', lootTableId: ASH_RAT_LOOT_TABLE_ID }),
|
||||||
|
expect.objectContaining({ key: 'road-bandit', lootTableId: ROAD_BANDIT_LOOT_TABLE_ID }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { DEMO_CHARACTER_ID } from '../../demo/demo-character.constants';
|
import { DEMO_CHARACTER_ID } from '../../demo/demo-character.constants';
|
||||||
import { Character } from '../../characters/entities/character.entity';
|
import { Character } from '../../characters/entities/character.entity';
|
||||||
|
import { ItemDefinition } from '../../items/entities/item-definition.entity';
|
||||||
|
import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity';
|
||||||
|
import { LootTable } from '../../loot/entities/loot-table.entity';
|
||||||
import { EncounterType } from '../../monsters/entities/encounter-type.enum';
|
import { EncounterType } from '../../monsters/entities/encounter-type.enum';
|
||||||
import { LocationMonster } from '../../monsters/entities/location-monster.entity';
|
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 {
|
||||||
|
ASH_RAT_LOOT_TABLE_ID,
|
||||||
|
ROAD_BANDIT_LOOT_TABLE_ID,
|
||||||
|
} from './item.constants';
|
||||||
|
import { ITEM_DEFINITIONS, LOOT_TABLES, LOOT_TABLE_ENTRIES } from './item-content';
|
||||||
import {
|
import {
|
||||||
ASH_RAT_MONSTER_ID,
|
ASH_RAT_MONSTER_ID,
|
||||||
BURNED_ROAD_ID,
|
BURNED_ROAD_ID,
|
||||||
@@ -21,6 +29,9 @@ export async function seedVisibleVerticalSlice(
|
|||||||
const characterRepository = dataSource.getRepository(Character);
|
const characterRepository = dataSource.getRepository(Character);
|
||||||
const monsterRepository = dataSource.getRepository(MonsterDefinition);
|
const monsterRepository = dataSource.getRepository(MonsterDefinition);
|
||||||
const locationMonsterRepository = dataSource.getRepository(LocationMonster);
|
const locationMonsterRepository = dataSource.getRepository(LocationMonster);
|
||||||
|
const itemRepository = dataSource.getRepository(ItemDefinition);
|
||||||
|
const lootTableRepository = dataSource.getRepository(LootTable);
|
||||||
|
const lootEntryRepository = dataSource.getRepository(LootTableEntry);
|
||||||
|
|
||||||
const locations = [
|
const locations = [
|
||||||
{
|
{
|
||||||
@@ -95,6 +106,15 @@ export async function seedVisibleVerticalSlice(
|
|||||||
['fromLocationId', 'toLocationId'],
|
['fromLocationId', 'toLocationId'],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Content is upserted by its stable key so re-running never duplicates rows
|
||||||
|
// and never touches player-owned character_items or combat_rewards.
|
||||||
|
await itemRepository.upsert(ITEM_DEFINITIONS, ['key']);
|
||||||
|
await lootTableRepository.upsert(LOOT_TABLES, ['key']);
|
||||||
|
await lootEntryRepository.upsert(LOOT_TABLE_ENTRIES, [
|
||||||
|
'lootTableId',
|
||||||
|
'itemDefinitionId',
|
||||||
|
]);
|
||||||
|
|
||||||
const monsters = [
|
const monsters = [
|
||||||
{
|
{
|
||||||
id: ASH_RAT_MONSTER_ID,
|
id: ASH_RAT_MONSTER_ID,
|
||||||
@@ -108,6 +128,7 @@ export async function seedVisibleVerticalSlice(
|
|||||||
silverMin: 4,
|
silverMin: 4,
|
||||||
silverMax: 7,
|
silverMax: 7,
|
||||||
artworkPath: '/images/monsters/ash-rat.png',
|
artworkPath: '/images/monsters/ash-rat.png',
|
||||||
|
lootTableId: ASH_RAT_LOOT_TABLE_ID,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: ROAD_BANDIT_MONSTER_ID,
|
id: ROAD_BANDIT_MONSTER_ID,
|
||||||
@@ -121,6 +142,7 @@ export async function seedVisibleVerticalSlice(
|
|||||||
silverMin: 9,
|
silverMin: 9,
|
||||||
silverMax: 15,
|
silverMax: 15,
|
||||||
artworkPath: '/images/monsters/road-bandit.png',
|
artworkPath: '/images/monsters/road-bandit.png',
|
||||||
|
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
let ashRatId = ASH_RAT_MONSTER_ID;
|
let ashRatId = ASH_RAT_MONSTER_ID;
|
||||||
@@ -176,6 +198,7 @@ export async function seedVisibleVerticalSlice(
|
|||||||
name: 'Aric Duskwalker',
|
name: 'Aric Duskwalker',
|
||||||
level: 1,
|
level: 1,
|
||||||
experience: 0,
|
experience: 0,
|
||||||
|
silver: 0,
|
||||||
baseHp: 100,
|
baseHp: 100,
|
||||||
baseAttack: 6,
|
baseAttack: 6,
|
||||||
currentHp: 100,
|
currentHp: 100,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Hunt } from './entities/hunt.entity';
|
|||||||
import { HuntEncounter } from './entities/hunt-encounter.entity';
|
import { HuntEncounter } from './entities/hunt-encounter.entity';
|
||||||
import { HuntingController } from './hunting.controller';
|
import { HuntingController } from './hunting.controller';
|
||||||
import { HuntingService } from './hunting.service';
|
import { HuntingService } from './hunting.service';
|
||||||
import { RANDOM_SOURCE, systemRandomSource } from './random-source';
|
import { RANDOM_SOURCE, systemRandomSource } from '../shared/random-source';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { HuntEncounterStatus } from './hunt-encounter-status.enum';
|
|||||||
import { HuntStatus } from './hunt-status.enum';
|
import { HuntStatus } from './hunt-status.enum';
|
||||||
import { HuntingDomainError } from './hunting.errors';
|
import { HuntingDomainError } from './hunting.errors';
|
||||||
import { HuntingService } from './hunting.service';
|
import { HuntingService } from './hunting.service';
|
||||||
import type { RandomSource } from './random-source';
|
import type { RandomSource } from '../shared/random-source';
|
||||||
|
|
||||||
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
|
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
|
||||||
const HUNTING_LOCATION_ID = '20000000-0000-4000-8000-000000000001';
|
const HUNTING_LOCATION_ID = '20000000-0000-4000-8000-000000000001';
|
||||||
@@ -235,6 +235,7 @@ function character(currentLocation: LocationDefinition): Character {
|
|||||||
name: 'Aric Duskwalker',
|
name: 'Aric Duskwalker',
|
||||||
level: 1,
|
level: 1,
|
||||||
experience: 0,
|
experience: 0,
|
||||||
|
silver: 0,
|
||||||
baseHp: 100,
|
baseHp: 100,
|
||||||
baseAttack: 6,
|
baseAttack: 6,
|
||||||
currentHp: 100,
|
currentHp: 100,
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ import {
|
|||||||
huntingNotAvailable,
|
huntingNotAvailable,
|
||||||
noHuntEncountersAvailable,
|
noHuntEncountersAvailable,
|
||||||
} from './hunting.errors';
|
} from './hunting.errors';
|
||||||
import { RANDOM_SOURCE } from './random-source';
|
import { RANDOM_SOURCE } from '../shared/random-source';
|
||||||
import type { RandomSource } from './random-source';
|
import type { RandomSource } from '../shared/random-source';
|
||||||
|
|
||||||
export interface MonsterSummary {
|
export interface MonsterSummary {
|
||||||
key: string;
|
key: string;
|
||||||
|
|||||||
51
apps/api/src/items/entities/character-item.entity.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Character } from '../../characters/entities/character.entity';
|
||||||
|
import { ItemDefinition } from './item-definition.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One stack of one item definition owned by one character.
|
||||||
|
*
|
||||||
|
* Duplicate drops increment `quantity` (spec §28 allows duplicates and forbids
|
||||||
|
* duplicate protection). Slice 0.5 equips a `CharacterItem.id`, never an
|
||||||
|
* `ItemDefinition.id`.
|
||||||
|
*/
|
||||||
|
@Entity({ name: 'character_items' })
|
||||||
|
@Index('IDX_character_items_character_item', ['characterId', 'itemDefinitionId'], {
|
||||||
|
unique: true,
|
||||||
|
})
|
||||||
|
export class CharacterItem {
|
||||||
|
@PrimaryGeneratedColumn('uuid', { name: 'id' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'character_id', type: 'uuid' })
|
||||||
|
characterId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'item_definition_id', type: 'uuid' })
|
||||||
|
itemDefinitionId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'quantity', type: 'integer' })
|
||||||
|
quantity!: number;
|
||||||
|
|
||||||
|
@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(() => ItemDefinition, { onDelete: 'RESTRICT' })
|
||||||
|
@JoinColumn({ name: 'item_definition_id' })
|
||||||
|
itemDefinition!: ItemDefinition;
|
||||||
|
}
|
||||||
74
apps/api/src/items/entities/item-definition.entity.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { EquipmentSlot } from '../equipment-slot.enum';
|
||||||
|
import { ItemRarity } from '../item-rarity.enum';
|
||||||
|
import { ItemType } from '../item-type.enum';
|
||||||
|
|
||||||
|
@Entity({ name: 'item_definitions' })
|
||||||
|
@Index('IDX_item_definitions_key', ['key'], { unique: true })
|
||||||
|
export class ItemDefinition {
|
||||||
|
@PrimaryGeneratedColumn('uuid', { name: 'id' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'key', type: 'varchar', length: 100 })
|
||||||
|
key!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'name', type: 'varchar', length: 150 })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'description', type: 'text' })
|
||||||
|
description!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'type', type: 'enum', enum: ItemType, enumName: 'item_type_enum' })
|
||||||
|
type!: ItemType;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'equipment_slot',
|
||||||
|
type: 'enum',
|
||||||
|
enum: EquipmentSlot,
|
||||||
|
enumName: 'equipment_slot_enum',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
equipmentSlot!: EquipmentSlot | null;
|
||||||
|
|
||||||
|
@Column({ name: 'rarity', type: 'enum', enum: ItemRarity, enumName: 'item_rarity_enum' })
|
||||||
|
rarity!: ItemRarity;
|
||||||
|
|
||||||
|
@Column({ name: 'tier', type: 'integer' })
|
||||||
|
tier!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'required_level', type: 'integer' })
|
||||||
|
requiredLevel!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'weapon_damage', type: 'integer' })
|
||||||
|
weaponDamage!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'bonus_hp', type: 'integer' })
|
||||||
|
bonusHp!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'bonus_attack', type: 'integer' })
|
||||||
|
bonusAttack!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'bonus_armor', type: 'integer' })
|
||||||
|
bonusArmor!: number;
|
||||||
|
|
||||||
|
// Always 0 in Slice 0.4: there are no merchants, and the balancing doc's
|
||||||
|
// Grenzmarken table lists purchase prices, not sell prices.
|
||||||
|
@Column({ name: 'sell_price', type: 'integer' })
|
||||||
|
sellPrice!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'icon_path', type: 'varchar', length: 255 })
|
||||||
|
iconPath!: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt!: Date;
|
||||||
|
}
|
||||||
10
apps/api/src/items/equipment-slot.enum.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// Slice 0.4 only stores the slot as content data. Slice 0.5 makes it functional.
|
||||||
|
export enum EquipmentSlot {
|
||||||
|
WEAPON = 'WEAPON',
|
||||||
|
HEAD = 'HEAD',
|
||||||
|
CHEST = 'CHEST',
|
||||||
|
HANDS = 'HANDS',
|
||||||
|
LEGS = 'LEGS',
|
||||||
|
FEET = 'FEET',
|
||||||
|
AMULET = 'AMULET',
|
||||||
|
}
|
||||||
7
apps/api/src/items/item-rarity.enum.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// Mirrors docs/Ashen_Realms_Balancing_Items_Loot_Design_V1.md §19:
|
||||||
|
// Gewöhnlich / Selten / Episch. German labels live in the frontend.
|
||||||
|
export enum ItemRarity {
|
||||||
|
COMMON = 'COMMON',
|
||||||
|
RARE = 'RARE',
|
||||||
|
EPIC = 'EPIC',
|
||||||
|
}
|
||||||
6
apps/api/src/items/item-type.enum.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export enum ItemType {
|
||||||
|
WEAPON = 'WEAPON',
|
||||||
|
ARMOR = 'ARMOR',
|
||||||
|
MATERIAL = 'MATERIAL',
|
||||||
|
CONSUMABLE = 'CONSUMABLE',
|
||||||
|
}
|
||||||
63
apps/api/src/loot/entities/loot-table-entry.entity.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { ItemDefinition } from '../../items/entities/item-definition.entity';
|
||||||
|
import { LootTable } from './loot-table.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One independently rolled drop (spec §17).
|
||||||
|
*
|
||||||
|
* `position` fixes the roll order so injected randoms are predictable in tests;
|
||||||
|
* it is content ordering, not priority. A guaranteed drop is simply
|
||||||
|
* `dropChance = 1.0000` — no extra mechanism needed (spec §14).
|
||||||
|
*/
|
||||||
|
@Entity({ name: 'loot_table_entries' })
|
||||||
|
@Index('IDX_loot_table_entries_table_position', ['lootTableId', 'position'], { unique: true })
|
||||||
|
@Index('IDX_loot_table_entries_table_item', ['lootTableId', 'itemDefinitionId'], { unique: true })
|
||||||
|
export class LootTableEntry {
|
||||||
|
@PrimaryGeneratedColumn('uuid', { name: 'id' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'loot_table_id', type: 'uuid' })
|
||||||
|
lootTableId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'item_definition_id', type: 'uuid' })
|
||||||
|
itemDefinitionId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'position', type: 'integer' })
|
||||||
|
position!: number;
|
||||||
|
|
||||||
|
// PostgreSQL numeric arrives as a string, like LocationConnection.ambushChance.
|
||||||
|
@Column({ name: 'drop_chance', type: 'numeric', precision: 5, scale: 4 })
|
||||||
|
dropChance!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'min_quantity', type: 'integer' })
|
||||||
|
minQuantity!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'max_quantity', type: 'integer' })
|
||||||
|
maxQuantity!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'enabled', type: 'boolean' })
|
||||||
|
enabled!: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt!: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => LootTable, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'loot_table_id' })
|
||||||
|
lootTable!: LootTable;
|
||||||
|
|
||||||
|
@ManyToOne(() => ItemDefinition, { onDelete: 'RESTRICT' })
|
||||||
|
@JoinColumn({ name: 'item_definition_id' })
|
||||||
|
itemDefinition!: ItemDefinition;
|
||||||
|
}
|
||||||
27
apps/api/src/loot/entities/loot-table.entity.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity({ name: 'loot_tables' })
|
||||||
|
@Index('IDX_loot_tables_key', ['key'], { unique: true })
|
||||||
|
export class LootTable {
|
||||||
|
@PrimaryGeneratedColumn('uuid', { name: 'id' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'key', type: 'varchar', length: 100 })
|
||||||
|
key!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'name', type: 'varchar', length: 150 })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt!: Date;
|
||||||
|
}
|
||||||
13
apps/api/src/loot/loot.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { RANDOM_SOURCE, systemRandomSource } from '../shared/random-source';
|
||||||
|
import { LootTable } from './entities/loot-table.entity';
|
||||||
|
import { LootTableEntry } from './entities/loot-table-entry.entity';
|
||||||
|
import { LootService } from './loot.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([LootTable, LootTableEntry])],
|
||||||
|
providers: [LootService, { provide: RANDOM_SOURCE, useValue: systemRandomSource }],
|
||||||
|
exports: [LootService],
|
||||||
|
})
|
||||||
|
export class LootModule {}
|
||||||
128
apps/api/src/loot/loot.service.spec.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import type { RandomSource } from '../shared/random-source';
|
||||||
|
import { LootTableEntry } from './entities/loot-table-entry.entity';
|
||||||
|
import { LootService } from './loot.service';
|
||||||
|
|
||||||
|
const ASH_RAT_TABLE = '60000000-0000-4000-8000-000000000001';
|
||||||
|
const ASH_PELT = '50000000-0000-4000-8000-00000000000c';
|
||||||
|
const WORN_SHORT_SWORD = '50000000-0000-4000-8000-000000000001';
|
||||||
|
|
||||||
|
function entry(overrides: Partial<LootTableEntry>): LootTableEntry {
|
||||||
|
return {
|
||||||
|
id: 'entry-1',
|
||||||
|
lootTableId: ASH_RAT_TABLE,
|
||||||
|
itemDefinitionId: ASH_PELT,
|
||||||
|
position: 1,
|
||||||
|
dropChance: '0.6000',
|
||||||
|
minQuantity: 1,
|
||||||
|
maxQuantity: 1,
|
||||||
|
enabled: true,
|
||||||
|
createdAt: new Date('2026-08-19T09:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-08-19T09:00:00.000Z'),
|
||||||
|
...overrides,
|
||||||
|
} as LootTableEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hands out the queued values in order, so a test states exactly which roll
|
||||||
|
// each value answers.
|
||||||
|
function queuedRandom(...values: number[]): RandomSource {
|
||||||
|
let index = 0;
|
||||||
|
return {
|
||||||
|
next: () => {
|
||||||
|
if (index >= values.length) {
|
||||||
|
throw new Error('LootService consumed more random values than the test queued');
|
||||||
|
}
|
||||||
|
return values[index++];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataSourceWith(entries: LootTableEntry[]): DataSource {
|
||||||
|
return {
|
||||||
|
getRepository: jest.fn(() => ({
|
||||||
|
find: jest.fn(async (options: { where: { lootTableId: string; enabled: boolean } }) =>
|
||||||
|
entries
|
||||||
|
.filter(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.lootTableId === options.where.lootTableId &&
|
||||||
|
candidate.enabled === options.where.enabled,
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.position - b.position),
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
} as unknown as DataSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('LootService', () => {
|
||||||
|
const ashRatEntries = [
|
||||||
|
entry({ id: 'entry-pelt', itemDefinitionId: ASH_PELT, position: 1, dropChance: '0.6000' }),
|
||||||
|
entry({
|
||||||
|
id: 'entry-sword',
|
||||||
|
itemDefinitionId: WORN_SHORT_SWORD,
|
||||||
|
position: 2,
|
||||||
|
dropChance: '0.0800',
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
it('drops an entry when the roll falls under its chance', async () => {
|
||||||
|
const service = new LootService(dataSourceWith(ashRatEntries), queuedRandom(0.59, 0.07));
|
||||||
|
|
||||||
|
await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({
|
||||||
|
items: [
|
||||||
|
{ itemDefinitionId: ASH_PELT, quantity: 1 },
|
||||||
|
{ itemDefinitionId: WORN_SHORT_SWORD, quantity: 1 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips an entry when the roll lands on or above its chance', async () => {
|
||||||
|
const service = new LootService(dataSourceWith(ashRatEntries), queuedRandom(0.6, 0.08));
|
||||||
|
|
||||||
|
await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({ items: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls each entry independently, so one combat can drop only the second item', async () => {
|
||||||
|
const service = new LootService(dataSourceWith(ashRatEntries), queuedRandom(0.9, 0.01));
|
||||||
|
|
||||||
|
await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({
|
||||||
|
items: [{ itemDefinitionId: WORN_SHORT_SWORD, quantity: 1 }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls entries in position order so injected values stay predictable', async () => {
|
||||||
|
const outOfOrder = [
|
||||||
|
entry({ id: 'entry-sword', itemDefinitionId: WORN_SHORT_SWORD, position: 2, dropChance: '1.0000' }),
|
||||||
|
entry({ id: 'entry-pelt', itemDefinitionId: ASH_PELT, position: 1, dropChance: '0.0000' }),
|
||||||
|
];
|
||||||
|
const service = new LootService(dataSourceWith(outOfOrder), queuedRandom(0.5, 0.5));
|
||||||
|
|
||||||
|
await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({
|
||||||
|
items: [{ itemDefinitionId: WORN_SHORT_SWORD, quantity: 1 }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores disabled entries', async () => {
|
||||||
|
const service = new LootService(
|
||||||
|
dataSourceWith([entry({ dropChance: '1.0000', enabled: false })]),
|
||||||
|
queuedRandom(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({ items: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('consumes no quantity roll when min and max match, and one when they differ', async () => {
|
||||||
|
const stackable = [entry({ dropChance: '1.0000', minQuantity: 2, maxQuantity: 4 })];
|
||||||
|
// First value drops the entry, second picks the quantity (0.5 -> 3).
|
||||||
|
const service = new LootService(dataSourceWith(stackable), queuedRandom(0.1, 0.5));
|
||||||
|
|
||||||
|
await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({
|
||||||
|
items: [{ itemDefinitionId: ASH_PELT, quantity: 3 }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns nothing for a monster without a loot table', async () => {
|
||||||
|
const service = new LootService(dataSourceWith(ashRatEntries), queuedRandom());
|
||||||
|
|
||||||
|
await expect(service.rollLoot(null)).resolves.toEqual({ items: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
66
apps/api/src/loot/loot.service.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { DataSource, EntityManager } from 'typeorm';
|
||||||
|
import { RANDOM_SOURCE } from '../shared/random-source';
|
||||||
|
import type { RandomSource } from '../shared/random-source';
|
||||||
|
import { rollInclusive } from '../shared/roll-range';
|
||||||
|
import { LootTableEntry } from './entities/loot-table-entry.entity';
|
||||||
|
|
||||||
|
export interface LootRollItem {
|
||||||
|
itemDefinitionId: string;
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LootRollResult {
|
||||||
|
items: LootRollItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LootService {
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
@Inject(RANDOM_SOURCE) private readonly randomSource: RandomSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rolls a loot table without persisting anything (spec §20).
|
||||||
|
*
|
||||||
|
* Every enabled entry is one independent roll in `position` order, so a
|
||||||
|
* single combat may yield nothing, one item, or several (spec §17). The
|
||||||
|
* quantity roll is skipped entirely when `minQuantity === maxQuantity`,
|
||||||
|
* which keeps the random sequence stable for the seeded content.
|
||||||
|
*/
|
||||||
|
async rollLoot(
|
||||||
|
lootTableId: string | null,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<LootRollResult> {
|
||||||
|
if (!lootTableId) {
|
||||||
|
return { items: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await (
|
||||||
|
manager?.getRepository(LootTableEntry) ??
|
||||||
|
this.dataSource.getRepository(LootTableEntry)
|
||||||
|
).find({
|
||||||
|
where: { lootTableId, enabled: true },
|
||||||
|
order: { position: 'ASC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const items: LootRollItem[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (this.randomSource.next() >= Number(entry.dropChance)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
itemDefinitionId: entry.itemDefinitionId,
|
||||||
|
quantity: rollInclusive(
|
||||||
|
this.randomSource,
|
||||||
|
entry.minQuantity,
|
||||||
|
entry.maxQuantity,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { items };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,12 @@ import {
|
|||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
Entity,
|
Entity,
|
||||||
Index,
|
Index,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
|
import { LootTable } from '../../loot/entities/loot-table.entity';
|
||||||
|
|
||||||
@Entity({ name: 'monster_definitions' })
|
@Entity({ name: 'monster_definitions' })
|
||||||
@Index('IDX_monster_definitions_key', ['key'], { unique: true })
|
@Index('IDX_monster_definitions_key', ['key'], { unique: true })
|
||||||
@@ -43,9 +46,16 @@ export class MonsterDefinition {
|
|||||||
@Column({ name: 'artwork_path', type: 'varchar', length: 255 })
|
@Column({ name: 'artwork_path', type: 'varchar', length: 255 })
|
||||||
artworkPath!: string;
|
artworkPath!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'loot_table_id', type: 'uuid', nullable: true })
|
||||||
|
lootTableId!: string | null;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
|
||||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
updatedAt!: Date;
|
updatedAt!: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => LootTable, { onDelete: 'RESTRICT', nullable: true })
|
||||||
|
@JoinColumn({ name: 'loot_table_id' })
|
||||||
|
lootTable!: LootTable | null;
|
||||||
}
|
}
|
||||||
|
|||||||
445
apps/api/src/rewards/combat-reward.service.spec.ts
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
import { EntityManager, EntityTarget } from 'typeorm';
|
||||||
|
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 { ItemRarity } from '../items/item-rarity.enum';
|
||||||
|
import { ItemType } from '../items/item-type.enum';
|
||||||
|
import { LootService } from '../loot/loot.service';
|
||||||
|
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
|
||||||
|
import type { RandomSource } from '../shared/random-source';
|
||||||
|
import { CombatRewardService } from './combat-reward.service';
|
||||||
|
import { CombatReward } from './entities/combat-reward.entity';
|
||||||
|
import { CombatRewardItem } from './entities/combat-reward-item.entity';
|
||||||
|
import { RewardDomainError } from './rewards.errors';
|
||||||
|
|
||||||
|
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
|
||||||
|
const COMBAT_ID = '20000000-0000-4000-8000-000000000001';
|
||||||
|
const ASH_RAT_ID = '30000000-0000-4000-8000-000000000001';
|
||||||
|
const ROAD_BANDIT_ID = '30000000-0000-4000-8000-000000000002';
|
||||||
|
const ASH_RAT_TABLE = '60000000-0000-4000-8000-000000000001';
|
||||||
|
const ROAD_BANDIT_TABLE = '60000000-0000-4000-8000-000000000002';
|
||||||
|
const BANDIT_BLADE = '50000000-0000-4000-8000-000000000002';
|
||||||
|
const BANDIT_HOOD = '50000000-0000-4000-8000-000000000001';
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
characters: Character[];
|
||||||
|
monsters: MonsterDefinition[];
|
||||||
|
itemDefinitions: ItemDefinition[];
|
||||||
|
characterItems: CharacterItem[];
|
||||||
|
combatRewards: CombatReward[];
|
||||||
|
combatRewardItems: CombatRewardItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeRepository<T extends { id: string }> {
|
||||||
|
constructor(
|
||||||
|
private readonly rows: T[],
|
||||||
|
private readonly prefix: string,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
findOne(options: { where: Partial<T> }): Promise<T | null> {
|
||||||
|
return Promise.resolve(this.rows.find((row) => this.matches(row, options.where)) ?? 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>;
|
||||||
|
order?: Partial<Record<keyof T, 'ASC' | 'DESC'>>;
|
||||||
|
}): Promise<T[]> {
|
||||||
|
const matched = this.rows.filter((row) => this.matches(row, options.where));
|
||||||
|
if (!options.order) {
|
||||||
|
return Promise.resolve(matched);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors TypeORM's `order` clause so tests can prove ordering comes from
|
||||||
|
// the query, not from insertion order happening to line up.
|
||||||
|
const [key, direction] = Object.entries(options.order)[0] as [keyof T, 'ASC' | 'DESC'];
|
||||||
|
const sorted = [...matched].sort((a, b) => {
|
||||||
|
const left = a[key];
|
||||||
|
const right = b[key];
|
||||||
|
const comparison = left < right ? -1 : left > right ? 1 : 0;
|
||||||
|
return direction === 'DESC' ? -comparison : comparison;
|
||||||
|
});
|
||||||
|
return Promise.resolve(sorted);
|
||||||
|
}
|
||||||
|
|
||||||
|
create(values: Partial<T>): T {
|
||||||
|
return { ...values } as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
save(entity: T): Promise<T> {
|
||||||
|
if (!entity.id) {
|
||||||
|
entity.id = `${this.prefix}-${this.rows.length + 1}`;
|
||||||
|
}
|
||||||
|
const index = this.rows.findIndex((row) => row.id === entity.id);
|
||||||
|
if (index === -1) {
|
||||||
|
this.rows.push(entity);
|
||||||
|
} else {
|
||||||
|
this.rows[index] = entity;
|
||||||
|
}
|
||||||
|
return Promise.resolve(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
private matches(row: T, where: Partial<T>): boolean {
|
||||||
|
return Object.entries(where).every(([key, value]) => row[key as keyof T] === value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeManager(state: State): EntityManager {
|
||||||
|
return {
|
||||||
|
getRepository: <T extends { id: string }>(target: EntityTarget<T>) => {
|
||||||
|
if (target === Character) return new FakeRepository(state.characters, 'character') as never;
|
||||||
|
if (target === MonsterDefinition) return new FakeRepository(state.monsters, 'monster') as never;
|
||||||
|
if (target === ItemDefinition)
|
||||||
|
return new FakeRepository(state.itemDefinitions, 'definition') as never;
|
||||||
|
if (target === CharacterItem)
|
||||||
|
return new FakeRepository(state.characterItems, 'character-item') as never;
|
||||||
|
if (target === CombatReward)
|
||||||
|
return new FakeRepository(state.combatRewards, 'reward') as never;
|
||||||
|
if (target === CombatRewardItem)
|
||||||
|
return new FakeRepository(state.combatRewardItems, 'reward-item') as never;
|
||||||
|
throw new Error('Unsupported repository');
|
||||||
|
},
|
||||||
|
} as unknown as EntityManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
function combat(overrides: Partial<Combat> = {}): Combat {
|
||||||
|
return {
|
||||||
|
id: COMBAT_ID,
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
monsterDefinitionId: ASH_RAT_ID,
|
||||||
|
status: CombatStatus.WON,
|
||||||
|
round: 4,
|
||||||
|
...overrides,
|
||||||
|
} as Combat;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createState(overrides: Partial<State> = {}): State {
|
||||||
|
return {
|
||||||
|
characters: [{ id: CHARACTER_ID, experience: 12, silver: 3 } as Character],
|
||||||
|
monsters: [
|
||||||
|
{
|
||||||
|
id: ASH_RAT_ID,
|
||||||
|
key: 'ash-rat',
|
||||||
|
experienceReward: 8,
|
||||||
|
silverMin: 4,
|
||||||
|
silverMax: 7,
|
||||||
|
lootTableId: ASH_RAT_TABLE,
|
||||||
|
} as MonsterDefinition,
|
||||||
|
{
|
||||||
|
id: ROAD_BANDIT_ID,
|
||||||
|
key: 'road-bandit',
|
||||||
|
experienceReward: 16,
|
||||||
|
silverMin: 9,
|
||||||
|
silverMax: 15,
|
||||||
|
lootTableId: ROAD_BANDIT_TABLE,
|
||||||
|
} as MonsterDefinition,
|
||||||
|
],
|
||||||
|
itemDefinitions: [
|
||||||
|
{
|
||||||
|
id: BANDIT_BLADE,
|
||||||
|
key: 'bandit-blade',
|
||||||
|
name: 'Räuberklinge',
|
||||||
|
type: ItemType.WEAPON,
|
||||||
|
rarity: ItemRarity.COMMON,
|
||||||
|
iconPath: '/images/items/bandit-blade.png',
|
||||||
|
} as ItemDefinition,
|
||||||
|
],
|
||||||
|
characterItems: [],
|
||||||
|
combatRewards: [],
|
||||||
|
combatRewardItems: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeDataSource(state: State): EntityManager {
|
||||||
|
// `loadRewards`'s no-manager branch only calls `getRepository`, which
|
||||||
|
// `fakeManager` already implements identically for `DataSource`; reusing
|
||||||
|
// it (rather than duplicating the repository-resolution switch) keeps this
|
||||||
|
// fake backed by the exact same `state` rows as the transactional path.
|
||||||
|
return fakeManager(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeLoot(...items: Array<{ itemDefinitionId: string; quantity: number }>): LootService {
|
||||||
|
return { rollLoot: jest.fn().mockResolvedValue({ items }) } as unknown as LootService;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixedRandom(value: number): RandomSource {
|
||||||
|
return { next: () => value };
|
||||||
|
}
|
||||||
|
|
||||||
|
function service(
|
||||||
|
state: State,
|
||||||
|
loot: LootService = fakeLoot(),
|
||||||
|
random: RandomSource = fixedRandom(0.5),
|
||||||
|
): CombatRewardService {
|
||||||
|
return new CombatRewardService({} as never, loot, random);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CombatRewardService', () => {
|
||||||
|
describe('eligibility', () => {
|
||||||
|
it('rejects an ACTIVE combat', async () => {
|
||||||
|
const state = createState();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service(state).grantVictoryRewards(fakeManager(state), combat({ status: CombatStatus.ACTIVE })),
|
||||||
|
).rejects.toMatchObject({ code: 'COMBAT_NOT_WON' });
|
||||||
|
expect(state.combatRewards).toHaveLength(0);
|
||||||
|
expect(state.characters[0].experience).toBe(12);
|
||||||
|
expect(state.characters[0].silver).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a LOST combat', async () => {
|
||||||
|
const state = createState();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service(state).grantVictoryRewards(fakeManager(state), combat({ status: CombatStatus.LOST })),
|
||||||
|
).rejects.toBeInstanceOf(RewardDomainError);
|
||||||
|
expect(state.combatRewards).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grants rewards for a WON combat', async () => {
|
||||||
|
const state = createState();
|
||||||
|
|
||||||
|
const reward = await service(state).grantVictoryRewards(fakeManager(state), combat());
|
||||||
|
|
||||||
|
expect(reward).toEqual({ experience: 8, silver: 6, items: [] });
|
||||||
|
expect(state.combatRewards).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Aschenratte', () => {
|
||||||
|
it('grants 8 XP and a silver roll inside 4-7, persisted on the character', async () => {
|
||||||
|
const state = createState();
|
||||||
|
|
||||||
|
const reward = await service(state, fakeLoot(), fixedRandom(0)).grantVictoryRewards(
|
||||||
|
fakeManager(state),
|
||||||
|
combat(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reward.experience).toBe(8);
|
||||||
|
expect(reward.silver).toBe(4);
|
||||||
|
expect(state.characters[0].experience).toBe(20);
|
||||||
|
expect(state.characters[0].silver).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls the top of the silver range from the top of the random range', async () => {
|
||||||
|
const state = createState();
|
||||||
|
|
||||||
|
const reward = await service(state, fakeLoot(), fixedRandom(0.99)).grantVictoryRewards(
|
||||||
|
fakeManager(state),
|
||||||
|
combat(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reward.silver).toBe(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Straßenräuber', () => {
|
||||||
|
const banditCombat = combat({ monsterDefinitionId: ROAD_BANDIT_ID });
|
||||||
|
|
||||||
|
it('grants 16 XP and a silver roll inside 9-15', async () => {
|
||||||
|
const state = createState();
|
||||||
|
|
||||||
|
const reward = await service(state, fakeLoot(), fixedRandom(0)).grantVictoryRewards(
|
||||||
|
fakeManager(state),
|
||||||
|
banditCombat,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reward.experience).toBe(16);
|
||||||
|
expect(reward.silver).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists a dropped Räuberklinge as a CharacterItem and references it in the reward', async () => {
|
||||||
|
const state = createState();
|
||||||
|
|
||||||
|
const reward = await service(
|
||||||
|
state,
|
||||||
|
fakeLoot({ itemDefinitionId: BANDIT_BLADE, quantity: 1 }),
|
||||||
|
).grantVictoryRewards(fakeManager(state), banditCombat);
|
||||||
|
|
||||||
|
expect(state.characterItems).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
itemDefinitionId: BANDIT_BLADE,
|
||||||
|
quantity: 1,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(reward.items).toEqual([
|
||||||
|
{
|
||||||
|
characterItemId: state.characterItems[0].id,
|
||||||
|
item: {
|
||||||
|
key: 'bandit-blade',
|
||||||
|
name: 'Räuberklinge',
|
||||||
|
rarity: ItemRarity.COMMON,
|
||||||
|
iconPath: '/images/items/bandit-blade.png',
|
||||||
|
},
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(state.combatRewardItems).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports no items when the Räuberklinge does not drop', async () => {
|
||||||
|
const state = createState();
|
||||||
|
|
||||||
|
const reward = await service(state, fakeLoot()).grantVictoryRewards(
|
||||||
|
fakeManager(state),
|
||||||
|
banditCombat,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reward.items).toEqual([]);
|
||||||
|
expect(state.characterItems).toHaveLength(0);
|
||||||
|
expect(state.combatRewardItems).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stacks a duplicate drop onto the existing CharacterItem without duplicate protection', async () => {
|
||||||
|
const state = createState({
|
||||||
|
characterItems: [
|
||||||
|
{
|
||||||
|
id: 'character-item-existing',
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
itemDefinitionId: BANDIT_BLADE,
|
||||||
|
quantity: 1,
|
||||||
|
} as CharacterItem,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const reward = await service(
|
||||||
|
state,
|
||||||
|
fakeLoot({ itemDefinitionId: BANDIT_BLADE, quantity: 1 }),
|
||||||
|
).grantVictoryRewards(fakeManager(state), banditCombat);
|
||||||
|
|
||||||
|
expect(state.characterItems).toHaveLength(1);
|
||||||
|
expect(state.characterItems[0].quantity).toBe(2);
|
||||||
|
// The reward reports what THIS combat granted, not the stack total.
|
||||||
|
expect(reward.items[0].quantity).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('idempotency', () => {
|
||||||
|
it('grants once and returns the same persisted reward on a repeat call', async () => {
|
||||||
|
const state = createState();
|
||||||
|
const loot = fakeLoot({ itemDefinitionId: BANDIT_BLADE, quantity: 1 });
|
||||||
|
const subject = service(state, loot, fixedRandom(0));
|
||||||
|
const manager = fakeManager(state);
|
||||||
|
|
||||||
|
const first = await subject.grantVictoryRewards(manager, combat());
|
||||||
|
const second = await subject.grantVictoryRewards(manager, combat());
|
||||||
|
|
||||||
|
expect(second).toEqual(first);
|
||||||
|
expect(state.combatRewards).toHaveLength(1);
|
||||||
|
expect(state.combatRewardItems).toHaveLength(1);
|
||||||
|
expect(state.characterItems).toHaveLength(1);
|
||||||
|
expect(state.characterItems[0].quantity).toBe(1);
|
||||||
|
expect(state.characters[0].experience).toBe(20);
|
||||||
|
expect(state.characters[0].silver).toBe(7);
|
||||||
|
expect(loot.rollLoot).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadRewards', () => {
|
||||||
|
it('returns null for a combat that was never rewarded', async () => {
|
||||||
|
const state = createState();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service(state).loadRewards(COMBAT_ID, fakeManager(state)),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replays the persisted reward without rerolling', async () => {
|
||||||
|
const state = createState();
|
||||||
|
const loot = fakeLoot({ itemDefinitionId: BANDIT_BLADE, quantity: 1 });
|
||||||
|
const subject = service(state, loot, fixedRandom(0));
|
||||||
|
const manager = fakeManager(state);
|
||||||
|
const granted = await subject.grantVictoryRewards(manager, combat());
|
||||||
|
|
||||||
|
const replayed = await subject.loadRewards(COMBAT_ID, manager);
|
||||||
|
|
||||||
|
expect(replayed).toEqual(granted);
|
||||||
|
expect(loot.rollLoot).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a persisted reward through the injected DataSource when no manager is passed', async () => {
|
||||||
|
const state = createState();
|
||||||
|
const loot = fakeLoot({ itemDefinitionId: BANDIT_BLADE, quantity: 1 });
|
||||||
|
const dataSource = fakeDataSource(state);
|
||||||
|
const subject = new CombatRewardService(dataSource as never, loot, fixedRandom(0));
|
||||||
|
const manager = fakeManager(state);
|
||||||
|
|
||||||
|
const granted = await subject.grantVictoryRewards(manager, combat());
|
||||||
|
|
||||||
|
// No manager argument: this is the non-transactional read the next
|
||||||
|
// task uses to render a reward screen.
|
||||||
|
const replayed = await subject.loadRewards(COMBAT_ID);
|
||||||
|
|
||||||
|
expect(replayed).toEqual(granted);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('failure handling', () => {
|
||||||
|
it('throws instead of half-granting when a rolled item definition is missing', async () => {
|
||||||
|
const state = createState();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service(state, fakeLoot({ itemDefinitionId: 'missing-item', quantity: 1 })).grantVictoryRewards(
|
||||||
|
fakeManager(state),
|
||||||
|
combat(),
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ code: 'REWARD_STATE_INVALID' });
|
||||||
|
expect(state.combatRewardItems).toHaveLength(0);
|
||||||
|
// Every rolled item definition is resolved before any mutation, so a
|
||||||
|
// missing one must leave no reward row and no character grant behind.
|
||||||
|
expect(state.combatRewards).toHaveLength(0);
|
||||||
|
expect(state.characters[0].experience).toBe(12);
|
||||||
|
expect(state.characters[0].silver).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('item order', () => {
|
||||||
|
it('orders items by itemDefinitionId in the immediate grant and the replayed reward, regardless of roll order', async () => {
|
||||||
|
const state = createState({
|
||||||
|
itemDefinitions: [
|
||||||
|
{
|
||||||
|
id: BANDIT_BLADE,
|
||||||
|
key: 'bandit-blade',
|
||||||
|
name: 'Räuberklinge',
|
||||||
|
type: ItemType.WEAPON,
|
||||||
|
rarity: ItemRarity.COMMON,
|
||||||
|
iconPath: '/images/items/bandit-blade.png',
|
||||||
|
} as ItemDefinition,
|
||||||
|
{
|
||||||
|
id: BANDIT_HOOD,
|
||||||
|
key: 'bandit-hood',
|
||||||
|
name: 'Räuberkapuze',
|
||||||
|
type: ItemType.ARMOR,
|
||||||
|
rarity: ItemRarity.COMMON,
|
||||||
|
iconPath: '/images/items/bandit-hood.png',
|
||||||
|
} as ItemDefinition,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const banditCombat = combat({ monsterDefinitionId: ROAD_BANDIT_ID });
|
||||||
|
// Roll order is blade-then-hood, but BANDIT_HOOD's id sorts before
|
||||||
|
// BANDIT_BLADE's, so this only passes if both response paths sort by
|
||||||
|
// itemDefinitionId rather than returning rows in roll/insertion order.
|
||||||
|
const loot = fakeLoot(
|
||||||
|
{ itemDefinitionId: BANDIT_BLADE, quantity: 1 },
|
||||||
|
{ itemDefinitionId: BANDIT_HOOD, quantity: 1 },
|
||||||
|
);
|
||||||
|
const subject = service(state, loot, fixedRandom(0));
|
||||||
|
const manager = fakeManager(state);
|
||||||
|
|
||||||
|
const granted = await subject.grantVictoryRewards(manager, banditCombat);
|
||||||
|
const replayed = await subject.loadRewards(banditCombat.id, manager);
|
||||||
|
|
||||||
|
const expectedKeys = ['bandit-hood', 'bandit-blade'];
|
||||||
|
expect(granted.items.map((item) => item.item.key)).toEqual(expectedKeys);
|
||||||
|
expect(replayed?.items.map((item) => item.item.key)).toEqual(expectedKeys);
|
||||||
|
expect(replayed).toEqual(granted);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
244
apps/api/src/rewards/combat-reward.service.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { DataSource, EntityManager } from 'typeorm';
|
||||||
|
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 { ItemRarity } from '../items/item-rarity.enum';
|
||||||
|
import { LootService } from '../loot/loot.service';
|
||||||
|
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
|
||||||
|
import { RANDOM_SOURCE } from '../shared/random-source';
|
||||||
|
import type { RandomSource } from '../shared/random-source';
|
||||||
|
import { rollInclusive } from '../shared/roll-range';
|
||||||
|
import { CombatReward } from './entities/combat-reward.entity';
|
||||||
|
import { CombatRewardItem } from './entities/combat-reward-item.entity';
|
||||||
|
import { combatNotWon, rewardStateInvalid } from './rewards.errors';
|
||||||
|
|
||||||
|
export interface CombatRewardItemDto {
|
||||||
|
characterItemId: string;
|
||||||
|
item: {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
rarity: ItemRarity;
|
||||||
|
iconPath: string;
|
||||||
|
};
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CombatRewardDto {
|
||||||
|
experience: number;
|
||||||
|
silver: number;
|
||||||
|
items: CombatRewardItemDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both DataSource and EntityManager expose this; naming it keeps the read path
|
||||||
|
// usable inside and outside a transaction without a union type.
|
||||||
|
type RepositoryScope = Pick<DataSource, 'getRepository'>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CombatRewardService {
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly lootService: LootService,
|
||||||
|
@Inject(RANDOM_SOURCE) private readonly randomSource: RandomSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grants a won combat's rewards exactly once (spec §7, §21).
|
||||||
|
*
|
||||||
|
* Runs inside the caller's transaction — `CombatService.performAction`
|
||||||
|
* already holds a pessimistic write lock on the combat row — so either
|
||||||
|
* everything below commits or nothing does.
|
||||||
|
*
|
||||||
|
* Roll order is fixed: silver first, then the loot table in `position`
|
||||||
|
* order. Tests depend on it.
|
||||||
|
*/
|
||||||
|
async grantVictoryRewards(
|
||||||
|
manager: EntityManager,
|
||||||
|
combat: Combat,
|
||||||
|
): Promise<CombatRewardDto> {
|
||||||
|
if (combat.status !== CombatStatus.WON) {
|
||||||
|
throw combatNotWon();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rewards = manager.getRepository(CombatReward);
|
||||||
|
const existing = await rewards.findOne({ where: { combatId: combat.id } });
|
||||||
|
if (existing) {
|
||||||
|
// Already rewarded: replay rather than roll again.
|
||||||
|
return this.toDto(manager, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const monster = await manager
|
||||||
|
.getRepository(MonsterDefinition)
|
||||||
|
.findOneBy({ id: combat.monsterDefinitionId });
|
||||||
|
if (!monster) {
|
||||||
|
throw rewardStateInvalid();
|
||||||
|
}
|
||||||
|
|
||||||
|
const experience = monster.experienceReward;
|
||||||
|
const silver = rollInclusive(
|
||||||
|
this.randomSource,
|
||||||
|
monster.silverMin,
|
||||||
|
monster.silverMax,
|
||||||
|
);
|
||||||
|
const roll = await this.lootService.rollLoot(monster.lootTableId, manager);
|
||||||
|
|
||||||
|
// Resolve every rolled item definition up front, before any mutation, so
|
||||||
|
// a missing definition throws `rewardStateInvalid()` before the
|
||||||
|
// character's XP/silver are touched or a `CombatReward` row is created.
|
||||||
|
// This keeps a failed grant from leaving partial writes behind.
|
||||||
|
const definitions = manager.getRepository(ItemDefinition);
|
||||||
|
const resolvedDefinitions = new Map<string, ItemDefinition>();
|
||||||
|
for (const rolled of roll.items) {
|
||||||
|
if (resolvedDefinitions.has(rolled.itemDefinitionId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const definition = await definitions.findOneBy({
|
||||||
|
id: rolled.itemDefinitionId,
|
||||||
|
});
|
||||||
|
if (!definition) {
|
||||||
|
throw rewardStateInvalid();
|
||||||
|
}
|
||||||
|
resolvedDefinitions.set(rolled.itemDefinitionId, definition);
|
||||||
|
}
|
||||||
|
|
||||||
|
const characters = manager.getRepository(Character);
|
||||||
|
const character = await characters.findOne({
|
||||||
|
where: { id: combat.characterId },
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
});
|
||||||
|
if (!character) {
|
||||||
|
throw rewardStateInvalid();
|
||||||
|
}
|
||||||
|
character.experience += experience;
|
||||||
|
character.silver += silver;
|
||||||
|
await characters.save(character);
|
||||||
|
|
||||||
|
const reward = await rewards.save(
|
||||||
|
rewards.create({
|
||||||
|
combatId: combat.id,
|
||||||
|
characterId: combat.characterId,
|
||||||
|
experienceGranted: experience,
|
||||||
|
silverGranted: silver,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const characterItems = manager.getRepository(CharacterItem);
|
||||||
|
const rewardItems = manager.getRepository(CombatRewardItem);
|
||||||
|
const granted: Array<{ itemDefinitionId: string; dto: CombatRewardItemDto }> = [];
|
||||||
|
|
||||||
|
for (const rolled of roll.items) {
|
||||||
|
const definition = resolvedDefinitions.get(rolled.itemDefinitionId)!;
|
||||||
|
|
||||||
|
const existingStack = await characterItems.findOne({
|
||||||
|
where: {
|
||||||
|
characterId: combat.characterId,
|
||||||
|
itemDefinitionId: rolled.itemDefinitionId,
|
||||||
|
},
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
});
|
||||||
|
// Duplicates stack; Slice 0.4 adds no duplicate protection (spec §28).
|
||||||
|
const characterItem = existingStack
|
||||||
|
? Object.assign(existingStack, {
|
||||||
|
quantity: existingStack.quantity + rolled.quantity,
|
||||||
|
})
|
||||||
|
: characterItems.create({
|
||||||
|
characterId: combat.characterId,
|
||||||
|
itemDefinitionId: rolled.itemDefinitionId,
|
||||||
|
quantity: rolled.quantity,
|
||||||
|
});
|
||||||
|
await characterItems.save(characterItem);
|
||||||
|
|
||||||
|
await rewardItems.save(
|
||||||
|
rewardItems.create({
|
||||||
|
combatRewardId: reward.id,
|
||||||
|
characterItemId: characterItem.id,
|
||||||
|
itemDefinitionId: definition.id,
|
||||||
|
quantity: rolled.quantity,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
granted.push({
|
||||||
|
itemDefinitionId: rolled.itemDefinitionId,
|
||||||
|
dto: this.toItemDto(characterItem.id, definition, rolled.quantity),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The immediate response and a later `loadRewards` replay must agree on
|
||||||
|
// item order; both sort on the same stable key (itemDefinitionId, which
|
||||||
|
// `toDto`'s query also orders by) rather than roll order.
|
||||||
|
granted.sort((a, b) => a.itemDefinitionId.localeCompare(b.itemDefinitionId));
|
||||||
|
const items = granted.map((entry) => entry.dto);
|
||||||
|
|
||||||
|
return { experience, silver, items };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads a persisted reward so a refresh replays it (spec §25, §48). */
|
||||||
|
async loadRewards(
|
||||||
|
combatId: string,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<CombatRewardDto | null> {
|
||||||
|
const scope: RepositoryScope = manager ?? this.dataSource;
|
||||||
|
const reward = await scope
|
||||||
|
.getRepository(CombatReward)
|
||||||
|
.findOne({ where: { combatId } });
|
||||||
|
|
||||||
|
return reward ? this.toDto(scope, reward) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async toDto(
|
||||||
|
scope: RepositoryScope,
|
||||||
|
reward: CombatReward,
|
||||||
|
): Promise<CombatRewardDto> {
|
||||||
|
// Ordered by itemDefinitionId to agree with the sort `grantVictoryRewards`
|
||||||
|
// applies to its own response — the immediate grant and a later replay
|
||||||
|
// must list items identically.
|
||||||
|
const rewardItems = await scope
|
||||||
|
.getRepository(CombatRewardItem)
|
||||||
|
.find({
|
||||||
|
where: { combatRewardId: reward.id },
|
||||||
|
order: { itemDefinitionId: 'ASC' },
|
||||||
|
});
|
||||||
|
const definitions = scope.getRepository(ItemDefinition);
|
||||||
|
|
||||||
|
const items: CombatRewardItemDto[] = [];
|
||||||
|
for (const rewardItem of rewardItems) {
|
||||||
|
const definition = await definitions.findOneBy({
|
||||||
|
id: rewardItem.itemDefinitionId,
|
||||||
|
});
|
||||||
|
if (!definition) {
|
||||||
|
// combat_reward_items.item_definition_id is a RESTRICT FK.
|
||||||
|
throw rewardStateInvalid();
|
||||||
|
}
|
||||||
|
items.push(
|
||||||
|
this.toItemDto(rewardItem.characterItemId, definition, rewardItem.quantity),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
experience: reward.experienceGranted,
|
||||||
|
silver: reward.silverGranted,
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toItemDto(
|
||||||
|
characterItemId: string,
|
||||||
|
definition: ItemDefinition,
|
||||||
|
quantity: number,
|
||||||
|
): CombatRewardItemDto {
|
||||||
|
// Drop chance, roll results, and loot-table ids never leave the server
|
||||||
|
// (spec §26).
|
||||||
|
return {
|
||||||
|
characterItemId,
|
||||||
|
item: {
|
||||||
|
key: definition.key,
|
||||||
|
name: definition.name,
|
||||||
|
rarity: definition.rarity,
|
||||||
|
iconPath: definition.iconPath,
|
||||||
|
},
|
||||||
|
quantity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
56
apps/api/src/rewards/entities/combat-reward-item.entity.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { CharacterItem } from '../../items/entities/character-item.entity';
|
||||||
|
import { ItemDefinition } from '../../items/entities/item-definition.entity';
|
||||||
|
import { CombatReward } from './combat-reward.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What this specific combat dropped.
|
||||||
|
*
|
||||||
|
* Needed because `CharacterItem.quantity` is a running stack total: after a
|
||||||
|
* duplicate drop it no longer says how much *this* victory granted, and a
|
||||||
|
* refreshed reward screen must replay the original result (spec §25, §48).
|
||||||
|
*/
|
||||||
|
@Entity({ name: 'combat_reward_items' })
|
||||||
|
@Index('IDX_combat_reward_items_reward', ['combatRewardId'])
|
||||||
|
@Index('IDX_combat_reward_items_reward_item', ['combatRewardId', 'itemDefinitionId'], {
|
||||||
|
unique: true,
|
||||||
|
})
|
||||||
|
export class CombatRewardItem {
|
||||||
|
@PrimaryGeneratedColumn('uuid', { name: 'id' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'combat_reward_id', type: 'uuid' })
|
||||||
|
combatRewardId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'character_item_id', type: 'uuid' })
|
||||||
|
characterItemId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'item_definition_id', type: 'uuid' })
|
||||||
|
itemDefinitionId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'quantity', type: 'integer' })
|
||||||
|
quantity!: number;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => CombatReward, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'combat_reward_id' })
|
||||||
|
combatReward!: CombatReward;
|
||||||
|
|
||||||
|
@ManyToOne(() => CharacterItem, { onDelete: 'RESTRICT' })
|
||||||
|
@JoinColumn({ name: 'character_item_id' })
|
||||||
|
characterItem!: CharacterItem;
|
||||||
|
|
||||||
|
@ManyToOne(() => ItemDefinition, { onDelete: 'RESTRICT' })
|
||||||
|
@JoinColumn({ name: 'item_definition_id' })
|
||||||
|
itemDefinition!: ItemDefinition;
|
||||||
|
}
|
||||||
48
apps/api/src/rewards/entities/combat-reward.entity.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Character } from '../../characters/entities/character.entity';
|
||||||
|
import { Combat } from '../../combat/entities/combat.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proof that one combat has already been rewarded (spec §8).
|
||||||
|
*
|
||||||
|
* The unique index on `combatId` is the database half of the idempotency
|
||||||
|
* invariant; `CombatRewardService` is the service half.
|
||||||
|
*/
|
||||||
|
@Entity({ name: 'combat_rewards' })
|
||||||
|
@Index('IDX_combat_rewards_combat', ['combatId'], { unique: true })
|
||||||
|
@Index('IDX_combat_rewards_character', ['characterId'])
|
||||||
|
export class CombatReward {
|
||||||
|
@PrimaryGeneratedColumn('uuid', { name: 'id' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'combat_id', type: 'uuid' })
|
||||||
|
combatId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'character_id', type: 'uuid' })
|
||||||
|
characterId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'experience_granted', type: 'integer' })
|
||||||
|
experienceGranted!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'silver_granted', type: 'integer' })
|
||||||
|
silverGranted!: number;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => Combat, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'combat_id' })
|
||||||
|
combat!: Combat;
|
||||||
|
|
||||||
|
@ManyToOne(() => Character, { onDelete: 'RESTRICT' })
|
||||||
|
@JoinColumn({ name: 'character_id' })
|
||||||
|
character!: Character;
|
||||||
|
}
|
||||||
29
apps/api/src/rewards/rewards.errors.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||||
|
|
||||||
|
export type RewardErrorCode = 'COMBAT_NOT_WON' | 'REWARD_STATE_INVALID';
|
||||||
|
|
||||||
|
export class RewardDomainError extends HttpException {
|
||||||
|
constructor(
|
||||||
|
public readonly code: RewardErrorCode,
|
||||||
|
status: HttpStatus,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super({ statusCode: status, code, message }, status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function combatNotWon(): RewardDomainError {
|
||||||
|
return new RewardDomainError(
|
||||||
|
'COMBAT_NOT_WON',
|
||||||
|
HttpStatus.CONFLICT,
|
||||||
|
'Only a won combat can grant victory rewards.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rewardStateInvalid(): RewardDomainError {
|
||||||
|
return new RewardDomainError(
|
||||||
|
'REWARD_STATE_INVALID',
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
'The reward references unavailable data.',
|
||||||
|
);
|
||||||
|
}
|
||||||
31
apps/api/src/rewards/rewards.module.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { Character } from '../characters/entities/character.entity';
|
||||||
|
import { CharacterItem } from '../items/entities/character-item.entity';
|
||||||
|
import { ItemDefinition } from '../items/entities/item-definition.entity';
|
||||||
|
import { LootModule } from '../loot/loot.module';
|
||||||
|
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
|
||||||
|
import { RANDOM_SOURCE, systemRandomSource } from '../shared/random-source';
|
||||||
|
import { CombatRewardService } from './combat-reward.service';
|
||||||
|
import { CombatReward } from './entities/combat-reward.entity';
|
||||||
|
import { CombatRewardItem } from './entities/combat-reward-item.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([
|
||||||
|
Character,
|
||||||
|
CharacterItem,
|
||||||
|
ItemDefinition,
|
||||||
|
MonsterDefinition,
|
||||||
|
CombatReward,
|
||||||
|
CombatRewardItem,
|
||||||
|
]),
|
||||||
|
LootModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
CombatRewardService,
|
||||||
|
{ provide: RANDOM_SOURCE, useValue: systemRandomSource },
|
||||||
|
],
|
||||||
|
exports: [CombatRewardService],
|
||||||
|
})
|
||||||
|
export class RewardsModule {}
|
||||||
28
apps/api/src/shared/roll-range.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { RandomSource } from './random-source';
|
||||||
|
import { rollInclusive } from './roll-range';
|
||||||
|
|
||||||
|
function fixed(...values: number[]): RandomSource {
|
||||||
|
let index = 0;
|
||||||
|
return { next: () => values[index++] };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('rollInclusive', () => {
|
||||||
|
it('maps the bottom of the random range to min and the top to max', () => {
|
||||||
|
expect(rollInclusive(fixed(0), 4, 7)).toBe(4);
|
||||||
|
expect(rollInclusive(fixed(0.999), 4, 7)).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spreads the random range evenly across every value in between', () => {
|
||||||
|
expect(rollInclusive(fixed(0.25), 4, 7)).toBe(5);
|
||||||
|
expect(rollInclusive(fixed(0.5), 4, 7)).toBe(6);
|
||||||
|
expect(rollInclusive(fixed(0.5), 9, 15)).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never exceeds max even if the source yields exactly 1', () => {
|
||||||
|
expect(rollInclusive(fixed(1), 9, 15)).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the single value when min equals max', () => {
|
||||||
|
expect(rollInclusive(fixed(0.7), 1, 1)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
19
apps/api/src/shared/roll-range.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { RandomSource } from './random-source';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rolls an inclusive integer in [min, max] from one value of `random`.
|
||||||
|
*
|
||||||
|
* `RandomSource.next()` is documented as [0, 1), but the clamp keeps a
|
||||||
|
* misbehaving or hand-stubbed source from ever exceeding `max`.
|
||||||
|
*/
|
||||||
|
export function rollInclusive(
|
||||||
|
random: RandomSource,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
): number {
|
||||||
|
if (max <= min) {
|
||||||
|
return min;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(max, min + Math.floor(random.next() * (max - min + 1)));
|
||||||
|
}
|
||||||
@@ -179,6 +179,7 @@ function createState(): FakeState {
|
|||||||
name: 'Aric Duskwalker',
|
name: 'Aric Duskwalker',
|
||||||
level: 1,
|
level: 1,
|
||||||
experience: 0,
|
experience: 0,
|
||||||
|
silver: 0,
|
||||||
baseHp: 100,
|
baseHp: 100,
|
||||||
baseAttack: 6,
|
baseAttack: 6,
|
||||||
currentHp: 100,
|
currentHp: 100,
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 404 KiB After Width: | Height: | Size: 404 KiB |
BIN
apps/web/public/images/items/ash-blade.png
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
apps/web/public/images/items/ash-boots.png
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
apps/web/public/images/items/ash-pelt.png
Normal file
|
After Width: | Height: | Size: 153 KiB |
BIN
apps/web/public/images/items/bandit-blade.png
Normal file
|
After Width: | Height: | Size: 119 KiB |
BIN
apps/web/public/images/items/bandit-hood.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
apps/web/public/images/items/borderwatch-sigil.png
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
apps/web/public/images/items/burned-captain-pendant.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
apps/web/public/images/items/guardsman-legs.png
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
apps/web/public/images/items/raider-gloves.png
Normal file
|
After Width: | Height: | Size: 127 KiB |
BIN
apps/web/public/images/items/reinforced-leather-jacket.png
Normal file
|
After Width: | Height: | Size: 137 KiB |
BIN
apps/web/public/images/items/small-healing-potion.png
Normal file
|
After Width: | Height: | Size: 134 KiB |
BIN
apps/web/public/images/items/worn-short-sword.png
Normal file
|
After Width: | Height: | Size: 121 KiB |
@@ -86,6 +86,7 @@ describe('App', () => {
|
|||||||
name: 'Mara Ashfall',
|
name: 'Mara Ashfall',
|
||||||
level: 7,
|
level: 7,
|
||||||
experience: 320,
|
experience: 320,
|
||||||
|
silver: 150,
|
||||||
currentHp: 52,
|
currentHp: 52,
|
||||||
maxHp: 80,
|
maxHp: 80,
|
||||||
attack: 12,
|
attack: 12,
|
||||||
@@ -99,6 +100,8 @@ describe('App', () => {
|
|||||||
);
|
);
|
||||||
expect(fixture.nativeElement.querySelector('app-top-bar')?.textContent).toContain('Stufe 7');
|
expect(fixture.nativeElement.querySelector('app-top-bar')?.textContent).toContain('Stufe 7');
|
||||||
expect(fixture.nativeElement.querySelector('app-top-bar')?.textContent).toContain('52 / 80');
|
expect(fixture.nativeElement.querySelector('app-top-bar')?.textContent).toContain('52 / 80');
|
||||||
|
expect(fixture.nativeElement.querySelector('app-top-bar')?.textContent).toContain('150');
|
||||||
|
expect(fixture.nativeElement.querySelector('app-top-bar')?.textContent).toContain('320');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('redirects root and unknown routes to the world shell', () => {
|
it('redirects root and unknown routes to the world shell', () => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface CharacterResponse {
|
|||||||
name: string;
|
name: string;
|
||||||
level: number;
|
level: number;
|
||||||
experience: number;
|
experience: number;
|
||||||
|
silver: number;
|
||||||
currentHp: number;
|
currentHp: number;
|
||||||
maxHp: number;
|
maxHp: number;
|
||||||
attack: number;
|
attack: number;
|
||||||
@@ -108,4 +109,26 @@ export interface Combat {
|
|||||||
player: CombatPlayer;
|
player: CombatPlayer;
|
||||||
monster: CombatMonster;
|
monster: CombatMonster;
|
||||||
events: CombatEvent[];
|
events: CombatEvent[];
|
||||||
|
rewards: CombatReward | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ItemRarity = 'COMMON' | 'RARE' | 'EPIC';
|
||||||
|
|
||||||
|
export interface RewardItemSummary {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
rarity: ItemRarity;
|
||||||
|
iconPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CombatRewardItem {
|
||||||
|
characterItemId: string;
|
||||||
|
item: RewardItemSummary;
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CombatReward {
|
||||||
|
experience: number;
|
||||||
|
silver: number;
|
||||||
|
items: CombatRewardItem[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<section class="combat" aria-label="Kampf">
|
<section class="combat" aria-label="Kampf">
|
||||||
@if (combat(); as combat) {
|
@if (combat(); as combat) {
|
||||||
<div class="combat__stage" [class.combat__stage--shaken]="phase() === 'hit'">
|
<div class="combat__stage" [class.combat__stage--shaken]="stageShake()">
|
||||||
<header class="combat__status">
|
<header class="combat__status">
|
||||||
<div class="fighter fighter--player">
|
<div class="fighter fighter--player">
|
||||||
<img class="fighter__icon" [src]="playerIcon" alt="" />
|
<img class="fighter__icon" [src]="playerIcon" alt="" />
|
||||||
@@ -86,7 +86,37 @@
|
|||||||
<div class="outcome outcome--won" data-combat-result="WON">
|
<div class="outcome outcome--won" data-combat-result="WON">
|
||||||
<h2 class="outcome__title">Sieg</h2>
|
<h2 class="outcome__title">Sieg</h2>
|
||||||
<p>{{ combat.monster.name }} wurde besiegt.</p>
|
<p>{{ combat.monster.name }} wurde besiegt.</p>
|
||||||
<p class="outcome__hint">Belohnungen werden im nächsten Schritt verarbeitet.</p>
|
|
||||||
|
@if (combat.rewards; as rewards) {
|
||||||
|
<section class="rewards" data-combat-rewards aria-label="Belohnungen">
|
||||||
|
<h3 class="rewards__title">Belohnungen</h3>
|
||||||
|
|
||||||
|
<dl class="rewards__currencies">
|
||||||
|
<div class="rewards__currency" data-reward-experience>
|
||||||
|
<dt>Erfahrung</dt>
|
||||||
|
<dd>+{{ rewards.experience }} XP</dd>
|
||||||
|
</div>
|
||||||
|
<div class="rewards__currency" data-reward-silver>
|
||||||
|
<dt>Silber</dt>
|
||||||
|
<dd>+{{ rewards.silver }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
@if (rewards.items.length) {
|
||||||
|
<h3 class="rewards__title">Beute</h3>
|
||||||
|
<ul class="rewards__loot">
|
||||||
|
@for (reward of rewards.items; track reward.characterItemId) {
|
||||||
|
<li>
|
||||||
|
<app-item-card [item]="reward.item" [quantity]="reward.quantity" />
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
} @else {
|
||||||
|
<p class="outcome__hint" data-reward-empty>Keine besondere Beute gefunden.</p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
<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
|
Zur Jagd
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -20,7 +20,12 @@
|
|||||||
container-type: inline-size;
|
container-type: inline-size;
|
||||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
gap: var(--ar-space-4);
|
gap: var(--ar-space-4);
|
||||||
min-block-size: 26rem;
|
// The sprites are sized as a share of the stage, so the stage has to carry a
|
||||||
|
// height of its own. With only a min-block-size the tallest cut-out -- the
|
||||||
|
// road bandit -- sized the battlefield row from its intrinsic height and
|
||||||
|
// pushed the page into a scrollbar. The subtracted 12.5rem is the shell
|
||||||
|
// chrome around the main column: top bar, footer and its own padding.
|
||||||
|
block-size: max(24rem, calc(100dvh - 12.5rem));
|
||||||
padding: var(--ar-space-4);
|
padding: var(--ar-space-4);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--ar-border);
|
border: 1px solid var(--ar-border);
|
||||||
@@ -182,7 +187,11 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
// The single row is spelled out rather than left implicit: only then is it a
|
||||||
|
// definite height, and only then do the sprites' percentage heights resolve
|
||||||
|
// against the battlefield instead of against their own artwork.
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
align-items: end;
|
align-items: end;
|
||||||
min-block-size: 0;
|
min-block-size: 0;
|
||||||
}
|
}
|
||||||
@@ -448,6 +457,10 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.outcome--won {
|
||||||
|
inline-size: min(32rem, 90%);
|
||||||
|
}
|
||||||
|
|
||||||
.outcome--won .outcome__title {
|
.outcome--won .outcome__title {
|
||||||
color: var(--ar-gold);
|
color: var(--ar-gold);
|
||||||
}
|
}
|
||||||
@@ -602,7 +615,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.combat__stage {
|
.combat__stage {
|
||||||
min-block-size: clamp(24rem, 55vh, 34rem);
|
block-size: clamp(20rem, 55dvh, 34rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.combat__log {
|
.combat__log {
|
||||||
@@ -641,3 +654,61 @@
|
|||||||
max-inline-size: none;
|
max-inline-size: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Reward summary: dark stone and bronze, large readable values, restrained
|
||||||
|
rarity emphasis. No confetti, no popups, no slot-machine reveal (spec §50). */
|
||||||
|
.rewards {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--ar-space-3);
|
||||||
|
justify-items: center;
|
||||||
|
inline-size: 100%;
|
||||||
|
margin-block-start: var(--ar-space-3);
|
||||||
|
padding-block-start: var(--ar-space-3);
|
||||||
|
border-block-start: 1px solid var(--ar-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewards__title {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ar-text-muted);
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
font-size: var(--ar-font-sm);
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewards__currencies {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--ar-space-6);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewards__currency {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--ar-space-1);
|
||||||
|
justify-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewards__currency dt {
|
||||||
|
color: var(--ar-text-muted);
|
||||||
|
font-size: var(--ar-font-sm);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewards__currency dd {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ar-gold);
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
font-size: clamp(1.25rem, 2.5vw, 1.6rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewards__loot {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--ar-space-4);
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ActivatedRoute, convertToParamMap, Router, provideRouter } from '@angul
|
|||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
import type { Combat } from '../../../core/api/game-api.models';
|
import type { Combat } from '../../../core/api/game-api.models';
|
||||||
import { CombatStore } from '../combat.store';
|
import { CombatStore } from '../combat.store';
|
||||||
|
import { WorldStore } from '../../world/world.store';
|
||||||
import { CombatPageComponent } from './combat-page.component';
|
import { CombatPageComponent } from './combat-page.component';
|
||||||
|
|
||||||
const activeCombat: Combat = {
|
const activeCombat: Combat = {
|
||||||
@@ -23,6 +24,7 @@ const activeCombat: Combat = {
|
|||||||
{ round: 1, sequence: 1, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 14 },
|
{ round: 1, sequence: 1, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 14 },
|
||||||
{ round: 1, sequence: 2, type: 'DAMAGE', source: 'MONSTER', target: 'PLAYER', amount: 5 },
|
{ round: 1, sequence: 2, type: 'DAMAGE', source: 'MONSTER', target: 'PLAYER', amount: 5 },
|
||||||
],
|
],
|
||||||
|
rewards: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const monsterHitLine = 'Aschenratte trifft Aric Duskwalker für 5 Schaden.';
|
const monsterHitLine = 'Aschenratte trifft Aric Duskwalker für 5 Schaden.';
|
||||||
@@ -40,6 +42,7 @@ describe('CombatPageComponent', () => {
|
|||||||
loadCombat: ReturnType<typeof vi.fn>;
|
loadCombat: ReturnType<typeof vi.fn>;
|
||||||
attack: ReturnType<typeof vi.fn>;
|
attack: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
|
let worldStore: { refreshCharacter: ReturnType<typeof vi.fn> };
|
||||||
let router: Router;
|
let router: Router;
|
||||||
|
|
||||||
async function setup(combat: Combat | null) {
|
async function setup(combat: Combat | null) {
|
||||||
@@ -51,12 +54,14 @@ describe('CombatPageComponent', () => {
|
|||||||
loadCombat: vi.fn(() => Promise.resolve()),
|
loadCombat: vi.fn(() => Promise.resolve()),
|
||||||
attack: vi.fn(() => Promise.resolve()),
|
attack: vi.fn(() => Promise.resolve()),
|
||||||
};
|
};
|
||||||
|
worldStore = { refreshCharacter: vi.fn(() => Promise.resolve()) };
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [CombatPageComponent],
|
imports: [CombatPageComponent],
|
||||||
providers: [
|
providers: [
|
||||||
provideRouter([]),
|
provideRouter([]),
|
||||||
{ provide: CombatStore, useValue: combatStore },
|
{ provide: CombatStore, useValue: combatStore },
|
||||||
|
{ provide: WorldStore, useValue: worldStore },
|
||||||
{
|
{
|
||||||
provide: ActivatedRoute,
|
provide: ActivatedRoute,
|
||||||
useValue: { snapshot: { paramMap: convertToParamMap({ combatId: 'combat-1' }) } },
|
useValue: { snapshot: { paramMap: convertToParamMap({ combatId: 'combat-1' }) } },
|
||||||
@@ -162,7 +167,8 @@ describe('CombatPageComponent', () => {
|
|||||||
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);
|
||||||
expect(monster?.classList.contains('sprite--flinch')).toBe(false);
|
expect(monster?.classList.contains('sprite--flinch')).toBe(false);
|
||||||
expect(stage?.classList.contains('combat__stage--shaken')).toBe(true);
|
// The stage jolt is wired up but no longer fires on an ordinary hit.
|
||||||
|
expect(stage?.classList.contains('combat__stage--shaken')).toBe(false);
|
||||||
expect(element.textContent).toContain('90 / 100');
|
expect(element.textContent).toContain('90 / 100');
|
||||||
expect(countOccurrences(element.textContent, monsterHitLine)).toBe(2);
|
expect(countOccurrences(element.textContent, monsterHitLine)).toBe(2);
|
||||||
|
|
||||||
@@ -208,6 +214,31 @@ describe('CombatPageComponent', () => {
|
|||||||
expect(monster?.classList.contains('sprite--lunge')).toBe(false);
|
expect(monster?.classList.contains('sprite--lunge')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('refreshes the character from the server once a combat is won', async () => {
|
||||||
|
const fixture = await setup(activeCombat);
|
||||||
|
combatStore.attack.mockImplementation(async () => {
|
||||||
|
combatStore.combat.set({
|
||||||
|
...activeCombat,
|
||||||
|
status: 'WON',
|
||||||
|
monster: { ...activeCombat.monster, currentHp: 0 },
|
||||||
|
rewards: { experience: 8, silver: 6, items: [] },
|
||||||
|
events: [
|
||||||
|
...activeCombat.events,
|
||||||
|
{ round: 2, sequence: 3, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 31 },
|
||||||
|
{ round: 2, sequence: 4, type: 'COMBAT_WON', source: 'PLAYER', target: 'MONSTER' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
element.querySelector<HTMLButtonElement>('[data-combat-attack]')?.click();
|
||||||
|
await vi.advanceTimersByTimeAsync(540);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(worldStore.refreshCharacter).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
it('disables Angriff while an action is pending', async () => {
|
it('disables Angriff while an action is pending', async () => {
|
||||||
const fixture = await setup(activeCombat);
|
const fixture = await setup(activeCombat);
|
||||||
combatStore.actionPending.set(true);
|
combatStore.actionPending.set(true);
|
||||||
@@ -257,4 +288,85 @@ describe('CombatPageComponent', () => {
|
|||||||
|
|
||||||
expect(combatStore.loadCombat).toHaveBeenCalledTimes(2);
|
expect(combatStore.loadCombat).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const wonWithRewards: Combat = {
|
||||||
|
...activeCombat,
|
||||||
|
status: 'WON',
|
||||||
|
monster: { ...activeCombat.monster, currentHp: 0 },
|
||||||
|
rewards: { experience: 8, silver: 6, items: [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
it('shows the granted XP and silver on the victory screen', async () => {
|
||||||
|
const fixture = await setup(wonWithRewards);
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
expect(element.querySelector('[data-combat-rewards]')).toBeTruthy();
|
||||||
|
expect(element.querySelector('[data-reward-experience]')?.textContent).toContain('8');
|
||||||
|
expect(element.querySelector('[data-reward-silver]')?.textContent).toContain('6');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a dropped item with its icon, name, and rarity', async () => {
|
||||||
|
const fixture = await setup({
|
||||||
|
...wonWithRewards,
|
||||||
|
monster: { ...activeCombat.monster, key: 'road-bandit', name: 'Straßenräuber', currentHp: 0 },
|
||||||
|
rewards: {
|
||||||
|
experience: 16,
|
||||||
|
silver: 12,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
characterItemId: 'character-item-1',
|
||||||
|
item: {
|
||||||
|
key: 'bandit-blade',
|
||||||
|
name: 'Räuberklinge',
|
||||||
|
rarity: 'COMMON',
|
||||||
|
iconPath: '/images/items/bandit-blade.png',
|
||||||
|
},
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
expect(element.querySelector<HTMLImageElement>('[data-item-icon]')?.getAttribute('src')).toBe(
|
||||||
|
'/images/items/bandit-blade.png',
|
||||||
|
);
|
||||||
|
expect(element.querySelector('[data-item-name]')?.textContent).toContain('Räuberklinge');
|
||||||
|
expect(element.querySelector('[data-item-rarity]')?.textContent).toContain('Gewöhnlich');
|
||||||
|
expect(element.querySelector('[data-reward-empty]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a victory without loot as complete, not as a failure', async () => {
|
||||||
|
const fixture = await setup(wonWithRewards);
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
expect(element.querySelector('[data-reward-empty]')?.textContent).toContain(
|
||||||
|
'Keine besondere Beute gefunden.',
|
||||||
|
);
|
||||||
|
expect(element.querySelector('[role="alert"]')).toBeNull();
|
||||||
|
// XP and silver still carry the screen.
|
||||||
|
expect(element.querySelector('[data-reward-experience]')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the persisted rewards of an already-won combat loaded from the server', async () => {
|
||||||
|
// Simulates a browser refresh: the page loads the combat by id and shows
|
||||||
|
// exactly what the server persisted, without rerolling anything.
|
||||||
|
const fixture = await setup({
|
||||||
|
...wonWithRewards,
|
||||||
|
rewards: { experience: 16, silver: 12, items: [] },
|
||||||
|
});
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
expect(combatStore.loadCombat).toHaveBeenCalledWith('combat-1');
|
||||||
|
expect(element.querySelector('[data-reward-experience]')?.textContent).toContain('16');
|
||||||
|
expect(element.querySelector('[data-reward-silver]')?.textContent).toContain('12');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still shows a plain victory when the server reports no reward record', async () => {
|
||||||
|
const fixture = await setup({ ...wonWithRewards, rewards: null });
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
expect(element.querySelector('[data-combat-result="WON"]')).toBeTruthy();
|
||||||
|
expect(element.querySelector('[data-combat-rewards]')).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
monsterIconPath,
|
monsterIconPath,
|
||||||
runtimeMonsterArtworkPath,
|
runtimeMonsterArtworkPath,
|
||||||
} from '../../../shared/monster-artwork';
|
} from '../../../shared/monster-artwork';
|
||||||
|
import { ItemCardComponent } from '../../../shared/item-card/item-card.component';
|
||||||
|
import { WorldStore } from '../../world/world.store';
|
||||||
import { CombatStore } from '../combat.store';
|
import { CombatStore } from '../combat.store';
|
||||||
|
|
||||||
interface CombatLogRound {
|
interface CombatLogRound {
|
||||||
@@ -28,14 +30,18 @@ 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 = 260;
|
||||||
|
// Length of the stage jolt keyframes, see `stage-shake` in the stylesheet.
|
||||||
|
const STAGE_SHAKE_MS = 200;
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-combat-page',
|
selector: 'app-combat-page',
|
||||||
templateUrl: './combat-page.component.html',
|
templateUrl: './combat-page.component.html',
|
||||||
styleUrl: './combat-page.component.scss',
|
styleUrl: './combat-page.component.scss',
|
||||||
|
imports: [ItemCardComponent],
|
||||||
})
|
})
|
||||||
export class CombatPageComponent implements OnInit {
|
export class CombatPageComponent implements OnInit {
|
||||||
protected readonly combatStore = inject(CombatStore);
|
protected readonly combatStore = inject(CombatStore);
|
||||||
|
private readonly worldStore = inject(WorldStore);
|
||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
@@ -47,9 +53,15 @@ export class CombatPageComponent implements OnInit {
|
|||||||
private readonly displayed = signal<Combat | null>(null);
|
private readonly displayed = signal<Combat | null>(null);
|
||||||
private readonly replaying = signal(false);
|
private readonly replaying = signal(false);
|
||||||
|
|
||||||
|
// The stage jolt stays wired up but is no longer fired by an ordinary hit --
|
||||||
|
// it was too much for every single round. Call `shakeStage()` to bring it
|
||||||
|
// back for a specific ability.
|
||||||
|
private readonly stageShaking = signal(false);
|
||||||
|
|
||||||
protected readonly combat = this.displayed.asReadonly();
|
protected readonly combat = this.displayed.asReadonly();
|
||||||
protected readonly phase = signal<CombatPhase>('idle');
|
protected readonly phase = signal<CombatPhase>('idle');
|
||||||
protected readonly monsterPhase = signal<MonsterPhase>('idle');
|
protected readonly monsterPhase = signal<MonsterPhase>('idle');
|
||||||
|
protected readonly stageShake = this.stageShaking.asReadonly();
|
||||||
protected readonly busy = computed(() => this.replaying() || this.combatStore.actionPending());
|
protected readonly busy = computed(() => this.replaying() || this.combatStore.actionPending());
|
||||||
protected readonly playerIcon = PLAYER_ICON;
|
protected readonly playerIcon = PLAYER_ICON;
|
||||||
|
|
||||||
@@ -87,6 +99,12 @@ export class CombatPageComponent implements OnInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (after.status === 'WON') {
|
||||||
|
// The server already granted XP and silver; pull the authoritative
|
||||||
|
// character so the HUD matches (spec §35).
|
||||||
|
void this.worldStore.refreshCharacter();
|
||||||
|
}
|
||||||
|
|
||||||
const riposte = after.events.find(
|
const riposte = after.events.find(
|
||||||
(event) =>
|
(event) =>
|
||||||
event.round === before.round && event.type === 'DAMAGE' && event.source === 'MONSTER',
|
event.round === before.round && event.type === 'DAMAGE' && event.source === 'MONSTER',
|
||||||
@@ -125,6 +143,16 @@ export class CombatPageComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Jolts the whole stage once. Reserved for abilities; no attack triggers it. */
|
||||||
|
protected shakeStage(): void {
|
||||||
|
this.stageShaking.set(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!this.destroyed) {
|
||||||
|
this.stageShaking.set(false);
|
||||||
|
}
|
||||||
|
}, STAGE_SHAKE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
protected retry(): void {
|
protected retry(): void {
|
||||||
void this.loadFromRoute();
|
void this.loadFromRoute();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const startedCombat: Combat = {
|
|||||||
artworkPath: '/images/monsters/ash-rat.png',
|
artworkPath: '/images/monsters/ash-rat.png',
|
||||||
},
|
},
|
||||||
events: [],
|
events: [],
|
||||||
|
rewards: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const afterAttack: Combat = {
|
const afterAttack: Combat = {
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ const startedCombat: Combat = {
|
|||||||
artworkPath: '/images/enemies/RoadBandit.png',
|
artworkPath: '/images/enemies/RoadBandit.png',
|
||||||
},
|
},
|
||||||
events: [],
|
events: [],
|
||||||
|
rewards: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('HuntPageComponent', () => {
|
describe('HuntPageComponent', () => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const character: CharacterResponse = {
|
|||||||
name: 'Aric Duskwalker',
|
name: 'Aric Duskwalker',
|
||||||
level: 1,
|
level: 1,
|
||||||
experience: 0,
|
experience: 0,
|
||||||
|
silver: 0,
|
||||||
currentHp: 100,
|
currentHp: 100,
|
||||||
maxHp: 100,
|
maxHp: 100,
|
||||||
attack: 6,
|
attack: 6,
|
||||||
@@ -331,4 +332,26 @@ describe('WorldStore', () => {
|
|||||||
expect(api.getCurrentTravel).toHaveBeenCalledTimes(2);
|
expect(api.getCurrentTravel).toHaveBeenCalledTimes(2);
|
||||||
expect(store.currentTravel()).toEqual(travelling);
|
expect(store.currentTravel()).toEqual(travelling);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('refreshCharacter replaces the character from authoritative server data', async () => {
|
||||||
|
await store.load();
|
||||||
|
|
||||||
|
api.getCharacter.mockReturnValue(of({ ...character, experience: 32, silver: 18 }));
|
||||||
|
await store.refreshCharacter();
|
||||||
|
|
||||||
|
expect(store.character()?.experience).toBe(32);
|
||||||
|
expect(store.character()?.silver).toBe(18);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the previous character when the refresh fails', async () => {
|
||||||
|
await store.load();
|
||||||
|
|
||||||
|
api.getCharacter.mockReturnValue(
|
||||||
|
throwError(() => new HttpErrorResponse({ status: 500 })),
|
||||||
|
);
|
||||||
|
await store.refreshCharacter();
|
||||||
|
|
||||||
|
expect(store.character()?.silver).toBe(0);
|
||||||
|
expect(store.error()).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -77,6 +77,27 @@ export class WorldStore implements OnDestroy {
|
|||||||
this.selectedConnectionState.set(connection);
|
this.selectedConnectionState.set(connection);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-reads the character from the server, e.g. after a combat granted XP and
|
||||||
|
* silver. Never mutates the values locally: the server owns them (spec §35).
|
||||||
|
* A failed refresh leaves the last known character in place rather than
|
||||||
|
* blanking the HUD.
|
||||||
|
*/
|
||||||
|
async refreshCharacter(): Promise<void> {
|
||||||
|
if (this.destroyed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const character = await firstValueFrom(this.api.getCharacter());
|
||||||
|
if (!this.destroyed) {
|
||||||
|
this.characterState.set(character);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep the previous character; the next load() will resync.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async startTravel(): Promise<void> {
|
async startTravel(): Promise<void> {
|
||||||
if (this.destroyed) {
|
if (this.destroyed) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<app-top-bar [character]="worldStore.character()" />
|
<app-top-bar [character]="worldStore.character()" />
|
||||||
|
|
||||||
<div class="app-shell__content">
|
<div class="app-shell__content" [class.app-shell__content--no-context]="inCombat()">
|
||||||
<app-side-navigation />
|
<app-side-navigation />
|
||||||
<main class="app-shell__main" aria-label="Spielinhalt">
|
<main class="app-shell__main" aria-label="Spielinhalt">
|
||||||
<router-outlet />
|
<router-outlet />
|
||||||
</main>
|
</main>
|
||||||
<app-context-panel />
|
@if (!inCombat()) {
|
||||||
|
<app-context-panel />
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<app-game-footer />
|
<app-game-footer />
|
||||||
|
|||||||
@@ -16,6 +16,12 @@
|
|||||||
min-block-size: 0;
|
min-block-size: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Without the context rail the main column takes its place. The narrow layouts
|
||||||
|
// below re-declare the template, so they keep working either way.
|
||||||
|
.app-shell__content--no-context {
|
||||||
|
grid-template-columns: minmax(11rem, 13rem) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
.app-shell__main {
|
.app-shell__main {
|
||||||
min-inline-size: 0;
|
min-inline-size: 0;
|
||||||
min-block-size: 0;
|
min-block-size: 0;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
import { RouterOutlet } from '@angular/router';
|
import { Router, RouterOutlet, isActive } from '@angular/router';
|
||||||
import { WorldStore } from '../../features/world/world.store';
|
import { WorldStore } from '../../features/world/world.store';
|
||||||
import { ContextPanelComponent } from '../context-panel/context-panel.component';
|
import { ContextPanelComponent } from '../context-panel/context-panel.component';
|
||||||
import { GameFooterComponent } from '../game-footer/game-footer.component';
|
import { GameFooterComponent } from '../game-footer/game-footer.component';
|
||||||
@@ -20,4 +20,8 @@ import { TopBarComponent } from '../top-bar/top-bar.component';
|
|||||||
})
|
})
|
||||||
export class AppShellComponent {
|
export class AppShellComponent {
|
||||||
protected readonly worldStore = inject(WorldStore);
|
protected readonly worldStore = inject(WorldStore);
|
||||||
|
|
||||||
|
// The fight has its own log rail and wants the width, and the area info
|
||||||
|
// belongs to the world view anyway, so the rail is dropped during combat.
|
||||||
|
protected readonly inCombat = isActive('/combat', inject(Router));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,16 @@
|
|||||||
></span>
|
></span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<dl class="top-bar__resources">
|
||||||
|
<div class="top-bar__resource" data-top-bar-silver>
|
||||||
|
<dt>Silber</dt>
|
||||||
|
<dd>{{ character.silver }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="top-bar__resource" data-top-bar-experience>
|
||||||
|
<dt>XP</dt>
|
||||||
|
<dd>{{ character.experience }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
} @else {
|
} @else {
|
||||||
<span class="top-bar__loading">Charakterdaten werden geladen</span>
|
<span class="top-bar__loading">Charakterdaten werden geladen</span>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,3 +110,29 @@
|
|||||||
min-inline-size: 5rem;
|
min-inline-size: 5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.top-bar__resources {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--ar-space-4);
|
||||||
|
margin: 0;
|
||||||
|
padding-inline-start: var(--ar-space-4);
|
||||||
|
border-inline-start: 1px solid var(--ar-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar__resource {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--ar-space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar__resource dt {
|
||||||
|
color: var(--ar-text-muted);
|
||||||
|
font-size: var(--ar-font-sm);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar__resource dd {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ar-gold);
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
}
|
||||||
|
|||||||
10
apps/web/src/app/shared/item-card/item-card.component.html
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<article class="item-card" [class]="rarityModifier()">
|
||||||
|
<div class="item-card__frame">
|
||||||
|
<img class="item-card__icon" data-item-icon [src]="item().iconPath" [alt]="item().name" />
|
||||||
|
@if (quantity() > 1) {
|
||||||
|
<span class="item-card__quantity" data-item-quantity>×{{ quantity() }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<p class="item-card__name" data-item-name>{{ item().name }}</p>
|
||||||
|
<p class="item-card__rarity" data-item-rarity>{{ rarityLabel() }}</p>
|
||||||
|
</article>
|
||||||
66
apps/web/src/app/shared/item-card/item-card.component.scss
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
.item-card {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--ar-space-1);
|
||||||
|
justify-items: center;
|
||||||
|
inline-size: 8.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card__frame {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
inline-size: 6rem;
|
||||||
|
block-size: 6rem;
|
||||||
|
padding: var(--ar-space-1);
|
||||||
|
border: 1px solid var(--ar-border);
|
||||||
|
background: linear-gradient(180deg, #1b1f22, #0d1012);
|
||||||
|
box-shadow: var(--ar-shadow-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card__icon {
|
||||||
|
inline-size: 100%;
|
||||||
|
block-size: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card__quantity {
|
||||||
|
position: absolute;
|
||||||
|
inset-block-end: 0.1rem;
|
||||||
|
inset-inline-end: 0.25rem;
|
||||||
|
color: var(--ar-text);
|
||||||
|
font-size: var(--ar-font-sm);
|
||||||
|
text-shadow: 0 0 0.3rem #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card__name {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ar-text);
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card__rarity {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ar-text-muted);
|
||||||
|
font-size: var(--ar-font-sm);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Restrained rarity emphasis: the frame edge carries it, nothing flashes. */
|
||||||
|
.item-card--rare .item-card__frame {
|
||||||
|
border-color: var(--ar-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card--rare .item-card__rarity {
|
||||||
|
color: var(--ar-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card--epic .item-card__frame {
|
||||||
|
border-color: var(--ar-border-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card--epic .item-card__rarity {
|
||||||
|
color: var(--ar-gold);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import type { RewardItemSummary } from '../../core/api/game-api.models';
|
||||||
|
import { ItemCardComponent } from './item-card.component';
|
||||||
|
|
||||||
|
const banditBlade: RewardItemSummary = {
|
||||||
|
key: 'bandit-blade',
|
||||||
|
name: 'Räuberklinge',
|
||||||
|
rarity: 'COMMON',
|
||||||
|
iconPath: '/images/items/bandit-blade.png',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function setup(item: RewardItemSummary, quantity = 1) {
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
await TestBed.configureTestingModule({ imports: [ItemCardComponent] }).compileComponents();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(ItemCardComponent);
|
||||||
|
fixture.componentRef.setInput('item', item);
|
||||||
|
fixture.componentRef.setInput('quantity', quantity);
|
||||||
|
fixture.detectChanges();
|
||||||
|
return fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ItemCardComponent', () => {
|
||||||
|
it('renders the icon, name, and German rarity label', async () => {
|
||||||
|
const fixture = await setup(banditBlade);
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
const icon = element.querySelector<HTMLImageElement>('[data-item-icon]');
|
||||||
|
expect(icon?.getAttribute('src')).toBe('/images/items/bandit-blade.png');
|
||||||
|
expect(icon?.getAttribute('alt')).toBe('Räuberklinge');
|
||||||
|
expect(element.querySelector('[data-item-name]')?.textContent).toContain('Räuberklinge');
|
||||||
|
expect(element.querySelector('[data-item-rarity]')?.textContent).toContain('Gewöhnlich');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('labels the other rarities in German too', async () => {
|
||||||
|
const rare = await setup({ ...banditBlade, rarity: 'RARE' });
|
||||||
|
expect(
|
||||||
|
(rare.nativeElement as HTMLElement).querySelector('[data-item-rarity]')?.textContent,
|
||||||
|
).toContain('Selten');
|
||||||
|
|
||||||
|
const epic = await setup({ ...banditBlade, rarity: 'EPIC' });
|
||||||
|
expect(
|
||||||
|
(epic.nativeElement as HTMLElement).querySelector('[data-item-rarity]')?.textContent,
|
||||||
|
).toContain('Episch');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the quantity for a single item and shows it for a stack', async () => {
|
||||||
|
const single = await setup(banditBlade, 1);
|
||||||
|
expect((single.nativeElement as HTMLElement).querySelector('[data-item-quantity]')).toBeNull();
|
||||||
|
|
||||||
|
const stack = await setup(banditBlade, 3);
|
||||||
|
expect(
|
||||||
|
(stack.nativeElement as HTMLElement).querySelector('[data-item-quantity]')?.textContent,
|
||||||
|
).toContain('3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers no equip or comparison affordance yet', async () => {
|
||||||
|
const fixture = await setup(banditBlade);
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
expect(element.querySelector('button')).toBeNull();
|
||||||
|
expect(element.textContent).not.toContain('Anlegen');
|
||||||
|
});
|
||||||
|
});
|
||||||
27
apps/web/src/app/shared/item-card/item-card.component.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Component, computed, input } from '@angular/core';
|
||||||
|
import type { ItemRarity, RewardItemSummary } from '../../core/api/game-api.models';
|
||||||
|
|
||||||
|
export const RARITY_LABELS: Readonly<Record<ItemRarity, string>> = {
|
||||||
|
COMMON: 'Gewöhnlich',
|
||||||
|
RARE: 'Selten',
|
||||||
|
EPIC: 'Episch',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact item presentation for loot (spec §33).
|
||||||
|
*
|
||||||
|
* Deliberately read-only: equipping, stat comparison, and slot replacement
|
||||||
|
* belong to Slice 0.5.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-item-card',
|
||||||
|
templateUrl: './item-card.component.html',
|
||||||
|
styleUrl: './item-card.component.scss',
|
||||||
|
})
|
||||||
|
export class ItemCardComponent {
|
||||||
|
readonly item = input.required<RewardItemSummary>();
|
||||||
|
readonly quantity = input(1);
|
||||||
|
|
||||||
|
protected readonly rarityLabel = computed(() => RARITY_LABELS[this.item().rarity]);
|
||||||
|
protected readonly rarityModifier = computed(() => `item-card--${this.item().rarity.toLowerCase()}`);
|
||||||
|
}
|
||||||
1916
docs/Ashen_Realms_Story_Narrative_Implementation_V1.md
Normal file
BIN
docs/references/verbrannte_strasse_in_den_aschenfeldern.png
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
3949
docs/superpowers/plans/2026-08-19-playable-slice-0.4-first-loot.md
Normal file
@@ -0,0 +1,921 @@
|
|||||||
|
# Ashen Realms – Local Location View Design Specification V1
|
||||||
|
|
||||||
|
## Zweck
|
||||||
|
|
||||||
|
Dieses Dokument definiert die dauerhaften Design- und Content-Regeln für die **lokale Ortsansicht** von Ashen Realms.
|
||||||
|
|
||||||
|
Die Ortsansicht bildet die fehlende Weltebene zwischen der globalen Karte und spezialisierten Aktivitäten wie Jagd, Kampf, Händler, Quests, Dungeons oder Bossen.
|
||||||
|
|
||||||
|
Sie beantwortet dem Spieler unmittelbar vier Fragen:
|
||||||
|
|
||||||
|
1. **Wo bin ich?**
|
||||||
|
2. **Wie sieht dieser Ort aus und wie fühlt er sich an?**
|
||||||
|
3. **Wer oder was befindet sich hier?**
|
||||||
|
4. **Was kann ich hier tun?**
|
||||||
|
|
||||||
|
Diese Spezifikation soll auch für zukünftige Gebiete und Orte gültig bleiben. Einzelne Orte dürfen sich visuell und spielerisch deutlich unterscheiden, verwenden aber dieselbe grundlegende Struktur und dasselbe Interaktionsmodell.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. Rolle im Kernloop
|
||||||
|
|
||||||
|
Die lokale Ortsansicht wird zum normalen Ankunfts- und Ausgangsscreen, solange sich der Charakter physisch an einem Ort befindet.
|
||||||
|
|
||||||
|
Empfohlener Ablauf:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Karte
|
||||||
|
→ Reise
|
||||||
|
→ Ortsansicht
|
||||||
|
→ Aktivität
|
||||||
|
→ Jagd
|
||||||
|
→ NPC
|
||||||
|
→ Untersuchung
|
||||||
|
→ Händler
|
||||||
|
→ Quest
|
||||||
|
→ Boss / Dungeon
|
||||||
|
→ zurück zur Ortsansicht
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Karte beantwortet:
|
||||||
|
|
||||||
|
**Wohin kann ich reisen?**
|
||||||
|
|
||||||
|
Die Ortsansicht beantwortet:
|
||||||
|
|
||||||
|
**Was gibt es hier?**
|
||||||
|
|
||||||
|
Die Jagd beantwortet:
|
||||||
|
|
||||||
|
**Was kann ich hier bekämpfen?**
|
||||||
|
|
||||||
|
Der Kampf beantwortet:
|
||||||
|
|
||||||
|
**Wie besiege ich diesen Gegner?**
|
||||||
|
|
||||||
|
Diese Verantwortlichkeiten sollen getrennt bleiben.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. Position in der Navigation
|
||||||
|
|
||||||
|
Die persistente linke Navigation erhält einen eigenen Eintrag:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Ort
|
||||||
|
Karte
|
||||||
|
Jagd
|
||||||
|
Quests
|
||||||
|
Inventar
|
||||||
|
Charakter
|
||||||
|
Shop
|
||||||
|
```
|
||||||
|
|
||||||
|
`Ort` ist aktiv hervorgehoben, solange der Spieler die lokale Ortsansicht betrachtet.
|
||||||
|
|
||||||
|
Der Spieler soll grundsätzlich jederzeit zu dieser Ansicht zurückkehren können, sofern kein blockierender Gameplay-Zustand aktiv ist, beispielsweise ein noch nicht abgeschlossener Kampf.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. Ziel des Spielerlebnisses
|
||||||
|
|
||||||
|
Ein Ort muss sich wie ein **Ort** anfühlen und nicht wie ein Datensatz oder Menüpunkt.
|
||||||
|
|
||||||
|
Der Screen soll beim Spieler das Gefühl erzeugen:
|
||||||
|
|
||||||
|
> Ich befinde mich gerade hier. Das ist meine Umgebung. Diese Personen und interessanten Punkte befinden sich hier. Von hier aus entscheide ich, was ich als Nächstes tue.
|
||||||
|
|
||||||
|
Die Ortsansicht ist deshalb kein Dashboard aus vielen kleinen Karten.
|
||||||
|
|
||||||
|
Das zentrale Artwork ist die wichtigste Präsentationsebene.
|
||||||
|
|
||||||
|
UI liegt darum herum oder gezielt darüber.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. Grundaufbau des Screens
|
||||||
|
|
||||||
|
Jede Ortsansicht verwendet den bestehenden Ashen-Realms-App-Shell:
|
||||||
|
|
||||||
|
- persistente Topbar
|
||||||
|
- persistente linke Navigation
|
||||||
|
- große zentrale Artwork-Fläche
|
||||||
|
- kontextuelle rechte Sidebar
|
||||||
|
- persistenter Footer / Weltstatus
|
||||||
|
|
||||||
|
Der eigentliche Ortsinhalt besteht aus fünf funktionalen Ebenen.
|
||||||
|
|
||||||
|
## 4.1 Ortskopf
|
||||||
|
|
||||||
|
Zeigt:
|
||||||
|
|
||||||
|
- Ortsname
|
||||||
|
- Gebiet / Region als Breadcrumb
|
||||||
|
- kurze atmosphärische Beschreibung
|
||||||
|
|
||||||
|
Beispiel:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Verbrannte Straße
|
||||||
|
Gebiet 1 > Aschenfelder
|
||||||
|
|
||||||
|
Ein alter Handelsweg, der durch Feuer und Krieg in Asche gelegt wurde.
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Beschreibung sollte meist in 1–3 kurzen Zeilen auskommen.
|
||||||
|
|
||||||
|
Längere Lore gehört in Dialoge, Quests, Bücher oder eigene Lore-Inhalte.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.2 Großes Orts-Artwork
|
||||||
|
|
||||||
|
Das Orts-Artwork ist das visuelle Zentrum des Screens.
|
||||||
|
|
||||||
|
Es soll vermitteln:
|
||||||
|
|
||||||
|
- Biom
|
||||||
|
- lokale Gefahr
|
||||||
|
- Wetter / Atmosphäre
|
||||||
|
- Architektur
|
||||||
|
- Spuren vergangener Ereignisse
|
||||||
|
- wichtige Landmarken
|
||||||
|
|
||||||
|
Beispiele:
|
||||||
|
|
||||||
|
### Aschenfelder
|
||||||
|
- verbrannte Straßen
|
||||||
|
- Asche
|
||||||
|
- Glut
|
||||||
|
- tote Bäume
|
||||||
|
- zerstörte Wagen
|
||||||
|
- Rauch
|
||||||
|
|
||||||
|
### Dämmerwald
|
||||||
|
- dichte Vegetation
|
||||||
|
- Nebel
|
||||||
|
- dunkles Blätterdach
|
||||||
|
- überwucherte Ruinen
|
||||||
|
- Spuren verdorbener Tiere
|
||||||
|
|
||||||
|
### Vergessene Ruinen
|
||||||
|
- alter Stein
|
||||||
|
- Gräber
|
||||||
|
- eingestürzte Mauern
|
||||||
|
- Banner
|
||||||
|
- Krypteneingänge
|
||||||
|
- kaltes magisches Licht
|
||||||
|
|
||||||
|
Interaktionsbeschriftungen dürfen **nicht fest in das Artwork eingebrannt** sein.
|
||||||
|
|
||||||
|
Marker und Labels werden durch die UI gerendert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. Points of Interest und Hotspots
|
||||||
|
|
||||||
|
Ein Ort kann direkt auf dem Artwork interaktive **Points of Interest (POIs)** besitzen.
|
||||||
|
|
||||||
|
Ein POI besteht aus:
|
||||||
|
|
||||||
|
- Icon
|
||||||
|
- Titel
|
||||||
|
- optionalem kurzen Aktionslabel
|
||||||
|
- normalisierter X-/Y-Position im Artwork
|
||||||
|
- Interaktionstyp
|
||||||
|
- optionalem Verfügbarkeitszustand
|
||||||
|
|
||||||
|
Beispiel:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[Truhen-Icon]
|
||||||
|
Verlassener Wagen
|
||||||
|
Durchsuchen
|
||||||
|
```
|
||||||
|
|
||||||
|
POIs sollen sich optisch in die Szene integrieren.
|
||||||
|
|
||||||
|
Der Screen darf dadurch nicht zu einem Hidden-Object-Spiel werden.
|
||||||
|
|
||||||
|
Empfohlene sichtbare Anzahl:
|
||||||
|
|
||||||
|
- einfacher Übergangsort: 1–3
|
||||||
|
- normaler Ort: 2–5
|
||||||
|
- großer Hub: 3–7
|
||||||
|
|
||||||
|
Wenn deutlich mehr Aktionen notwendig sind, müssen sie gruppiert oder in eigene Panels/Screens ausgelagert werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 6. Wiederverwendbare Interaktionstypen
|
||||||
|
|
||||||
|
Die erste gemeinsame Interaktionssprache lautet:
|
||||||
|
|
||||||
|
```text
|
||||||
|
HUNT
|
||||||
|
INVESTIGATE
|
||||||
|
SEARCH
|
||||||
|
NPC
|
||||||
|
MAP
|
||||||
|
TRAVEL
|
||||||
|
SHOP
|
||||||
|
QUEST
|
||||||
|
BOSS
|
||||||
|
DUNGEON
|
||||||
|
```
|
||||||
|
|
||||||
|
Nicht jeder Typ muss bereits im ersten Slice vollständig umgesetzt sein.
|
||||||
|
|
||||||
|
Wichtig ist, dass neue Orte aus wiederverwendbaren Interaktionstypen aufgebaut werden und nicht jeweils eigene Angular-Spezialkomponenten erhalten.
|
||||||
|
|
||||||
|
## 6.1 HUNT
|
||||||
|
|
||||||
|
Zweck:
|
||||||
|
|
||||||
|
Startet die normale Jagd-/Encounter-Schleife des aktuellen Ortes.
|
||||||
|
|
||||||
|
Ablauf:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Ortsansicht
|
||||||
|
→ Jagd
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Ortsansicht würfelt oder rendert selbst keine Encounter-Auswahl.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.2 INVESTIGATE
|
||||||
|
|
||||||
|
Zweck:
|
||||||
|
|
||||||
|
Untersucht Spuren, Ruinen, Symbole, Leichen, Zeichen oder andere Hinweise in der Umgebung.
|
||||||
|
|
||||||
|
Mögliche Ergebnisse:
|
||||||
|
|
||||||
|
- atmosphärischer Text
|
||||||
|
- Lore-Hinweis
|
||||||
|
- Hinweis auf einen Ort
|
||||||
|
- später Questfortschritt
|
||||||
|
- später versteckte Verbindung
|
||||||
|
|
||||||
|
Für die erste Umsetzung reicht ein serverseitig geliefertes Ergebnis-Panel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.3 SEARCH
|
||||||
|
|
||||||
|
Zweck:
|
||||||
|
|
||||||
|
Durchsucht ein konkretes Objekt oder einen kleinen Bereich.
|
||||||
|
|
||||||
|
Beispiele:
|
||||||
|
|
||||||
|
- verlassener Wagen
|
||||||
|
- alte Vorratskiste
|
||||||
|
- Lagerreste
|
||||||
|
- Kryptennische
|
||||||
|
|
||||||
|
Später mögliche Ergebnisse:
|
||||||
|
|
||||||
|
- Item
|
||||||
|
- Währung
|
||||||
|
- Verbrauchsgegenstand
|
||||||
|
- Hinweis
|
||||||
|
- nichts
|
||||||
|
|
||||||
|
Sobald eine Interaktion echte Belohnungen vergibt, muss sie serverautoritativ sein.
|
||||||
|
|
||||||
|
Die erste Version darf reine Text-/Informationsresultate verwenden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.4 NPC
|
||||||
|
|
||||||
|
Zweck:
|
||||||
|
|
||||||
|
Interaktion mit einem sichtbar am Ort vorhandenen NPC.
|
||||||
|
|
||||||
|
Für V1 reicht ein einfaches Dialogpanel.
|
||||||
|
|
||||||
|
Spätere Erweiterungen können sein:
|
||||||
|
|
||||||
|
- Quests
|
||||||
|
- Händler
|
||||||
|
- Ruf-/Tauschsystem
|
||||||
|
- Dienstleistungen
|
||||||
|
- Fraktionsinteraktionen
|
||||||
|
|
||||||
|
NPCs sollen möglichst im Kontext der Szene sichtbar sein und nicht ausschließlich als Textliste in einem Panel erscheinen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.5 MAP
|
||||||
|
|
||||||
|
Zweck:
|
||||||
|
|
||||||
|
Öffnet die bestehende Karten-/Reiseansicht.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Ortsansicht
|
||||||
|
→ Karte
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.6 TRAVEL
|
||||||
|
|
||||||
|
Reisen bleibt Bestandteil des bestehenden Karten-/Reisesystems.
|
||||||
|
|
||||||
|
Die Ortsansicht darf visuell Ausgänge oder Ziele zeigen, soll aber im Normalfall in den vorhandenen Reiseflow weiterleiten statt eine zweite Reiselogik zu bauen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.7 SHOP, QUEST, BOSS, DUNGEON
|
||||||
|
|
||||||
|
Diese Typen sind Erweiterungspunkte für späteren Content.
|
||||||
|
|
||||||
|
Sie verwenden dieselben POI- und Aktionsregeln, öffnen aber jeweils einen eigenen Screen, ein Panel oder einen spezialisierten Flow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 7. Primäre Aktionsleiste
|
||||||
|
|
||||||
|
Die wichtigsten lokalen Aktionen werden zusätzlich in einer großen Aktionsleiste unterhalb des Artworks dargestellt.
|
||||||
|
|
||||||
|
Diese Dopplung ist beabsichtigt.
|
||||||
|
|
||||||
|
POIs erzeugen Atmosphäre und räumlichen Kontext.
|
||||||
|
|
||||||
|
Die Aktionsleiste sorgt für klare Bedienbarkeit.
|
||||||
|
|
||||||
|
Empfohlenes Maximum:
|
||||||
|
|
||||||
|
**4 primäre Ortsaktionen**
|
||||||
|
|
||||||
|
Beispiel:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[Jagd beginnen]
|
||||||
|
[Spuren untersuchen]
|
||||||
|
[Umgebung durchsuchen]
|
||||||
|
[Zur Karte]
|
||||||
|
```
|
||||||
|
|
||||||
|
Sekundäre Aktionen dürfen ausschließlich als POI oder in der rechten Sidebar erscheinen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 8. Rechte Kontext-Sidebar
|
||||||
|
|
||||||
|
Die rechte Sidebar fasst die spielerische Bedeutung des aktuellen Ortes zusammen.
|
||||||
|
|
||||||
|
Empfohlene Bereiche:
|
||||||
|
|
||||||
|
## Ortsidentität
|
||||||
|
|
||||||
|
- Gebiet / Region
|
||||||
|
- Ortsname
|
||||||
|
- Ortstyp
|
||||||
|
|
||||||
|
## Progressionskontext
|
||||||
|
|
||||||
|
- empfohlener Fortschrittsbereich oder Empfehlung
|
||||||
|
- relative Gefahr
|
||||||
|
|
||||||
|
Diese Information darf keine harte Zugangssperre sein.
|
||||||
|
|
||||||
|
Der bestehende Ashen-Realms-Grundsatz bleibt:
|
||||||
|
|
||||||
|
> Die Welt kommuniziert Gefahr, ohne Experimente künstlich zu verbieten.
|
||||||
|
|
||||||
|
## Mögliche Begegnungen
|
||||||
|
|
||||||
|
Zeigt repräsentative Gegner oder Encounter-Kategorien.
|
||||||
|
|
||||||
|
Das ist nur Vorschauinformation.
|
||||||
|
|
||||||
|
Der tatsächliche Encounter-Wurf erfolgt weiterhin im Jagdsystem.
|
||||||
|
|
||||||
|
## Verfügbare Interaktionen
|
||||||
|
|
||||||
|
Beispiele:
|
||||||
|
|
||||||
|
- Jagd
|
||||||
|
- Untersuchen
|
||||||
|
- Durchsuchen
|
||||||
|
- Sprechen
|
||||||
|
|
||||||
|
## Mögliche Belohnungen
|
||||||
|
|
||||||
|
Optionale Vorschau-Icons für:
|
||||||
|
|
||||||
|
- Ausrüstung
|
||||||
|
- Materialien
|
||||||
|
- Verbrauchsgegenstände
|
||||||
|
- Handelswaren
|
||||||
|
- regionsspezifische Belohnungen
|
||||||
|
|
||||||
|
Es dürfen keine exakten Belohnungen versprochen werden, wenn der zugrunde liegende Content diese nicht garantiert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 9. Ortstypen
|
||||||
|
|
||||||
|
Orte können über wiederverwendbare Typen ihre grundlegende Rolle ausdrücken.
|
||||||
|
|
||||||
|
Empfohlene Startwerte:
|
||||||
|
|
||||||
|
```text
|
||||||
|
SAFE_HUB
|
||||||
|
TRANSITION
|
||||||
|
HUNTING_GROUND
|
||||||
|
QUEST_LOCATION
|
||||||
|
OUTPOST
|
||||||
|
ELITE_ZONE
|
||||||
|
BOSS_LOCATION
|
||||||
|
DUNGEON_ENTRANCE
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Typ beeinflusst Präsentation und verfügbare Aktionen, benötigt aber keine eigene Page-Implementierung.
|
||||||
|
|
||||||
|
Beispiele:
|
||||||
|
|
||||||
|
### Südtor von Graufurt
|
||||||
|
|
||||||
|
```text
|
||||||
|
TRANSITION
|
||||||
|
```
|
||||||
|
|
||||||
|
Fokus:
|
||||||
|
|
||||||
|
- Wachposten
|
||||||
|
- Reise
|
||||||
|
- Gebietsinformation
|
||||||
|
- wenig oder keine reguläre Jagd
|
||||||
|
|
||||||
|
### Verbrannte Straße
|
||||||
|
|
||||||
|
```text
|
||||||
|
HUNTING_GROUND
|
||||||
|
```
|
||||||
|
|
||||||
|
Fokus:
|
||||||
|
|
||||||
|
- Jagd
|
||||||
|
- Umgebungsinformationen
|
||||||
|
- Spuren
|
||||||
|
- erste NPC-Begegnung
|
||||||
|
|
||||||
|
### Verlassener Wachtposten
|
||||||
|
|
||||||
|
```text
|
||||||
|
OUTPOST / QUEST_LOCATION
|
||||||
|
```
|
||||||
|
|
||||||
|
Fokus:
|
||||||
|
|
||||||
|
- NPC
|
||||||
|
- stärkere Jagd
|
||||||
|
- Untersuchung
|
||||||
|
- Storyfortschritt
|
||||||
|
|
||||||
|
### Aschengrube
|
||||||
|
|
||||||
|
```text
|
||||||
|
ELITE_ZONE / BOSS_LOCATION
|
||||||
|
```
|
||||||
|
|
||||||
|
Fokus:
|
||||||
|
|
||||||
|
- Gefahr
|
||||||
|
- Elite-/Bosszugang
|
||||||
|
- kaum zivile Interaktionen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 10. Wiederverwendbares Content-Modell
|
||||||
|
|
||||||
|
Die Ortsansicht soll datengetrieben sein.
|
||||||
|
|
||||||
|
Ein konzeptionelles View-Model sollte mindestens enthalten:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface LocalLocationView {
|
||||||
|
locationId: string;
|
||||||
|
locationKey: string;
|
||||||
|
name: string;
|
||||||
|
regionName: string;
|
||||||
|
description: string;
|
||||||
|
locationType: LocationType;
|
||||||
|
artworkPath: string;
|
||||||
|
dangerRating: DangerRating;
|
||||||
|
recommendationLabel?: string;
|
||||||
|
huntingEnabled: boolean;
|
||||||
|
pointsOfInterest: LocationPointOfInterest[];
|
||||||
|
primaryActions: LocationAction[];
|
||||||
|
encounterPreview: EncounterPreview[];
|
||||||
|
rewardPreview: RewardPreview[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Konzeptionelles POI-Modell:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface LocationPointOfInterest {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
actionLabel?: string;
|
||||||
|
type: LocationInteractionType;
|
||||||
|
iconKey: string;
|
||||||
|
xPercent: number;
|
||||||
|
yPercent: number;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Koordinaten werden als Prozentwerte gespeichert, damit Hotspots beim Skalieren des Artworks an derselben Stelle bleiben.
|
||||||
|
|
||||||
|
Beispiel:
|
||||||
|
|
||||||
|
```text
|
||||||
|
xPercent: 42.5
|
||||||
|
yPercent: 66.0
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 11. Interaktionsergebnisse
|
||||||
|
|
||||||
|
Lokale Interaktionen sollen ein vorhersehbares Ergebnisformat zurückgeben.
|
||||||
|
|
||||||
|
Konzeptionell:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface LocationInteractionResult {
|
||||||
|
interactionKey: string;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
nextAction?: {
|
||||||
|
type: string;
|
||||||
|
target?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Die erste Version bleibt bewusst klein.
|
||||||
|
|
||||||
|
Nicht nur für diesen Screen bauen:
|
||||||
|
|
||||||
|
- generische Visual-Novel-Engine
|
||||||
|
- verzweigte Dialogengine
|
||||||
|
- Event-Skriptsprache
|
||||||
|
- komplexen Questgraph
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 12. Navigationsregeln
|
||||||
|
|
||||||
|
## Nach abgeschlossener Reise
|
||||||
|
|
||||||
|
Der Spieler landet in der lokalen Ortsansicht des neuen aktuellen Ortes.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Reise abgeschlossen
|
||||||
|
→ current location aktualisiert
|
||||||
|
→ Ortsansicht
|
||||||
|
```
|
||||||
|
|
||||||
|
## Nach normalem Kampf
|
||||||
|
|
||||||
|
Empfohlener Standard:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Kampfergebnis / Loot
|
||||||
|
→ Ortsansicht
|
||||||
|
```
|
||||||
|
|
||||||
|
Von dort kann der Spieler erneut jagen.
|
||||||
|
|
||||||
|
## Beim Verlassen der Jagd
|
||||||
|
|
||||||
|
`Zurück` führt zur Ortsansicht.
|
||||||
|
|
||||||
|
## Karte
|
||||||
|
|
||||||
|
`Zur Karte` öffnet die Karte, verändert aber nicht den aktuellen Ort.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 13. Screen-Zustände
|
||||||
|
|
||||||
|
Die Ortsansicht muss mindestens folgende Zustände definieren.
|
||||||
|
|
||||||
|
## Loading
|
||||||
|
|
||||||
|
- App-Shell bleibt sichtbar
|
||||||
|
- zurückhaltender Ladezustand im Hauptbereich
|
||||||
|
|
||||||
|
## Loaded
|
||||||
|
|
||||||
|
- Artwork
|
||||||
|
- POIs
|
||||||
|
- Aktionen
|
||||||
|
- Kontext-Sidebar
|
||||||
|
|
||||||
|
## Interaktion geöffnet
|
||||||
|
|
||||||
|
- Ort bleibt im Hintergrund sichtbar
|
||||||
|
- kurze Untersuchungen und einfache NPC-Dialoge öffnen kein komplett neues Seitenlayout
|
||||||
|
|
||||||
|
## Aktion nicht verfügbar
|
||||||
|
|
||||||
|
- sichtbar deaktiviert
|
||||||
|
- optional kurze Erklärung / Tooltip
|
||||||
|
|
||||||
|
## API-Fehler
|
||||||
|
|
||||||
|
- Navigation bleibt benutzbar
|
||||||
|
- kompakter Retry-Zustand
|
||||||
|
|
||||||
|
## Aktiver Kampf
|
||||||
|
|
||||||
|
Wenn der Server einen noch nicht abgeschlossenen Kampf meldet, darf die Ortsansicht nicht zum Umgehen dieses Kampfes genutzt werden.
|
||||||
|
|
||||||
|
Der bestehende Combat-Resume-Flow hat Vorrang.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 14. Visuelle Regeln
|
||||||
|
|
||||||
|
Die Ortsansicht folgt vollständig der bestehenden Ashen-Realms-UI-Spezifikation.
|
||||||
|
|
||||||
|
Wichtig:
|
||||||
|
|
||||||
|
- Artwork zuerst
|
||||||
|
- dunkles Metall / Stein / Leder
|
||||||
|
- dezente Goldverzierungen
|
||||||
|
- keine weißen SaaS-Cards
|
||||||
|
- kein Glassmorphism
|
||||||
|
- keine Neon-Dashboard-Optik
|
||||||
|
- Fantasy-Serif für große Überschriften
|
||||||
|
- sehr gut lesbare UI-Schrift für normalen Text
|
||||||
|
- zurückhaltender Glow
|
||||||
|
- eindeutige Interaktionszustände
|
||||||
|
- konsistente Icon-Sprache
|
||||||
|
|
||||||
|
POI-Marker müssen erkennbar sein, dürfen aber das Artwork nicht dominieren.
|
||||||
|
|
||||||
|
Bevorzugt werden kleine runde oder schildartige Fantasy-Marker statt moderner Map-Pins.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 15. Responsive Verhalten
|
||||||
|
|
||||||
|
Desktop bleibt das primäre Ziel.
|
||||||
|
|
||||||
|
## Desktop
|
||||||
|
|
||||||
|
- volle linke Navigation
|
||||||
|
- Artwork mit positionierten POIs
|
||||||
|
- rechte Sidebar sichtbar
|
||||||
|
- primäre Aktionsleiste sichtbar
|
||||||
|
|
||||||
|
## Tablet
|
||||||
|
|
||||||
|
- rechte Sidebar darf einklappbar werden
|
||||||
|
- Hauptaktionen bleiben sichtbar
|
||||||
|
- POIs bleiben durch Prozentkoordinaten korrekt verankert
|
||||||
|
|
||||||
|
## Mobile – später
|
||||||
|
|
||||||
|
Nicht V1-Priorität.
|
||||||
|
|
||||||
|
Mögliche spätere Anpassung:
|
||||||
|
|
||||||
|
- Artwork oben
|
||||||
|
- POI-Liste unterhalb des Artworks
|
||||||
|
- rechte Sidebar wird einklappbare Ortsinfo
|
||||||
|
- Aktionen als zweispaltiges Grid
|
||||||
|
|
||||||
|
Das Desktop-Design soll jetzt nicht zugunsten von Mobile kompromittiert werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 16. Content-Regeln für zukünftige Orte
|
||||||
|
|
||||||
|
Bei jedem neuen Ort werden in dieser Reihenfolge definiert:
|
||||||
|
|
||||||
|
1. **Zweck des Ortes**
|
||||||
|
2. **Visuelle Szene**
|
||||||
|
3. **Wichtigste Spielerentscheidung**
|
||||||
|
4. **2–5 sinnvolle POIs**
|
||||||
|
5. **Bis zu 4 primäre Aktionen**
|
||||||
|
6. **Encounter-Vorschau, falls relevant**
|
||||||
|
7. **Informationen für die rechte Sidebar**
|
||||||
|
8. **Rückkehr- und Weiterreise-Flow**
|
||||||
|
|
||||||
|
Für jeden relevanten Ort muss beantwortet werden:
|
||||||
|
|
||||||
|
> Warum soll sich der Spieler an diesen Ort erinnern und ihn nicht nur als weiteren Menüpunkt wahrnehmen?
|
||||||
|
|
||||||
|
Mindestens ein identitätsstiftendes Element sollte vorhanden sein:
|
||||||
|
|
||||||
|
- markanter NPC
|
||||||
|
- besondere Landmarke
|
||||||
|
- Untersuchung
|
||||||
|
- spezieller Service
|
||||||
|
- Elitezugang
|
||||||
|
- Bosszugang
|
||||||
|
- ungewöhnlicher Encounter-Pool
|
||||||
|
- Story-Hinweis
|
||||||
|
- visuelles Ereignis
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 17. Was ausdrücklich nicht gebaut werden soll
|
||||||
|
|
||||||
|
Die Ortsansicht ist kein:
|
||||||
|
|
||||||
|
- frei begehbarer Screen
|
||||||
|
- WASD-Bewegungssystem
|
||||||
|
- Point-and-Click-Adventure
|
||||||
|
- Hidden-Object-Spiel
|
||||||
|
- zweiter Kartenscreen
|
||||||
|
- Ersatz für die Jagd
|
||||||
|
- Ersatz für Quests
|
||||||
|
- universelles Modal für alle Spielsysteme
|
||||||
|
|
||||||
|
Vermeiden:
|
||||||
|
|
||||||
|
- dutzende Hotspots
|
||||||
|
- individuelle Komponenten pro Ort
|
||||||
|
- Kampflogik im Orts-Screen
|
||||||
|
- clientseitige Loot-Rolls
|
||||||
|
- clientseitiger Reiseabschluss
|
||||||
|
- duplizierte Jagdlogik
|
||||||
|
- lange Lore-Textwände direkt auf der Szene
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 18. V1-Referenzort – Verbrannte Straße
|
||||||
|
|
||||||
|
Die erste Referenzimplementierung ist die **Verbrannte Straße** in den **Aschenfeldern**.
|
||||||
|
|
||||||
|
## Zweck
|
||||||
|
|
||||||
|
Beweisen, dass die Ortsansicht Weltgefühl, Jagd, einfache Exploration und NPC-Präsenz verbinden kann, ohne ein neues komplexes Subsystem zu werden.
|
||||||
|
|
||||||
|
## Visuelle Identität
|
||||||
|
|
||||||
|
- verbrannte Handelsstraße
|
||||||
|
- zerstörter Wagen
|
||||||
|
- aschebedeckter Boden
|
||||||
|
- glühende Risse / Glut
|
||||||
|
- tote Bäume
|
||||||
|
- Wachtstrukturen in der Ferne
|
||||||
|
- Rauch
|
||||||
|
- Spuren früherer Kämpfe
|
||||||
|
|
||||||
|
## POIs
|
||||||
|
|
||||||
|
### Jagdgebiet
|
||||||
|
|
||||||
|
Typ:
|
||||||
|
|
||||||
|
```text
|
||||||
|
HUNT
|
||||||
|
```
|
||||||
|
|
||||||
|
Aktion:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Jagd beginnen
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verdächtige Spuren
|
||||||
|
|
||||||
|
Typ:
|
||||||
|
|
||||||
|
```text
|
||||||
|
INVESTIGATE
|
||||||
|
```
|
||||||
|
|
||||||
|
Aktion:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Untersuchen
|
||||||
|
```
|
||||||
|
|
||||||
|
Beispielresultat:
|
||||||
|
|
||||||
|
> Zwischen Asche und zerbrochenen Steinen erkennst du mehrere frische Stiefelabdrücke. Sie führen nach Osten, in Richtung des verlassenen Wachtpostens.
|
||||||
|
|
||||||
|
### Verlassener Wagen
|
||||||
|
|
||||||
|
Typ:
|
||||||
|
|
||||||
|
```text
|
||||||
|
SEARCH
|
||||||
|
```
|
||||||
|
|
||||||
|
Aktion:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Durchsuchen
|
||||||
|
```
|
||||||
|
|
||||||
|
Beispielresultat für die erste Umsetzung:
|
||||||
|
|
||||||
|
> Der Wagen wurde gründlich geplündert. Zwischen verbrannten Brettern findest du nur leere Kisten und Spuren eines hastigen Aufbruchs.
|
||||||
|
|
||||||
|
In der ersten Implementierung muss diese Aktion noch keine Belohnung vergeben.
|
||||||
|
|
||||||
|
### Verwundeter Kundschafter
|
||||||
|
|
||||||
|
Typ:
|
||||||
|
|
||||||
|
```text
|
||||||
|
NPC
|
||||||
|
```
|
||||||
|
|
||||||
|
Aktion:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Sprechen
|
||||||
|
```
|
||||||
|
|
||||||
|
Beispieldialog:
|
||||||
|
|
||||||
|
> „Die Straße ist nicht mehr sicher. Die Plünderer kommen aus Richtung des alten Wachtpostens. Wenn du weitergehst, halte die Augen offen.“
|
||||||
|
|
||||||
|
Für diese erste NPC-Interaktion ist noch kein Quest-System notwendig.
|
||||||
|
|
||||||
|
## Primäre Aktionen
|
||||||
|
|
||||||
|
```text
|
||||||
|
Jagd beginnen
|
||||||
|
Spuren untersuchen
|
||||||
|
Umgebung durchsuchen
|
||||||
|
Zur Karte
|
||||||
|
```
|
||||||
|
|
||||||
|
## Encounter-Vorschau
|
||||||
|
|
||||||
|
Repräsentative Gegner:
|
||||||
|
|
||||||
|
- Aschenratte
|
||||||
|
- Verwilderter Straßenhund
|
||||||
|
- Straßenräuber
|
||||||
|
- Verkohlter Plünderer
|
||||||
|
|
||||||
|
Die Vorschau ist rein informativ.
|
||||||
|
|
||||||
|
Sie ersetzt nicht den gewichteten Encounter-Pool der Jagd.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 19. Visuelle Referenz
|
||||||
|
|
||||||
|
Für dieses Konzept wurde folgender Referenzscreen erzeugt:
|
||||||
|
|
||||||
|
```text
|
||||||
|
verbrannte_strasse_in_den_aschenfeldern.png
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Screenshot definiert Komposition und Zielgefühl, aber keine pixelgenauen Maße.
|
||||||
|
|
||||||
|
Zukünftige Ortsansichten sollen insbesondere beibehalten:
|
||||||
|
|
||||||
|
- denselben App-Shell
|
||||||
|
- dominantes Orts-Artwork
|
||||||
|
- dieselbe POI-Markersprache
|
||||||
|
- primäre Aktionsleiste unten
|
||||||
|
- kontextuelle rechte Sidebar
|
||||||
|
- klare Trennung zwischen Ort, Karte, Jagd und Kampf
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 20. Definition of Done für zukünftige Orte
|
||||||
|
|
||||||
|
Eine Ortsansicht ist fertig, wenn:
|
||||||
|
|
||||||
|
- aktueller Ort und Region sofort erkennbar sind
|
||||||
|
- das Artwork den Ort eindeutig vermittelt
|
||||||
|
- verfügbare Aktionen klar sind
|
||||||
|
- wichtige NPCs / POIs im Kontext sichtbar sind
|
||||||
|
- POIs beim Skalieren korrekt positioniert bleiben
|
||||||
|
- Jagdaktionen den bestehenden Jagdflow verwenden
|
||||||
|
- Reisen den bestehenden Karten-/Reiseflow verwenden
|
||||||
|
- lokale Interaktionen keine fremden Systeme duplizieren
|
||||||
|
- serverautoritative Aktionen serverautoritativ bleiben
|
||||||
|
- derselbe Komponentenaufbau den nächsten Ort nur über andere Daten rendern kann
|
||||||
|
- der Screen wie ein Bestandteil desselben Ashen-Realms-UI-Systems wirkt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 21. Leitprinzip
|
||||||
|
|
||||||
|
> **Die Karte zeigt dem Spieler, wohin er gehen kann. Die Ortsansicht lässt ihn spüren, wo er gerade ist.**
|
||||||
|
|
||||||
|
Jede zukünftige Ortsansicht soll genau diese Trennung stärken.
|
||||||
65
tools/extract-item-icons.ps1
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# tools/extract-item-icons.ps1
|
||||||
|
# Crops the Tier-1 item icons out of art/Items.png into apps/web/public/images/items.
|
||||||
|
# Re-runnable: overwrites its own output and touches nothing else.
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
Add-Type -AssemblyName System.Drawing
|
||||||
|
|
||||||
|
$root = Split-Path -Parent $PSScriptRoot
|
||||||
|
$sheet = Join-Path $root 'art\Items.png'
|
||||||
|
$ratPortrait = Join-Path $root 'art\enemies\AschenratteIcon.png'
|
||||||
|
$outDir = Join-Path $root 'apps\web\public\images\items'
|
||||||
|
New-Item -ItemType Directory -Force -Path $outDir | Out-Null
|
||||||
|
|
||||||
|
function Export-Icon {
|
||||||
|
param(
|
||||||
|
[System.Drawing.Image] $Source,
|
||||||
|
[int] $X, [int] $Y, [int] $Size,
|
||||||
|
[string] $Key
|
||||||
|
)
|
||||||
|
$bitmap = New-Object System.Drawing.Bitmap 256, 256
|
||||||
|
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||||
|
$graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
|
||||||
|
$graphics.DrawImage(
|
||||||
|
$Source,
|
||||||
|
(New-Object System.Drawing.Rectangle 0, 0, 256, 256),
|
||||||
|
(New-Object System.Drawing.Rectangle $X, $Y, $Size, $Size),
|
||||||
|
[System.Drawing.GraphicsUnit]::Pixel)
|
||||||
|
$graphics.Dispose()
|
||||||
|
$bitmap.Save((Join-Path $outDir "$Key.png"), [System.Drawing.Imaging.ImageFormat]::Png)
|
||||||
|
$bitmap.Dispose()
|
||||||
|
Write-Host "wrote $Key.png"
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = [System.Drawing.Image]::FromFile($sheet)
|
||||||
|
try {
|
||||||
|
# Rows 1-2: cells are 350 wide with a caption strip at the bottom, so the
|
||||||
|
# artwork is the 300x300 square inset by 25px horizontally.
|
||||||
|
$columns = 8, 366, 724, 1082
|
||||||
|
$rowOne = 'worn-short-sword', 'bandit-blade', 'ash-blade', 'bandit-hood'
|
||||||
|
$rowTwo = 'reinforced-leather-jacket', 'raider-gloves', 'guardsman-legs', 'ash-boots'
|
||||||
|
|
||||||
|
for ($i = 0; $i -lt 4; $i++) {
|
||||||
|
Export-Icon -Source $items -X ($columns[$i] + 25) -Y 8 -Size 300 -Key $rowOne[$i]
|
||||||
|
Export-Icon -Source $items -X ($columns[$i] + 25) -Y 364 -Size 300 -Key $rowTwo[$i]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Row 3 is centred and shorter: three cells, 262x262 artwork inset by 44px.
|
||||||
|
$rowThreeColumns = 191, 549, 907
|
||||||
|
$rowThree = 'borderwatch-sigil', 'burned-captain-pendant', 'small-healing-potion'
|
||||||
|
for ($i = 0; $i -lt 3; $i++) {
|
||||||
|
Export-Icon -Source $items -X ($rowThreeColumns[$i] + 44) -Y 720 -Size 262 -Key $rowThree[$i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$items.Dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
# STAND-IN ART: no Aschenfell artwork exists yet, so the pelt icon is a scorched
|
||||||
|
# hide patch cropped from the Aschenratte portrait. Replace when real art lands.
|
||||||
|
$rat = [System.Drawing.Image]::FromFile($ratPortrait)
|
||||||
|
try {
|
||||||
|
Export-Icon -Source $rat -X 760 -Y 300 -Size 320 -Key 'ash-pelt'
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$rat.Dispose()
|
||||||
|
}
|
||||||