diff --git a/apps/web/src/app/core/api/game-api.models.ts b/apps/web/src/app/core/api/game-api.models.ts index c951c3a..3fd4052 100644 --- a/apps/web/src/app/core/api/game-api.models.ts +++ b/apps/web/src/app/core/api/game-api.models.ts @@ -170,7 +170,10 @@ export type CombatEventType = | 'STATUS_DAMAGE' | 'STATUS_EXPIRED' | 'COMBAT_WON' - | 'COMBAT_LOST'; + | 'COMBAT_LOST' + | 'GUARD_RAISED' + | 'GUARD_ENDED' + | 'ENRAGED'; export type StatusEffectType = 'BLEED'; export type CombatSide = 'PLAYER' | 'MONSTER'; export type CombatAction = 'ATTACK' | 'HEAVY_STRIKE' | 'SHIELD_BASH' | 'DEFEND' | 'POTION'; @@ -209,6 +212,9 @@ export interface CombatMonster { currentHp: number; artworkPath: string; pendingIntent: CombatMonsterIntent | null; + /** Rounds the monster's raised guard still covers, or null when open. */ + guardRemainingRounds: number | null; + enraged: boolean; } export interface Combat { diff --git a/apps/web/src/app/core/resume-combat.spec.ts b/apps/web/src/app/core/resume-combat.spec.ts index 7fbc9bc..9134561 100644 --- a/apps/web/src/app/core/resume-combat.spec.ts +++ b/apps/web/src/app/core/resume-combat.spec.ts @@ -25,6 +25,8 @@ const runningCombat: Combat = { currentHp: 30, artworkPath: '/images/enemies/RoadBandit.png', pendingIntent: null, + guardRemainingRounds: null, + enraged: false, }, events: [], rewards: null, diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.html b/apps/web/src/app/features/combat/combat-page/combat-page.component.html index f7a8c61..fa8831b 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.html +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.html @@ -69,6 +69,16 @@

{{ intent }}

} + @if (monsterGuardLabel(); as guard) { +

{{ guard }}

+ } + + @if (monsterIsEnraged()) { +

+ {{ combat.monster.name }} is enraged. +

+ } +
{ 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, diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.ts b/apps/web/src/app/features/combat/combat-page/combat-page.component.ts index d554fc2..d424e97 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.ts +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.ts @@ -323,6 +323,20 @@ export class CombatPageComponent implements OnInit { return `${combat.monster.name} is winding up a Heavy Strike.`; } + protected monsterGuardLabel(): string | null { + const combat = this.displayed(); + const rounds = combat?.monster.guardRemainingRounds; + if (!combat || !rounds) { + return null; + } + const roundWord = rounds === 1 ? 'round' : 'rounds'; + return `${combat.monster.name} is covering — ${rounds} ${roundWord}. Shield Bash breaks it.`; + } + + protected monsterIsEnraged(): boolean { + return this.displayed()?.monster.enraged ?? false; + } + protected logRounds(): CombatLogRound[] { const combat = this.displayed(); if (!combat) { @@ -366,6 +380,18 @@ export class CombatPageComponent implements OnInit { return `${playerName} interrupts ${monsterName}'s attack.`; } + if (event.type === 'GUARD_RAISED') { + return `${monsterName} raises its guard.`; + } + + if (event.type === 'GUARD_ENDED') { + return `${monsterName}'s guard drops.`; + } + + if (event.type === 'ENRAGED') { + return `${monsterName} turns savage.`; + } + const effect = event.statusEffect ? STATUS_EFFECT_LABELS[event.statusEffect] : 'An effect'; if (event.type === 'STATUS_APPLIED') { diff --git a/apps/web/src/app/features/combat/combat.store.spec.ts b/apps/web/src/app/features/combat/combat.store.spec.ts index ead0e41..e46093c 100644 --- a/apps/web/src/app/features/combat/combat.store.spec.ts +++ b/apps/web/src/app/features/combat/combat.store.spec.ts @@ -26,6 +26,8 @@ const startedCombat: Combat = { currentHp: 45, artworkPath: '/images/monsters/ash-rat.png', pendingIntent: null, + guardRemainingRounds: null, + enraged: false, }, events: [], rewards: null, diff --git a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts index 2ae54bc..54c9f16 100644 --- a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts +++ b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts @@ -88,6 +88,8 @@ const startedCombat: Combat = { currentHp: 75, artworkPath: '/images/enemies/RoadBandit.png', pendingIntent: null, + guardRemainingRounds: null, + enraged: false, }, events: [], rewards: null,