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 @@
{
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,