feat(combat): add the five-action bar, telegraph banner, and generalized round animation

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-20 22:19:13 +02:00
parent b8d00278b8
commit 0126d1dea1
4 changed files with 270 additions and 21 deletions

View File

@@ -11,7 +11,7 @@ const activeCombat: Combat = {
id: 'combat-1',
status: 'ACTIVE',
round: 2,
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 95 },
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 95, potionsRemaining: 2, potionsMax: 2 },
monster: {
key: 'ash-rat',
name: 'Aschenratte',
@@ -19,6 +19,7 @@ const activeCombat: Combat = {
maxHp: 45,
currentHp: 31,
artworkPath: '/images/monsters/ash-rat.png',
pendingIntent: null,
},
events: [
{ round: 1, sequence: 1, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 14 },
@@ -40,7 +41,7 @@ describe('CombatPageComponent', () => {
actionPending: ReturnType<typeof signal<boolean>>;
error: ReturnType<typeof signal<string | null>>;
loadCombat: ReturnType<typeof vi.fn>;
attack: ReturnType<typeof vi.fn>;
performAction: ReturnType<typeof vi.fn>;
};
let worldStore: { refreshCharacter: ReturnType<typeof vi.fn> };
let router: Router;
@@ -52,7 +53,7 @@ describe('CombatPageComponent', () => {
actionPending: signal(false),
error: signal<string | null>(null),
loadCombat: vi.fn(() => Promise.resolve()),
attack: vi.fn(() => Promise.resolve()),
performAction: vi.fn(() => Promise.resolve()),
};
worldStore = { refreshCharacter: vi.fn(() => Promise.resolve()) };
@@ -102,6 +103,82 @@ describe('CombatPageComponent', () => {
expect(element.querySelector('[data-combat-attack]')).toBeTruthy();
});
it('shows all five combat actions with their German labels', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-combat-attack]')?.textContent).toContain('Angriff');
expect(element.querySelector('[data-combat-heavy-strike]')?.textContent).toContain('Schwerer Hieb');
expect(element.querySelector('[data-combat-shield-bash]')?.textContent).toContain('Schildstoß');
expect(element.querySelector('[data-combat-defend]')?.textContent).toContain('Verteidigen');
expect(element.querySelector('[data-combat-potion]')?.textContent).toContain('Trank 2/2');
});
it('sends the matching action for each of the four new buttons', 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');
element.querySelector<HTMLButtonElement>('[data-combat-shield-bash]')?.click();
expect(combatStore.performAction).toHaveBeenCalledWith('SHIELD_BASH');
element.querySelector<HTMLButtonElement>('[data-combat-defend]')?.click();
expect(combatStore.performAction).toHaveBeenCalledWith('DEFEND');
element.querySelector<HTMLButtonElement>('[data-combat-potion]')?.click();
expect(combatStore.performAction).toHaveBeenCalledWith('POTION');
});
it('disables the potion button once both potions are used', async () => {
const fixture = await setup({
...activeCombat,
player: { ...activeCombat.player, potionsRemaining: 0 },
});
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector<HTMLButtonElement>('[data-combat-potion]')?.disabled).toBe(true);
expect(element.querySelector<HTMLButtonElement>('[data-combat-attack]')?.disabled).toBe(false);
});
it('shows a prominent telegraph banner when the monster has a pending Heavy Attack', async () => {
const fixture = await setup({
...activeCombat,
monster: { ...activeCombat.monster, pendingIntent: 'HEAVY_ATTACK' },
});
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-combat-telegraph]')?.textContent).toContain(
'Aschenratte bereitet Schweren Hieb vor.',
);
});
it('shows no telegraph banner when nothing is pending', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-combat-telegraph]')).toBeNull();
});
it('renders HEAL, DEFEND, TELEGRAPH, and INTERRUPT log lines', async () => {
const fixture = await setup({
...activeCombat,
events: [
{ round: 1, sequence: 1, type: 'HEAL', source: 'PLAYER', target: 'PLAYER', amount: 35 },
{ round: 1, sequence: 2, type: 'DEFEND', source: 'PLAYER', target: 'PLAYER' },
{ round: 1, sequence: 3, type: 'TELEGRAPH', source: 'MONSTER', target: 'PLAYER' },
{ round: 1, sequence: 4, type: 'INTERRUPT', source: 'PLAYER', target: 'MONSTER' },
],
});
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('Aric Duskwalker trinkt einen Trank und heilt 35 Lebenspunkte.');
expect(element.textContent).toContain('Aric Duskwalker geht in die Verteidigung.');
expect(element.textContent).toContain('Aschenratte bereitet Schweren Hieb vor.');
expect(element.textContent).toContain('Aric Duskwalker unterbricht den vorbereiteten Angriff von Aschenratte.');
});
it('renders the structured events as readable German combat-log entries', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
@@ -110,13 +187,13 @@ describe('CombatPageComponent', () => {
expect(element.textContent).toContain('Aschenratte trifft Aric Duskwalker für 5 Schaden.');
});
it('calls combatStore.attack() when Angriff is clicked', async () => {
it('calls combatStore.performAction("ATTACK") when Angriff is clicked', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-combat-attack]')?.click();
expect(combatStore.attack).toHaveBeenCalledOnce();
expect(combatStore.performAction).toHaveBeenCalledWith('ATTACK');
});
it('plays the swing, reveals the monster damage, then the recoil a beat later', async () => {
@@ -132,7 +209,7 @@ describe('CombatPageComponent', () => {
{ round: 2, sequence: 4, type: 'DAMAGE', source: 'MONSTER', target: 'PLAYER', amount: 5 },
],
};
combatStore.attack.mockImplementation(async () => {
combatStore.performAction.mockImplementation(async () => {
combatStore.combat.set(resolvedRound);
});
vi.useFakeTimers();
@@ -179,6 +256,70 @@ describe('CombatPageComponent', () => {
expect(stage?.classList.contains('combat__stage--shaken')).toBe(false);
});
it('reveals the telegraph banner only after the reply beat, and never lunges for it', async () => {
const fixture = await setup(activeCombat);
const telegraphed: Combat = {
...activeCombat,
round: 3,
events: [
...activeCombat.events,
{ round: 2, sequence: 3, type: 'DEFEND', source: 'PLAYER', target: 'PLAYER' },
{ round: 2, sequence: 4, type: 'TELEGRAPH', source: 'MONSTER', target: 'PLAYER' },
],
monster: { ...activeCombat.monster, pendingIntent: 'HEAVY_ATTACK' },
};
combatStore.performAction.mockImplementation(async () => {
combatStore.combat.set(telegraphed);
});
vi.useFakeTimers();
const element = fixture.nativeElement as HTMLElement;
const monster = element.querySelector('.sprite--monster');
element.querySelector<HTMLButtonElement>('[data-combat-defend]')?.click();
fixture.detectChanges();
await vi.advanceTimersByTimeAsync(540);
fixture.detectChanges();
expect(element.querySelector('[data-combat-telegraph]')).toBeNull();
await vi.advanceTimersByTimeAsync(1260);
fixture.detectChanges();
expect(element.querySelector('[data-combat-telegraph]')?.textContent).toContain('bereitet Schweren Hieb vor');
expect(monster?.classList.contains('sprite--lunge')).toBe(false);
});
it('shows the INTERRUPT log line immediately and skips the lunge when SHIELD_BASH interrupts', async () => {
const fixture = await setup({
...activeCombat,
monster: { ...activeCombat.monster, pendingIntent: 'HEAVY_ATTACK' },
});
const interrupted: Combat = {
...activeCombat,
round: 3,
monster: { ...activeCombat.monster, currentHp: 21, pendingIntent: null },
events: [
...activeCombat.events,
{ round: 2, sequence: 3, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 10 },
{ round: 2, sequence: 4, type: 'INTERRUPT', source: 'PLAYER', target: 'MONSTER' },
],
};
combatStore.performAction.mockImplementation(async () => {
combatStore.combat.set(interrupted);
});
vi.useFakeTimers();
const element = fixture.nativeElement as HTMLElement;
const monster = element.querySelector('.sprite--monster');
element.querySelector<HTMLButtonElement>('[data-combat-shield-bash]')?.click();
await vi.advanceTimersByTimeAsync(540);
fixture.detectChanges();
expect(element.textContent).toContain('Aric Duskwalker unterbricht den vorbereiteten Angriff von Aschenratte.');
expect(element.querySelector('[data-combat-telegraph]')).toBeNull();
expect(monster?.classList.contains('sprite--lunge')).toBe(false);
});
it('skips the recoil when the round ends without the monster striking back', async () => {
const fixture = await setup(activeCombat);
const won: Combat = {
@@ -191,7 +332,7 @@ describe('CombatPageComponent', () => {
{ round: 2, sequence: 4, type: 'COMBAT_WON', source: 'PLAYER', target: 'MONSTER' },
],
};
combatStore.attack.mockImplementation(async () => {
combatStore.performAction.mockImplementation(async () => {
combatStore.combat.set(won);
});
vi.useFakeTimers();
@@ -216,7 +357,7 @@ describe('CombatPageComponent', () => {
it('refreshes the character from the server once a combat is won', async () => {
const fixture = await setup(activeCombat);
combatStore.attack.mockImplementation(async () => {
combatStore.performAction.mockImplementation(async () => {
combatStore.combat.set({
...activeCombat,
status: 'WON',