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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user