Covers the engine rewrite for HEAVY_STRIKE/SHIELD_BASH/DEFEND/POTION, deterministic monster telegraphing/interrupt, the DB migration for the new combat event types, and the web action bar + telegraph banner. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2038 lines
74 KiB
Markdown
2038 lines
74 KiB
Markdown
# 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.png` for the two melee actions, `/images/hud/runtime/CharacterIcon-128.png` for SHIELD_BASH/DEFEND, and the existing `/images/items/small-healing-potion.png` for 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` — `multiplier` defaults to `1`, applied to the mitigated damage before the existing round + min-1 floor. All later engine tasks call this with `1`, `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`):
|
|
|
|
```ts
|
|
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`:
|
|
|
|
```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**
|
|
|
|
```bash
|
|
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: `CombatAction` now has members `ATTACK | HEAVY_STRIKE | SHIELD_BASH | DEFEND | POTION`. `CombatEventType` now has members `DAMAGE | 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`:
|
|
|
|
```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`:
|
|
|
|
```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):
|
|
|
|
```ts
|
|
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):
|
|
|
|
```ts
|
|
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**
|
|
|
|
```bash
|
|
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: `CombatEventType` from Task 2 (`apps/api/src/combat/combat-event-type.enum.ts`).
|
|
- Produces: the Postgres type `combat_event_type_enum` (already created by `1788100000000-CreateCombatSystem.ts`) gains `HEAL`, `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 (see `combat.service.spec.ts`), never a real Postgres connection — so this task is verified the same way the existing `equipment.migration.spec.ts` verifies 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`:
|
|
|
|
```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`:
|
|
|
|
```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**
|
|
|
|
```bash
|
|
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 from `combat-engine.types.ts`). `CombatEngineCombatantStats` gains optional `potionsRemaining?: number` and `pendingAction?: CombatIntent`. `CombatCombatantState` (entity) gains optional `pendingAction?: CombatIntent`; `CombatPlayerState` gains required `potionsRemaining: 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 new test — its correctness is verified by the whole project still compiling and the existing suite staying green.
|
|
|
|
- [ ] **Step 1: Extend the engine types**
|
|
|
|
Replace the full contents of `apps/api/src/combat/combat-engine.types.ts`:
|
|
|
|
```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):
|
|
|
|
```ts
|
|
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 constructs yet outside of test fixtures using object literals typed loosely (`as Combat`), which TypeScript doesn't strictly check field-by-field.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
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), `CombatAction` members (Task 2), `CombatEventType` members (Task 2), `CombatEngineCombatant`/`CombatEngineCombatantStats`/`CombatEngineState`/`CombatActionInput`/`CombatEngineEvent`/`CombatEngineResult` (Task 4).
|
|
- Produces: `CombatEngineService.resolveAction(state, input)` now handles all 5 `CombatAction` members. `result.state.player.stats.potionsRemaining` and `result.state.monster.stats.pendingAction` are mutated as described below — Task 7 persists these back onto `combat.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):
|
|
|
|
```ts
|
|
it('throws UnsupportedCombatActionError for an action it does not implement', () => {
|
|
expect(() =>
|
|
engine.resolveAction(baseState(), { action: 'HEAVY_STRIKE' as CombatAction }),
|
|
).toThrow(UnsupportedCombatActionError);
|
|
});
|
|
```
|
|
|
|
Replace with:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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`:
|
|
|
|
```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**
|
|
|
|
```bash
|
|
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(): CombatDomainError` with `code: '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):
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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**
|
|
|
|
```bash
|
|
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: `CombatPlayerDto` gains `potionsRemaining: number` and `potionsMax: number`. `CombatMonsterDto` gains `pendingIntent: CombatIntent | null`. `startCombat` seeds `playerState.potionsRemaining = 2`. `performAction` rejects `POTION` with `COMBAT_NO_POTIONS_REMAINING` when none remain, and persists the engine's mutated `player.stats`/`monster.stats` back onto `combat.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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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):
|
|
|
|
```ts
|
|
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`:
|
|
|
|
```ts
|
|
import { CombatEngineService } from './combat-engine.service';
|
|
import { CombatEngineState, CombatIntent } from './combat-engine.types';
|
|
```
|
|
|
|
Then replace the `CombatPlayerDto` and `CombatMonsterDto` interfaces (around lines 32-45):
|
|
|
|
```ts
|
|
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`:
|
|
|
|
```ts
|
|
// 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 `potionsRemaining` in `startCombat`**
|
|
|
|
In `startCombat`, find the `playerState` field of the `combats.create({...})` call (around line 141-145):
|
|
|
|
```ts
|
|
playerState: {
|
|
attack: playerStats.attack,
|
|
weaponDamage: playerStats.weaponDamage,
|
|
armor: playerStats.armor,
|
|
},
|
|
```
|
|
|
|
Replace with:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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):
|
|
|
|
```ts
|
|
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):
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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**
|
|
|
|
```bash
|
|
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`
|
|
|
|
**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'`. `CombatPlayer` gains `potionsRemaining: number` and `potionsMax: number`. `CombatMonster` gains `pendingIntent: 'HEAVY_ATTACK' | null`. `CombatStore.performAction(action: CombatAction): Promise<void>` replaces `CombatStore.attack(): Promise<void>` — consumed by Task 9 (combat page component).
|
|
|
|
- [ ] **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):
|
|
|
|
```ts
|
|
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()` to `performAction()` 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`):
|
|
|
|
```ts
|
|
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';`):
|
|
|
|
```ts
|
|
import { Combat, CombatAction } from '../../core/api/game-api.models';
|
|
```
|
|
|
|
Then replace the `attack()` method:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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 `attack` call 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:
|
|
|
|
```ts
|
|
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):
|
|
|
|
```ts
|
|
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'`:
|
|
|
|
```ts
|
|
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: Run to verify the store spec passes**
|
|
|
|
Run: `npx ng test --watch=false --include='**/combat.store.spec.ts'` (from `apps/web`)
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
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
|
|
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`/`performAction` throughout the spec**
|
|
|
|
In `apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts`:
|
|
|
|
Replace the `activeCombat` fixture (lines 10-28):
|
|
|
|
```ts
|
|
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):
|
|
|
|
```ts
|
|
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):
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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):
|
|
|
|
```ts
|
|
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):
|
|
|
|
```ts
|
|
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`:
|
|
|
|
```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):
|
|
|
|
```html
|
|
</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):
|
|
|
|
```html
|
|
<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):
|
|
|
|
```scss
|
|
.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 {`):
|
|
|
|
```scss
|
|
.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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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.
|