Files
ashen-realms/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts

927 lines
35 KiB
TypeScript

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,
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,
statusEffects: [],
},
monster: {
key: 'ash-rat',
name: 'Ash Rat',
level: 1,
maxHp: 45,
currentHp: 31,
artworkPath: '/images/monsters/ash-rat.png',
pendingIntent: null,
guardRemainingRounds: null,
enraged: false,
},
events: [
{ round: 1, sequence: 1, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 14 },
{ round: 1, sequence: 2, type: 'DAMAGE', source: 'MONSTER', target: 'PLAYER', amount: 5 },
],
rewards: null,
};
const monsterHitLine = 'Ash Rat hits Aric Duskwalker for 5 damage.';
function countOccurrences(haystack: string | null, needle: string): number {
return haystack ? haystack.split(needle).length - 1 : 0;
}
describe('CombatPageComponent', () => {
let combatStore: {
combat: ReturnType<typeof signal<Combat | null>>;
loading: ReturnType<typeof signal<boolean>>;
actionPending: ReturnType<typeof signal<boolean>>;
error: ReturnType<typeof signal<string | null>>;
loadCombat: ReturnType<typeof vi.fn>;
performAction: ReturnType<typeof vi.fn>;
};
let worldStore: { refreshCharacter: ReturnType<typeof vi.fn> };
let router: Router;
async function setup(combat: Combat | null) {
combatStore = {
combat: signal(combat),
loading: signal(false),
actionPending: signal(false),
error: signal<string | null>(null),
loadCombat: vi.fn(() => Promise.resolve()),
performAction: vi.fn(() => Promise.resolve()),
};
worldStore = { refreshCharacter: vi.fn(() => Promise.resolve()) };
await TestBed.configureTestingModule({
imports: [CombatPageComponent],
providers: [
provideRouter([]),
{ provide: CombatStore, useValue: combatStore },
{ provide: WorldStore, useValue: worldStore },
{
provide: ActivatedRoute,
useValue: { snapshot: { paramMap: convertToParamMap({ combatId: 'combat-1' }) } },
},
],
}).compileComponents();
router = TestBed.inject(Router);
vi.spyOn(router, 'navigate').mockResolvedValue(true);
const fixture = TestBed.createComponent(CombatPageComponent);
fixture.detectChanges();
// The route load resolves on the microtask queue before the combat renders.
await fixture.whenStable();
fixture.detectChanges();
return fixture;
}
afterEach(() => {
vi.useRealTimers();
});
it('loads the combat from the route param on init', async () => {
await setup(activeCombat);
expect(combatStore.loadCombat).toHaveBeenCalledWith('combat-1');
});
it('shows the player, monster, HP bars, round, and the Attack action', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('Aric Duskwalker');
expect(element.textContent).toContain('95 / 100');
expect(element.textContent).toContain('Ash Rat');
expect(element.textContent).toContain('31 / 45');
expect(element.querySelector('[data-combat-round]')?.textContent).toContain('Round 2');
expect(element.querySelector('[data-combat-attack]')).toBeTruthy();
});
it('shows all five combat actions with their English labels', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-combat-attack]')?.textContent).toContain('Attack');
expect(element.querySelector('[data-combat-heavy-strike]')?.textContent).toContain('Heavy Strike');
expect(element.querySelector('[data-combat-shield-bash]')?.textContent).toContain('Shield Bash');
expect(element.querySelector('[data-combat-defend]')?.textContent).toContain('Defend');
expect(element.querySelector('[data-combat-potion]')?.textContent).toContain('Potion 2/2');
});
it('sends HEAVY_STRIKE when Heavy Strike is clicked', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-combat-heavy-strike]')?.click();
expect(combatStore.performAction).toHaveBeenCalledWith('HEAVY_STRIKE');
});
it('sends SHIELD_BASH when Shield Bash is clicked', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-combat-shield-bash]')?.click();
expect(combatStore.performAction).toHaveBeenCalledWith('SHIELD_BASH');
});
it('sends DEFEND when Defend is clicked', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-combat-defend]')?.click();
expect(combatStore.performAction).toHaveBeenCalledWith('DEFEND');
});
it('sends POTION when Potion is clicked', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-combat-potion]')?.click();
expect(combatStore.performAction).toHaveBeenCalledWith('POTION');
});
it('disables the potion button once both potions are used', async () => {
const fixture = await setup({
...activeCombat,
player: { ...activeCombat.player, potionsRemaining: 0 },
});
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector<HTMLButtonElement>('[data-combat-potion]')?.disabled).toBe(true);
expect(element.querySelector<HTMLButtonElement>('[data-combat-attack]')?.disabled).toBe(false);
});
it('shows a prominent telegraph banner when the monster has a pending Heavy Attack', async () => {
const fixture = await setup({
...activeCombat,
monster: { ...activeCombat.monster, pendingIntent: 'HEAVY_ATTACK' },
});
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-combat-telegraph]')?.textContent).toContain(
'Ash Rat is winding up a Heavy Strike.',
);
});
it('shows no telegraph banner when nothing is pending', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-combat-telegraph]')).toBeNull();
});
const veteran: Combat = {
...activeCombat,
monster: { ...activeCombat.monster, name: 'Raider Veteran' },
};
it('announces a raised guard with the rounds it still covers', async () => {
const fixture = await setup({
...veteran,
monster: { ...veteran.monster, guardRemainingRounds: 2 },
});
const banner: HTMLElement | null = fixture.nativeElement.querySelector(
'[data-combat-guard]',
);
expect(banner?.textContent).toContain('Raider Veteran');
expect(banner?.textContent).toContain('2');
});
it('says nothing about a guard when the monster is open', async () => {
const fixture = await setup(activeCombat);
expect(
fixture.nativeElement.querySelector('[data-combat-guard]'),
).toBeNull();
});
it('marks an enraged monster', async () => {
const fixture = await setup({
...activeCombat,
monster: { ...activeCombat.monster, enraged: true },
});
expect(
fixture.nativeElement.querySelector('[data-combat-enraged]'),
).not.toBeNull();
});
it('reads the new events back in the log', async () => {
const fixture = await setup({
...veteran,
events: [
{ round: 1, sequence: 1, type: 'GUARD_RAISED', source: 'MONSTER', target: 'MONSTER', amount: 2 },
{ round: 1, sequence: 2, type: 'ENRAGED', source: 'MONSTER', target: 'MONSTER' },
{ round: 2, sequence: 1, type: 'GUARD_ENDED', source: 'MONSTER', target: 'MONSTER' },
],
});
const log = (
fixture.nativeElement as HTMLElement
).querySelector('.combat__log')?.textContent;
expect(log).toContain('Raider Veteran raises its guard.');
expect(log).toContain('Raider Veteran turns savage.');
expect(log).toContain("Raider Veteran's guard drops.");
});
it('renders HEAL, DEFEND, TELEGRAPH, and INTERRUPT log lines', async () => {
const fixture = await setup({
...activeCombat,
events: [
{ round: 1, sequence: 1, type: 'HEAL', source: 'PLAYER', target: 'PLAYER', amount: 35 },
{ round: 1, sequence: 2, type: 'DEFEND', source: 'PLAYER', target: 'PLAYER' },
{ round: 1, sequence: 3, type: 'TELEGRAPH', source: 'MONSTER', target: 'PLAYER' },
{ round: 1, sequence: 4, type: 'INTERRUPT', source: 'PLAYER', target: 'MONSTER' },
],
});
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('Aric Duskwalker drinks a potion and heals 35 HP.');
expect(element.textContent).toContain('Aric Duskwalker braces to defend.');
expect(element.textContent).toContain('Ash Rat is winding up a Heavy Strike.');
expect(element.textContent).toContain("Aric Duskwalker interrupts Ash Rat's attack.");
});
it('renders the structured events as readable English combat-log entries', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('Aric Duskwalker hits Ash Rat for 14 damage.');
expect(element.textContent).toContain('Ash Rat hits Aric Duskwalker for 5 damage.');
});
it('calls combatStore.performAction("ATTACK") when Attack is clicked', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-combat-attack]')?.click();
expect(combatStore.performAction).toHaveBeenCalledWith('ATTACK');
});
it('plays the swing, reveals the monster damage, then the recoil a beat later', async () => {
const fixture = await setup(activeCombat);
const resolvedRound: Combat = {
...activeCombat,
round: 3,
player: { ...activeCombat.player, currentHp: 90 },
monster: { ...activeCombat.monster, currentHp: 17 },
events: [
...activeCombat.events,
{ round: 2, sequence: 3, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 14 },
{ round: 2, sequence: 4, type: 'DAMAGE', source: 'MONSTER', target: 'PLAYER', amount: 5 },
],
};
combatStore.performAction.mockImplementation(async () => {
combatStore.combat.set(resolvedRound);
});
vi.useFakeTimers();
const element = fixture.nativeElement as HTMLElement;
const sprite = element.querySelector('.sprite--player');
const monster = element.querySelector('.sprite--monster');
const stage = element.querySelector('.combat__stage');
element.querySelector<HTMLButtonElement>('[data-combat-attack]')?.click();
fixture.detectChanges();
expect(sprite?.classList.contains('sprite--attacking')).toBe(true);
expect(monster?.classList.contains('sprite--flinch')).toBe(false);
expect(element.textContent).toContain('31 / 45');
expect(element.textContent).toContain('95 / 100');
// Swing lands: the monster loses HP and flinches, its own loss is held back.
await vi.advanceTimersByTimeAsync(540);
fixture.detectChanges();
expect(sprite?.classList.contains('sprite--attacking')).toBe(false);
expect(monster?.classList.contains('sprite--flinch')).toBe(true);
expect(monster?.classList.contains('sprite--lunge')).toBe(false);
expect(stage?.classList.contains('combat__stage--shaken')).toBe(false);
expect(element.textContent).toContain('17 / 45');
expect(element.textContent).toContain('95 / 100');
// Only round 1's identical line is logged so far, not round 2's.
expect(countOccurrences(element.textContent, monsterHitLine)).toBe(1);
// The monster strikes back after the beat.
await vi.advanceTimersByTimeAsync(1260);
fixture.detectChanges();
expect(sprite?.classList.contains('sprite--hit')).toBe(true);
expect(monster?.classList.contains('sprite--lunge')).toBe(true);
expect(monster?.classList.contains('sprite--flinch')).toBe(false);
// 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(countOccurrences(element.textContent, monsterHitLine)).toBe(2);
await vi.advanceTimersByTimeAsync(540);
fixture.detectChanges();
expect(sprite?.classList.contains('sprite--hit')).toBe(false);
expect(monster?.classList.contains('sprite--lunge')).toBe(false);
expect(stage?.classList.contains('combat__stage--shaken')).toBe(false);
});
it('reveals the telegraph banner only after the reply beat, and never lunges for it', async () => {
const fixture = await setup(activeCombat);
const telegraphed: Combat = {
...activeCombat,
round: 3,
events: [
...activeCombat.events,
{ round: 2, sequence: 3, type: 'DEFEND', source: 'PLAYER', target: 'PLAYER' },
{ round: 2, sequence: 4, type: 'TELEGRAPH', source: 'MONSTER', target: 'PLAYER' },
],
monster: { ...activeCombat.monster, pendingIntent: 'HEAVY_ATTACK' },
};
combatStore.performAction.mockImplementation(async () => {
combatStore.combat.set(telegraphed);
});
vi.useFakeTimers();
const element = fixture.nativeElement as HTMLElement;
const monster = element.querySelector('.sprite--monster');
element.querySelector<HTMLButtonElement>('[data-combat-defend]')?.click();
fixture.detectChanges();
await vi.advanceTimersByTimeAsync(540);
fixture.detectChanges();
expect(element.querySelector('[data-combat-telegraph]')).toBeNull();
await vi.advanceTimersByTimeAsync(1260);
fixture.detectChanges();
expect(element.querySelector('[data-combat-telegraph]')?.textContent).toContain('is winding up a Heavy Strike');
expect(monster?.classList.contains('sprite--lunge')).toBe(false);
});
it('shows the INTERRUPT log line immediately and skips the lunge when SHIELD_BASH interrupts', async () => {
const fixture = await setup({
...activeCombat,
monster: { ...activeCombat.monster, pendingIntent: 'HEAVY_ATTACK' },
});
const interrupted: Combat = {
...activeCombat,
round: 3,
monster: { ...activeCombat.monster, currentHp: 21, pendingIntent: null },
events: [
...activeCombat.events,
{ round: 2, sequence: 3, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 10 },
{ round: 2, sequence: 4, type: 'INTERRUPT', source: 'PLAYER', target: 'MONSTER' },
],
};
combatStore.performAction.mockImplementation(async () => {
combatStore.combat.set(interrupted);
});
vi.useFakeTimers();
const element = fixture.nativeElement as HTMLElement;
const monster = element.querySelector('.sprite--monster');
element.querySelector<HTMLButtonElement>('[data-combat-shield-bash]')?.click();
await vi.advanceTimersByTimeAsync(540);
fixture.detectChanges();
expect(element.textContent).toContain("Aric Duskwalker interrupts Ash Rat's attack.");
expect(element.querySelector('[data-combat-telegraph]')).toBeNull();
expect(monster?.classList.contains('sprite--lunge')).toBe(false);
});
it('shows the potion heal at the first checkpoint instead of waiting for the riposte reveal', async () => {
const fixture = await setup({
...activeCombat,
player: { ...activeCombat.player, currentHp: 70 },
});
const healed: Combat = {
...activeCombat,
round: 3,
player: { ...activeCombat.player, currentHp: 80, potionsRemaining: 1 },
events: [
...activeCombat.events,
{ round: 2, sequence: 3, type: 'HEAL', source: 'PLAYER', target: 'PLAYER', amount: 15 },
{ round: 2, sequence: 4, type: 'DAMAGE', source: 'MONSTER', target: 'PLAYER', amount: 5 },
],
};
combatStore.performAction.mockImplementation(async () => {
combatStore.combat.set(healed);
});
vi.useFakeTimers();
const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-combat-potion]')?.click();
fixture.detectChanges();
// Before the checkpoint: the pre-heal HP and potion count still show.
expect(element.textContent).toContain('70 / 100');
expect(element.querySelector('[data-combat-potion]')?.textContent).toContain('Potion 2/2');
// First checkpoint: the heal already landed from the player's own action,
// so the HP bar and potion count update here -- well before the monster's
// held-back reply resolves.
await vi.advanceTimersByTimeAsync(540);
fixture.detectChanges();
expect(element.textContent).toContain('85 / 100');
expect(element.querySelector('[data-combat-potion]')?.textContent).toContain('Potion 1/2');
expect(element.textContent).toContain('Aric Duskwalker drinks a potion and heals 15 HP.');
// The monster's reply is still held back at this point.
expect(countOccurrences(element.textContent, monsterHitLine)).toBe(1);
await vi.advanceTimersByTimeAsync(1260);
fixture.detectChanges();
expect(element.textContent).toContain('80 / 100');
});
it('skips the recoil when the round ends without the monster striking back', async () => {
const fixture = await setup(activeCombat);
const won: Combat = {
...activeCombat,
status: 'WON',
monster: { ...activeCombat.monster, currentHp: 0 },
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' },
],
};
combatStore.performAction.mockImplementation(async () => {
combatStore.combat.set(won);
});
vi.useFakeTimers();
const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-combat-attack]')?.click();
await vi.advanceTimersByTimeAsync(540);
fixture.detectChanges();
const monster = element.querySelector('.sprite--monster');
expect(element.querySelector('.sprite--player')?.classList.contains('sprite--hit')).toBe(false);
// The killing blow still registers on the monster, it just never lunges back.
expect(monster?.classList.contains('sprite--flinch')).toBe(true);
expect(monster?.classList.contains('sprite--lunge')).toBe(false);
expect(element.querySelector('[data-combat-result="WON"]')).toBeTruthy();
expect(element.textContent).toContain('0 / 45');
await vi.advanceTimersByTimeAsync(800);
fixture.detectChanges();
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.performAction.mockImplementation(async () => {
combatStore.combat.set({
...activeCombat,
status: 'WON',
monster: { ...activeCombat.monster, currentHp: 0 },
rewards: { items: [], capacities: FULL_CAPACITIES },
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 Attack while an action is pending', async () => {
const fixture = await setup(activeCombat);
combatStore.actionPending.set(true);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector<HTMLButtonElement>('[data-combat-attack]')?.disabled).toBe(true);
});
it('shows the victory state and hides Attack when the combat is WON', async () => {
const fixture = await setup({ ...activeCombat, status: 'WON' });
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-combat-result="WON"]')).toBeTruthy();
expect(element.textContent).toContain('Victory');
expect(element.querySelector('[data-combat-attack]')).toBeNull();
});
it('shows the defeat state and hides Attack when the combat is LOST', async () => {
const fixture = await setup({ ...activeCombat, status: 'LOST' });
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-combat-result="LOST"]')).toBeTruthy();
expect(element.textContent).toContain('Defeat');
expect(element.querySelector('[data-combat-attack]')).toBeNull();
});
it('keeps the one-click hunt loop from the victory screen', async () => {
const fixture = await setup({ ...activeCombat, status: 'WON' });
const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-combat-to-hunt]')?.click();
expect(router.navigate).toHaveBeenCalledWith(['/hunt']);
});
it('also offers the way back to the location from the victory screen', async () => {
const fixture = await setup({ ...activeCombat, status: 'WON' });
const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-combat-to-location]')?.click();
expect(router.navigate).toHaveBeenCalledWith(['/location']);
});
it('offers the same two ways out after a defeat', async () => {
const fixture = await setup({ ...activeCombat, status: 'LOST' });
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-combat-to-hunt]')).not.toBeNull();
element.querySelector<HTMLButtonElement>('[data-combat-to-location]')?.click();
expect(router.navigate).toHaveBeenCalledWith(['/location']);
});
it('navigates to /inventory from the victory screen', async () => {
const fixture = await setup({ ...activeCombat, status: 'WON' });
const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-combat-to-inventory]')?.click();
expect(router.navigate).toHaveBeenCalledWith(['/inventory']);
});
it('shows an error and retries loading the combat', async () => {
const fixture = await setup(null);
combatStore.error.set('This combat could not be found.');
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[role="alert"]')?.textContent).toContain(
'This combat could not be found.',
);
element.querySelector<HTMLButtonElement>('[data-combat-retry]')?.click();
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: { items: [], capacities: FULL_CAPACITIES },
};
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();
// 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('separates trade goods, equipment, and consumables in the loot summary', async () => {
const fixture = await setup({
...wonWithRewards,
rewards: { items: [banditBlade, ashenPelt, healingPotion], capacities: FULL_CAPACITIES },
});
const element = fixture.nativeElement as HTMLElement;
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: { items: [banditBlade], capacities: FULL_CAPACITIES },
});
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('Bandit Blade');
expect(element.querySelector('[data-item-rarity]')?.textContent).toContain('Common');
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(
'No notable loot found.',
);
expect(element.querySelector('[role="alert"]')).toBeNull();
});
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: { items: [ashenPelt], capacities: FULL_CAPACITIES },
});
const element = fixture.nativeElement as HTMLElement;
expect(combatStore.loadCombat).toHaveBeenCalledWith('combat-1');
expect(element.querySelector('[data-item-name]')?.textContent).toContain('Ashen Pelt');
});
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();
});
});