This commit is contained in:
Bastian Wagner
2026-08-22 16:41:47 +02:00
parent dfa62fd152
commit 081c9f83f9
137 changed files with 11594 additions and 1302 deletions

View File

@@ -2,16 +2,33 @@ import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, Router, provideRouter } from '@angular/router';
import { vi } from 'vitest';
import type { Combat } from '../../../core/api/game-api.models';
import type {
Combat,
CombatRewardItem,
LootCapacity,
} from '../../../core/api/game-api.models';
import { CombatStore } from '../combat.store';
import { WorldStore } from '../../world/world.store';
import { CombatPageComponent } from './combat-page.component';
// Roomy enough that no test trips the "full" styling unless it asks to.
const FULL_CAPACITIES: LootCapacity[] = [
{ category: 'HIDE', current: 1, capacity: 5, bag: null },
{ category: 'RAIDER_TROPHY', current: 0, capacity: 5, bag: null },
];
const activeCombat: Combat = {
id: 'combat-1',
status: 'ACTIVE',
round: 2,
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 95, potionsRemaining: 2, potionsMax: 2 },
player: {
name: 'Aric Duskwalker',
maxHp: 100,
currentHp: 95,
potionsRemaining: 2,
potionsMax: 2,
statusEffects: [],
},
monster: {
key: 'ash-rat',
name: 'Ash Rat',
@@ -426,7 +443,7 @@ describe('CombatPageComponent', () => {
...activeCombat,
status: 'WON',
monster: { ...activeCombat.monster, currentHp: 0 },
rewards: { silver: 6, items: [] },
rewards: { items: [], capacities: FULL_CAPACITIES },
events: [
...activeCombat.events,
{ round: 2, sequence: 3, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 31 },
@@ -522,57 +539,292 @@ describe('CombatPageComponent', () => {
expect(combatStore.loadCombat).toHaveBeenCalledTimes(2);
});
describe('Bleeding (spec §4)', () => {
it('shows an active effect with the rounds it still has to run', async () => {
const fixture = await setup({
...activeCombat,
player: {
...activeCombat.player,
statusEffects: [{ type: 'BLEED', remainingRounds: 2, damagePerRound: 5 }],
},
});
const element = fixture.nativeElement as HTMLElement;
const badge = element.querySelector('[data-combat-status="BLEED"]');
expect(badge).toBeTruthy();
expect(badge?.textContent).toContain('Bleeding');
expect(badge?.textContent).toContain('2');
});
it('shows no effect strip while the player is unafflicted', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-combat-statuses]')).toBeNull();
});
it('reads the status events in the combat log', async () => {
const fixture = await setup({
...activeCombat,
player: {
...activeCombat.player,
statusEffects: [{ type: 'BLEED', remainingRounds: 1, damagePerRound: 5 }],
},
events: [
{
round: 1,
sequence: 1,
type: 'STATUS_APPLIED',
source: 'MONSTER',
target: 'PLAYER',
amount: 2,
statusEffect: 'BLEED',
},
{
round: 1,
sequence: 2,
type: 'STATUS_DAMAGE',
source: 'MONSTER',
target: 'PLAYER',
amount: 5,
statusEffect: 'BLEED',
},
{
round: 1,
sequence: 3,
type: 'STATUS_EXPIRED',
source: 'MONSTER',
target: 'PLAYER',
statusEffect: 'BLEED',
},
],
});
const element = fixture.nativeElement as HTMLElement;
const log = element.querySelector('.combat__log-body')?.textContent ?? '';
expect(log).toContain('inflicts Bleeding');
expect(log).toContain('Bleeding costs Aric Duskwalker 5 HP');
expect(log).toContain('Bleeding fades');
});
});
const wonWithRewards: Combat = {
...activeCombat,
status: 'WON',
monster: { ...activeCombat.monster, currentHp: 0 },
rewards: { silver: 6, items: [] },
rewards: { items: [], capacities: FULL_CAPACITIES },
};
it('shows the granted silver on the victory screen', async () => {
const fixture = await setup(wonWithRewards);
const ashenPelt: CombatRewardItem = {
characterItemId: 'character-item-pelt',
item: {
key: 'ash-pelt',
name: 'Ashen Pelt',
type: 'TRADE_GOOD',
lootCategory: 'HIDE',
rarity: 'COMMON',
iconPath: '/images/items/ash-pelt.png',
},
quantity: 1,
quantityLeftBehind: 0,
};
const banditBlade: CombatRewardItem = {
characterItemId: 'character-item-blade',
item: {
key: 'bandit-blade',
name: 'Bandit Blade',
type: 'EQUIPMENT',
lootCategory: null,
rarity: 'COMMON',
iconPath: '/images/items/bandit-blade.png',
},
quantity: 1,
quantityLeftBehind: 0,
};
const healingPotion: CombatRewardItem = {
characterItemId: 'character-item-potion',
item: {
key: 'small-healing-potion',
name: 'Small Healing Potion',
type: 'CONSUMABLE',
lootCategory: null,
rarity: 'COMMON',
iconPath: '/images/items/small-healing-potion.png',
},
quantity: 1,
quantityLeftBehind: 0,
};
it('shows the reward panel without any currency row', async () => {
const fixture = await setup({ ...wonWithRewards, rewards: { items: [ashenPelt], capacities: FULL_CAPACITIES } });
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-combat-rewards]')).toBeTruthy();
expect(element.querySelector('[data-reward-silver]')?.textContent).toContain('6');
// R16: the XP block is removed entirely, not hidden -- guard against it
// reappearing in the markup.
// Spec §9: no XP or Silver rows. Both blocks are removed from the markup
// rather than hidden, so guard against either reappearing.
expect(element.querySelector('[data-reward-silver]')).toBeNull();
expect(element.querySelector('[data-reward-experience]')).toBeNull();
});
it('hides the silver tile entirely when the kill granted none', async () => {
// Every monster seeded in this slice rolls silverMin/silverMax = 0 (design
// R7), so without this guard the victory screen advertises "Silver +0"
// after every single fight in the shipped content.
it('separates trade goods, equipment, and consumables in the loot summary', async () => {
const fixture = await setup({
...wonWithRewards,
rewards: { silver: 0, items: [] },
rewards: { items: [banditBlade, ashenPelt, healingPotion], capacities: FULL_CAPACITIES },
});
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-reward-silver]')).toBeNull();
expect(element.querySelector('[data-combat-rewards]')).toBeTruthy();
const groups = [...element.querySelectorAll('[data-reward-group]')];
expect(groups.map((group) => group.getAttribute('data-reward-group'))).toEqual([
'trade-goods',
'equipment',
'consumables',
]);
expect(groups.map((group) => group.textContent?.trim())).toEqual([
'Trade Goods',
'Equipment',
'Consumables',
]);
});
it('omits a category the fight did not drop anything for', async () => {
const fixture = await setup({ ...wonWithRewards, rewards: { items: [ashenPelt], capacities: FULL_CAPACITIES } });
const element = fixture.nativeElement as HTMLElement;
const groups = [...element.querySelectorAll('[data-reward-group]')];
expect(groups).toHaveLength(1);
expect(groups[0].getAttribute('data-reward-group')).toBe('trade-goods');
});
it('files a trophy with the trade goods, because both are merchant fodder', async () => {
const insignia: CombatRewardItem = {
characterItemId: 'character-item-insignia',
item: {
key: 'bandit-insignia',
name: 'Raider Insignia',
type: 'TROPHY',
lootCategory: 'RAIDER_TROPHY',
rarity: 'COMMON',
iconPath: '/images/items/bandit-insignia.png',
},
quantity: 1,
quantityLeftBehind: 0,
};
const fixture = await setup({ ...wonWithRewards, rewards: { items: [insignia], capacities: FULL_CAPACITIES } });
const element = fixture.nativeElement as HTMLElement;
expect(
element.querySelector('[data-reward-group="trade-goods"]'),
).toBeTruthy();
expect(element.querySelector('[data-item-name]')?.textContent).toContain(
'Raider Insignia',
);
});
describe('full loot bags (slice 0.7.5 §9, §10)', () => {
const refusedPelt: CombatRewardItem = {
...ashenPelt,
characterItemId: null,
quantity: 0,
quantityLeftBehind: 1,
};
const fullHides: LootCapacity[] = [
{ category: 'HIDE', current: 5, capacity: 5, bag: null },
{ category: 'RAIDER_TROPHY', current: 0, capacity: 5, bag: null },
];
it('names what the bag refused instead of dropping it silently', async () => {
const fixture = await setup({
...wonWithRewards,
rewards: { items: [refusedPelt], capacities: fullHides },
});
const element = fixture.nativeElement as HTMLElement;
const refused = element.querySelector('[data-reward-left-behind]');
expect(refused).toBeTruthy();
expect(refused?.textContent).toContain('Ashen Pelt');
expect(refused?.textContent).toContain('1');
});
it('keeps a fully refused drop out of the loot the player actually got', async () => {
const fixture = await setup({
...wonWithRewards,
rewards: { items: [refusedPelt], capacities: fullHides },
});
const element = fixture.nativeElement as HTMLElement;
// Nothing was banked, so no Trade Goods heading and no item card.
expect(element.querySelector('[data-reward-group="trade-goods"]')).toBeNull();
expect(element.querySelector('[data-item-name]')).toBeNull();
});
it('lists a partial grant under both loot and left behind', async () => {
const fixture = await setup({
...wonWithRewards,
rewards: {
items: [{ ...ashenPelt, quantity: 1, quantityLeftBehind: 1 }],
capacities: fullHides,
},
});
const element = fixture.nativeElement as HTMLElement;
// §10: granted 1, left behind 1 -- the player has to see both halves.
expect(element.querySelector('[data-item-name]')?.textContent).toContain(
'Ashen Pelt',
);
expect(
element.querySelector('[data-reward-refused="ash-pelt"]')?.textContent,
).toContain('1');
});
it('still shows equipment that landed while the hide bag was full', async () => {
const fixture = await setup({
...wonWithRewards,
rewards: { items: [refusedPelt, banditBlade], capacities: fullHides },
});
const element = fixture.nativeElement as HTMLElement;
// §9: equipment must not be lost to a full trade-good bag.
expect(element.querySelector('[data-reward-group="equipment"]')).toBeTruthy();
expect(element.querySelector('[data-item-name]')?.textContent).toContain(
'Bandit Blade',
);
expect(element.querySelector('[data-reward-left-behind]')).toBeTruthy();
});
it('says nothing about left-behind loot when everything fit', async () => {
const fixture = await setup({
...wonWithRewards,
rewards: { items: [ashenPelt], capacities: FULL_CAPACITIES },
});
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-reward-left-behind]')).toBeNull();
});
it('shows the carrying state after the fight', async () => {
const fixture = await setup({
...wonWithRewards,
rewards: { items: [refusedPelt], capacities: fullHides },
});
const element = fixture.nativeElement as HTMLElement;
// §11/§12: the player learns the trip is over on the victory screen,
// without a second request.
expect(element.querySelector('[data-loot-capacities]')).toBeTruthy();
expect(
element.querySelector('[data-loot-capacity="HIDE"] [data-loot-capacity-full]'),
).toBeTruthy();
});
});
it('renders a dropped item with its icon, name, and rarity', async () => {
const fixture = await setup({
...wonWithRewards,
monster: { ...activeCombat.monster, key: 'road-bandit', name: 'Road Bandit', currentHp: 0 },
rewards: {
silver: 12,
items: [
{
characterItemId: 'character-item-1',
item: {
key: 'bandit-blade',
name: 'Bandit Blade',
rarity: 'COMMON',
iconPath: '/images/items/bandit-blade.png',
},
quantity: 1,
},
],
},
rewards: { items: [banditBlade], capacities: FULL_CAPACITIES },
});
const element = fixture.nativeElement as HTMLElement;
@@ -599,12 +851,12 @@ describe('CombatPageComponent', () => {
// exactly what the server persisted, without rerolling anything.
const fixture = await setup({
...wonWithRewards,
rewards: { silver: 12, items: [] },
rewards: { items: [ashenPelt], capacities: FULL_CAPACITIES },
});
const element = fixture.nativeElement as HTMLElement;
expect(combatStore.loadCombat).toHaveBeenCalledWith('combat-1');
expect(element.querySelector('[data-reward-silver]')?.textContent).toContain('12');
expect(element.querySelector('[data-item-name]')?.textContent).toContain('Ashen Pelt');
});
it('still shows a plain victory when the server reports no reward record', async () => {