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

This commit is contained in:
Bastian Wagner
2026-08-19 22:47:34 +02:00
21 changed files with 522 additions and 59 deletions

View File

@@ -10,14 +10,18 @@ import { CombatService } from './combat.service';
describe('CombatController', () => { describe('CombatController', () => {
let app: INestApplication<App>; let app: INestApplication<App>;
const getCombat = jest.fn(); const getCombat = jest.fn();
const getActiveCombat = jest.fn();
const performAction = jest.fn(); const performAction = jest.fn();
beforeEach(async () => { beforeEach(async () => {
getCombat.mockReset(); getCombat.mockReset();
getActiveCombat.mockReset();
performAction.mockReset(); performAction.mockReset();
const module = await Test.createTestingModule({ const module = await Test.createTestingModule({
controllers: [CombatController], controllers: [CombatController],
providers: [{ provide: CombatService, useValue: { getCombat, performAction } }], providers: [
{ provide: CombatService, useValue: { getCombat, getActiveCombat, performAction } },
],
}).compile(); }).compile();
app = module.createNestApplication<App>(); app = module.createNestApplication<App>();
@@ -39,6 +43,27 @@ describe('CombatController', () => {
expect(response.body).toEqual(combat); expect(response.body).toEqual(combat);
}); });
it('delegates GET /api/combats/active to combatService.getActiveCombat', async () => {
const combat = { id: 'combat-1', status: 'ACTIVE', round: 3, player: {}, monster: {}, events: [] };
getActiveCombat.mockResolvedValue(combat);
const response = await request(app.getHttpServer()).get('/api/combats/active').expect(200);
expect(getActiveCombat).toHaveBeenCalledWith(DEMO_CHARACTER_ID);
expect(getCombat).not.toHaveBeenCalled();
expect(response.body).toEqual(combat);
});
it('returns an empty body from GET /api/combats/active when no combat is running', async () => {
getActiveCombat.mockResolvedValue(null);
const response = await request(app.getHttpServer()).get('/api/combats/active').expect(200);
expect(getActiveCombat).toHaveBeenCalledWith(DEMO_CHARACTER_ID);
expect(response.body).toEqual({});
expect(getCombat).not.toHaveBeenCalled();
});
it('delegates POST /api/combats/:combatId/actions with only the action field', async () => { it('delegates POST /api/combats/:combatId/actions with only the action field', async () => {
const combat = { id: 'combat-1', status: 'ACTIVE', round: 2, player: {}, monster: {}, events: [] }; const combat = { id: 'combat-1', status: 'ACTIVE', round: 2, player: {}, monster: {}, events: [] };
performAction.mockResolvedValue(combat); performAction.mockResolvedValue(combat);

View File

@@ -7,6 +7,12 @@ import { CombatService } from './combat.service';
export class CombatController { export class CombatController {
constructor(private readonly combatService: CombatService) {} constructor(private readonly combatService: CombatService) {}
// Declared before ':combatId' so the literal segment wins the route match.
@Get('active')
getActiveCombat() {
return this.combatService.getActiveCombat(DEMO_CHARACTER_ID);
}
@Get(':combatId') @Get(':combatId')
getCombat(@Param('combatId') combatId: string) { getCombat(@Param('combatId') combatId: string) {
return this.combatService.getCombat(DEMO_CHARACTER_ID, combatId); return this.combatService.getCombat(DEMO_CHARACTER_ID, combatId);

View File

@@ -587,6 +587,58 @@ describe('CombatService', () => {
); );
}); });
it('resolves the character ACTIVE combat so the hunt page can rejoin it', async () => {
const context = createService();
const started = await context.service.startCombat(
CHARACTER_ID,
ENCOUNTER_ID,
);
await context.service.performAction(
CHARACTER_ID,
started.id,
CombatAction.ATTACK,
);
const active = await context.service.getActiveCombat(CHARACTER_ID);
expect(active?.id).toBe(started.id);
expect(active?.status).toBe('ACTIVE');
expect(active?.round).toBe(2);
});
it('resolves null when the character has no ACTIVE combat', async () => {
const { service } = createService();
await expect(service.getActiveCombat(CHARACTER_ID)).resolves.toBeNull();
});
it('resolves null once the only combat has finished', async () => {
const state = createState({ monsters: [monster({ maxHp: 10 })] });
const context = createService({ state });
const started = await context.service.startCombat(
CHARACTER_ID,
ENCOUNTER_ID,
);
await context.service.performAction(
CHARACTER_ID,
started.id,
CombatAction.ATTACK,
);
await expect(
context.service.getActiveCombat(CHARACTER_ID),
).resolves.toBeNull();
});
it('does not resolve another character ACTIVE combat', async () => {
const context = createService();
await context.service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
await expect(
context.service.getActiveCombat(OTHER_CHARACTER_ID),
).resolves.toBeNull();
});
it('keeps returning LOST after the combat has ended', async () => { it('keeps returning LOST after the combat has ended', async () => {
const state = createState({ const state = createState({
characters: [character({ baseHp: 1 })], characters: [character({ baseHp: 1 })],

View File

@@ -168,6 +168,24 @@ export class CombatService {
return this.toCombatDto(combat, character.name, monster, events); return this.toCombatDto(combat, character.name, monster, events);
} }
async getActiveCombat(characterId: string): Promise<CombatDto | null> {
const combats = this.dataSource.getRepository(Combat);
const combat = await combats.findOne({
where: { characterId, status: CombatStatus.ACTIVE },
});
if (!combat) {
return null;
}
const [character, monster, events] = await Promise.all([
this.loadCharacter(combat.characterId),
this.loadMonster(combat.monsterDefinitionId),
this.loadEvents(combat.id),
]);
return this.toCombatDto(combat, character.name, monster, events);
}
async performAction( async performAction(
characterId: string, characterId: string,
combatId: string, combatId: string,

View File

@@ -45,7 +45,7 @@
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",
"maximumWarning": "4kB", "maximumWarning": "4kB",
"maximumError": "8kB" "maximumError": "12kB"
} }
], ],
"outputHashing": "all" "outputHashing": "all"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 348 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

View File

@@ -65,6 +65,14 @@ describe('GameApiService', () => {
request.flush({}); request.flush({});
}); });
it('gets the running combat from the literal active route', () => {
service.getActiveCombat().subscribe();
const request = http.expectOne('/api/combats/active');
expect(request.request.method).toBe('GET');
request.flush({});
});
it('posts only the action enum when performing a combat action', () => { it('posts only the action enum when performing a combat action', () => {
service.performCombatAction('combat-uuid', 'ATTACK').subscribe(); service.performCombatAction('combat-uuid', 'ATTACK').subscribe();

View File

@@ -42,6 +42,10 @@ export class GameApiService {
return this.http.get<Combat>(`/api/combats/${combatId}`); return this.http.get<Combat>(`/api/combats/${combatId}`);
} }
getActiveCombat(): Observable<Combat | null> {
return this.http.get<Combat | null>('/api/combats/active');
}
performCombatAction(combatId: string, action: CombatAction): Observable<Combat> { performCombatAction(combatId: string, action: CombatAction): Observable<Combat> {
return this.http.post<Combat>(`/api/combats/${combatId}/actions`, { action }); return this.http.post<Combat>(`/api/combats/${combatId}/actions`, { action });
} }

View File

@@ -1,5 +1,5 @@
<section class="combat" aria-label="Kampf"> <section class="combat" aria-label="Kampf">
@if (combatStore.combat(); as combat) { @if (combat(); as combat) {
<div class="combat__stage"> <div class="combat__stage">
<header class="combat__status"> <header class="combat__status">
<div class="fighter fighter--player"> <div class="fighter fighter--player">
@@ -49,9 +49,16 @@
</header> </header>
<div class="combat__field"> <div class="combat__field">
<img class="sprite sprite--player" [src]="playerSprite" [alt]="combat.player.name" /> <div
class="sprite sprite--player"
[class.sprite--attacking]="phase() === 'attacking'"
[class.sprite--hit]="phase() === 'hit'"
role="img"
[attr.aria-label]="combat.player.name"
></div>
<img <img
class="sprite sprite--monster" class="sprite sprite--monster"
[style.--sprite-scale]="monsterSpriteScale(combat.monster.key)"
[src]="monsterSprite(combat.monster.key, combat.monster.artworkPath)" [src]="monsterSprite(combat.monster.key, combat.monster.artworkPath)"
[alt]="combat.monster.name" [alt]="combat.monster.name"
/> />
@@ -63,7 +70,7 @@
type="button" type="button"
class="action" class="action"
data-combat-attack data-combat-attack
[disabled]="combatStore.actionPending()" [disabled]="busy()"
(click)="attack()" (click)="attack()"
> >
<img class="action__icon" src="/images/hud/runtime/AttackIcon-96.png" alt="" /> <img class="action__icon" src="/images/hud/runtime/AttackIcon-96.png" alt="" />

View File

@@ -12,9 +12,12 @@
/* ---------- stage ---------- */ /* ---------- stage ---------- */
// The stage is a container so the status row reflows on its own width: the
// surrounding rails can squeeze it narrow while the viewport is still wide.
.combat__stage { .combat__stage {
position: relative; position: relative;
display: grid; display: grid;
container-type: inline-size;
grid-template-rows: auto minmax(0, 1fr) auto; grid-template-rows: auto minmax(0, 1fr) auto;
gap: var(--ar-space-4); gap: var(--ar-space-4);
min-block-size: 26rem; min-block-size: 26rem;
@@ -126,7 +129,7 @@
block-size: 1.25rem; block-size: 1.25rem;
overflow: hidden; overflow: hidden;
border: 1px solid var(--ar-border); border: 1px solid var(--ar-border);
background: linear-gradient(180deg, rgb(0 0 0 / 0.85), rgb(0 0 0 / 0.6)); background: rgb(0 0 0 / 0.72);
box-shadow: inset 0 0 0.6rem rgb(0 0 0 / 0.9); box-shadow: inset 0 0 0.6rem rgb(0 0 0 / 0.9);
} }
@@ -186,21 +189,43 @@
.sprite { .sprite {
display: block; display: block;
max-block-size: 100%; max-inline-size: 100%;
inline-size: auto; inline-size: auto;
object-fit: contain; object-fit: contain;
filter: drop-shadow(0 1rem 1.5rem rgb(0 0 0 / 0.75)); object-position: bottom;
filter: drop-shadow(0 0.5rem 0.9rem rgb(0 0 0 / 0.7));
} }
/* Six 384px frames laid out horizontally; frame 0 is the resting stance. */
.sprite--player { .sprite--player {
justify-self: start; justify-self: start;
block-size: clamp(11rem, 30vh, 19rem); block-size: 80%;
aspect-ratio: 1;
transform: scaleX(-1); transform: scaleX(-1);
background-image: url('/images/combat/sprites/warrior-attack-sheet-384.png');
background-repeat: no-repeat;
background-position: 0% 0;
background-size: 600% 100%;
}
.sprite--hit {
background-image: url('/images/combat/sprites/warrior-hit-sheet-384.png');
}
/* 6 frames across a 600%-wide sheet land on 0/20/40/60/80/100%. */
@keyframes warrior-frames {
from {
background-position: 0% 0;
}
to {
background-position: 120% 0;
}
} }
.sprite--monster { .sprite--monster {
justify-self: end; justify-self: end;
block-size: clamp(9rem, 24vh, 15rem); block-size: calc(var(--sprite-scale, 0.6) * 100%);
} }
/* ---------- action bar ---------- */ /* ---------- action bar ---------- */
@@ -224,21 +249,23 @@
cursor: pointer; cursor: pointer;
} }
.action__icon { .action__icon,
.action__label,
.action__key {
position: absolute; position: absolute;
inset-block-start: 32%;
inset-inline-start: 50%; inset-inline-start: 50%;
inline-size: 34%;
translate: -50% -50%; translate: -50% -50%;
}
.action__icon {
inset-block-start: 32%;
inline-size: 34%;
border-radius: 50%; border-radius: 50%;
opacity: 0.92; opacity: 0.92;
} }
.action__label { .action__label {
position: absolute;
inset-block-start: 62%; inset-block-start: 62%;
inset-inline-start: 50%;
translate: -50% -50%;
color: var(--ar-text); color: var(--ar-text);
font-family: Georgia, 'Times New Roman', serif; font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(0.85rem, 1.2vw, 1rem); font-size: clamp(0.85rem, 1.2vw, 1rem);
@@ -247,10 +274,7 @@
} }
.action__key { .action__key {
position: absolute;
inset-block-start: 90%; inset-block-start: 90%;
inset-inline-start: 50%;
translate: -50% -50%;
color: var(--ar-text-muted); color: var(--ar-text-muted);
font-size: var(--ar-font-sm); font-size: var(--ar-font-sm);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
@@ -317,8 +341,8 @@
font-style: italic; font-style: italic;
} }
.outcome__button { .outcome__button,
margin-block-start: var(--ar-space-2); .combat__notice--error button {
padding: var(--ar-space-2) var(--ar-space-5); padding: var(--ar-space-2) var(--ar-space-5);
border: 1px solid var(--ar-border-highlight); border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-sm); border-radius: var(--ar-radius-sm);
@@ -329,7 +353,12 @@
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
.outcome__button:hover { .outcome__button {
margin-block-start: var(--ar-space-2);
}
.outcome__button:hover,
.combat__notice--error button:hover {
border-color: var(--ar-gold); border-color: var(--ar-gold);
color: var(--ar-gold); color: var(--ar-gold);
} }
@@ -407,15 +436,14 @@
.combat__notice--error button { .combat__notice--error button {
flex: 0 0 auto; 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) { @media (prefers-reduced-motion: no-preference) {
.sprite--attacking,
.sprite--hit {
animation: warrior-frames 540ms steps(6) 1;
}
.bar__fill { .bar__fill {
transition: inline-size var(--ar-motion-base); transition: inline-size var(--ar-motion-base);
} }
@@ -444,7 +472,9 @@
} }
} }
@media (width < 40rem) { // Below this the three-column status row cannot hold two names and two bars
// side by side, so the fighters stack under a centred round marker.
@container (width < 38rem) {
.combat__status { .combat__status {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
gap: var(--ar-space-2); gap: var(--ar-space-2);
@@ -469,11 +499,7 @@
inset-inline-end: auto; inset-inline-end: auto;
} }
.sprite--player { .fighter__meter {
block-size: clamp(8rem, 22vh, 12rem); max-inline-size: none;
}
.sprite--monster {
block-size: clamp(7rem, 18vh, 10rem);
} }
} }

View File

@@ -25,6 +25,12 @@ const activeCombat: Combat = {
], ],
}; };
const monsterHitLine = 'Aschenratte trifft Aric Duskwalker für 5 Schaden.';
function countOccurrences(haystack: string | null, needle: string): number {
return haystack ? haystack.split(needle).length - 1 : 0;
}
describe('CombatPageComponent', () => { describe('CombatPageComponent', () => {
let combatStore: { let combatStore: {
combat: ReturnType<typeof signal<Combat | null>>; combat: ReturnType<typeof signal<Combat | null>>;
@@ -63,9 +69,16 @@ describe('CombatPageComponent', () => {
const fixture = TestBed.createComponent(CombatPageComponent); const fixture = TestBed.createComponent(CombatPageComponent);
fixture.detectChanges(); fixture.detectChanges();
// The route load resolves on the microtask queue before the combat renders.
await fixture.whenStable();
fixture.detectChanges();
return fixture; return fixture;
} }
afterEach(() => {
vi.useRealTimers();
});
it('loads the combat from the route param on init', async () => { it('loads the combat from the route param on init', async () => {
await setup(activeCombat); await setup(activeCombat);
@@ -101,6 +114,81 @@ describe('CombatPageComponent', () => {
expect(combatStore.attack).toHaveBeenCalledOnce(); expect(combatStore.attack).toHaveBeenCalledOnce();
}); });
it('plays the swing, reveals the monster damage, then the recoil a beat later', async () => {
const fixture = await setup(activeCombat);
const resolvedRound: Combat = {
...activeCombat,
round: 3,
player: { ...activeCombat.player, currentHp: 90 },
monster: { ...activeCombat.monster, currentHp: 17 },
events: [
...activeCombat.events,
{ round: 2, sequence: 3, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 14 },
{ round: 2, sequence: 4, type: 'DAMAGE', source: 'MONSTER', target: 'PLAYER', amount: 5 },
],
};
combatStore.attack.mockImplementation(async () => {
combatStore.combat.set(resolvedRound);
});
vi.useFakeTimers();
const element = fixture.nativeElement as HTMLElement;
const sprite = element.querySelector('.sprite--player');
element.querySelector<HTMLButtonElement>('[data-combat-attack]')?.click();
fixture.detectChanges();
expect(sprite?.classList.contains('sprite--attacking')).toBe(true);
expect(element.textContent).toContain('31 / 45');
expect(element.textContent).toContain('95 / 100');
// Swing lands: the monster loses HP, the player's own loss is held back.
await vi.advanceTimersByTimeAsync(540);
fixture.detectChanges();
expect(sprite?.classList.contains('sprite--attacking')).toBe(false);
expect(element.textContent).toContain('17 / 45');
expect(element.textContent).toContain('95 / 100');
// Only round 1's identical line is logged so far, not round 2's.
expect(countOccurrences(element.textContent, monsterHitLine)).toBe(1);
// The monster strikes back after the beat.
await vi.advanceTimersByTimeAsync(260);
fixture.detectChanges();
expect(sprite?.classList.contains('sprite--hit')).toBe(true);
expect(element.textContent).toContain('90 / 100');
expect(countOccurrences(element.textContent, monsterHitLine)).toBe(2);
await vi.advanceTimersByTimeAsync(540);
fixture.detectChanges();
expect(sprite?.classList.contains('sprite--hit')).toBe(false);
});
it('skips the recoil when the round ends without the monster striking back', async () => {
const fixture = await setup(activeCombat);
const won: Combat = {
...activeCombat,
status: 'WON',
monster: { ...activeCombat.monster, currentHp: 0 },
events: [
...activeCombat.events,
{ round: 2, sequence: 3, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 31 },
{ round: 2, sequence: 4, type: 'COMBAT_WON', source: 'PLAYER', target: 'MONSTER' },
],
};
combatStore.attack.mockImplementation(async () => {
combatStore.combat.set(won);
});
vi.useFakeTimers();
const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-combat-attack]')?.click();
await vi.advanceTimersByTimeAsync(540);
fixture.detectChanges();
expect(element.querySelector('.sprite--player')?.classList.contains('sprite--hit')).toBe(false);
expect(element.querySelector('[data-combat-result="WON"]')).toBeTruthy();
expect(element.textContent).toContain('0 / 45');
});
it('disables Angriff while an action is pending', async () => { it('disables Angriff while an action is pending', async () => {
const fixture = await setup(activeCombat); const fixture = await setup(activeCombat);
combatStore.actionPending.set(true); combatStore.actionPending.set(true);

View File

@@ -1,9 +1,10 @@
import { Component, OnInit, inject } from '@angular/core'; import { Component, DestroyRef, OnInit, computed, inject, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import type { CombatEvent } from '../../../core/api/game-api.models'; import type { Combat, CombatEvent } from '../../../core/api/game-api.models';
import { import {
combatMonsterIconPath, combatMonsterIconPath,
combatMonsterSpritePath, combatMonsterSpritePath,
combatMonsterSpriteScale,
runtimeMonsterArtworkPath, runtimeMonsterArtworkPath,
} from '../../../shared/monster-artwork'; } from '../../../shared/monster-artwork';
import { CombatStore } from '../combat.store'; import { CombatStore } from '../combat.store';
@@ -13,9 +14,17 @@ interface CombatLogRound {
events: CombatEvent[]; events: CombatEvent[];
} }
const PLAYER_SPRITE = '/images/combat/sprites/warrior-attack-512.png'; type CombatPhase = 'idle' | 'attacking' | 'hit';
const PLAYER_ICON = '/images/hud/runtime/CharacterIcon-128.png'; 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 = 260;
@Component({ @Component({
selector: 'app-combat-page', selector: 'app-combat-page',
templateUrl: './combat-page.component.html', templateUrl: './combat-page.component.html',
@@ -25,46 +34,120 @@ export class CombatPageComponent implements OnInit {
protected readonly combatStore = inject(CombatStore); protected readonly combatStore = inject(CombatStore);
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
private destroyed = false;
ngOnInit(): void { // The server resolves a whole round at once. `combat` is what the screen is
this.loadFromRoute(); // 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);
protected readonly combat = this.displayed.asReadonly();
protected readonly phase = signal<CombatPhase>('idle');
protected readonly busy = computed(() => this.replaying() || this.combatStore.actionPending());
protected readonly playerIcon = PLAYER_ICON;
constructor() {
this.destroyRef.onDestroy(() => {
this.destroyed = true;
});
} }
protected attack(): void { ngOnInit(): void {
void this.combatStore.attack(); void this.loadFromRoute();
}
protected async attack(): Promise<void> {
const before = this.displayed();
if (!before || this.busy()) {
return;
}
this.replaying.set(true);
try {
this.phase.set('attacking');
const swing = this.wait(SWING_MS);
await this.combatStore.attack();
await swing;
if (this.destroyed) {
return;
}
this.phase.set('idle');
const after = this.combatStore.combat();
if (!after) {
return;
}
const riposte = after.events.find(
(event) =>
event.round === before.round && event.type === 'DAMAGE' && event.source === 'MONSTER',
);
if (!riposte) {
this.displayed.set(after);
return;
}
// Show the blow the player just landed, holding back the monster's reply.
this.displayed.set({
...after,
player: before.player,
events: after.events.filter((event) => event.sequence < riposte.sequence),
});
await this.wait(RIPOSTE_DELAY_MS);
if (this.destroyed) {
return;
}
this.phase.set('hit');
this.displayed.set(after);
await this.wait(RECOIL_MS);
if (this.destroyed) {
return;
}
this.phase.set('idle');
} finally {
if (!this.destroyed) {
this.replaying.set(false);
}
}
} }
protected retry(): void { protected retry(): void {
this.loadFromRoute(); void this.loadFromRoute();
} }
protected goToHunt(): void { protected goToHunt(): void {
void this.router.navigate(['/hunt']); void this.router.navigate(['/hunt']);
} }
protected readonly playerSprite = PLAYER_SPRITE;
protected readonly playerIcon = PLAYER_ICON;
protected monsterSprite(monsterKey: string, artworkPath: string): string { protected monsterSprite(monsterKey: string, artworkPath: string): string {
return combatMonsterSpritePath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath; return combatMonsterSpritePath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath;
} }
protected monsterSpriteScale(monsterKey: string): number {
return combatMonsterSpriteScale(monsterKey);
}
protected monsterIcon(monsterKey: string, artworkPath: string): string { protected monsterIcon(monsterKey: string, artworkPath: string): string {
return combatMonsterIconPath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath; return combatMonsterIconPath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath;
} }
protected playerHpPercent(): number { protected playerHpPercent(): number {
const combat = this.combatStore.combat(); const combat = this.displayed();
return combat ? (combat.player.currentHp / combat.player.maxHp) * 100 : 0; return combat ? (combat.player.currentHp / combat.player.maxHp) * 100 : 0;
} }
protected monsterHpPercent(): number { protected monsterHpPercent(): number {
const combat = this.combatStore.combat(); const combat = this.displayed();
return combat ? (combat.monster.currentHp / combat.monster.maxHp) * 100 : 0; return combat ? (combat.monster.currentHp / combat.monster.maxHp) * 100 : 0;
} }
protected logRounds(): CombatLogRound[] { protected logRounds(): CombatLogRound[] {
const combat = this.combatStore.combat(); const combat = this.displayed();
if (!combat) { if (!combat) {
return []; return [];
} }
@@ -80,7 +163,7 @@ export class CombatPageComponent implements OnInit {
} }
protected formatEvent(event: CombatEvent): string { protected formatEvent(event: CombatEvent): string {
const combat = this.combatStore.combat(); const combat = this.displayed();
const playerName = combat?.player.name ?? 'Du'; const playerName = combat?.player.name ?? 'Du';
const monsterName = combat?.monster.name ?? 'Der Gegner'; const monsterName = combat?.monster.name ?? 'Der Gegner';
@@ -97,10 +180,19 @@ export class CombatPageComponent implements OnInit {
return `${playerName} wurde im Kampf besiegt.`; return `${playerName} wurde im Kampf besiegt.`;
} }
private loadFromRoute(): void { 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'); const combatId = this.route.snapshot.paramMap.get('combatId');
if (combatId) { if (!combatId) {
void this.combatStore.loadCombat(combatId); return;
}
await this.combatStore.loadCombat(combatId);
if (!this.destroyed) {
this.displayed.set(this.combatStore.combat());
} }
} }
} }

View File

@@ -37,6 +37,7 @@ describe('CombatStore', () => {
let api: { let api: {
startCombat: ReturnType<typeof vi.fn>; startCombat: ReturnType<typeof vi.fn>;
getCombat: ReturnType<typeof vi.fn>; getCombat: ReturnType<typeof vi.fn>;
getActiveCombat: ReturnType<typeof vi.fn>;
performCombatAction: ReturnType<typeof vi.fn>; performCombatAction: ReturnType<typeof vi.fn>;
}; };
let store: CombatStore; let store: CombatStore;
@@ -45,6 +46,7 @@ describe('CombatStore', () => {
api = { api = {
startCombat: vi.fn(() => of(startedCombat)), startCombat: vi.fn(() => of(startedCombat)),
getCombat: vi.fn(() => of(startedCombat)), getCombat: vi.fn(() => of(startedCombat)),
getActiveCombat: vi.fn(() => of(startedCombat)),
performCombatAction: vi.fn(() => of(afterAttack)), performCombatAction: vi.fn(() => of(afterAttack)),
}; };
@@ -77,6 +79,37 @@ describe('CombatStore', () => {
expect(store.combat()).toBeNull(); expect(store.combat()).toBeNull();
expect(store.error()).toBe('Du befindest dich bereits in einem Kampf.'); expect(store.error()).toBe('Du befindest dich bereits in einem Kampf.');
expect(store.errorCode()).toBe('COMBAT_ALREADY_ACTIVE');
});
it('loads the running combat and clears the error that sent us looking for it', async () => {
api.startCombat.mockReturnValue(
throwError(
() =>
new HttpErrorResponse({
status: 409,
error: { statusCode: 409, code: 'COMBAT_ALREADY_ACTIVE', message: 'Active.' },
}),
),
);
await store.startCombat('encounter-1');
const active = await store.loadActiveCombat();
expect(api.getActiveCombat).toHaveBeenCalledOnce();
expect(active).toEqual(startedCombat);
expect(store.combat()).toEqual(startedCombat);
expect(store.error()).toBeNull();
expect(store.errorCode()).toBeNull();
});
it('resolves null and keeps the combat empty when no fight is running', async () => {
api.getActiveCombat.mockReturnValue(of(null));
const active = await store.loadActiveCombat();
expect(active).toBeNull();
expect(store.combat()).toBeNull();
}); });
it('loads a combat by id', async () => { it('loads a combat by id', async () => {

View File

@@ -24,24 +24,46 @@ export class CombatStore {
private readonly loadingState = signal(false); private readonly loadingState = signal(false);
private readonly actionPendingState = signal(false); private readonly actionPendingState = signal(false);
private readonly errorState = signal<string | null>(null); private readonly errorState = signal<string | null>(null);
private readonly errorCodeState = signal<string | null>(null);
readonly combat = this.combatState.asReadonly(); readonly combat = this.combatState.asReadonly();
readonly loading = this.loadingState.asReadonly(); readonly loading = this.loadingState.asReadonly();
readonly actionPending = this.actionPendingState.asReadonly(); readonly actionPending = this.actionPendingState.asReadonly();
readonly error = this.errorState.asReadonly(); readonly error = this.errorState.asReadonly();
readonly errorCode = this.errorCodeState.asReadonly();
constructor(private readonly api: GameApiService) {} constructor(private readonly api: GameApiService) {}
async startCombat(encounterId: string): Promise<void> { async startCombat(encounterId: string): Promise<void> {
this.loadingState.set(true); this.loadingState.set(true);
this.errorState.set(null); this.clearError();
try { try {
const combat = await firstValueFrom(this.api.startCombat(encounterId)); const combat = await firstValueFrom(this.api.startCombat(encounterId));
this.combatState.set(combat); this.combatState.set(combat);
} catch (error) { } catch (error) {
this.combatState.set(null); this.combatState.set(null);
this.errorState.set(this.toErrorMessage(error)); this.setError(error);
} finally {
this.loadingState.set(false);
}
}
// Resolves the combat the character is already in, so an attack rejected with
// COMBAT_ALREADY_ACTIVE can rejoin that fight instead of dead-ending.
async loadActiveCombat(): Promise<Combat | null> {
this.loadingState.set(true);
try {
const combat = await firstValueFrom(this.api.getActiveCombat());
if (combat) {
this.combatState.set(combat);
this.clearError();
}
return combat;
} catch (error) {
this.setError(error);
return null;
} finally { } finally {
this.loadingState.set(false); this.loadingState.set(false);
} }
@@ -49,13 +71,13 @@ export class CombatStore {
async loadCombat(combatId: string): Promise<void> { async loadCombat(combatId: string): Promise<void> {
this.loadingState.set(true); this.loadingState.set(true);
this.errorState.set(null); this.clearError();
try { try {
const combat = await firstValueFrom(this.api.getCombat(combatId)); const combat = await firstValueFrom(this.api.getCombat(combatId));
this.combatState.set(combat); this.combatState.set(combat);
} catch (error) { } catch (error) {
this.errorState.set(this.toErrorMessage(error)); this.setError(error);
} finally { } finally {
this.loadingState.set(false); this.loadingState.set(false);
} }
@@ -68,13 +90,13 @@ export class CombatStore {
} }
this.actionPendingState.set(true); this.actionPendingState.set(true);
this.errorState.set(null); this.clearError();
try { try {
const updated = await firstValueFrom(this.api.performCombatAction(combat.id, 'ATTACK')); const updated = await firstValueFrom(this.api.performCombatAction(combat.id, 'ATTACK'));
this.combatState.set(updated); this.combatState.set(updated);
} catch (error) { } catch (error) {
this.errorState.set(this.toErrorMessage(error)); this.setError(error);
} finally { } finally {
this.actionPendingState.set(false); this.actionPendingState.set(false);
} }
@@ -82,6 +104,20 @@ export class CombatStore {
clearError(): void { clearError(): void {
this.errorState.set(null); this.errorState.set(null);
this.errorCodeState.set(null);
}
private setError(error: unknown): void {
this.errorCodeState.set(this.toErrorCode(error));
this.errorState.set(this.toErrorMessage(error));
}
private toErrorCode(error: unknown): string | null {
if (error instanceof HttpErrorResponse) {
return (error.error as { code?: string } | null)?.code ?? null;
}
return null;
} }
private toErrorMessage(error: unknown): string { private toErrorMessage(error: unknown): string {

View File

@@ -98,7 +98,9 @@ describe('HuntPageComponent', () => {
let combatStore: { let combatStore: {
combat: ReturnType<typeof signal<Combat | null>>; combat: ReturnType<typeof signal<Combat | null>>;
error: ReturnType<typeof signal<string | null>>; error: ReturnType<typeof signal<string | null>>;
errorCode: ReturnType<typeof signal<string | null>>;
startCombat: ReturnType<typeof vi.fn>; startCombat: ReturnType<typeof vi.fn>;
loadActiveCombat: ReturnType<typeof vi.fn>;
clearError: ReturnType<typeof vi.fn>; clearError: ReturnType<typeof vi.fn>;
}; };
let router: Router; let router: Router;
@@ -118,7 +120,9 @@ describe('HuntPageComponent', () => {
combatStore = { combatStore = {
combat: signal<Combat | null>(null), combat: signal<Combat | null>(null),
error: signal<string | null>(null), error: signal<string | null>(null),
errorCode: signal<string | null>(null),
startCombat: vi.fn(() => Promise.resolve()), startCombat: vi.fn(() => Promise.resolve()),
loadActiveCombat: vi.fn(() => Promise.resolve(null)),
clearError: vi.fn(), clearError: vi.fn(),
}; };
@@ -226,6 +230,47 @@ describe('HuntPageComponent', () => {
expect(router.navigate).not.toHaveBeenCalledWith(['/combat', expect.anything()]); expect(router.navigate).not.toHaveBeenCalledWith(['/combat', expect.anything()]);
}); });
it('rejoins the running combat when the attack is rejected with COMBAT_ALREADY_ACTIVE', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
combatStore.startCombat.mockImplementation(async () => {
combatStore.errorCode.set('COMBAT_ALREADY_ACTIVE');
combatStore.error.set('Du befindest dich bereits in einem Kampf.');
});
combatStore.loadActiveCombat.mockResolvedValue({ ...startedCombat, id: 'combat-running' });
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();
await Promise.resolve();
expect(combatStore.loadActiveCombat).toHaveBeenCalledOnce();
expect(router.navigate).toHaveBeenCalledWith(['/combat', 'combat-running']);
});
it('does not look for a running combat when the attack fails for another reason', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
combatStore.startCombat.mockImplementation(async () => {
combatStore.errorCode.set('HUNT_ENCOUNTER_ALREADY_CONSUMED');
combatStore.error.set('Diese Begegnung wurde bereits genutzt.');
});
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();
await Promise.resolve();
expect(combatStore.loadActiveCombat).not.toHaveBeenCalled();
expect(router.navigate).not.toHaveBeenCalledWith(['/combat', expect.anything()]);
});
it('shows a combat-start error and dismisses it', async () => { it('shows a combat-start error and dismisses it', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt); const fixture = await setup(burnedRoad, threeEncounterHunt);
combatStore.error.set('Du befindest dich bereits in einem Kampf.'); combatStore.error.set('Du befindest dich bereits in einem Kampf.');

View File

@@ -48,6 +48,16 @@ export class HuntPageComponent implements OnInit {
const combat = this.combatStore.combat(); const combat = this.combatStore.combat();
if (combat) { if (combat) {
void this.router.navigate(['/combat', combat.id]); void this.router.navigate(['/combat', combat.id]);
return;
}
// A fight already running is not a dead end: rejoin it rather than
// leaving the player stuck behind an error they cannot act on.
if (this.combatStore.errorCode() === 'COMBAT_ALREADY_ACTIVE') {
const active = await this.combatStore.loadActiveCombat();
if (active) {
void this.router.navigate(['/combat', active.id]);
}
} }
} }

View File

@@ -17,10 +17,23 @@ const COMBAT_MONSTER_ICON: Readonly<Record<string, string>> = {
'road-bandit': '/images/combat/icons/road-bandit-128.png', 'road-bandit': '/images/combat/icons/road-bandit-128.png',
}; };
// Share of the battlefield height each monster sprite occupies, so a hulking
// bandit and a low-slung rat keep believable proportions against the player.
const COMBAT_MONSTER_SCALE: Readonly<Record<string, number>> = {
'ash-rat': 0.46,
'road-bandit': 0.82,
};
const DEFAULT_MONSTER_SCALE = 0.6;
export function combatMonsterSpritePath(monsterKey: string): string | undefined { export function combatMonsterSpritePath(monsterKey: string): string | undefined {
return COMBAT_MONSTER_SPRITE[monsterKey]; return COMBAT_MONSTER_SPRITE[monsterKey];
} }
export function combatMonsterSpriteScale(monsterKey: string): number {
return COMBAT_MONSTER_SCALE[monsterKey] ?? DEFAULT_MONSTER_SCALE;
}
export function combatMonsterIconPath(monsterKey: string): string | undefined { export function combatMonsterIconPath(monsterKey: string): string | undefined {
return COMBAT_MONSTER_ICON[monsterKey]; return COMBAT_MONSTER_ICON[monsterKey];
} }