927 lines
35 KiB
TypeScript
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();
|
|
});
|
|
});
|