Merge branch 'worktree-slice-0.3-first-combat'

This commit is contained in:
Bastian Wagner
2026-08-19 22:40:32 +02:00
52 changed files with 3191 additions and 111 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

View File

@@ -22,10 +22,10 @@ export const routes: Routes = [
),
},
{
path: 'combat/new',
path: 'combat/:combatId',
loadComponent: () =>
import('./features/combat/combat-placeholder-page.component').then(
(module) => module.CombatPlaceholderPageComponent,
import('./features/combat/combat-page/combat-page.component').then(
(module) => module.CombatPageComponent,
),
},
],

View File

@@ -68,3 +68,41 @@ export interface HuntResult {
location: LocationSummary;
encounters: HuntEncounter[];
}
export type CombatStatus = 'ACTIVE' | 'WON' | 'LOST';
export type CombatEventType = 'DAMAGE' | 'COMBAT_WON' | 'COMBAT_LOST';
export type CombatSide = 'PLAYER' | 'MONSTER';
export type CombatAction = '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;
}
export interface CombatMonster {
key: string;
name: string;
level: number;
maxHp: number;
currentHp: number;
artworkPath: string;
}
export interface Combat {
id: string;
status: CombatStatus;
round: number;
player: CombatPlayer;
monster: CombatMonster;
events: CombatEvent[];
}

View File

@@ -47,4 +47,30 @@ describe('GameApiService', () => {
expect(request.request.body).toEqual({ targetLocationId: 'target-uuid' });
request.flush({ status: 'IDLE' });
});
it('posts to the encounter-scoped attack endpoint with an empty body to start a combat', () => {
service.startCombat('encounter-uuid').subscribe();
const request = http.expectOne('/api/hunt-encounters/encounter-uuid/attack');
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({});
request.flush({});
});
it('gets a combat by id', () => {
service.getCombat('combat-uuid').subscribe();
const request = http.expectOne('/api/combats/combat-uuid');
expect(request.request.method).toBe('GET');
request.flush({});
});
it('posts only the action enum when performing a combat action', () => {
service.performCombatAction('combat-uuid', 'ATTACK').subscribe();
const request = http.expectOne('/api/combats/combat-uuid/actions');
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({ action: 'ATTACK' });
request.flush({});
});
});

View File

@@ -1,7 +1,14 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { CharacterResponse, CurrentLocationResponse, CurrentTravel, HuntResult } from './game-api.models';
import {
CharacterResponse,
Combat,
CombatAction,
CurrentLocationResponse,
CurrentTravel,
HuntResult,
} from './game-api.models';
@Injectable({ providedIn: 'root' })
export class GameApiService {
@@ -26,4 +33,16 @@ export class GameApiService {
startHunt(): Observable<HuntResult> {
return this.http.post<HuntResult>('/api/hunts', {});
}
startCombat(encounterId: string): Observable<Combat> {
return this.http.post<Combat>(`/api/hunt-encounters/${encounterId}/attack`, {});
}
getCombat(combatId: string): Observable<Combat> {
return this.http.get<Combat>(`/api/combats/${combatId}`);
}
performCombatAction(combatId: string, action: CombatAction): Observable<Combat> {
return this.http.post<Combat>(`/api/combats/${combatId}/actions`, { action });
}
}

View File

@@ -0,0 +1,119 @@
<section class="combat" aria-label="Kampf">
@if (combatStore.combat(); as combat) {
<div class="combat__stage">
<header class="combat__status">
<div class="fighter fighter--player">
<img class="fighter__icon" [src]="playerIcon" alt="" />
<div class="fighter__meter">
<p class="fighter__name">{{ combat.player.name }}</p>
<div
class="bar bar--player"
role="progressbar"
[attr.aria-label]="'Lebenspunkte ' + combat.player.name"
[attr.aria-valuenow]="combat.player.currentHp"
[attr.aria-valuemin]="0"
[attr.aria-valuemax]="combat.player.maxHp"
>
<span class="bar__fill" [style.inline-size.%]="playerHpPercent()"></span>
<span class="bar__text">{{ combat.player.currentHp }} / {{ combat.player.maxHp }}</span>
</div>
</div>
</div>
<p class="combat__round" data-combat-round>Runde {{ combat.round }}</p>
<div class="fighter fighter--monster">
<div class="fighter__meter">
<p class="fighter__name">
{{ combat.monster.name }}
<span class="fighter__level">Stufe {{ combat.monster.level }}</span>
</p>
<div
class="bar bar--monster"
role="progressbar"
[attr.aria-label]="'Lebenspunkte ' + combat.monster.name"
[attr.aria-valuenow]="combat.monster.currentHp"
[attr.aria-valuemin]="0"
[attr.aria-valuemax]="combat.monster.maxHp"
>
<span class="bar__fill" [style.inline-size.%]="monsterHpPercent()"></span>
<span class="bar__text">{{ combat.monster.currentHp }} / {{ combat.monster.maxHp }}</span>
</div>
</div>
<img
class="fighter__icon"
[src]="monsterIcon(combat.monster.key, combat.monster.artworkPath)"
alt=""
/>
</div>
</header>
<div class="combat__field">
<img class="sprite sprite--player" [src]="playerSprite" [alt]="combat.player.name" />
<img
class="sprite sprite--monster"
[src]="monsterSprite(combat.monster.key, combat.monster.artworkPath)"
[alt]="combat.monster.name"
/>
</div>
<footer class="combat__actions">
@if (combat.status === 'ACTIVE') {
<button
type="button"
class="action"
data-combat-attack
[disabled]="combatStore.actionPending()"
(click)="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>
}
</footer>
@if (combat.status === 'WON') {
<div class="outcome outcome--won" data-combat-result="WON">
<h2 class="outcome__title">Sieg</h2>
<p>{{ combat.monster.name }} wurde besiegt.</p>
<p class="outcome__hint">Belohnungen werden im nächsten Schritt verarbeitet.</p>
<button type="button" class="outcome__button" data-combat-to-hunt (click)="goToHunt()">
Zur Jagd
</button>
</div>
} @else if (combat.status === 'LOST') {
<div class="outcome outcome--lost" data-combat-result="LOST">
<h2 class="outcome__title">Niederlage</h2>
<p>{{ combat.player.name }} wurde im Kampf besiegt.</p>
<button type="button" class="outcome__button" data-combat-to-hunt (click)="goToHunt()">
Zur Jagd
</button>
</div>
}
</div>
<aside class="combat__log" aria-label="Kampfprotokoll">
<h2 class="combat__log-title">Kampflog</h2>
<div class="combat__log-body">
@for (round of logRounds(); track round.round) {
<p class="combat__log-round">Runde {{ round.round }}</p>
@for (event of round.events; track event.sequence) {
<p class="combat__log-entry" [class.combat__log-entry--player]="event.source === 'PLAYER'">
{{ formatEvent(event) }}
</p>
}
}
</div>
</aside>
} @else if (combatStore.loading()) {
<p class="combat__notice" role="status">Kampf wird geladen…</p>
}
@if (combatStore.error(); as error) {
<section class="combat__notice combat__notice--error" role="alert">
<p>{{ error }}</p>
<button type="button" data-combat-retry (click)="retry()">Erneut versuchen</button>
</section>
}
</section>

View File

@@ -0,0 +1,479 @@
:host {
display: block;
block-size: 100%;
}
.combat {
display: grid;
grid-template-columns: minmax(0, 1fr) clamp(13rem, 17vw, 17rem);
gap: var(--ar-space-4);
block-size: 100%;
}
/* ---------- stage ---------- */
.combat__stage {
position: relative;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: var(--ar-space-4);
min-block-size: 26rem;
padding: var(--ar-space-4);
overflow: hidden;
border: 1px solid var(--ar-border);
background-color: var(--ar-bg);
background-image: url('/images/backgrounds/Aschestrasse.png');
background-position: center;
background-repeat: no-repeat;
background-size: cover;
box-shadow: var(--ar-shadow-raised);
}
@supports (
background-image: image-set(
url('/images/backgrounds/runtime/Aschestrasse-960.jpg') type('image/jpeg') 1x
)
) {
.combat__stage {
background-image: image-set(
url('/images/backgrounds/runtime/Aschestrasse-960.jpg') type('image/jpeg') 1x
);
}
}
.combat__stage::before {
position: absolute;
z-index: 0;
inset: 0;
content: '';
background:
linear-gradient(180deg, rgb(4 6 8 / 0.72) 0%, rgb(4 6 8 / 0.1) 26%, rgb(4 6 8 / 0.66) 100%);
pointer-events: none;
}
/* ---------- combatant status ---------- */
.combat__status {
position: relative;
z-index: 2;
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: start;
gap: var(--ar-space-4);
}
.fighter {
display: flex;
align-items: center;
gap: var(--ar-space-3);
min-inline-size: 0;
}
.fighter--monster {
justify-content: flex-end;
}
.fighter__icon {
flex: 0 0 auto;
inline-size: clamp(2.75rem, 5vw, 3.75rem);
block-size: clamp(2.75rem, 5vw, 3.75rem);
object-fit: cover;
border-radius: 50%;
box-shadow:
0 0 0 1px var(--ar-border-highlight),
0 0.3rem 0.9rem rgb(0 0 0 / 0.8);
}
.fighter__meter {
display: grid;
gap: 0.3rem;
min-inline-size: 0;
flex: 1 1 auto;
max-inline-size: 22rem;
}
.fighter--monster .fighter__meter {
justify-items: end;
text-align: end;
}
.fighter__name {
margin: 0;
overflow: hidden;
color: var(--ar-text);
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(0.95rem, 1.5vw, 1.15rem);
letter-spacing: 0.02em;
text-overflow: ellipsis;
text-shadow: 0 0.1rem 0.5rem rgb(0 0 0 / 0.95);
white-space: nowrap;
}
.fighter__level {
color: var(--ar-gold);
font-family: system-ui, sans-serif;
font-size: var(--ar-font-sm);
letter-spacing: 0.08em;
text-transform: uppercase;
}
/* ---------- hp bars ---------- */
.bar {
position: relative;
display: block;
inline-size: 100%;
block-size: 1.25rem;
overflow: hidden;
border: 1px solid var(--ar-border);
background: linear-gradient(180deg, rgb(0 0 0 / 0.85), rgb(0 0 0 / 0.6));
box-shadow: inset 0 0 0.6rem rgb(0 0 0 / 0.9);
}
.bar__fill {
position: absolute;
z-index: 0;
inset-block: 0;
inset-inline-start: 0;
display: block;
background: linear-gradient(180deg, #d0564a, #8e2b23);
}
.bar--monster .bar__fill {
inset-inline-start: auto;
inset-inline-end: 0;
}
.bar__text {
position: relative;
z-index: 1;
display: grid;
block-size: 100%;
place-items: center;
color: var(--ar-text);
font-size: var(--ar-font-sm);
font-variant-numeric: tabular-nums;
letter-spacing: 0.04em;
text-shadow: 0 0.05rem 0.25rem rgb(0 0 0 / 1);
}
/* ---------- round marker ---------- */
.combat__round {
margin: 0;
align-self: center;
padding: var(--ar-space-1) var(--ar-space-5);
border-block: 1px solid var(--ar-border-highlight);
color: var(--ar-gold);
background: linear-gradient(90deg, transparent, rgb(9 11 13 / 0.9) 18%, rgb(9 11 13 / 0.9) 82%, transparent);
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(0.95rem, 1.4vw, 1.1rem);
letter-spacing: 0.14em;
text-transform: uppercase;
white-space: nowrap;
}
/* ---------- battlefield ---------- */
.combat__field {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 1fr 1fr;
align-items: end;
min-block-size: 0;
}
.sprite {
display: block;
max-block-size: 100%;
inline-size: auto;
object-fit: contain;
filter: drop-shadow(0 1rem 1.5rem rgb(0 0 0 / 0.75));
}
.sprite--player {
justify-self: start;
block-size: clamp(11rem, 30vh, 19rem);
transform: scaleX(-1);
}
.sprite--monster {
justify-self: end;
block-size: clamp(9rem, 24vh, 15rem);
}
/* ---------- action bar ---------- */
.combat__actions {
position: relative;
z-index: 2;
display: flex;
gap: var(--ar-space-3);
min-block-size: clamp(6.5rem, 11vw, 8.5rem);
}
.action {
position: relative;
inline-size: clamp(6.5rem, 11vw, 8.5rem);
aspect-ratio: 1;
padding: 0;
border: 0;
background: transparent url('/images/hud/runtime/fight-action-frame-360.png') center / 100% 100%
no-repeat;
cursor: pointer;
}
.action__icon {
position: absolute;
inset-block-start: 32%;
inset-inline-start: 50%;
inline-size: 34%;
translate: -50% -50%;
border-radius: 50%;
opacity: 0.92;
}
.action__label {
position: absolute;
inset-block-start: 62%;
inset-inline-start: 50%;
translate: -50% -50%;
color: var(--ar-text);
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(0.85rem, 1.2vw, 1rem);
letter-spacing: 0.03em;
white-space: nowrap;
}
.action__key {
position: absolute;
inset-block-start: 90%;
inset-inline-start: 50%;
translate: -50% -50%;
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
font-variant-numeric: tabular-nums;
}
.action:hover:not(:disabled) .action__label,
.action:focus-visible .action__label {
color: var(--ar-gold);
}
.action:focus-visible {
outline: 2px solid var(--ar-gold);
outline-offset: 3px;
}
.action:disabled {
cursor: progress;
opacity: 0.55;
}
/* ---------- outcome overlay ---------- */
.outcome {
position: absolute;
z-index: 3;
inset-block-start: 50%;
inset-inline-start: 50%;
display: grid;
gap: var(--ar-space-2);
justify-items: center;
inline-size: min(26rem, 80%);
padding: var(--ar-space-5);
translate: -50% -50%;
border: 1px solid var(--ar-border-highlight);
background: rgb(9 11 13 / 0.94);
box-shadow: var(--ar-shadow-raised);
text-align: center;
}
.outcome p {
margin: 0;
color: var(--ar-text-muted);
}
.outcome__title {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(1.5rem, 3vw, 2rem);
font-weight: 400;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.outcome--won .outcome__title {
color: var(--ar-gold);
}
.outcome--lost .outcome__title {
color: var(--ar-danger);
}
.outcome__hint {
font-size: var(--ar-font-sm);
font-style: italic;
}
.outcome__button {
margin-block-start: var(--ar-space-2);
padding: var(--ar-space-2) var(--ar-space-5);
border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-sm);
color: var(--ar-text);
background: linear-gradient(180deg, #23282c, #14181b);
cursor: pointer;
font-family: Georgia, 'Times New Roman', serif;
letter-spacing: 0.04em;
}
.outcome__button:hover {
border-color: var(--ar-gold);
color: var(--ar-gold);
}
/* ---------- combat log ---------- */
.combat__log {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
min-block-size: 0;
border: 1px solid var(--ar-border);
background: var(--ar-panel-muted);
box-shadow: var(--ar-shadow-raised);
}
.combat__log-title {
margin: 0;
padding: var(--ar-space-3) var(--ar-space-4);
border-block-end: 1px solid var(--ar-border);
color: var(--ar-gold);
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.05rem;
font-weight: 400;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.combat__log-body {
padding: var(--ar-space-3) var(--ar-space-4) var(--ar-space-4);
overflow-y: auto;
}
.combat__log-round {
margin: var(--ar-space-4) 0 var(--ar-space-2);
color: var(--ar-gold);
font-size: var(--ar-font-sm);
letter-spacing: 0.1em;
text-transform: uppercase;
}
.combat__log-round:first-child {
margin-block-start: 0;
}
.combat__log-entry {
margin: 0 0 var(--ar-space-1);
padding-inline-start: var(--ar-space-3);
border-inline-start: 2px solid var(--ar-danger);
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
line-height: 1.45;
}
.combat__log-entry--player {
border-inline-start-color: var(--ar-border-highlight);
}
/* ---------- notices ---------- */
.combat__notice {
grid-column: 1 / -1;
padding: var(--ar-space-4);
border: 1px solid var(--ar-border);
background: var(--ar-panel);
box-shadow: var(--ar-shadow-raised);
}
.combat__notice--error {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ar-space-4);
border-color: var(--ar-danger);
}
.combat__notice--error button {
flex: 0 0 auto;
padding: var(--ar-space-2) var(--ar-space-3);
border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-sm);
color: var(--ar-text);
background: #1a2023;
cursor: pointer;
}
@media (prefers-reduced-motion: no-preference) {
.bar__fill {
transition: inline-size var(--ar-motion-base);
}
.action__label {
transition: color var(--ar-motion-fast);
}
}
@media (width < 60rem) {
:host,
.combat {
block-size: auto;
}
.combat {
grid-template-columns: minmax(0, 1fr);
}
.combat__stage {
min-block-size: clamp(24rem, 55vh, 34rem);
}
.combat__log {
max-block-size: 18rem;
}
}
@media (width < 40rem) {
.combat__status {
grid-template-columns: minmax(0, 1fr);
gap: var(--ar-space-2);
}
.combat__round {
order: -1;
justify-self: center;
}
.fighter--monster {
justify-content: flex-start;
}
.fighter--monster .fighter__meter {
justify-items: start;
text-align: start;
}
.bar--monster .bar__fill {
inset-inline-start: 0;
inset-inline-end: auto;
}
.sprite--player {
block-size: clamp(8rem, 22vh, 12rem);
}
.sprite--monster {
block-size: clamp(7rem, 18vh, 10rem);
}
}

View File

@@ -0,0 +1,153 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, Router, provideRouter } from '@angular/router';
import { vi } from 'vitest';
import type { Combat } from '../../../core/api/game-api.models';
import { CombatStore } from '../combat.store';
import { CombatPageComponent } from './combat-page.component';
const activeCombat: Combat = {
id: 'combat-1',
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: '/images/monsters/ash-rat.png',
},
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 },
],
};
describe('CombatPageComponent', () => {
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>;
attack: ReturnType<typeof vi.fn>;
};
let router: Router;
async function setup(combat: Combat | null) {
combatStore = {
combat: signal(combat),
loading: signal(false),
actionPending: signal(false),
error: signal<string | null>(null),
loadCombat: vi.fn(() => Promise.resolve()),
attack: vi.fn(() => Promise.resolve()),
};
await TestBed.configureTestingModule({
imports: [CombatPageComponent],
providers: [
provideRouter([]),
{ provide: CombatStore, useValue: combatStore },
{
provide: ActivatedRoute,
useValue: { snapshot: { paramMap: convertToParamMap({ combatId: 'combat-1' }) } },
},
],
}).compileComponents();
router = TestBed.inject(Router);
vi.spyOn(router, 'navigate').mockResolvedValue(true);
const fixture = TestBed.createComponent(CombatPageComponent);
fixture.detectChanges();
return fixture;
}
it('loads the combat from the route param on init', async () => {
await setup(activeCombat);
expect(combatStore.loadCombat).toHaveBeenCalledWith('combat-1');
});
it('shows the player, monster, HP bars, round, and the Angriff action', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('Aric Duskwalker');
expect(element.textContent).toContain('95 / 100');
expect(element.textContent).toContain('Aschenratte');
expect(element.textContent).toContain('31 / 45');
expect(element.querySelector('[data-combat-round]')?.textContent).toContain('Runde 2');
expect(element.querySelector('[data-combat-attack]')).toBeTruthy();
});
it('renders the structured events as readable German combat-log entries', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('Aric Duskwalker trifft Aschenratte für 14 Schaden.');
expect(element.textContent).toContain('Aschenratte trifft Aric Duskwalker für 5 Schaden.');
});
it('calls combatStore.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.attack).toHaveBeenCalledOnce();
});
it('disables Angriff while an action is pending', async () => {
const fixture = await setup(activeCombat);
combatStore.actionPending.set(true);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector<HTMLButtonElement>('[data-combat-attack]')?.disabled).toBe(true);
});
it('shows the victory state and hides Angriff when the combat is WON', async () => {
const fixture = await setup({ ...activeCombat, status: 'WON' });
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-combat-result="WON"]')).toBeTruthy();
expect(element.textContent).toContain('Sieg');
expect(element.querySelector('[data-combat-attack]')).toBeNull();
});
it('shows the defeat state and hides Angriff when the combat is LOST', async () => {
const fixture = await setup({ ...activeCombat, status: 'LOST' });
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-combat-result="LOST"]')).toBeTruthy();
expect(element.textContent).toContain('Niederlage');
expect(element.querySelector('[data-combat-attack]')).toBeNull();
});
it('navigates to /hunt from the victory screen', async () => {
const fixture = await setup({ ...activeCombat, status: 'WON' });
const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-combat-to-hunt]')?.click();
expect(router.navigate).toHaveBeenCalledWith(['/hunt']);
});
it('shows an error and retries loading the combat', async () => {
const fixture = await setup(null);
combatStore.error.set('Dieser Kampf wurde nicht gefunden.');
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[role="alert"]')?.textContent).toContain(
'Dieser Kampf wurde nicht gefunden.',
);
element.querySelector<HTMLButtonElement>('[data-combat-retry]')?.click();
expect(combatStore.loadCombat).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,106 @@
import { Component, OnInit, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import type { CombatEvent } from '../../../core/api/game-api.models';
import {
combatMonsterIconPath,
combatMonsterSpritePath,
runtimeMonsterArtworkPath,
} from '../../../shared/monster-artwork';
import { CombatStore } from '../combat.store';
interface CombatLogRound {
round: number;
events: CombatEvent[];
}
const PLAYER_SPRITE = '/images/combat/sprites/warrior-attack-512.png';
const PLAYER_ICON = '/images/hud/runtime/CharacterIcon-128.png';
@Component({
selector: 'app-combat-page',
templateUrl: './combat-page.component.html',
styleUrl: './combat-page.component.scss',
})
export class CombatPageComponent implements OnInit {
protected readonly combatStore = inject(CombatStore);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
ngOnInit(): void {
this.loadFromRoute();
}
protected attack(): void {
void this.combatStore.attack();
}
protected retry(): void {
this.loadFromRoute();
}
protected goToHunt(): void {
void this.router.navigate(['/hunt']);
}
protected readonly playerSprite = PLAYER_SPRITE;
protected readonly playerIcon = PLAYER_ICON;
protected monsterSprite(monsterKey: string, artworkPath: string): string {
return combatMonsterSpritePath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath;
}
protected monsterIcon(monsterKey: string, artworkPath: string): string {
return combatMonsterIconPath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath;
}
protected playerHpPercent(): number {
const combat = this.combatStore.combat();
return combat ? (combat.player.currentHp / combat.player.maxHp) * 100 : 0;
}
protected monsterHpPercent(): number {
const combat = this.combatStore.combat();
return combat ? (combat.monster.currentHp / combat.monster.maxHp) * 100 : 0;
}
protected logRounds(): CombatLogRound[] {
const combat = this.combatStore.combat();
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.combatStore.combat();
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 === 'COMBAT_WON') {
return `${monsterName} wurde besiegt.`;
}
return `${playerName} wurde im Kampf besiegt.`;
}
private loadFromRoute(): void {
const combatId = this.route.snapshot.paramMap.get('combatId');
if (combatId) {
void this.combatStore.loadCombat(combatId);
}
}
}

View File

@@ -1,72 +0,0 @@
import { Component, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
@Component({
selector: 'app-combat-placeholder-page',
template: `
<section class="combat-placeholder" aria-label="Kampfvorbereitung">
<span class="combat-placeholder__eyebrow">KAMPF</span>
<h2>Vorbereitung auf den Kampf</h2>
<p>
Die Klinge ist gezogen, der Gegner steht bereit doch das eigentliche Gefecht liegt noch
vor dir. Diese Ansicht ist ein Zwischenhalt auf dem Weg in den Kampf, der in einem
späteren Schritt folgt.
</p>
@if (encounterId) {
<p class="combat-placeholder__id" data-encounter-id>
Vorbereitung auf den Kampf gegen Begegnung {{ encounterId }}…
</p>
}
</section>
`,
styles: [
`
:host {
display: block;
}
.combat-placeholder {
display: grid;
gap: var(--ar-space-3);
max-inline-size: 40rem;
margin: var(--ar-space-6) auto;
padding: var(--ar-space-5);
border: 1px solid var(--ar-border);
background: var(--ar-panel);
box-shadow: var(--ar-shadow-raised);
text-align: center;
}
.combat-placeholder__eyebrow {
color: var(--ar-gold);
font-size: var(--ar-font-sm);
letter-spacing: 0.14em;
text-transform: uppercase;
}
.combat-placeholder h2 {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.6rem;
font-weight: 400;
}
.combat-placeholder p {
margin: 0;
color: var(--ar-text-muted);
line-height: 1.55;
}
.combat-placeholder__id {
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
font-style: italic;
}
`,
],
})
export class CombatPlaceholderPageComponent {
private readonly route = inject(ActivatedRoute);
protected readonly encounterId = this.route.snapshot.queryParamMap.get('encounterId');
}

View File

@@ -0,0 +1,161 @@
import { HttpErrorResponse } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { from, of, throwError } from 'rxjs';
import { vi } from 'vitest';
import type { Combat } from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service';
import { CombatStore } from './combat.store';
const startedCombat: Combat = {
id: 'combat-1',
status: 'ACTIVE',
round: 1,
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100 },
monster: {
key: 'ash-rat',
name: 'Aschenratte',
level: 1,
maxHp: 45,
currentHp: 45,
artworkPath: '/images/monsters/ash-rat.png',
},
events: [],
};
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 },
],
};
describe('CombatStore', () => {
let api: {
startCombat: ReturnType<typeof vi.fn>;
getCombat: ReturnType<typeof vi.fn>;
performCombatAction: ReturnType<typeof vi.fn>;
};
let store: CombatStore;
beforeEach(() => {
api = {
startCombat: vi.fn(() => of(startedCombat)),
getCombat: vi.fn(() => of(startedCombat)),
performCombatAction: vi.fn(() => of(afterAttack)),
};
TestBed.configureTestingModule({
providers: [CombatStore, { provide: GameApiService, useValue: api }],
});
store = TestBed.inject(CombatStore);
});
it('starts a combat and stores it', async () => {
await store.startCombat('encounter-1');
expect(api.startCombat).toHaveBeenCalledWith('encounter-1');
expect(store.combat()).toEqual(startedCombat);
expect(store.error()).toBeNull();
});
it('clears any previous combat and reports the mapped error when starting fails', async () => {
api.startCombat.mockReturnValue(
throwError(
() =>
new HttpErrorResponse({
status: 409,
error: { statusCode: 409, code: 'COMBAT_ALREADY_ACTIVE', message: 'Active.' },
}),
),
);
await store.startCombat('encounter-1');
expect(store.combat()).toBeNull();
expect(store.error()).toBe('Du befindest dich bereits in einem Kampf.');
});
it('loads a combat by id', async () => {
await store.loadCombat('combat-1');
expect(api.getCombat).toHaveBeenCalledWith('combat-1');
expect(store.combat()).toEqual(startedCombat);
});
it('reports the mapped error when loading an unknown combat', async () => {
api.getCombat.mockReturnValue(
throwError(
() =>
new HttpErrorResponse({
status: 404,
error: { statusCode: 404, code: 'COMBAT_NOT_FOUND', message: 'Not found.' },
}),
),
);
await store.loadCombat('unknown');
expect(store.error()).toBe('Dieser Kampf wurde nicht gefunden.');
});
it('sends only the ATTACK action and replaces combat with the server response', async () => {
await store.startCombat('encounter-1');
await store.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.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.attack();
expect(store.actionPending()).toBe(true);
const second = store.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.attack();
expect(store.actionPending()).toBe(false);
expect(store.combat()).toEqual(startedCombat);
expect(store.error()).toBe('Netzwerkfehler');
});
it('clears the error message', async () => {
api.startCombat.mockReturnValue(throwError(() => new Error('x')));
await store.startCombat('encounter-1');
expect(store.error()).not.toBeNull();
store.clearError();
expect(store.error()).toBeNull();
});
});

View File

@@ -0,0 +1,95 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Injectable, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { Combat } from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service';
const GENERIC_ERROR_MESSAGE = 'Der Kampf konnte nicht geladen werden.';
// Mirrors the combat error codes returned by the combat endpoints.
// Unknown/missing codes fall back to `GENERIC_ERROR_MESSAGE`.
const COMBAT_ERROR_MESSAGES: Readonly<Record<string, string>> = {
HUNT_ENCOUNTER_NOT_FOUND: 'Diese Begegnung wurde nicht gefunden.',
HUNT_ENCOUNTER_ALREADY_CONSUMED: 'Diese Begegnung wurde bereits genutzt.',
INVALID_HUNT_ENCOUNTER: 'Diese Begegnung ist nicht mehr gültig.',
CHARACTER_TRAVELLING: 'Du kannst nicht kämpfen, während du unterwegs bist.',
COMBAT_ALREADY_ACTIVE: 'Du befindest dich bereits in einem Kampf.',
COMBAT_NOT_FOUND: 'Dieser Kampf wurde nicht gefunden.',
COMBAT_ALREADY_FINISHED: 'Dieser Kampf ist bereits beendet.',
};
@Injectable({ providedIn: 'root' })
export class CombatStore {
private readonly combatState = signal<Combat | null>(null);
private readonly loadingState = signal(false);
private readonly actionPendingState = signal(false);
private readonly errorState = signal<string | null>(null);
readonly combat = this.combatState.asReadonly();
readonly loading = this.loadingState.asReadonly();
readonly actionPending = this.actionPendingState.asReadonly();
readonly error = this.errorState.asReadonly();
constructor(private readonly api: GameApiService) {}
async startCombat(encounterId: string): Promise<void> {
this.loadingState.set(true);
this.errorState.set(null);
try {
const combat = await firstValueFrom(this.api.startCombat(encounterId));
this.combatState.set(combat);
} catch (error) {
this.combatState.set(null);
this.errorState.set(this.toErrorMessage(error));
} finally {
this.loadingState.set(false);
}
}
async loadCombat(combatId: string): Promise<void> {
this.loadingState.set(true);
this.errorState.set(null);
try {
const combat = await firstValueFrom(this.api.getCombat(combatId));
this.combatState.set(combat);
} catch (error) {
this.errorState.set(this.toErrorMessage(error));
} finally {
this.loadingState.set(false);
}
}
async attack(): Promise<void> {
const combat = this.combatState();
if (!combat || this.actionPendingState()) {
return;
}
this.actionPendingState.set(true);
this.errorState.set(null);
try {
const updated = await firstValueFrom(this.api.performCombatAction(combat.id, 'ATTACK'));
this.combatState.set(updated);
} catch (error) {
this.errorState.set(this.toErrorMessage(error));
} finally {
this.actionPendingState.set(false);
}
}
clearError(): void {
this.errorState.set(null);
}
private toErrorMessage(error: unknown): string {
if (error instanceof HttpErrorResponse) {
const code = (error.error as { code?: string } | null)?.code;
return (code && COMBAT_ERROR_MESSAGES[code]) || GENERIC_ERROR_MESSAGE;
}
return error instanceof Error ? error.message : GENERIC_ERROR_MESSAGE;
}
}

View File

@@ -1,12 +1,8 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { HuntEncounter } from '../../../core/api/game-api.models';
import { runtimeMonsterArtworkPath } from '../../../shared/monster-artwork';
import { DangerBadgeComponent } from '../../../shared/danger-badge/danger-badge.component';
const runtimeArtworkPaths: Readonly<Record<string, string>> = {
'/images/monsters/ash-rat.png': '/images/monsters/runtime/ash-rat-560.jpg',
'/images/monsters/road-bandit.png': '/images/monsters/runtime/road-bandit-560.jpg',
};
@Component({
selector: 'app-encounter-card',
imports: [DangerBadgeComponent],
@@ -22,6 +18,6 @@ export class EncounterCardComponent {
}
protected runtimeArtworkPath(artworkPath: string): string | undefined {
return runtimeArtworkPaths[artworkPath];
return runtimeMonsterArtworkPath(artworkPath);
}
}

View File

@@ -64,4 +64,11 @@
<button type="button" data-hunt-retry (click)="retry()">Erneut versuchen</button>
</section>
}
@if (combatStore.error(); as combatError) {
<section class="hunt-page__error" role="alert">
<p>{{ combatError }}</p>
<button type="button" data-hunt-combat-dismiss (click)="dismissCombatError()">Schließen</button>
</section>
}
</section>

View File

@@ -1,8 +1,10 @@
// apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Router, provideRouter } from '@angular/router';
import { vi } from 'vitest';
import type { CurrentLocationResponse, HuntResult } from '../../../core/api/game-api.models';
import type { Combat, CurrentLocationResponse, HuntResult } from '../../../core/api/game-api.models';
import { CombatStore } from '../../combat/combat.store';
import { WorldStore } from '../../world/world.store';
import { HuntingStore } from '../hunting.store';
import { HuntPageComponent } from './hunt-page.component';
@@ -42,12 +44,7 @@ const threeEncounterHunt: HuntResult = {
encounters: [
{
id: 'encounter-1',
monster: {
key: 'ash-rat',
name: 'Aschenratte',
level: 1,
artworkPath: '/images/enemies/AshRat.png',
},
monster: { key: 'ash-rat', name: 'Aschenratte', level: 1, artworkPath: '/images/enemies/AshRat.png' },
dangerRating: 'WEAK',
},
{
@@ -62,17 +59,28 @@ const threeEncounterHunt: HuntResult = {
},
{
id: 'encounter-3',
monster: {
key: 'ash-rat',
name: 'Aschenratte',
level: 1,
artworkPath: '/images/enemies/AshRat.png',
},
monster: { key: 'ash-rat', name: 'Aschenratte', level: 1, artworkPath: '/images/enemies/AshRat.png' },
dangerRating: 'WEAK',
},
],
};
const startedCombat: Combat = {
id: 'combat-2',
status: 'ACTIVE',
round: 1,
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100 },
monster: {
key: 'road-bandit',
name: 'Straßenräuber',
level: 3,
maxHp: 75,
currentHp: 75,
artworkPath: '/images/enemies/RoadBandit.png',
},
events: [],
};
describe('HuntPageComponent', () => {
let worldStore: {
currentLocation: ReturnType<typeof signal<CurrentLocationResponse | null>>;
@@ -87,6 +95,12 @@ describe('HuntPageComponent', () => {
refreshHunt: ReturnType<typeof vi.fn>;
selectEncounter: ReturnType<typeof vi.fn>;
};
let combatStore: {
combat: ReturnType<typeof signal<Combat | null>>;
error: ReturnType<typeof signal<string | null>>;
startCombat: ReturnType<typeof vi.fn>;
clearError: ReturnType<typeof vi.fn>;
};
let router: Router;
async function setup(location: CurrentLocationResponse | null, hunt: HuntResult | null = null) {
@@ -101,6 +115,12 @@ describe('HuntPageComponent', () => {
refreshHunt: vi.fn(() => Promise.resolve()),
selectEncounter: vi.fn(),
};
combatStore = {
combat: signal<Combat | null>(null),
error: signal<string | null>(null),
startCombat: vi.fn(() => Promise.resolve()),
clearError: vi.fn(),
};
await TestBed.configureTestingModule({
imports: [HuntPageComponent],
@@ -108,6 +128,7 @@ describe('HuntPageComponent', () => {
provideRouter([]),
{ provide: WorldStore, useValue: worldStore },
{ provide: HuntingStore, useValue: huntingStore },
{ provide: CombatStore, useValue: combatStore },
],
}).compileComponents();
@@ -124,9 +145,7 @@ describe('HuntPageComponent', () => {
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('Keine Jagd verfügbar');
expect(element.textContent).toContain(
'Am Südtor von Graufurt gibt es keine regulären Jagdgebiete.',
);
expect(element.textContent).toContain('Am Südtor von Graufurt gibt es keine regulären Jagdgebiete.');
expect(
Array.from(element.querySelectorAll('button')).some(
(button) => button.textContent?.trim() === 'Jagd beginnen',
@@ -171,8 +190,11 @@ describe('HuntPageComponent', () => {
expect(huntingStore.refreshHunt).toHaveBeenCalledOnce();
});
it('navigates to /combat/new with the encounter id (not the monster key) when Angreifen is clicked', async () => {
it('starts a real combat from the encounter id (not the monster key) and navigates to /combat/:combatId', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
combatStore.startCombat.mockImplementation(async () => {
combatStore.combat.set(startedCombat);
});
const element = fixture.nativeElement as HTMLElement;
const attackButtons = Array.from(element.querySelectorAll('button')).filter(
@@ -181,14 +203,43 @@ describe('HuntPageComponent', () => {
expect(attackButtons.length).toBe(3);
attackButtons[1].click();
await Promise.resolve();
await Promise.resolve();
expect(huntingStore.selectEncounter).toHaveBeenCalledWith('encounter-2');
expect(router.navigate).toHaveBeenCalledWith(['/combat/new'], {
queryParams: { encounterId: 'encounter-2' },
});
expect(router.navigate).not.toHaveBeenCalledWith(['/combat/new'], {
queryParams: { encounterId: 'road-bandit' },
});
expect(combatStore.startCombat).toHaveBeenCalledWith('encounter-2');
expect(combatStore.startCombat).not.toHaveBeenCalledWith('road-bandit');
expect(router.navigate).toHaveBeenCalledWith(['/combat', 'combat-2']);
});
it('does not navigate when starting the combat fails', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
const element = fixture.nativeElement as HTMLElement;
const attackButtons = Array.from(element.querySelectorAll('button')).filter(
(button) => button.textContent?.trim() === 'Angreifen',
);
attackButtons[0].click();
await Promise.resolve();
await Promise.resolve();
expect(combatStore.startCombat).toHaveBeenCalledWith('encounter-1');
expect(router.navigate).not.toHaveBeenCalledWith(['/combat', expect.anything()]);
});
it('shows a combat-start error and dismisses it', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
combatStore.error.set('Du befindest dich bereits in einem Kampf.');
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const alerts = Array.from(element.querySelectorAll('[role="alert"]'));
expect(alerts.some((alert) => alert.textContent?.includes('Du befindest dich bereits in einem Kampf.'))).toBe(
true,
);
element.querySelector<HTMLButtonElement>('[data-hunt-combat-dismiss]')?.click();
expect(combatStore.clearError).toHaveBeenCalledOnce();
});
it('does not trigger a hunt automatically on page entry', async () => {

View File

@@ -1,8 +1,9 @@
import { Component, OnInit, inject } from '@angular/core';
import { Router } from '@angular/router';
import { CombatStore } from '../../combat/combat.store';
import { WorldStore } from '../../world/world.store';
import { EncounterCardComponent } from '../encounter-card/encounter-card.component';
import { HuntingStore } from '../hunting.store';
import { WorldStore } from '../../world/world.store';
@Component({
selector: 'app-hunt-page',
@@ -13,6 +14,7 @@ import { WorldStore } from '../../world/world.store';
export class HuntPageComponent implements OnInit {
protected readonly worldStore = inject(WorldStore);
protected readonly huntingStore = inject(HuntingStore);
protected readonly combatStore = inject(CombatStore);
private readonly router = inject(Router);
ngOnInit(): void {
@@ -41,8 +43,15 @@ export class HuntPageComponent implements OnInit {
void this.router.navigate(['/world']);
}
protected onAttack(encounterId: string): void {
this.huntingStore.selectEncounter(encounterId);
void this.router.navigate(['/combat/new'], { queryParams: { encounterId } });
protected async onAttack(encounterId: string): Promise<void> {
await this.combatStore.startCombat(encounterId);
const combat = this.combatStore.combat();
if (combat) {
void this.router.navigate(['/combat', combat.id]);
}
}
protected dismissCombatError(): void {
this.combatStore.clearError();
}
}

View File

@@ -0,0 +1,13 @@
import { runtimeMonsterArtworkPath } from './monster-artwork';
describe('runtimeMonsterArtworkPath', () => {
it('returns the optimized JPEG derivative for a known monster artwork path', () => {
expect(runtimeMonsterArtworkPath('/images/monsters/ash-rat.png')).toBe(
'/images/monsters/runtime/ash-rat-560.jpg',
);
});
it('returns undefined for an artwork path with no runtime derivative', () => {
expect(runtimeMonsterArtworkPath('/images/enemies/Dawnwolf.png')).toBeUndefined();
});
});

View File

@@ -0,0 +1,26 @@
const RUNTIME_MONSTER_ARTWORK: Readonly<Record<string, string>> = {
'/images/monsters/ash-rat.png': '/images/monsters/runtime/ash-rat-560.jpg',
'/images/monsters/road-bandit.png': '/images/monsters/runtime/road-bandit-560.jpg',
};
export function runtimeMonsterArtworkPath(artworkPath: string): string | undefined {
return RUNTIME_MONSTER_ARTWORK[artworkPath];
}
const COMBAT_MONSTER_SPRITE: Readonly<Record<string, string>> = {
'ash-rat': '/images/combat/sprites/ash-rat-760.png',
'road-bandit': '/images/combat/sprites/road-bandit-620.png',
};
const COMBAT_MONSTER_ICON: Readonly<Record<string, string>> = {
'ash-rat': '/images/combat/icons/ash-rat-128.png',
'road-bandit': '/images/combat/icons/road-bandit-128.png',
};
export function combatMonsterSpritePath(monsterKey: string): string | undefined {
return COMBAT_MONSTER_SPRITE[monsterKey];
}
export function combatMonsterIconPath(monsterKey: string): string | undefined {
return COMBAT_MONSTER_ICON[monsterKey];
}