hunt-page.component.spec.ts and resume-combat.spec.ts each build a direct type-annotated Combat literal that the new required CombatPlayer/CombatMonster fields would break at compile time. Verified with an isolated tsc --strict probe, and used the same probe to confirm the combat.service.spec.ts `as Combat` fixtures do NOT break (structural widening through the type assertion), so no change needed there. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
77 KiB
Playable Slice 0.6 — Full First Combat Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the technical ATTACK-only fight with five real combat actions (ATTACK, HEAVY_STRIKE, SHIELD_BASH, DEFEND, POTION), a deterministic monster telegraph/interrupt mechanic, and a combat UI that shows all of it.
Architecture: The combat engine (CombatEngineService) grows from a single resolveAttack method into a dispatcher over five resolvers that all funnel through one shared finishRound/resolveMonsterTurn pair, so every action gets the same win/loss/monster-turn handling. Monster "intent" (a pending Heavy Attack) and the player's potion count are carried as extra fields on the existing playerState/monsterState JSONB columns — no new columns, only new enum values for CombatEventType. The web combat page generalizes its existing swing/riposte animation to replay whatever events the server actually returned instead of assuming attack-then-counterattack.
Tech Stack: NestJS + TypeORM + PostgreSQL (API), Angular (signals, standalone components) + Vitest (Web), Jest (API).
Spec: docs/playable-slices/Ashen_Realms_Playable_Slice_0.6_Full_First_Combat.md
Global Constraints
- ATTACK = 100% damage, HEAVY_STRIKE = 160%, SHIELD_BASH = 70% (spec §2).
- DEFEND halves incoming damage for the round it's used (spec §2).
- POTION heals 35% of max HP, capped at max HP, uses the round's action (spec §2).
- Combat bag = exactly 2 potions per combat, tracked server-side (spec §3).
- The monster telegraphs a Heavy Attack instead of attacking normally, then resolves it the following round unless interrupted (spec §6). Design decision (user-approved): telegraph fires deterministically when
round % 3 === 0— not RNG — because spec §10 requires combat to stay deterministic. - The monster's resolved Heavy Attack uses the same 160% multiplier as the player's HEAVY_STRIKE (user-approved).
- SHIELD_BASH interrupts a prepared (pending) enemy action (spec §2, §6).
- The server is the sole authority on action validity, damage, healing, potion count, defend mitigation, interrupt success, enemy intent, monster action, and combat result — the client only ever sends the chosen action enum (spec §8).
- Do not implement: bleeding, poison, mana, crit, dodge, block chance, cooldowns, sets, boss phases, flee, elemental damage, resistances (spec §9).
- A killed monster never resolves a pending attack (spec §10).
- Icon assets: no bespoke art exists for HEAVY_STRIKE/SHIELD_BASH/DEFEND. This plan reuses
/images/hud/runtime/AttackIcon-96.pngfor the two melee actions,/images/hud/runtime/CharacterIcon-128.pngfor SHIELD_BASH/DEFEND, and the existing/images/items/small-healing-potion.pngfor POTION — all already-shipped assets. Swap these later if bespoke art arrives; that's out of scope here.
Task 1: Damage multiplier support
Files:
- Modify:
apps/api/src/combat/combat-damage.ts - Test:
apps/api/src/combat/combat-damage.spec.ts
Interfaces:
-
Produces:
calculateDamage(attacker: DamageAttacker, targetArmor: number, multiplier?: number): number—multiplierdefaults to1, applied to the mitigated damage before the existing round + min-1 floor. All later engine tasks call this with1,1.6,0.7, or a defend-combined multiplier. -
Step 1: Add failing tests for the multiplier
Add to the end of apps/api/src/combat/combat-damage.spec.ts (inside the existing describe('calculateDamage', ...) block, after the last it):
it('applies a damage multiplier before the minimum-1 floor', () => {
// raw = 27; mitigated = 20.25; *1.6 = 32.4 -> rounds to 32
expect(calculateDamage({ attack: 12, weaponDamage: 15 }, 20, 1.6)).toBe(32);
});
it('still floors at 1 damage even with a small multiplier', () => {
expect(calculateDamage({ attack: 1, weaponDamage: 0 }, 100_000, 0.5)).toBe(1);
});
- Step 2: Run to verify the new tests fail
Run: npm run test --workspace=@ashen-realms/api -- combat-damage.spec.ts
Expected: FAIL — calculateDamage doesn't accept a third argument yet (TypeScript will actually fail to compile since the call site passes an extra arg to a function that doesn't declare it).
- Step 3: Implement the multiplier
Replace the full contents of apps/api/src/combat/combat-damage.ts:
export interface DamageAttacker {
attack: number;
weaponDamage?: number;
}
const ARMOR_MITIGATION_CONSTANT = 60;
export function calculateDamage(
attacker: DamageAttacker,
targetArmor: number,
multiplier = 1,
): number {
const rawDamage = attacker.attack + (attacker.weaponDamage ?? 0);
const mitigatedDamage =
(rawDamage * ARMOR_MITIGATION_CONSTANT) / (ARMOR_MITIGATION_CONSTANT + targetArmor);
return Math.max(1, Math.round(mitigatedDamage * multiplier));
}
- Step 4: Run to verify all combat-damage tests pass
Run: npm run test --workspace=@ashen-realms/api -- combat-damage.spec.ts
Expected: PASS (5 tests)
- Step 5: Commit
git add apps/api/src/combat/combat-damage.ts apps/api/src/combat/combat-damage.spec.ts
git commit -m "feat(combat): support a damage multiplier in calculateDamage"
Task 2: Combat action & event-type enums
Files:
- Modify:
apps/api/src/combat/combat-action.enum.ts - Modify:
apps/api/src/combat/combat-event-type.enum.ts - Modify:
apps/api/src/combat/combat.controller.spec.ts:80-87
Interfaces:
- Produces:
CombatActionnow has membersATTACK | HEAVY_STRIKE | SHIELD_BASH | DEFEND | POTION.CombatEventTypenow has membersDAMAGE | HEAL | DEFEND | TELEGRAPH | INTERRUPT | COMBAT_WON | COMBAT_LOST. Both are consumed by Task 4 (engine types), Task 5 (engine), and Task 7 (service).
This task has no new tests of its own — extending CombatAction immediately changes the behavior of an existing test in combat.controller.spec.ts (the DTO's @IsEnum(CombatAction) validator now accepts 'HEAVY_STRIKE'), so fixing that existing test is this task's verification step.
- Step 1: Extend
CombatAction
Replace the full contents of apps/api/src/combat/combat-action.enum.ts:
export enum CombatAction {
ATTACK = 'ATTACK',
HEAVY_STRIKE = 'HEAVY_STRIKE',
SHIELD_BASH = 'SHIELD_BASH',
DEFEND = 'DEFEND',
POTION = 'POTION',
}
- Step 2: Extend
CombatEventType
Replace the full contents of apps/api/src/combat/combat-event-type.enum.ts:
export enum CombatEventType {
DAMAGE = 'DAMAGE',
HEAL = 'HEAL',
DEFEND = 'DEFEND',
TELEGRAPH = 'TELEGRAPH',
INTERRUPT = 'INTERRUPT',
COMBAT_WON = 'COMBAT_WON',
COMBAT_LOST = 'COMBAT_LOST',
}
- Step 3: Run the full API test suite to see what broke
Run: npm run test --workspace=@ashen-realms/api
Expected: FAIL — combat.controller.spec.ts's 'rejects an unknown action value' test now gets 201 instead of the expected 400, because 'HEAVY_STRIKE' is a real enum member.
- Step 4: Fix the stale controller test
In apps/api/src/combat/combat.controller.spec.ts, find this test (around line 80):
it('rejects an unknown action value', async () => {
await request(app.getHttpServer())
.post('/api/combats/combat-1/actions')
.send({ action: 'HEAVY_STRIKE' })
.expect(400);
expect(performAction).not.toHaveBeenCalled();
});
Replace it with (using FLEE, a spec §9 non-goal, so this stays a genuinely-invalid action even as future slices add real ones):
it('rejects an unknown action value', async () => {
await request(app.getHttpServer())
.post('/api/combats/combat-1/actions')
.send({ action: 'FLEE' })
.expect(400);
expect(performAction).not.toHaveBeenCalled();
});
- Step 5: Run the full API test suite to confirm it's green again
Run: npm run test --workspace=@ashen-realms/api
Expected: PASS (same total count as before Step 1, since Task 1 already passed)
- Step 6: Commit
git add apps/api/src/combat/combat-action.enum.ts apps/api/src/combat/combat-event-type.enum.ts apps/api/src/combat/combat.controller.spec.ts
git commit -m "feat(combat): add the Slice 0.6 action and event-type enum members"
Task 3: Migration — extend combat_event_type_enum
Files:
- Create:
apps/api/src/database/migrations/1790000000000-ExtendCombatEventTypes.ts - Create:
apps/api/src/database/migrations/extend-combat-event-types.migration.spec.ts
Interfaces:
-
Consumes:
CombatEventTypefrom Task 2 (apps/api/src/combat/combat-event-type.enum.ts). -
Produces: the Postgres type
combat_event_type_enum(already created by1788100000000-CreateCombatSystem.ts) gainsHEAL,DEFEND,TELEGRAPH,INTERRUPT. Nothing in later tasks depends on this migration having run against a live database — the project's Jest suites use an in-memory fake repository (seecombat.service.spec.ts), never a real Postgres connection — so this task is verified the same way the existingequipment.migration.spec.tsverifies its migration: by asserting the TypeORM entity metadata matches, not by executing SQL against a live database. -
Step 1: Write the failing metadata spec
Create apps/api/src/database/migrations/extend-combat-event-types.migration.spec.ts:
import 'reflect-metadata';
import { getMetadataArgsStorage } from 'typeorm';
import { CombatEvent } from '../../combat/entities/combat-event.entity';
import { CombatEventType } from '../../combat/combat-event-type.enum';
describe('combat_events.type enum', () => {
it('includes the Playable Slice 0.6 event types', () => {
const metadata = getMetadataArgsStorage();
const column = metadata.columns.find(
(candidate) => candidate.target === CombatEvent && candidate.propertyName === 'type',
);
expect(column).toBeDefined();
expect(column?.options.enum).toBe(CombatEventType);
expect(Object.values(CombatEventType)).toEqual(
expect.arrayContaining([
'DAMAGE',
'HEAL',
'DEFEND',
'TELEGRAPH',
'INTERRUPT',
'COMBAT_WON',
'COMBAT_LOST',
]),
);
});
});
- Step 2: Run to verify it currently fails
Run: npm run test --workspace=@ashen-realms/api -- extend-combat-event-types.migration.spec.ts
Expected: PASS already, actually — Task 2 already updated the CombatEventType enum object, and the entity decorator (combat-event.entity.ts) references that same enum object by reference, so this spec is really confirming Task 2 didn't accidentally diverge the entity from the enum. If it fails, the entity's enum: option isn't pointing at the same CombatEventType import — that would be a bug to fix in apps/api/src/combat/entities/combat-event.entity.ts, not in this migration.
- Step 3: Write the migration
Create apps/api/src/database/migrations/1790000000000-ExtendCombatEventTypes.ts:
import { MigrationInterface, QueryRunner } from 'typeorm';
export class ExtendCombatEventTypes1790000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TYPE "combat_event_type_enum" ADD VALUE 'HEAL'`);
await queryRunner.query(`ALTER TYPE "combat_event_type_enum" ADD VALUE 'DEFEND'`);
await queryRunner.query(`ALTER TYPE "combat_event_type_enum" ADD VALUE 'TELEGRAPH'`);
await queryRunner.query(`ALTER TYPE "combat_event_type_enum" ADD VALUE 'INTERRUPT'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Postgres has no "DROP VALUE"; rebuild the type from scratch instead.
// This fails if any row already uses one of the new values -- expected
// for a dev rollback, same tradeoff Postgres migrations always make here.
await queryRunner.query(
`ALTER TABLE "combat_events" ALTER COLUMN "type" TYPE varchar USING "type"::text`,
);
await queryRunner.query(`DROP TYPE "combat_event_type_enum"`);
await queryRunner.query(
`CREATE TYPE "combat_event_type_enum" AS ENUM ('DAMAGE', 'COMBAT_WON', 'COMBAT_LOST')`,
);
await queryRunner.query(
`ALTER TABLE "combat_events" ALTER COLUMN "type" TYPE "combat_event_type_enum" USING "type"::"combat_event_type_enum"`,
);
}
}
- Step 4: Run the migration spec again
Run: npm run test --workspace=@ashen-realms/api -- extend-combat-event-types.migration.spec.ts
Expected: PASS (1 test)
- Step 5: Commit
git add apps/api/src/database/migrations/1790000000000-ExtendCombatEventTypes.ts apps/api/src/database/migrations/extend-combat-event-types.migration.spec.ts
git commit -m "feat(combat): migrate combat_event_type_enum for Slice 0.6 event types"
Task 4: Engine types & entity state
Files:
- Modify:
apps/api/src/combat/combat-engine.types.ts - Modify:
apps/api/src/combat/entities/combat.entity.ts:16-23
Interfaces:
- Consumes: nothing new (pure type changes).
- Produces:
CombatIntent = 'HEAVY_ATTACK'(exported fromcombat-engine.types.ts).CombatEngineCombatantStatsgains optionalpotionsRemaining?: numberandpendingAction?: CombatIntent.CombatCombatantState(entity) gains optionalpendingAction?: CombatIntent;CombatPlayerStategains requiredpotionsRemaining: number. Task 5 (engine) and Task 7 (service DTOs) both depend on these exact field names.
This task is pure types with no runtime behavior, so there's no dedicated new test — its correctness is verified by the whole project still compiling and the existing suite staying green. (Pre-flight check: the pre-existing { ... } as Combat fixtures in combat.service.spec.ts that build playerState without potionsRemaining were checked against this change with an isolated tsc --strict probe — a type assertion's structural-widening direction accepts them since Combat is assignable to the fixture's narrower inferred type, so nothing there needs touching. Direct type-annotated literals elsewhere are a different story — see Task 8's pre-flight ruling.)
- Step 1: Extend the engine types
Replace the full contents of apps/api/src/combat/combat-engine.types.ts:
import { Combatant } from './combatant.enum';
import { CombatAction } from './combat-action.enum';
import { CombatEventType } from './combat-event-type.enum';
import { CombatStatus } from './combat-status.enum';
// Only HEAVY_ATTACK needs telegraphing today; NORMAL_ATTACK resolves
// immediately and is never held as a pending intent (Playable Slice 0.6
// spec §5). Add members here as future slices add more prepared actions.
export type CombatIntent = 'HEAVY_ATTACK';
export interface CombatEngineCombatantStats {
attack: number;
weaponDamage?: number;
armor: number;
// Player-only: seeded at combat start, decremented by POTION. Optional
// because the monster's stats never carry it.
potionsRemaining?: number;
// Monster-only: set when it telegraphs, cleared when the action resolves
// or is interrupted. Optional because the player's stats never carry it.
pendingAction?: CombatIntent;
}
export interface CombatEngineCombatant {
currentHp: number;
maxHp: number;
stats: CombatEngineCombatantStats;
}
export interface CombatEngineState {
status: CombatStatus;
round: number;
player: CombatEngineCombatant;
monster: CombatEngineCombatant;
}
export interface CombatActionInput {
action: CombatAction;
}
export interface CombatEngineEvent {
source: Combatant;
target: Combatant;
type: CombatEventType;
amount?: number;
}
export interface CombatEngineResult {
state: CombatEngineState;
events: CombatEngineEvent[];
}
- Step 2: Extend the entity state interfaces
In apps/api/src/combat/entities/combat.entity.ts, replace lines 1-23 (the imports and the two state interfaces):
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Character } from '../../characters/entities/character.entity';
import { HuntEncounter } from '../../hunting/entities/hunt-encounter.entity';
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
import { CombatIntent } from '../combat-engine.types';
import { CombatStatus } from '../combat-status.enum';
export interface CombatCombatantState {
attack: number;
armor: number;
pendingAction?: CombatIntent;
}
export interface CombatPlayerState extends CombatCombatantState {
weaponDamage: number;
potionsRemaining: number;
}
Leave the rest of the file (the @Entity class itself) unchanged.
- Step 3: Run the full API test suite to confirm nothing broke
Run: npm run test --workspace=@ashen-realms/api
Expected: PASS — these are purely additive optional fields plus one new required field (potionsRemaining) that nothing in the existing suite constructs directly: the only Combat-typed test fixtures use { ... } as Combat, and a type assertion accepts this because Combat structurally widens into the fixture's narrower inferred type.
- Step 4: Commit
git add apps/api/src/combat/combat-engine.types.ts apps/api/src/combat/entities/combat.entity.ts
git commit -m "feat(combat): model monster intent and potion count in combat state types"
Task 5: Combat engine — implement all five actions
Files:
- Modify:
apps/api/src/combat/combat-engine.service.ts - Modify:
apps/api/src/combat/combat-engine.service.spec.ts
Interfaces:
- Consumes:
calculateDamage(attacker, targetArmor, multiplier?)(Task 1),CombatActionmembers (Task 2),CombatEventTypemembers (Task 2),CombatEngineCombatant/CombatEngineCombatantStats/CombatEngineState/CombatActionInput/CombatEngineEvent/CombatEngineResult(Task 4). - Produces:
CombatEngineService.resolveAction(state, input)now handles all 5CombatActionmembers.result.state.player.stats.potionsRemainingandresult.state.monster.stats.pendingActionare mutated as described below — Task 7 persists these back ontocombat.playerState/combat.monsterState.
This is the core mechanic of the whole slice. The existing 6 tests in combat-engine.service.spec.ts must keep passing unchanged except test 6 (the "unsupported action" test), which needs its example action swapped since HEAVY_STRIKE becomes supported.
- Step 1: Fix the "unsupported action" test's example
In apps/api/src/combat/combat-engine.service.spec.ts, find (around line 102):
it('throws UnsupportedCombatActionError for an action it does not implement', () => {
expect(() =>
engine.resolveAction(baseState(), { action: 'HEAVY_STRIKE' as CombatAction }),
).toThrow(UnsupportedCombatActionError);
});
Replace with:
it('throws UnsupportedCombatActionError for an action it does not implement', () => {
expect(() =>
engine.resolveAction(baseState(), { action: 'FLEE' as CombatAction }),
).toThrow(UnsupportedCombatActionError);
});
- Step 2: Add the new failing tests
Append to the end of apps/api/src/combat/combat-engine.service.spec.ts, just before the final closing }); of the describe('CombatEngineService', ...) block:
it('HEAVY_STRIKE deals 160% damage to the monster', () => {
const result = engine.resolveAction(baseState(), { action: CombatAction.HEAVY_STRIKE });
// raw = 14; 14 * 1.6 = 22.4 -> rounds to 22
expect(result.events[0]).toEqual({
source: Combatant.PLAYER,
target: Combatant.MONSTER,
type: CombatEventType.DAMAGE,
amount: 22,
});
expect(result.state.monster.currentHp).toBe(45 - 22);
});
it('SHIELD_BASH deals 70% damage and does not emit INTERRUPT when nothing is pending', () => {
const result = engine.resolveAction(baseState(), { action: CombatAction.SHIELD_BASH });
// raw = 14; 14 * 0.7 = 9.8 -> rounds to 10
expect(result.events[0]).toEqual({
source: Combatant.PLAYER,
target: Combatant.MONSTER,
type: CombatEventType.DAMAGE,
amount: 10,
});
expect(result.events.some((event) => event.type === CombatEventType.INTERRUPT)).toBe(false);
});
it('SHIELD_BASH interrupts a pending Heavy Attack and the monster does not act this round', () => {
const state = baseState({
monster: {
currentHp: 45,
maxHp: 45,
stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' },
},
});
const result = engine.resolveAction(state, { action: CombatAction.SHIELD_BASH });
expect(result.events).toEqual([
{ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.DAMAGE, amount: 10 },
{ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.INTERRUPT },
]);
expect(result.state.monster.stats.pendingAction).toBeUndefined();
expect(result.state.player.currentHp).toBe(100);
expect(result.state.round).toBe(2);
});
it('DEFEND deals no damage and halves the monster normal attack this round', () => {
const result = engine.resolveAction(baseState(), { action: CombatAction.DEFEND });
expect(result.state.monster.currentHp).toBe(45);
// raw = 5; mitigated = 4.545...; * 0.5 = 2.27 -> rounds to 2
expect(result.events).toEqual([
{ source: Combatant.PLAYER, target: Combatant.PLAYER, type: CombatEventType.DEFEND },
{ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.DAMAGE, amount: 2 },
]);
expect(result.state.player.currentHp).toBe(98);
});
it('POTION heals 35% of max HP, decrements potionsRemaining, and the monster still acts', () => {
const state = baseState({
player: {
currentHp: 60,
maxHp: 100,
stats: { attack: 6, weaponDamage: 8, armor: 6, potionsRemaining: 2 },
},
});
const result = engine.resolveAction(state, { action: CombatAction.POTION });
// 35% of 100 = 35
expect(result.events[0]).toEqual({
source: Combatant.PLAYER,
target: Combatant.PLAYER,
type: CombatEventType.HEAL,
amount: 35,
});
expect(result.events[1]).toEqual({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.DAMAGE,
amount: 5,
});
// 60 + 35 healed - 5 monster hit = 90
expect(result.state.player.currentHp).toBe(90);
expect(result.state.player.stats.potionsRemaining).toBe(1);
});
it('caps POTION healing at the maximum HP', () => {
const state = baseState({
player: {
currentHp: 90,
maxHp: 100,
stats: { attack: 6, weaponDamage: 8, armor: 6, potionsRemaining: 1 },
},
});
const result = engine.resolveAction(state, { action: CombatAction.POTION });
// 35% of 100 = 35, but only 10 HP is missing
expect(result.events[0]).toEqual({
source: Combatant.PLAYER,
target: Combatant.PLAYER,
type: CombatEventType.HEAL,
amount: 10,
});
});
it('telegraphs a Heavy Attack instead of attacking on the third round, and resolves it the round after', () => {
const round3State = baseState({ round: 3 });
const telegraphResult = engine.resolveAction(round3State, { action: CombatAction.ATTACK });
expect(telegraphResult.state.player.currentHp).toBe(100);
expect(telegraphResult.state.monster.stats.pendingAction).toBe('HEAVY_ATTACK');
expect(telegraphResult.events[1]).toEqual({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.TELEGRAPH,
});
expect(telegraphResult.state.round).toBe(4);
const resolveResult = engine.resolveAction(telegraphResult.state, { action: CombatAction.ATTACK });
// raw = 5; mitigated = 4.545...; heavy * 1.6 = 7.27 -> rounds to 7
expect(resolveResult.events[1]).toEqual({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.DAMAGE,
amount: 7,
});
expect(resolveResult.state.monster.stats.pendingAction).toBeUndefined();
expect(resolveResult.state.player.currentHp).toBe(100 - 7);
});
it('does not resolve a pending Heavy Attack when the monster is killed this round', () => {
const state = baseState({
monster: {
currentHp: 10,
maxHp: 45,
stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' },
},
});
const result = engine.resolveAction(state, { action: CombatAction.ATTACK });
expect(result.state.status).toBe(CombatStatus.WON);
expect(result.state.player.currentHp).toBe(100);
expect(result.events).toEqual([
{ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.DAMAGE, amount: 14 },
{ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.COMBAT_WON },
]);
});
it('is deterministic for HEAVY_STRIKE as well', () => {
const state = baseState();
const first = engine.resolveAction(state, { action: CombatAction.HEAVY_STRIKE });
const second = engine.resolveAction(state, { action: CombatAction.HEAVY_STRIKE });
expect(first).toEqual(second);
});
- Step 3: Run to verify the new tests fail
Run: npm run test --workspace=@ashen-realms/api -- combat-engine.service.spec.ts
Expected: FAIL — HEAVY_STRIKE/SHIELD_BASH/DEFEND/POTION all still throw UnsupportedCombatActionError.
- Step 4: Rewrite the engine
Replace the full contents of apps/api/src/combat/combat-engine.service.ts:
import { Injectable } from '@nestjs/common';
import { CombatAction } from './combat-action.enum';
import { calculateDamage } from './combat-damage';
import { Combatant } from './combatant.enum';
import {
CombatActionInput,
CombatEngineCombatant,
CombatEngineEvent,
CombatEngineResult,
CombatEngineState,
} from './combat-engine.types';
import { CombatEventType } from './combat-event-type.enum';
import { CombatStatus } from './combat-status.enum';
export class UnsupportedCombatActionError extends Error {
constructor(action: string) {
super(`Unsupported combat action: ${action}`);
}
}
// The monster telegraphs a Heavy Attack instead of striking every third
// round it acts, then resolves it the round after unless SHIELD_BASH
// interrupts it. A fixed cadence (not RNG) keeps combat deterministic
// (Playable Slice 0.6 spec §10/§11).
const TELEGRAPH_ROUND_INTERVAL = 3;
const HEAVY_ATTACK_MULTIPLIER = 1.6;
const SHIELD_BASH_MULTIPLIER = 0.7;
const DEFEND_MITIGATION_MULTIPLIER = 0.5;
const POTION_HEAL_FRACTION = 0.35;
@Injectable()
export class CombatEngineService {
resolveAction(state: CombatEngineState, input: CombatActionInput): CombatEngineResult {
switch (input.action) {
case CombatAction.ATTACK:
return this.resolvePlayerStrike(state, 1);
case CombatAction.HEAVY_STRIKE:
return this.resolvePlayerStrike(state, HEAVY_ATTACK_MULTIPLIER);
case CombatAction.SHIELD_BASH:
return this.resolveShieldBash(state);
case CombatAction.DEFEND:
return this.resolveDefend(state);
case CombatAction.POTION:
return this.resolvePotion(state);
default:
throw new UnsupportedCombatActionError(input.action);
}
}
private resolvePlayerStrike(state: CombatEngineState, multiplier: number): CombatEngineResult {
const player = this.cloneCombatant(state.player);
const monster = this.cloneCombatant(state.monster);
const events: CombatEngineEvent[] = [];
const damage = calculateDamage(player.stats, monster.stats.armor, multiplier);
monster.currentHp = Math.max(0, monster.currentHp - damage);
events.push({
source: Combatant.PLAYER,
target: Combatant.MONSTER,
type: CombatEventType.DAMAGE,
amount: damage,
});
return this.finishRound(state, player, monster, events, false);
}
private resolveShieldBash(state: CombatEngineState): CombatEngineResult {
const player = this.cloneCombatant(state.player);
const monster = this.cloneCombatant(state.monster);
const events: CombatEngineEvent[] = [];
const damage = calculateDamage(player.stats, monster.stats.armor, SHIELD_BASH_MULTIPLIER);
monster.currentHp = Math.max(0, monster.currentHp - damage);
events.push({
source: Combatant.PLAYER,
target: Combatant.MONSTER,
type: CombatEventType.DAMAGE,
amount: damage,
});
let interrupted = false;
if (monster.stats.pendingAction) {
monster.stats.pendingAction = undefined;
interrupted = true;
events.push({ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.INTERRUPT });
}
return this.finishRound(state, player, monster, events, false, interrupted);
}
private resolveDefend(state: CombatEngineState): CombatEngineResult {
const player = this.cloneCombatant(state.player);
const monster = this.cloneCombatant(state.monster);
const events: CombatEngineEvent[] = [
{ source: Combatant.PLAYER, target: Combatant.PLAYER, type: CombatEventType.DEFEND },
];
return this.finishRound(state, player, monster, events, true);
}
private resolvePotion(state: CombatEngineState): CombatEngineResult {
const player = this.cloneCombatant(state.player);
const monster = this.cloneCombatant(state.monster);
const rawHeal = Math.round(player.maxHp * POTION_HEAL_FRACTION);
const healed = Math.min(rawHeal, player.maxHp - player.currentHp);
player.currentHp += healed;
player.stats.potionsRemaining = (player.stats.potionsRemaining ?? 0) - 1;
const events: CombatEngineEvent[] = [
{ source: Combatant.PLAYER, target: Combatant.PLAYER, type: CombatEventType.HEAL, amount: healed },
];
return this.finishRound(state, player, monster, events, false);
}
private finishRound(
state: CombatEngineState,
player: CombatEngineCombatant,
monster: CombatEngineCombatant,
events: CombatEngineEvent[],
defended: boolean,
interrupted = false,
): CombatEngineResult {
if (monster.currentHp <= 0) {
events.push({ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.COMBAT_WON });
return { state: { ...state, player, monster, status: CombatStatus.WON }, events };
}
if (!interrupted) {
this.resolveMonsterTurn(state.round, monster, player, defended, events);
if (player.currentHp <= 0) {
events.push({ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.COMBAT_LOST });
return { state: { ...state, player, monster, status: CombatStatus.LOST }, events };
}
}
return {
state: { ...state, player, monster, status: CombatStatus.ACTIVE, round: state.round + 1 },
events,
};
}
private resolveMonsterTurn(
round: number,
monster: CombatEngineCombatant,
player: CombatEngineCombatant,
defended: boolean,
events: CombatEngineEvent[],
): void {
const defendMultiplier = defended ? DEFEND_MITIGATION_MULTIPLIER : 1;
if (monster.stats.pendingAction === 'HEAVY_ATTACK') {
monster.stats.pendingAction = undefined;
const damage = calculateDamage(monster.stats, player.stats.armor, HEAVY_ATTACK_MULTIPLIER * defendMultiplier);
player.currentHp = Math.max(0, player.currentHp - damage);
events.push({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.DAMAGE,
amount: damage,
});
return;
}
if (round % TELEGRAPH_ROUND_INTERVAL === 0) {
monster.stats.pendingAction = 'HEAVY_ATTACK';
events.push({ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.TELEGRAPH });
return;
}
const damage = calculateDamage(monster.stats, player.stats.armor, defendMultiplier);
player.currentHp = Math.max(0, player.currentHp - damage);
events.push({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.DAMAGE,
amount: damage,
});
}
private cloneCombatant(combatant: CombatEngineCombatant): CombatEngineCombatant {
return { ...combatant, stats: { ...combatant.stats } };
}
}
- Step 5: Run to verify all engine tests pass
Run: npm run test --workspace=@ashen-realms/api -- combat-engine.service.spec.ts
Expected: PASS (14 tests: 5 original unchanged + 1 fixed + 8 new)
- Step 6: Run the full API suite to check for regressions
Run: npm run test --workspace=@ashen-realms/api
Expected: PASS
- Step 7: Commit
git add apps/api/src/combat/combat-engine.service.ts apps/api/src/combat/combat-engine.service.spec.ts
git commit -m "feat(combat): implement HEAVY_STRIKE, SHIELD_BASH, DEFEND, POTION, and monster telegraphing"
Task 6: Combat error — no potions remaining
Files:
- Modify:
apps/api/src/combat/combat.errors.ts
Interfaces:
-
Produces:
combatNoPotionsRemaining(): CombatDomainErrorwithcode: 'COMBAT_NO_POTIONS_REMAINING', HTTP 409. Consumed by Task 7. -
Step 1: Add the error code and factory
In apps/api/src/combat/combat.errors.ts, change the CombatErrorCode union (near the top):
export type CombatErrorCode =
| 'HUNT_ENCOUNTER_NOT_FOUND'
| 'HUNT_ENCOUNTER_ALREADY_CONSUMED'
| 'INVALID_HUNT_ENCOUNTER'
| 'CHARACTER_TRAVELLING'
| 'COMBAT_ALREADY_ACTIVE'
| 'COMBAT_NOT_FOUND'
| 'COMBAT_ALREADY_FINISHED'
| 'COMBAT_STATE_INVALID'
| 'COMBAT_NO_POTIONS_REMAINING';
Then add a new factory function, right after combatStateInvalid() and before the final export { characterNotFound } ... line:
export function combatNoPotionsRemaining(): CombatDomainError {
return new CombatDomainError(
'COMBAT_NO_POTIONS_REMAINING',
HttpStatus.CONFLICT,
'No potions remain in this combat.',
);
}
- Step 2: Run the full API suite to confirm it still compiles and passes
Run: npm run test --workspace=@ashen-realms/api
Expected: PASS — this function isn't called yet, so nothing exercises it, but the file must still compile cleanly.
- Step 3: Commit
git add apps/api/src/combat/combat.errors.ts
git commit -m "feat(combat): add the COMBAT_NO_POTIONS_REMAINING domain error"
Task 7: Combat service — potions, telegraph, and the DTO surface
Files:
- Modify:
apps/api/src/combat/combat.service.ts - Modify:
apps/api/src/combat/combat.service.spec.ts
Interfaces:
-
Consumes:
CombatIntent(Task 4),combatNoPotionsRemaining(Task 6),CombatAction.POTION(Task 2). -
Produces:
CombatPlayerDtogainspotionsRemaining: numberandpotionsMax: number.CombatMonsterDtogainspendingIntent: CombatIntent | null.startCombatseedsplayerState.potionsRemaining = 2.performActionrejectsPOTIONwithCOMBAT_NO_POTIONS_REMAININGwhen none remain, and persists the engine's mutatedplayer.stats/monster.statsback ontocombat.playerState/combat.monsterState(previously never re-saved, since stats used to be immutable for the whole fight). Task 9/10 (web) consume this DTO shape directly. -
Step 1: Update the two existing exact-equality assertions that this DTO change breaks
In apps/api/src/combat/combat.service.spec.ts, find the 'starts an ACTIVE combat with snapshotted stats and full HP' test (around line 317) and replace its two toEqual blocks:
expect(combat.player).toEqual({
name: 'Aric Duskwalker',
maxHp: 100,
currentHp: 100,
});
expect(combat.monster).toEqual({
key: 'ash-rat',
name: 'Aschenratte',
level: 1,
maxHp: 45,
currentHp: 45,
artworkPath: '/images/monsters/ash-rat.png',
});
with:
expect(combat.player).toEqual({
name: 'Aric Duskwalker',
maxHp: 100,
currentHp: 100,
potionsRemaining: 2,
potionsMax: 2,
});
expect(combat.monster).toEqual({
key: 'ash-rat',
name: 'Aschenratte',
level: 1,
maxHp: 45,
currentHp: 45,
artworkPath: '/images/monsters/ash-rat.png',
pendingIntent: null,
});
- Step 2: Add the new failing tests
Add these tests inside describe('performAction', ...), right after the existing it('locks the combat row for the duration of the action', ...) test (before its closing and the describe('performAction', ...)'s own closing brace):
it('resolves POTION, heals the player, and persists the reduced potion count', async () => {
const { service, combatId } = await startedCombat();
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
const result = await service.performAction(CHARACTER_ID, combatId, CombatAction.POTION);
expect(result.player.potionsRemaining).toBe(1);
expect(result.player.currentHp).toBe(95);
const reloaded = await service.getCombat(CHARACTER_ID, combatId);
expect(reloaded.player.potionsRemaining).toBe(1);
});
it('rejects POTION once both potions have been used', async () => {
const { service, combatId } = await startedCombat();
await service.performAction(CHARACTER_ID, combatId, CombatAction.POTION);
await service.performAction(CHARACTER_ID, combatId, CombatAction.POTION);
await expectCombatDomainError(
service.performAction(CHARACTER_ID, combatId, CombatAction.POTION),
'COMBAT_NO_POTIONS_REMAINING',
);
});
it('persists the telegraphed Heavy Attack across a reload', async () => {
const { service, combatId } = await startedCombat();
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
const telegraphed = await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
expect(telegraphed.monster.pendingIntent).toBe('HEAVY_ATTACK');
const reloaded = await service.getCombat(CHARACTER_ID, combatId);
expect(reloaded.monster.pendingIntent).toBe('HEAVY_ATTACK');
});
- Step 3: Run to verify the new/changed tests fail
Run: npm run test --workspace=@ashen-realms/api -- combat.service.spec.ts
Expected: FAIL — potionsRemaining/potionsMax/pendingIntent aren't on the DTO yet, POTION isn't validated, and playerState/monsterState aren't persisted after performAction.
- Step 4: Update the DTO interfaces and imports
In apps/api/src/combat/combat.service.ts, change the import block (around line 15-16) to also pull in CombatIntent:
import { CombatEngineService } from './combat-engine.service';
import { CombatEngineState, CombatIntent } from './combat-engine.types';
Then replace the CombatPlayerDto and CombatMonsterDto interfaces (around lines 32-45):
export interface CombatPlayerDto {
name: string;
maxHp: number;
currentHp: number;
potionsRemaining: number;
potionsMax: number;
}
export interface CombatMonsterDto {
key: string;
name: string;
level: number;
maxHp: number;
currentHp: number;
artworkPath: string;
pendingIntent: CombatIntent | null;
}
And add a constant near the top of the file, right after the imports and before export interface CombatPlayerDto:
// Playable Slice 0.6 spec §3: fixed at 2 for V1, not yet backed by the
// persistent consumable inventory.
const STARTING_POTION_COUNT = 2;
- Step 5: Seed
potionsRemaininginstartCombat
In startCombat, find the playerState field of the combats.create({...}) call (around line 141-145):
playerState: {
attack: playerStats.attack,
weaponDamage: playerStats.weaponDamage,
armor: playerStats.armor,
},
Replace with:
playerState: {
attack: playerStats.attack,
weaponDamage: playerStats.weaponDamage,
armor: playerStats.armor,
potionsRemaining: STARTING_POTION_COUNT,
},
- Step 6: Validate POTION and persist mutated stats in
performAction
In performAction, right after the if (combat.status !== CombatStatus.ACTIVE) { throw combatAlreadyFinished(); } check (around line 220-222), add:
if (action === CombatAction.POTION && (combat.playerState.potionsRemaining ?? 0) <= 0) {
throw combatNoPotionsRemaining();
}
Then, right after the existing HP/round/status assignments (around lines 228-231, i.e. after combat.monsterCurrentHp = result.state.monster.currentHp; and before the if (combat.status !== CombatStatus.ACTIVE) { block), add:
combat.playerState = result.state.player.stats as CombatPlayerState;
combat.monsterState = result.state.monster.stats;
Also add combatNoPotionsRemaining to the existing named import from ./combat.errors (around line 17-27):
import {
characterNotFound,
characterTravelling,
combatAlreadyActive,
combatAlreadyFinished,
combatNoPotionsRemaining,
combatNotFound,
combatStateInvalid,
huntEncounterAlreadyConsumed,
huntEncounterNotFound,
invalidHuntEncounter,
} from './combat.errors';
And import CombatPlayerState from the entity file (it's already importing Combat from ./entities/combat.entity, so extend that import):
import { Combat, CombatPlayerState } from './entities/combat.entity';
- Step 7: Expose the new fields on
toCombatDto
In toCombatDto (near the bottom of the file), replace the player: and monster: blocks:
player: {
name: playerName,
maxHp: combat.playerMaxHp,
currentHp: combat.playerCurrentHp,
potionsRemaining: combat.playerState.potionsRemaining,
potionsMax: STARTING_POTION_COUNT,
},
monster: {
key: monster.key,
name: monster.name,
level: monster.level,
maxHp: combat.monsterMaxHp,
currentHp: combat.monsterCurrentHp,
artworkPath: monster.artworkPath,
pendingIntent: combat.monsterState.pendingAction ?? null,
},
- Step 8: Run to verify all combat.service tests pass
Run: npm run test --workspace=@ashen-realms/api -- combat.service.spec.ts
Expected: PASS
- Step 9: Run the full API suite to check for regressions
Run: npm run test --workspace=@ashen-realms/api
Expected: PASS
- Step 10: Commit
git add apps/api/src/combat/combat.service.ts apps/api/src/combat/combat.service.spec.ts
git commit -m "feat(combat): validate potions, persist telegraph state, and expose both on the combat DTO"
Task 8: Web combat models & store
Files:
- Modify:
apps/web/src/app/core/api/game-api.models.ts:151-188 - Modify:
apps/web/src/app/features/combat/combat.store.ts - Modify:
apps/web/src/app/features/combat/combat.store.spec.ts - Modify:
apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts:50-65(fixture only, see Step 4) - Modify:
apps/web/src/app/core/resume-combat.spec.ts:8-23(fixture only, see Step 4)
Interfaces:
- Consumes: nothing new outside the web app.
- Produces:
CombatAction(web) ='ATTACK' | 'HEAVY_STRIKE' | 'SHIELD_BASH' | 'DEFEND' | 'POTION'.CombatEventType(web) gains'HEAL' | 'DEFEND' | 'TELEGRAPH' | 'INTERRUPT'.CombatPlayergainspotionsRemaining: numberandpotionsMax: number.CombatMonstergainspendingIntent: 'HEAVY_ATTACK' | null.CombatStore.performAction(action: CombatAction): Promise<void>replacesCombatStore.attack(): Promise<void>— consumed by Task 9 (combat page component).
Pre-flight ruling: making potionsRemaining/potionsMax/pendingIntent required breaks two more const x: Combat = {...} fixtures outside the combat feature — hunt-page.component.spec.ts and resume-combat.spec.ts both build a full Combat literal for their own unrelated tests (hunt resumption, not combat mechanics). A direct type-annotated object literal missing required properties is a guaranteed TypeScript error (stricter than the as Combat cast fixed in Task 4). Step 4 below patches both.
- Step 1: Extend the web models
In apps/web/src/app/core/api/game-api.models.ts, replace lines 151-188 (from export type CombatStatus = ... through the end of the Combat interface):
export type CombatStatus = 'ACTIVE' | 'WON' | 'LOST';
export type CombatEventType = 'DAMAGE' | 'HEAL' | 'DEFEND' | 'TELEGRAPH' | 'INTERRUPT' | 'COMBAT_WON' | 'COMBAT_LOST';
export type CombatSide = 'PLAYER' | 'MONSTER';
export type CombatAction = 'ATTACK' | 'HEAVY_STRIKE' | 'SHIELD_BASH' | 'DEFEND' | 'POTION';
export type CombatMonsterIntent = 'HEAVY_ATTACK';
export interface CombatEvent {
round: number;
sequence: number;
type: CombatEventType;
source: CombatSide;
target: CombatSide;
amount?: number;
}
export interface CombatPlayer {
name: string;
maxHp: number;
currentHp: number;
potionsRemaining: number;
potionsMax: number;
}
export interface CombatMonster {
key: string;
name: string;
level: number;
maxHp: number;
currentHp: number;
artworkPath: string;
pendingIntent: CombatMonsterIntent | null;
}
export interface Combat {
id: string;
status: CombatStatus;
round: number;
player: CombatPlayer;
monster: CombatMonster;
events: CombatEvent[];
rewards: CombatReward | null;
}
- Step 2: Rename
attack()toperformAction()in the store, and map the new error code
In apps/web/src/app/features/combat/combat.store.ts, add the new error message to COMBAT_ERROR_MESSAGES (after COMBAT_ALREADY_FINISHED):
COMBAT_ALREADY_FINISHED: 'Dieser Kampf ist bereits beendet.',
COMBAT_NO_POTIONS_REMAINING: 'Du hast keine Tränke mehr.',
};
First, add CombatAction to the existing import from ../../core/api/game-api.models at the top of the file (currently import { Combat } from '../../core/api/game-api.models';):
import { Combat, CombatAction } from '../../core/api/game-api.models';
Then replace the attack() method:
async attack(): Promise<void> {
const combat = this.combatState();
if (!combat || this.actionPendingState()) {
return;
}
this.actionPendingState.set(true);
this.clearError();
try {
const updated = await firstValueFrom(this.api.performCombatAction(combat.id, 'ATTACK'));
this.combatState.set(updated);
} catch (error) {
this.setError(error);
} finally {
this.actionPendingState.set(false);
}
}
with:
async performAction(action: CombatAction): Promise<void> {
const combat = this.combatState();
if (!combat || this.actionPendingState()) {
return;
}
this.actionPendingState.set(true);
this.clearError();
try {
const updated = await firstValueFrom(this.api.performCombatAction(combat.id, action));
this.combatState.set(updated);
} catch (error) {
this.setError(error);
} finally {
this.actionPendingState.set(false);
}
}
- Step 3: Update the store spec's fixtures and rename
attackcall sites
In apps/web/src/app/features/combat/combat.store.spec.ts, update both Combat fixtures (startedCombat and afterAttack) to include the new required fields:
const startedCombat: Combat = {
id: 'combat-1',
status: 'ACTIVE',
round: 1,
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100, potionsRemaining: 2, potionsMax: 2 },
monster: {
key: 'ash-rat',
name: 'Aschenratte',
level: 1,
maxHp: 45,
currentHp: 45,
artworkPath: '/images/monsters/ash-rat.png',
pendingIntent: null,
},
events: [],
rewards: null,
};
const afterAttack: Combat = {
...startedCombat,
round: 2,
player: { ...startedCombat.player, currentHp: 95 },
monster: { ...startedCombat.monster, currentHp: 31 },
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 },
],
};
Then rename every store.attack() call to store.performAction('ATTACK') and every combatStore: { ...; attack: ... } type reference to performAction. Concretely, replace these five tests' bodies (they keep the same names and assertions otherwise — only the method name changes):
it('sends only the ATTACK action and replaces combat with the server response', async () => {
await store.startCombat('encounter-1');
await store.performAction('ATTACK');
expect(api.performCombatAction).toHaveBeenCalledWith('combat-1', 'ATTACK');
expect(store.combat()).toEqual(afterAttack);
});
it('does nothing when attacking without a loaded combat', async () => {
await store.performAction('ATTACK');
expect(api.performCombatAction).not.toHaveBeenCalled();
});
it('ignores a second attack while the first is still pending', async () => {
await store.startCombat('encounter-1');
let resolveAttack!: (value: Combat) => void;
api.performCombatAction.mockReturnValue(
from(
new Promise<Combat>((resolve) => {
resolveAttack = resolve;
}),
),
);
const first = store.performAction('ATTACK');
expect(store.actionPending()).toBe(true);
const second = store.performAction('ATTACK');
resolveAttack(afterAttack);
await Promise.all([first, second]);
expect(api.performCombatAction).toHaveBeenCalledOnce();
});
it('clears actionPending after a failed attack and keeps the previous combat state', async () => {
await store.startCombat('encounter-1');
api.performCombatAction.mockReturnValue(throwError(() => new Error('Netzwerkfehler')));
await store.performAction('ATTACK');
expect(store.actionPending()).toBe(false);
expect(store.combat()).toEqual(startedCombat);
expect(store.error()).toBe('Netzwerkfehler');
});
Also add one new test, right after the "sends only the ATTACK action..." test, to prove the method is genuinely generic and not hardcoded to 'ATTACK':
it('sends whichever action is requested', async () => {
await store.startCombat('encounter-1');
await store.performAction('HEAVY_STRIKE');
expect(api.performCombatAction).toHaveBeenCalledWith('combat-1', 'HEAVY_STRIKE');
});
- Step 4: Patch the two out-of-feature fixtures
These two files build their own Combat fixture for unrelated tests (hunt resumption) and will fail to compile once CombatPlayer/CombatMonster gain required fields.
In apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts, change the startedCombat fixture (around lines 50-65):
const startedCombat: Combat = {
id: 'combat-2',
status: 'ACTIVE',
round: 1,
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100, potionsRemaining: 2, potionsMax: 2 },
monster: {
key: 'road-bandit',
name: 'Straßenräuber',
level: 3,
maxHp: 75,
currentHp: 75,
artworkPath: '/images/enemies/RoadBandit.png',
pendingIntent: null,
},
events: [],
rewards: null,
};
In apps/web/src/app/core/resume-combat.spec.ts, change the runningCombat fixture (around lines 8-23):
const runningCombat: Combat = {
id: 'combat-running',
status: 'ACTIVE',
round: 4,
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 62, potionsRemaining: 2, potionsMax: 2 },
monster: {
key: 'road-bandit',
name: 'Straßenräuber',
level: 3,
maxHp: 75,
currentHp: 30,
artworkPath: '/images/enemies/RoadBandit.png',
pendingIntent: null,
},
events: [],
rewards: null,
};
- Step 5: Run to verify everything this task touches compiles and passes
Run: npx ng test --watch=false --include='**/combat.store.spec.ts' --include='**/hunt-page.component.spec.ts' --include='**/resume-combat.spec.ts' (from apps/web)
Expected: PASS — this also proves the Step 4 fixture patches compile, since ng test type-checks the whole project before running any spec.
- Step 6: Commit
git add apps/web/src/app/core/api/game-api.models.ts apps/web/src/app/features/combat/combat.store.ts apps/web/src/app/features/combat/combat.store.spec.ts apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts apps/web/src/app/core/resume-combat.spec.ts
git commit -m "feat(combat): generalize the web combat action and expose potions/monster intent"
Task 9: Web combat page — action bar, telegraph banner, generalized animation
Files:
- Modify:
apps/web/src/app/features/combat/combat-page/combat-page.component.ts - Modify:
apps/web/src/app/features/combat/combat-page/combat-page.component.html:49-83 - Modify:
apps/web/src/app/features/combat/combat-page/combat-page.component.scss:362-368 - Modify:
apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts
Interfaces:
- Consumes:
CombatStore.performAction(action: CombatAction)(Task 8),Combat/CombatPlayer/CombatMonster/CombatEvent/CombatAction(Task 8). - Produces: nothing consumed by later tasks — this is the last task.
This is the biggest single task. Read it in full before starting; the component rewrite, the HTML action bar, the SCSS, and roughly a dozen spec changes all have to land together for the page to render and its tests to pass.
- Step 1: Update the existing fixture and rename
attack/performActionthroughout the spec
In apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts:
Replace the activeCombat fixture (lines 10-28):
const activeCombat: Combat = {
id: 'combat-1',
status: 'ACTIVE',
round: 2,
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 95, potionsRemaining: 2, potionsMax: 2 },
monster: {
key: 'ash-rat',
name: 'Aschenratte',
level: 1,
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 },
{ round: 1, sequence: 2, type: 'DAMAGE', source: 'MONSTER', target: 'PLAYER', amount: 5 },
],
rewards: null,
};
Replace the combatStore type declaration (lines 37-44):
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>;
};
Replace the combatStore = {...} construction inside setup (lines 49-56):
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()),
};
Then, in every remaining test, rename combatStore.attack to combatStore.performAction (this affects the mock-implementation assignments in "plays the swing, reveals the monster damage...", "skips the recoil...", and "refreshes the character...") and rename the 'calls combatStore.attack() when Angriff is clicked' test:
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.performAction).toHaveBeenCalledWith('ATTACK');
});
(Every other occurrence is just combatStore.attack.mockImplementation(...) → combatStore.performAction.mockImplementation(...), three occurrences total, no other changes to those three tests.)
- Step 2: Add new spec coverage for the action bar, potions, and telegraph banner
Add these tests right after the 'shows the player, monster, HP bars, round, and the Angriff action' test:
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.');
});
Add this test right after the existing 'plays the swing, reveals the monster damage, then the recoil a beat later' test, to cover the telegraph-specific animation timing (banner appears only after the reply beat, not immediately, and the monster never lunges for a telegraph):
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);
});
Add this test right after it, to cover SHIELD_BASH's interrupt path (no riposte delay, no lunge, immediate log line):
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);
});
- Step 3: Run to verify all the new/changed tests fail
Run: npx ng test --watch=false --include='**/combat-page.component.spec.ts' (from apps/web)
Expected: FAIL — performAction doesn't exist on the component yet, there's only one button, no telegraph banner, and formatEvent doesn't handle the new event types.
- Step 4: Rewrite the component
Replace the full contents of apps/web/src/app/features/combat/combat-page/combat-page.component.ts:
import { Component, DestroyRef, OnInit, computed, inject, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import type { Combat, CombatAction, CombatEvent } from '../../../core/api/game-api.models';
import {
combatMonsterSpriteScale,
monsterCutoutPath,
monsterIconPath,
runtimeMonsterArtworkPath,
} from '../../../shared/monster-artwork';
import { ItemCardComponent } from '../../../shared/item-card/item-card.component';
import { WorldStore } from '../../world/world.store';
import { CombatStore } from '../combat.store';
interface CombatLogRound {
round: number;
events: CombatEvent[];
}
type CombatPhase = 'idle' | 'attacking' | 'hit';
// The monster is a single cut-out with no sheets, so its beats are pure
// CSS transforms and run offset from the player's: it flinches when the
// player's blow lands and lunges while the player is recoiling.
type MonsterPhase = 'idle' | 'flinch' | 'lunge';
const PLAYER_ICON = '/images/hud/runtime/CharacterIcon-128.png';
// Must stay in step with the sprite-sheet animations in the stylesheet: the
// swing and the recoil each run six frames over these durations.
const SWING_MS = 540;
const RECOIL_MS = 540;
// Beat between the player's blow landing and the monster striking back.
const RIPOSTE_DELAY_MS = 1260;
// Length of the stage jolt keyframes, see `stage-shake` in the stylesheet.
const STAGE_SHAKE_MS = 200;
// Actions that land a blow on the monster this round -- everything else
// (DEFEND, POTION) skips the swing wind-up so the player sprite doesn't
// mime an attack it didn't make.
const DAMAGING_ACTIONS: ReadonlySet<CombatAction> = new Set(['ATTACK', 'HEAVY_STRIKE', 'SHIELD_BASH']);
@Component({
selector: 'app-combat-page',
templateUrl: './combat-page.component.html',
styleUrl: './combat-page.component.scss',
imports: [ItemCardComponent],
})
export class CombatPageComponent implements OnInit {
protected readonly combatStore = inject(CombatStore);
private readonly worldStore = inject(WorldStore);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
private destroyed = false;
// The server resolves a whole round at once. `combat` is what the screen is
// currently showing, so the round can be played back a beat at a time
// instead of both blows landing together.
private readonly displayed = signal<Combat | null>(null);
private readonly replaying = signal(false);
// The stage jolt stays wired up but is no longer fired by an ordinary hit --
// it was too much for every single round. Call `shakeStage()` to bring it
// back for a specific ability.
private readonly stageShaking = signal(false);
protected readonly combat = this.displayed.asReadonly();
protected readonly phase = signal<CombatPhase>('idle');
protected readonly monsterPhase = signal<MonsterPhase>('idle');
protected readonly stageShake = this.stageShaking.asReadonly();
protected readonly busy = computed(() => this.replaying() || this.combatStore.actionPending());
protected readonly playerIcon = PLAYER_ICON;
constructor() {
this.destroyRef.onDestroy(() => {
this.destroyed = true;
});
}
ngOnInit(): void {
void this.loadFromRoute();
}
protected async performAction(action: CombatAction): Promise<void> {
const before = this.displayed();
if (!before || this.busy()) {
return;
}
this.replaying.set(true);
try {
const damaging = DAMAGING_ACTIONS.has(action);
this.phase.set(damaging ? 'attacking' : 'idle');
this.monsterPhase.set('idle');
const swing = this.wait(SWING_MS);
await this.combatStore.performAction(action);
await swing;
if (this.destroyed) {
return;
}
this.phase.set('idle');
const after = this.combatStore.combat();
if (!after) {
return;
}
if (after.status === 'WON') {
// The server already granted XP and silver; pull the authoritative
// character so the HUD matches (spec §35).
void this.worldStore.refreshCharacter();
}
const roundEvents = after.events.filter((event) => event.round === before.round);
const dealtDamage = roundEvents.some(
(event) => event.source === 'PLAYER' && event.target === 'MONSTER' && event.type === 'DAMAGE',
);
this.monsterPhase.set(dealtDamage ? 'flinch' : 'idle');
const monsterEvent = roundEvents.find((event) => event.source === 'MONSTER');
if (!monsterEvent) {
// No reply this round: either the fight just ended, or SHIELD_BASH
// interrupted the monster's turn outright.
this.displayed.set(after);
return;
}
// Show what the player's own action produced, holding back the
// monster's reply -- including whether it just started telegraphing.
this.displayed.set({
...after,
player: before.player,
monster: {
...(dealtDamage ? after.monster : before.monster),
pendingIntent: before.monster.pendingIntent,
},
events: after.events.filter((event) => event.sequence < monsterEvent.sequence),
});
await this.wait(RIPOSTE_DELAY_MS);
if (this.destroyed) {
return;
}
if (monsterEvent.type === 'TELEGRAPH') {
this.displayed.set(after);
return;
}
this.phase.set('hit');
this.monsterPhase.set('lunge');
this.displayed.set(after);
await this.wait(RECOIL_MS);
if (this.destroyed) {
return;
}
this.phase.set('idle');
this.monsterPhase.set('idle');
} finally {
if (!this.destroyed) {
this.replaying.set(false);
}
}
}
/** Jolts the whole stage once. Reserved for abilities; no attack triggers it. */
protected shakeStage(): void {
this.stageShaking.set(true);
setTimeout(() => {
if (!this.destroyed) {
this.stageShaking.set(false);
}
}, STAGE_SHAKE_MS);
}
protected retry(): void {
void this.loadFromRoute();
}
protected goToHunt(): void {
void this.router.navigate(['/hunt']);
}
// The location is the screen a fight resolves back into. It sits beside
// "Weiter jagen" rather than replacing it, so the hunt loop keeps its
// one-click rhythm.
protected goToLocation(): void {
void this.router.navigate(['/location']);
}
protected goToInventory(): void {
void this.router.navigate(['/inventory']);
}
protected monsterSprite(monsterKey: string, artworkPath: string): string {
return monsterCutoutPath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath;
}
protected monsterSpriteScale(monsterKey: string): number {
return combatMonsterSpriteScale(monsterKey);
}
protected monsterIcon(monsterKey: string, artworkPath: string): string {
return monsterIconPath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath;
}
protected playerHpPercent(): number {
const combat = this.displayed();
return combat ? (combat.player.currentHp / combat.player.maxHp) * 100 : 0;
}
protected monsterHpPercent(): number {
const combat = this.displayed();
return combat ? (combat.monster.currentHp / combat.monster.maxHp) * 100 : 0;
}
protected monsterIntentLabel(): string | null {
const combat = this.displayed();
if (!combat || combat.monster.pendingIntent !== 'HEAVY_ATTACK') {
return null;
}
return `${combat.monster.name} bereitet Schweren Hieb vor.`;
}
protected logRounds(): CombatLogRound[] {
const combat = this.displayed();
if (!combat) {
return [];
}
const rounds = new Map<number, CombatEvent[]>();
for (const event of combat.events) {
const events = rounds.get(event.round) ?? [];
events.push(event);
rounds.set(event.round, events);
}
return [...rounds.entries()].sort(([a], [b]) => a - b).map(([round, events]) => ({ round, events }));
}
protected formatEvent(event: CombatEvent): string {
const combat = this.displayed();
const playerName = combat?.player.name ?? 'Du';
const monsterName = combat?.monster.name ?? 'Der Gegner';
if (event.type === 'DAMAGE') {
const attacker = event.source === 'PLAYER' ? playerName : monsterName;
const defender = event.target === 'PLAYER' ? playerName : monsterName;
return `${attacker} trifft ${defender} für ${event.amount} Schaden.`;
}
if (event.type === 'HEAL') {
return `${playerName} trinkt einen Trank und heilt ${event.amount} Lebenspunkte.`;
}
if (event.type === 'DEFEND') {
return `${playerName} geht in die Verteidigung.`;
}
if (event.type === 'TELEGRAPH') {
return `${monsterName} bereitet Schweren Hieb vor.`;
}
if (event.type === 'INTERRUPT') {
return `${playerName} unterbricht den vorbereiteten Angriff von ${monsterName}.`;
}
if (event.type === 'COMBAT_WON') {
return `${monsterName} wurde besiegt.`;
}
return `${playerName} wurde im Kampf besiegt.`;
}
private wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private async loadFromRoute(): Promise<void> {
const combatId = this.route.snapshot.paramMap.get('combatId');
if (!combatId) {
return;
}
await this.combatStore.loadCombat(combatId);
if (!this.destroyed) {
this.phase.set('idle');
this.monsterPhase.set('idle');
this.displayed.set(this.combatStore.combat());
}
}
}
- Step 5: Rewrite the action bar and add the telegraph banner in the template
In apps/web/src/app/features/combat/combat-page/combat-page.component.html, insert the telegraph banner right after the closing </header> (line 49) and before <div class="combat__field"> (line 51):
</header>
@if (monsterIntentLabel(); as intent) {
<p class="combat__telegraph" data-combat-telegraph role="status">{{ intent }}</p>
}
<div class="combat__field">
Then replace the whole <footer class="combat__actions">...</footer> block (originally lines 69-83):
<footer class="combat__actions">
@if (combat.status === 'ACTIVE') {
<button
type="button"
class="action"
data-combat-attack
[disabled]="busy()"
(click)="performAction('ATTACK')"
>
<img class="action__icon" src="/images/hud/runtime/AttackIcon-96.png" alt="" />
<span class="action__label">Angriff</span>
<span class="action__key">1</span>
</button>
<button
type="button"
class="action"
data-combat-heavy-strike
[disabled]="busy()"
(click)="performAction('HEAVY_STRIKE')"
>
<img class="action__icon" src="/images/hud/runtime/AttackIcon-96.png" alt="" />
<span class="action__label">Schwerer Hieb</span>
<span class="action__key">2</span>
</button>
<button
type="button"
class="action"
data-combat-shield-bash
[disabled]="busy()"
(click)="performAction('SHIELD_BASH')"
>
<img class="action__icon" src="/images/hud/runtime/CharacterIcon-128.png" alt="" />
<span class="action__label">Schildstoß</span>
<span class="action__key">3</span>
</button>
<button
type="button"
class="action"
data-combat-defend
[disabled]="busy()"
(click)="performAction('DEFEND')"
>
<img class="action__icon" src="/images/hud/runtime/CharacterIcon-128.png" alt="" />
<span class="action__label">Verteidigen</span>
<span class="action__key">4</span>
</button>
<button
type="button"
class="action"
data-combat-potion
[disabled]="busy() || combat.player.potionsRemaining <= 0"
(click)="performAction('POTION')"
>
<img class="action__icon" src="/images/items/small-healing-potion.png" alt="" />
<span class="action__label">Trank {{ combat.player.potionsRemaining }}/{{ combat.player.potionsMax }}</span>
<span class="action__key">5</span>
</button>
}
</footer>
- Step 6: Style the telegraph banner and let the action bar wrap on narrow screens
In apps/web/src/app/features/combat/combat-page/combat-page.component.scss, replace the .combat__actions rule (lines 362-368):
.combat__actions {
position: relative;
z-index: 2;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--ar-space-3);
min-block-size: clamp(6.5rem, 11vw, 8.5rem);
}
Then add a new rule right after it (before .action {):
.combat__telegraph {
position: relative;
z-index: 2;
margin: 0;
padding: var(--ar-space-2) var(--ar-space-4);
border: 1px solid var(--ar-gold);
border-radius: var(--ar-radius-sm);
background: rgb(9 11 13 / 0.85);
color: var(--ar-gold);
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(1rem, 1.6vw, 1.15rem);
letter-spacing: 0.03em;
text-align: center;
}
- Step 7: Run to verify all combat-page tests pass
Run: npx ng test --watch=false --include='**/combat-page.component.spec.ts' (from apps/web)
Expected: PASS
- Step 8: Run the full web test suite to check for regressions
Run: npm run test --workspace=@ashen-realms/web
Expected: PASS
- Step 9: Commit
git add apps/web/src/app/features/combat/combat-page/combat-page.component.ts apps/web/src/app/features/combat/combat-page/combat-page.component.html apps/web/src/app/features/combat/combat-page/combat-page.component.scss apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts
git commit -m "feat(combat): add the five-action bar, telegraph banner, and generalized round animation"
Final Verification
- Step 1: Run both full test suites one more time
npm run test --workspace=@ashen-realms/api
npm run test --workspace=@ashen-realms/web
Expected: PASS for both (API: 31+ suites; Web: 22+ suites, ~215+ tests total including the ~25 new/changed ones this plan adds).
- Step 2: Manually sanity-check the Definition of Done from the spec (§11)
Start the dev servers (npm run dev or the project's usual two-terminal start:api / start:web), start a fight against a Straßenräuber (road-bandit, level 2, 75 HP), and play three ATTACK rounds to trigger the round-3 telegraph. Confirm:
- The action bar shows all five buttons with the right labels and icons.
- On the telegraph round, a prominent banner reads "Straßenräuber bereitet Schweren Hieb vor." and no damage lands that round.
- SHIELD_BASH on the following round clears the banner and logs the interrupt, without a monster hit.
- DEFEND on the following round (start a fresh fight, let it re-telegraph) noticeably reduces the resolved Heavy Attack's damage versus taking it undefended.
- POTION heals visibly, decrements the counter to "Trank 1/2", and is disabled once both are used.