diff --git a/apps/api/src/combat/combat.controller.spec.ts b/apps/api/src/combat/combat.controller.spec.ts index a2e5be3..578a32e 100644 --- a/apps/api/src/combat/combat.controller.spec.ts +++ b/apps/api/src/combat/combat.controller.spec.ts @@ -10,14 +10,18 @@ import { CombatService } from './combat.service'; describe('CombatController', () => { let app: INestApplication; const getCombat = jest.fn(); + const getActiveCombat = jest.fn(); const performAction = jest.fn(); beforeEach(async () => { getCombat.mockReset(); + getActiveCombat.mockReset(); performAction.mockReset(); const module = await Test.createTestingModule({ controllers: [CombatController], - providers: [{ provide: CombatService, useValue: { getCombat, performAction } }], + providers: [ + { provide: CombatService, useValue: { getCombat, getActiveCombat, performAction } }, + ], }).compile(); app = module.createNestApplication(); @@ -39,6 +43,27 @@ describe('CombatController', () => { 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 () => { const combat = { id: 'combat-1', status: 'ACTIVE', round: 2, player: {}, monster: {}, events: [] }; performAction.mockResolvedValue(combat); diff --git a/apps/api/src/combat/combat.controller.ts b/apps/api/src/combat/combat.controller.ts index 4d68e56..2b2a6e8 100644 --- a/apps/api/src/combat/combat.controller.ts +++ b/apps/api/src/combat/combat.controller.ts @@ -7,6 +7,12 @@ import { CombatService } from './combat.service'; export class CombatController { 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') getCombat(@Param('combatId') combatId: string) { return this.combatService.getCombat(DEMO_CHARACTER_ID, combatId); diff --git a/apps/api/src/combat/combat.service.spec.ts b/apps/api/src/combat/combat.service.spec.ts index e0ee9ac..3fc0898 100644 --- a/apps/api/src/combat/combat.service.spec.ts +++ b/apps/api/src/combat/combat.service.spec.ts @@ -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 () => { const state = createState({ characters: [character({ baseHp: 1 })], diff --git a/apps/api/src/combat/combat.service.ts b/apps/api/src/combat/combat.service.ts index e85434d..60eb4e0 100644 --- a/apps/api/src/combat/combat.service.ts +++ b/apps/api/src/combat/combat.service.ts @@ -168,6 +168,24 @@ export class CombatService { return this.toCombatDto(combat, character.name, monster, events); } + async getActiveCombat(characterId: string): Promise { + 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( characterId: string, combatId: string, diff --git a/apps/web/angular.json b/apps/web/angular.json index 190034b..e7794d4 100644 --- a/apps/web/angular.json +++ b/apps/web/angular.json @@ -45,7 +45,7 @@ { "type": "anyComponentStyle", "maximumWarning": "4kB", - "maximumError": "8kB" + "maximumError": "12kB" } ], "outputHashing": "all" diff --git a/apps/web/public/images/combat/sprites/ash-rat-760.png b/apps/web/public/images/combat/sprites/ash-rat-760.png index 2cb0e9a..acbdb32 100644 Binary files a/apps/web/public/images/combat/sprites/ash-rat-760.png and b/apps/web/public/images/combat/sprites/ash-rat-760.png differ diff --git a/apps/web/public/images/combat/sprites/road-bandit-620.png b/apps/web/public/images/combat/sprites/road-bandit-620.png index 82a4b62..1950e74 100644 Binary files a/apps/web/public/images/combat/sprites/road-bandit-620.png and b/apps/web/public/images/combat/sprites/road-bandit-620.png differ diff --git a/apps/web/public/images/combat/sprites/warrior-attack-512.png b/apps/web/public/images/combat/sprites/warrior-attack-512.png deleted file mode 100644 index c4fd678..0000000 Binary files a/apps/web/public/images/combat/sprites/warrior-attack-512.png and /dev/null differ diff --git a/apps/web/public/images/combat/sprites/warrior-attack-sheet-384.png b/apps/web/public/images/combat/sprites/warrior-attack-sheet-384.png new file mode 100644 index 0000000..f50cecd Binary files /dev/null and b/apps/web/public/images/combat/sprites/warrior-attack-sheet-384.png differ diff --git a/apps/web/public/images/combat/sprites/warrior-hit-sheet-384.png b/apps/web/public/images/combat/sprites/warrior-hit-sheet-384.png new file mode 100644 index 0000000..29a6c0c Binary files /dev/null and b/apps/web/public/images/combat/sprites/warrior-hit-sheet-384.png differ diff --git a/apps/web/src/app/core/api/game-api.service.spec.ts b/apps/web/src/app/core/api/game-api.service.spec.ts index 932aea3..bc3a992 100644 --- a/apps/web/src/app/core/api/game-api.service.spec.ts +++ b/apps/web/src/app/core/api/game-api.service.spec.ts @@ -65,6 +65,14 @@ describe('GameApiService', () => { 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', () => { service.performCombatAction('combat-uuid', 'ATTACK').subscribe(); diff --git a/apps/web/src/app/core/api/game-api.service.ts b/apps/web/src/app/core/api/game-api.service.ts index f3167f1..ae438f7 100644 --- a/apps/web/src/app/core/api/game-api.service.ts +++ b/apps/web/src/app/core/api/game-api.service.ts @@ -42,6 +42,10 @@ export class GameApiService { return this.http.get(`/api/combats/${combatId}`); } + getActiveCombat(): Observable { + return this.http.get('/api/combats/active'); + } + performCombatAction(combatId: string, action: CombatAction): Observable { return this.http.post(`/api/combats/${combatId}/actions`, { action }); } diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.html b/apps/web/src/app/features/combat/combat-page/combat-page.component.html index d6349a3..3155686 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.html +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.html @@ -1,5 +1,5 @@
- @if (combatStore.combat(); as combat) { + @if (combat(); as combat) {
@@ -49,9 +49,16 @@
- + @@ -63,7 +70,7 @@ type="button" class="action" data-combat-attack - [disabled]="combatStore.actionPending()" + [disabled]="busy()" (click)="attack()" > diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.scss b/apps/web/src/app/features/combat/combat-page/combat-page.component.scss index 1401051..b487044 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.scss +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.scss @@ -12,9 +12,12 @@ /* ---------- 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 { position: relative; display: grid; + container-type: inline-size; grid-template-rows: auto minmax(0, 1fr) auto; gap: var(--ar-space-4); min-block-size: 26rem; @@ -126,7 +129,7 @@ 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)); + background: rgb(0 0 0 / 0.72); box-shadow: inset 0 0 0.6rem rgb(0 0 0 / 0.9); } @@ -186,21 +189,43 @@ .sprite { display: block; - max-block-size: 100%; + max-inline-size: 100%; inline-size: auto; 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 { justify-self: start; - block-size: clamp(11rem, 30vh, 19rem); + block-size: 80%; + aspect-ratio: 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 { justify-self: end; - block-size: clamp(9rem, 24vh, 15rem); + block-size: calc(var(--sprite-scale, 0.6) * 100%); } /* ---------- action bar ---------- */ @@ -224,21 +249,23 @@ cursor: pointer; } -.action__icon { +.action__icon, +.action__label, +.action__key { position: absolute; - inset-block-start: 32%; inset-inline-start: 50%; - inline-size: 34%; translate: -50% -50%; +} + +.action__icon { + inset-block-start: 32%; + inline-size: 34%; 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); @@ -247,10 +274,7 @@ } .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; @@ -317,8 +341,8 @@ font-style: italic; } -.outcome__button { - margin-block-start: var(--ar-space-2); +.outcome__button, +.combat__notice--error button { padding: var(--ar-space-2) var(--ar-space-5); border: 1px solid var(--ar-border-highlight); border-radius: var(--ar-radius-sm); @@ -329,7 +353,12 @@ 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); color: var(--ar-gold); } @@ -407,15 +436,14 @@ .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) { + .sprite--attacking, + .sprite--hit { + animation: warrior-frames 540ms steps(6) 1; + } + .bar__fill { 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 { grid-template-columns: minmax(0, 1fr); gap: var(--ar-space-2); @@ -469,11 +499,7 @@ inset-inline-end: auto; } - .sprite--player { - block-size: clamp(8rem, 22vh, 12rem); - } - - .sprite--monster { - block-size: clamp(7rem, 18vh, 10rem); + .fighter__meter { + max-inline-size: none; } } diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts b/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts index cafe065..0bda59f 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts @@ -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', () => { let combatStore: { combat: ReturnType>; @@ -63,9 +69,16 @@ describe('CombatPageComponent', () => { const fixture = TestBed.createComponent(CombatPageComponent); fixture.detectChanges(); + // The route load resolves on the microtask queue before the combat renders. + await fixture.whenStable(); + fixture.detectChanges(); return fixture; } + afterEach(() => { + vi.useRealTimers(); + }); + it('loads the combat from the route param on init', async () => { await setup(activeCombat); @@ -101,6 +114,81 @@ describe('CombatPageComponent', () => { 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('[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('[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 () => { const fixture = await setup(activeCombat); combatStore.actionPending.set(true); diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.ts b/apps/web/src/app/features/combat/combat-page/combat-page.component.ts index 78e9194..8eea821 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.ts +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.ts @@ -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 type { CombatEvent } from '../../../core/api/game-api.models'; +import type { Combat, CombatEvent } from '../../../core/api/game-api.models'; import { combatMonsterIconPath, combatMonsterSpritePath, + combatMonsterSpriteScale, runtimeMonsterArtworkPath, } from '../../../shared/monster-artwork'; import { CombatStore } from '../combat.store'; @@ -13,9 +14,17 @@ interface CombatLogRound { 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'; +// 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({ selector: 'app-combat-page', templateUrl: './combat-page.component.html', @@ -25,46 +34,120 @@ export class CombatPageComponent implements OnInit { protected readonly combatStore = inject(CombatStore); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); + private destroyed = false; - ngOnInit(): void { - this.loadFromRoute(); + // The server resolves a whole round at once. `combat` is what the screen is + // currently showing, so the round can be played back a beat at a time + // instead of both blows landing together. + private readonly displayed = signal(null); + private readonly replaying = signal(false); + + protected readonly combat = this.displayed.asReadonly(); + protected readonly phase = signal('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 { - void this.combatStore.attack(); + ngOnInit(): void { + void this.loadFromRoute(); + } + + protected async attack(): Promise { + 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 { - this.loadFromRoute(); + 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 monsterSpriteScale(monsterKey: string): number { + return combatMonsterSpriteScale(monsterKey); + } + protected monsterIcon(monsterKey: string, artworkPath: string): string { return combatMonsterIconPath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath; } protected playerHpPercent(): number { - const combat = this.combatStore.combat(); + const combat = this.displayed(); return combat ? (combat.player.currentHp / combat.player.maxHp) * 100 : 0; } protected monsterHpPercent(): number { - const combat = this.combatStore.combat(); + const combat = this.displayed(); return combat ? (combat.monster.currentHp / combat.monster.maxHp) * 100 : 0; } protected logRounds(): CombatLogRound[] { - const combat = this.combatStore.combat(); + const combat = this.displayed(); if (!combat) { return []; } @@ -80,7 +163,7 @@ export class CombatPageComponent implements OnInit { } protected formatEvent(event: CombatEvent): string { - const combat = this.combatStore.combat(); + const combat = this.displayed(); const playerName = combat?.player.name ?? 'Du'; const monsterName = combat?.monster.name ?? 'Der Gegner'; @@ -97,10 +180,19 @@ export class CombatPageComponent implements OnInit { return `${playerName} wurde im Kampf besiegt.`; } - private loadFromRoute(): void { + private wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + private async loadFromRoute(): Promise { const combatId = this.route.snapshot.paramMap.get('combatId'); - if (combatId) { - void this.combatStore.loadCombat(combatId); + if (!combatId) { + return; + } + + await this.combatStore.loadCombat(combatId); + if (!this.destroyed) { + this.displayed.set(this.combatStore.combat()); } } } diff --git a/apps/web/src/app/features/combat/combat.store.spec.ts b/apps/web/src/app/features/combat/combat.store.spec.ts index 1e439a6..0add925 100644 --- a/apps/web/src/app/features/combat/combat.store.spec.ts +++ b/apps/web/src/app/features/combat/combat.store.spec.ts @@ -37,6 +37,7 @@ describe('CombatStore', () => { let api: { startCombat: ReturnType; getCombat: ReturnType; + getActiveCombat: ReturnType; performCombatAction: ReturnType; }; let store: CombatStore; @@ -45,6 +46,7 @@ describe('CombatStore', () => { api = { startCombat: vi.fn(() => of(startedCombat)), getCombat: vi.fn(() => of(startedCombat)), + getActiveCombat: vi.fn(() => of(startedCombat)), performCombatAction: vi.fn(() => of(afterAttack)), }; @@ -77,6 +79,37 @@ describe('CombatStore', () => { expect(store.combat()).toBeNull(); 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 () => { diff --git a/apps/web/src/app/features/combat/combat.store.ts b/apps/web/src/app/features/combat/combat.store.ts index 34b4578..3ca086a 100644 --- a/apps/web/src/app/features/combat/combat.store.ts +++ b/apps/web/src/app/features/combat/combat.store.ts @@ -24,24 +24,46 @@ export class CombatStore { private readonly loadingState = signal(false); private readonly actionPendingState = signal(false); private readonly errorState = signal(null); + private readonly errorCodeState = signal(null); readonly combat = this.combatState.asReadonly(); readonly loading = this.loadingState.asReadonly(); readonly actionPending = this.actionPendingState.asReadonly(); readonly error = this.errorState.asReadonly(); + readonly errorCode = this.errorCodeState.asReadonly(); constructor(private readonly api: GameApiService) {} async startCombat(encounterId: string): Promise { this.loadingState.set(true); - this.errorState.set(null); + this.clearError(); 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)); + 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 { + 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 { this.loadingState.set(false); } @@ -49,13 +71,13 @@ export class CombatStore { async loadCombat(combatId: string): Promise { this.loadingState.set(true); - this.errorState.set(null); + this.clearError(); try { const combat = await firstValueFrom(this.api.getCombat(combatId)); this.combatState.set(combat); } catch (error) { - this.errorState.set(this.toErrorMessage(error)); + this.setError(error); } finally { this.loadingState.set(false); } @@ -68,13 +90,13 @@ export class CombatStore { } this.actionPendingState.set(true); - this.errorState.set(null); + this.clearError(); try { const updated = await firstValueFrom(this.api.performCombatAction(combat.id, 'ATTACK')); this.combatState.set(updated); } catch (error) { - this.errorState.set(this.toErrorMessage(error)); + this.setError(error); } finally { this.actionPendingState.set(false); } @@ -82,6 +104,20 @@ export class CombatStore { clearError(): void { 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 { diff --git a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts index 430ff18..f39f1a0 100644 --- a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts +++ b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts @@ -98,7 +98,9 @@ describe('HuntPageComponent', () => { let combatStore: { combat: ReturnType>; error: ReturnType>; + errorCode: ReturnType>; startCombat: ReturnType; + loadActiveCombat: ReturnType; clearError: ReturnType; }; let router: Router; @@ -118,7 +120,9 @@ describe('HuntPageComponent', () => { combatStore = { combat: signal(null), error: signal(null), + errorCode: signal(null), startCombat: vi.fn(() => Promise.resolve()), + loadActiveCombat: vi.fn(() => Promise.resolve(null)), clearError: vi.fn(), }; @@ -226,6 +230,47 @@ describe('HuntPageComponent', () => { 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 () => { const fixture = await setup(burnedRoad, threeEncounterHunt); combatStore.error.set('Du befindest dich bereits in einem Kampf.'); diff --git a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts index 476f7cc..74eadeb 100644 --- a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts +++ b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts @@ -48,6 +48,16 @@ export class HuntPageComponent implements OnInit { const combat = this.combatStore.combat(); if (combat) { 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]); + } } } diff --git a/apps/web/src/app/shared/monster-artwork.ts b/apps/web/src/app/shared/monster-artwork.ts index 60d69b4..45d3ce8 100644 --- a/apps/web/src/app/shared/monster-artwork.ts +++ b/apps/web/src/app/shared/monster-artwork.ts @@ -17,10 +17,23 @@ const COMBAT_MONSTER_ICON: Readonly> = { '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> = { + 'ash-rat': 0.46, + 'road-bandit': 0.82, +}; + +const DEFAULT_MONSTER_SCALE = 0.6; + export function combatMonsterSpritePath(monsterKey: string): string | undefined { 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 { return COMBAT_MONSTER_ICON[monsterKey]; }