Files
ashen-realms/docs/playable-slices/Ashen Realms – Playable Slice 0.3_ First Combat.md
Bastian Wagner 5b662aad4c docs
2026-08-19 11:14:17 +02:00

25 KiB
Raw Blame History

Ashen Realms Playable Slice 0.3: First Combat

Status: Ready for implementation
Prerequisite: Playable Slice 0.2 First Hunt
Scope: First persistent, server-authoritative combat
Primary Enemies: Aschenratte, Straßenräuber
Next Slice: Playable Slice 0.4 First Loot


1. Goal

Playable Slice 0.3 implements the first real combat in Ashen Realms.

The existing flow:

World
→ Travel
→ Hunt
→ Encounter selection

is extended to:

World
→ Travel
→ Hunt
→ Encounter selection
→ Combat
→ Victory or defeat

The slice must prove that:

  • combat can only start from a valid HuntEncounter
  • combat state is persisted on the server
  • the client sends decisions, never results
  • combat resolution is deterministic
  • player and monster HP are server-authoritative
  • combat proceeds round by round
  • combat events can drive frontend presentation
  • victory and defeat are persisted
  • refreshing the browser does not lose the fight

This slice deliberately ends when the combat reaches:

WON

or:

LOST

No rewards are granted yet.


2. Core player flow

Verbrannte Straße
↓
Jagd beginnen
↓
Encounter auswählen
↓
Angreifen
↓
Server validates HuntEncounter
↓
Combat created
↓
/combat/:combatId
↓
Player and monster are displayed
↓
Player chooses Angriff
↓
Server resolves round
↓
CombatEvents returned
↓
Frontend updates HP and combat log
↓
next round
↓
monster reaches 0 HP
↓
Combat = WON

Defeat must also work:

player reaches 0 HP
↓
Combat = LOST

3. Scope

Implement:

Combat entity
CombatEvent entity
CombatEngineService
CombatService
CombatController

combat creation from HuntEncounter

ATTACK player action

basic monster attack

server-side damage calculation

round progression

player HP

monster HP

combat status

combat event persistence

combat page

player presentation

monster presentation

HP bars

Angriff action

round display

combat log

victory state

defeat state

4. Explicit non-goals

Do not implement:

loot
XP granting
silver granting
inventory
equipment management
item drops
quests
bosses
elites
sets
area currencies
random combat rewards
combat animations requiring complex timing
WebSockets
PvP
multiplayer combat

Also do not implement the complete final action system yet.

The first playable combat uses only:

ATTACK

for player input.

Future slices may add:

HEAVY_STRIKE
SHIELD_BASH
DEFEND
POTION
FLEE

The combat architecture must allow these later without implementing them prematurely.


5. Combat design principle

Combat is turn-based.

The player makes exactly one primary decision per round.

For Slice 0.3:

Player chooses ATTACK
↓
player attack resolves
↓
if monster survives:
    monster attack resolves
↓
round ends

The next round begins only after the server has resolved the previous round.

Angular must never independently resolve a combat round.


6. Combat start boundary

A combat may only be created from:

HuntEncounter.id

Never from:

MonsterDefinition.id

directly supplied by the frontend.

Required endpoint:

POST /api/hunt-encounters/:encounterId/attack

The backend must validate:

HuntEncounter exists
↓
encounter belongs to current character's Hunt
↓
Hunt belongs to current character
↓
Hunt is valid/current
↓
encounter has not already been consumed
↓
character is not travelling
↓
character does not already have another ACTIVE combat

Only then may combat be created.


7. Encounter consumption

A HuntEncounter must not be usable repeatedly to create unlimited combats.

Introduce an appropriate state or relation.

Possible states:

AVAILABLE
SELECTED
CONSUMED

For this slice it is sufficient if the selected encounter becomes:

CONSUMED

when combat is successfully created.

The exact persistence model may follow existing project conventions.

The important invariant is:

One HuntEncounter may create at most one Combat.

Prefer protecting this through both service validation and a database uniqueness constraint where practical.


8. Combat entity

Create:

apps/api/src/combat/entities/combat.entity.ts

Required conceptual fields:

id: uuid;

characterId: uuid;
huntEncounterId: uuid;
monsterDefinitionId: uuid;

status: CombatStatus;

round: number;

playerMaxHp: number;
playerCurrentHp: number;

monsterMaxHp: number;
monsterCurrentHp: number;

playerState: jsonb;
monsterState: jsonb;

createdAt: timestamptz;
updatedAt: timestamptz;
completedAt?: timestamptz;

Supported statuses:

ACTIVE
WON
LOST

Do not add reward-related states yet.


9. Combat snapshot principle

Combat must snapshot the relevant stats at combat creation.

A running combat must not unexpectedly change because content definitions or character state are edited elsewhere.

The combat snapshot should contain enough information to reproduce its deterministic calculations.

Conceptually:

playerState = {
  attack: number,
  weaponDamage: number,
  armor: number
}

monsterState = {
  attack: number,
  armor: number
}

The exact JSON structure may be strongly typed in TypeScript.

Avoid unstructured arbitrary JSON access throughout the engine.


10. Player stats for the first combat

The final game uses effective character values derived from:

base character stats
+
equipment

For the first combat slice, reuse CharacterStatsService if it already exists.

If equipment has not yet been implemented, do not implement the entire inventory/equipment system solely for Slice 0.3.

The demo character starts conceptually with:

100 HP
6 base attack
8 weapon damage
6 armor

These correspond to the established starting reference character.

Any temporary mechanism needed to supply the starting weapon/armor values must:

  • remain server-side
  • be isolated behind the character/combat stats boundary
  • not leak into Angular
  • be easy to replace when Slice 0.5 introduces real equipment
  • not change the public combat API

Do not create fake frontend equipment state.


11. Damage formula

Use the established Ashen Realms damage model.

Raw damage:

Raw Damage =
Weapon Damage + Attack

Armor mitigation:

Damage =
Raw Damage × 60 / (60 + Armor)

Round to the nearest integer.

Minimum damage:

1

Conceptual implementation:

const rawDamage =
  attack + weaponDamage;

const mitigatedDamage =
  rawDamage * 60 / (60 + targetArmor);

return Math.max(
  1,
  Math.round(mitigatedDamage),
);

For monsters without explicit weapon damage, their attack may represent their complete offensive base value for this first slice.

Keep this distinction explicit in engine types.


12. No combat randomness

Slice 0.3 combat contains no random damage range.

Do not implement:

critical hits
dodge
block chance
accuracy
hit chance
random damage variance
elemental damage
resistances

Given the same combat state and action:

the same result must be produced.

This is intentional.


13. CombatEngineService

Create a framework-light engine:

apps/api/src/combat/combat-engine.service.ts

The engine should not directly access PostgreSQL.

Required conceptual API:

resolveAction(
  state: CombatEngineState,
  action: CombatActionInput,
): CombatEngineResult

The engine receives:

current round
player stats/state
monster stats/state
requested player action

and returns:

new combat state
events
status/result

The engine must be unit-testable without Nest repositories.


14. Combat action enum

Introduce:

ATTACK

as a real enum/API value.

Design the enum so future values can be added:

HEAVY_STRIKE
SHIELD_BASH
DEFEND
POTION
FLEE

But do not implement their behavior in Slice 0.3.

If an unsupported action is submitted, return a domain validation error.


15. Round resolution

For:

ATTACK

resolve:

Step 1 Player attack

Calculate damage against monster armor.

Reduce:

monsterCurrentHp

but never below:

0

Create a combat event.


Step 2 Victory check

If:

monsterCurrentHp <= 0

then:

status = WON

The monster must not receive another attack.

The round ends.


Step 3 Monster attack

If the monster survived:

Calculate monster damage against player armor.

Reduce:

playerCurrentHp

but never below:

0

Create a combat event.


Step 4 Defeat check

If:

playerCurrentHp <= 0

then:

status = LOST

Step 5 Round progression

If combat remains active:

round += 1

Use one consistent round-number convention throughout API, persistence and UI.

Recommended:

Combat starts at round 1.
After resolving round 1 successfully:
round becomes 2.

16. CombatEvent entity

Create:

apps/api/src/combat/entities/combat-event.entity.ts

Fields should support ordered event history.

Conceptually:

id: uuid;

combatId: uuid;

round: number;
sequence: number;

type: CombatEventType;

source: PLAYER | MONSTER;
target: PLAYER | MONSTER;

amount?: number;

createdAt: timestamptz;

Initial event types should include:

DAMAGE
COMBAT_WON
COMBAT_LOST

Avoid storing presentation sentences as the authoritative event format.

Store structured events.

Angular may transform them into German display text.


17. Example events

Player attacks Aschenratte:

{
  "type": "DAMAGE",
  "source": "PLAYER",
  "target": "MONSTER",
  "amount": 14
}

Monster attacks player:

{
  "type": "DAMAGE",
  "source": "MONSTER",
  "target": "PLAYER",
  "amount": 5
}

Combat victory:

{
  "type": "COMBAT_WON",
  "source": "PLAYER",
  "target": "MONSTER"
}

18. Combat creation transaction

Creating combat should be atomic.

Conceptual transaction:

lock/validate HuntEncounter
↓
verify no active combat
↓
load monster
↓
calculate/snapshot player stats
↓
create Combat
↓
consume HuntEncounter
↓
commit

Concurrent requests for the same encounter must not create two combats.


19. CombatService

CombatService owns persistence and orchestration.

Responsibilities:

create combat from encounter

load combat

validate combat ownership/state

call CombatEngineService

persist updated HP/state

persist ordered CombatEvents

persist WON/LOST status

return combat DTO

Do not put damage formulas into the controller.

Do not put TypeORM repository access into the pure combat engine.


20. Combat API

Required endpoints:

POST /api/hunt-encounters/:encounterId/attack

GET /api/combats/:combatId

POST /api/combats/:combatId/actions

21. Start combat request

Example:

POST /api/hunt-encounters/abc123/attack

No body containing combat values is required.

The server derives:

character
monster
player stats
monster stats
starting HP

Response should return the newly created combat.


22. Combat action request

Request:

{
  "action": "ATTACK"
}

The frontend must never send:

damage
playerHp
monsterHp
armor
attack
weaponDamage
round
combat status

These values are entirely server-owned.


23. Combat response DTO

Conceptually:

{
  "id": "combat-uuid",
  "status": "ACTIVE",
  "round": 2,

  "player": {
    "name": "Aric Duskwalker",
    "maxHp": 100,
    "currentHp": 95
  },

  "monster": {
    "key": "ash-rat",
    "name": "Aschenratte",
    "level": 1,
    "maxHp": 45,
    "currentHp": 31,
    "artworkPath": "/assets/monsters/ash-rat.webp"
  },

  "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
    }
  ]
}

Do not expose unnecessary internal engine state.


24. GET combat

GET /api/combats/:combatId must allow the browser to restore the fight after:

refresh
route reload
browser reconnect

The frontend must not depend on transient local state for current HP or round.

The server response remains authoritative.


25. Finished combat behavior

If combat status is:

WON

or:

LOST

then further calls to:

POST /api/combats/:combatId/actions

must be rejected.

Example error:

{
  "statusCode": 409,
  "code": "COMBAT_ALREADY_FINISHED",
  "message": "This combat has already finished."
}

26. Active combat restriction

A character may have at most one:

ACTIVE

combat.

Starting another encounter while a combat is active must fail.

Example:

COMBAT_ALREADY_ACTIVE

Where practical, protect this invariant using a partial unique database index in addition to service validation.


27. Invalid encounter errors

Use stable domain errors.

Examples:

HUNT_ENCOUNTER_NOT_FOUND
HUNT_ENCOUNTER_ALREADY_CONSUMED
INVALID_HUNT_ENCOUNTER
CHARACTER_TRAVELLING
COMBAT_ALREADY_ACTIVE

Do not expose raw TypeORM/PostgreSQL errors to the client.


28. Database migration

Create a reviewed TypeORM migration for:

combat
combat_event

and any required HuntEncounter state/constraint changes.

The migration must:

  • preserve existing world/travel/hunting data
  • use UUID primary keys
  • create foreign keys
  • create useful indexes
  • prevent obvious duplicate active-combat states where practical
  • not recreate unrelated tables
  • keep synchronize disabled

29. Angular route

Implement:

/combat/:combatId

The Hunt screen's:

Angreifen

action changes from the Slice 0.2 placeholder to:

POST combat from encounter
↓
receive combat ID
↓
navigate to /combat/:combatId

Remove the temporary combat placeholder from Slice 0.2.


30. Combat screen composition

The combat screen follows the established Ashen Realms layout:

player left
monster right
location background
combat actions below
combat information clearly visible
combat log in contextual area/right side

The fight must visually feel like a confrontation.

Do not render combat as a statistics table.


31. Player presentation

Display:

character artwork
character name
current HP
maximum HP
HP bar

Use existing player artwork.

Do not introduce a new art style.


32. Monster presentation

Display:

monster artwork
monster name
monster level
current HP
maximum HP
HP bar

Use the artwork path supplied by the API/content definition.

The monster should receive similar visual weight to the player.


33. Background

Use the current combat location:

Verbrannte Straße

as the scene context.

Reuse the existing location artwork where appropriate.

The background must support the confrontation rather than compete with HP/action readability.


34. Combat action bar

Slice 0.3 shows one functional action:

Angriff

The action should be implemented using the visual language intended for future combat buttons.

Recommended display:

icon
Angriff
optional hotkey

Do not show fake functional buttons for unimplemented mechanics.

It is acceptable to reserve visual space for future actions, but disabled placeholders should only be used if they improve layout validation.


35. Input locking

When an action request is in progress:

disable combat actions

The player must not accidentally submit the same round multiple times.

The backend must still protect against concurrency.

Frontend locking is only UX protection.

Server validation remains authoritative.


36. Combat log

Display structured events as readable German log entries.

Example:

Runde 1

Aric trifft Aschenratte für 14 Schaden.
Aschenratte trifft Aric für 5 Schaden.

Do not persist these sentences as domain truth.

Build them from structured CombatEventDto objects.


37. Round display

Clearly show:

Runde 1
Runde 2
...

Round state must come from the API.

Angular must never independently increment the authoritative round value.


38. Victory state

When:

status = WON

show a clear victory state.

Example:

Sieg

Die Aschenratte wurde besiegt.

At this point Slice 0.3 ends.

Do not grant or invent rewards.

A small deliberate message may indicate:

Belohnungen werden im nächsten Schritt verarbeitet.

Primary action may be:

Zur Jagd

Do not automatically create another hunt.


39. Defeat state

When:

status = LOST

show a clear defeat state.

Example:

Niederlage

Aric wurde im Kampf besiegt.

For Slice 0.3, do not build the complete long-term defeat/reset system unless it already exists.

The combat must remain persisted as:

LOST

A simple return action to the world/hunt context is sufficient for this slice.

Any final safe-location reset should be defined in a later dedicated slice unless already implemented.


40. Refresh/recovery behavior

Refreshing:

/combat/:combatId

must reload combat from the server.

The UI must restore:

combat status
round
player HP
monster HP
monster
combat history

No combat-critical state may exist only in Angular memory.


41. Frontend state

Recommended state:

combat
loading
actionPending
error

Derived presentation state may include:

player hp percentage
monster hp percentage
formatted combat events

Do not duplicate authoritative gameplay values in mutable frontend state.


42. Combat API service

Add focused methods equivalent to:

startCombat(
  encounterId: string,
): Observable<CombatDto>

getCombat(
  combatId: string,
): Observable<CombatDto>

performAction(
  combatId: string,
  action: CombatAction,
): Observable<CombatDto>

Only send the action enum for combat actions.


43. CombatEngine unit tests

At minimum cover:

Damage formula

Example established case:

attack = 12
weaponDamage = 15
target armor = 20

Expected:

20 damage

Minimum damage

Extremely high armor must still result in:

1 damage minimum

Player attack

Verify:

monster HP decreases correctly
DAMAGE event created

Monster retaliation

If the monster survives:

player HP decreases
monster DAMAGE event created

No retaliation after death

If player attack reduces monster to zero:

monster does not attack
combat = WON

Defeat

If monster attack reduces player to zero:

combat = LOST

Determinism

The same state plus:

ATTACK

must produce the same result.


44. CombatService tests

At minimum cover:

Valid encounter starts combat

available HuntEncounter
→ ACTIVE Combat

Arbitrary monster cannot start combat

There must be no public API accepting an arbitrary monster definition as the combat source.


Encounter can only be consumed once

Two attempts using the same encounter must not produce two combats.


Active combat restriction

Character with an ACTIVE combat cannot create another.


Persistence

After an action:

HP changes persisted
round persisted
CombatEvents persisted

Finished combat

Further actions after:

WON

or:

LOST

must fail.


Transaction/concurrency

Where feasible, verify concurrent or repeated requests cannot resolve the same combat round twice.


45. Frontend tests

At minimum cover:

Combat creation

Clicking:

Angreifen

on the Hunt page:

uses HuntEncounter.id
calls combat creation
navigates to /combat/:combatId

Combat load

Opening:

/combat/:combatId

loads the authoritative combat state.


Combat presentation

Verify visibility of:

player
monster
both HP bars
round
Angriff
combat log

Combat action

Click:

Angriff

and verify the frontend sends only:

{
  "action": "ATTACK"
}

Action lock

While the action request is unresolved:

Angriff disabled

Event rendering

Structured DAMAGE events are rendered as readable combat-log entries.


Victory

When API returns:

WON

the UI shows victory and disables combat actions.


Defeat

When API returns:

LOST

the UI shows defeat and disables combat actions.


46. Visual requirements

Follow the existing Ashen Realms visual design.

Combat should emphasize:

large character artwork
large monster artwork
dark fantasy location background
clear HP bars
clear action area
strong confrontation
dark metal/stone panels
bronze detail
restrained functional colors

Do not use:

generic cards
SaaS dashboard layout
white panels
Material defaults
Bootstrap defaults
glassmorphism
neon-heavy effects

47. Animation scope

Simple presentation effects are allowed:

small hit reaction
short damage number
subtle impact animation
HP-bar transition

But animations must be driven from server-returned events.

Do not delay or alter authoritative combat resolution for presentation.

Do not introduce a complex combat animation queue unless the existing frontend architecture already supports one cleanly.

Respect:

prefers-reduced-motion

48. Server-authority checklist

Server decides:

whether combat can start
which monster belongs to encounter
combat starting stats
player HP
monster HP
damage
round progression
monster retaliation
victory
defeat
combat status
combat events

Client decides only:

which valid HuntEncounter to attack
when to submit ATTACK during an active combat

49. Security/domain constraints

The client must never be able to submit:

monsterId
damage
target HP
player HP
armor
attack
weapon damage
victory
defeat
reward
round number

as authoritative combat inputs.

Do not trust disabled frontend buttons as domain validation.

Every action must be validated server-side.


50. Definition of Done

Playable Slice 0.3 is complete when this complete browser flow works:

Südtor
↓
travel to Verbrannte Straße
↓
Jagd
↓
Jagd beginnen
↓
select Aschenratte or Straßenräuber
↓
Angreifen
↓
server creates Combat from HuntEncounter
↓
navigate to /combat/:combatId
↓
player and monster appear
↓
both HP bars visible
↓
Runde 1 visible
↓
player clicks Angriff
↓
server resolves complete round
↓
combat events appear
↓
HP changes
↓
continue attacking
↓
combat reaches WON or LOST
↓
finished state displayed

51. Required verification

Before considering the slice complete, run all relevant:

API unit tests
API integration tests
Angular tests
API build
Angular build
migration compilation
migration execution
seed verification

Perform browser walkthroughs for at least:

Aschenratte combat
Straßenräuber combat
victory
defeat or deterministic forced-loss test setup
browser refresh during ACTIVE combat
browser refresh after finished combat
reusing consumed HuntEncounter
attempting second combat while one is ACTIVE

52. Acceptance criteria

The implementation is accepted when:

  • Combat starts only from persisted HuntEncounter IDs
  • one encounter cannot create multiple combats
  • one character cannot have multiple active combats
  • combat state is stored in PostgreSQL
  • player and monster HP are server-owned
  • ATTACK is resolved server-side
  • damage follows the defined formula
  • minimum damage is 1
  • monster attacks only if still alive
  • combat progresses round by round
  • CombatEvents are persisted
  • events are ordered
  • combat can reach WON
  • combat can reach LOST
  • finished combat rejects further actions
  • page refresh restores combat state
  • Angular never calculates authoritative damage
  • Angular displays player and monster visually
  • HP bars, round and combat log work
  • combat visually matches the existing Ashen Realms UI
  • no rewards are granted yet
  • all relevant tests pass
  • frontend and backend builds succeed

53. Handoff to Playable Slice 0.4

The exact output boundary of this slice is:

Combat.status = WON

with a persisted finished combat.

Playable Slice 0.4 will begin from that state and implement:

WON Combat
↓
reward resolution
↓
XP
↓
silver
↓
loot roll
↓
reward persistence
↓
loot summary

The combat system must therefore finish a fight cleanly without granting rewards itself.

Reward calculation belongs to the next slice.


54. Architectural boundary

The final separation should be:

HuntingService
    ↓
valid HuntEncounter

CombatService
    ↓
persistent combat orchestration

CombatEngineService
    ↓
pure deterministic round resolution

Slice 0.4
    ↓
reward / loot processing

Do not let these responsibilities collapse into a single service.

The combat engine resolves combat.

It does not know about:

TypeORM
loot tables
inventory
XP progression
shops
quests
Angular

55. Summary

Playable Slice 0.3 proves the second major gameplay pillar of Ashen Realms:

A server-generated encounter becomes a real visual, deterministic, persistent turn-based fight.

The player can now:

travel
→ hunt
→ choose an enemy
→ fight
→ win or lose

The next slice adds the reason to keep doing it:

loot and progression