Merge branch 'worktree-slice-0.3-first-combat'
@@ -10,14 +10,18 @@ import { CombatService } from './combat.service';
|
||||
describe('CombatController', () => {
|
||||
let app: INestApplication<App>;
|
||||
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<App>();
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 })],
|
||||
|
||||
@@ -168,6 +168,24 @@ export class CombatService {
|
||||
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(
|
||||
characterId: string,
|
||||
combatId: string,
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "4kB",
|
||||
"maximumError": "8kB"
|
||||
"maximumError": "12kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
|
||||
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 348 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 243 KiB |
BIN
apps/web/public/images/combat/sprites/warrior-hit-sheet-384.png
Normal file
|
After Width: | Height: | Size: 369 KiB |
@@ -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();
|
||||
|
||||
|
||||
@@ -42,6 +42,10 @@ export class GameApiService {
|
||||
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> {
|
||||
return this.http.post<Combat>(`/api/combats/${combatId}/actions`, { action });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<section class="combat" aria-label="Kampf">
|
||||
@if (combatStore.combat(); as combat) {
|
||||
@if (combat(); as combat) {
|
||||
<div class="combat__stage">
|
||||
<header class="combat__status">
|
||||
<div class="fighter fighter--player">
|
||||
@@ -49,9 +49,16 @@
|
||||
</header>
|
||||
|
||||
<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
|
||||
class="sprite sprite--monster"
|
||||
[style.--sprite-scale]="monsterSpriteScale(combat.monster.key)"
|
||||
[src]="monsterSprite(combat.monster.key, combat.monster.artworkPath)"
|
||||
[alt]="combat.monster.name"
|
||||
/>
|
||||
@@ -63,7 +70,7 @@
|
||||
type="button"
|
||||
class="action"
|
||||
data-combat-attack
|
||||
[disabled]="combatStore.actionPending()"
|
||||
[disabled]="busy()"
|
||||
(click)="attack()"
|
||||
>
|
||||
<img class="action__icon" src="/images/hud/runtime/AttackIcon-96.png" alt="" />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<typeof signal<Combat | null>>;
|
||||
@@ -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<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 () => {
|
||||
const fixture = await setup(activeCombat);
|
||||
combatStore.actionPending.set(true);
|
||||
|
||||
@@ -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<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 {
|
||||
void this.combatStore.attack();
|
||||
ngOnInit(): void {
|
||||
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 {
|
||||
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<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
private async loadFromRoute(): Promise<void> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ describe('CombatStore', () => {
|
||||
let api: {
|
||||
startCombat: ReturnType<typeof vi.fn>;
|
||||
getCombat: ReturnType<typeof vi.fn>;
|
||||
getActiveCombat: ReturnType<typeof vi.fn>;
|
||||
performCombatAction: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
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 () => {
|
||||
|
||||
@@ -24,24 +24,46 @@ export class CombatStore {
|
||||
private readonly loadingState = signal(false);
|
||||
private readonly actionPendingState = signal(false);
|
||||
private readonly errorState = signal<string | null>(null);
|
||||
private readonly errorCodeState = signal<string | null>(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<void> {
|
||||
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<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 {
|
||||
this.loadingState.set(false);
|
||||
}
|
||||
@@ -49,13 +71,13 @@ export class CombatStore {
|
||||
|
||||
async loadCombat(combatId: string): Promise<void> {
|
||||
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 {
|
||||
|
||||
@@ -98,7 +98,9 @@ describe('HuntPageComponent', () => {
|
||||
let combatStore: {
|
||||
combat: ReturnType<typeof signal<Combat | null>>;
|
||||
error: ReturnType<typeof signal<string | null>>;
|
||||
errorCode: ReturnType<typeof signal<string | null>>;
|
||||
startCombat: ReturnType<typeof vi.fn>;
|
||||
loadActiveCombat: ReturnType<typeof vi.fn>;
|
||||
clearError: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let router: Router;
|
||||
@@ -118,7 +120,9 @@ describe('HuntPageComponent', () => {
|
||||
combatStore = {
|
||||
combat: signal<Combat | null>(null),
|
||||
error: signal<string | null>(null),
|
||||
errorCode: signal<string | null>(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.');
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,10 +17,23 @@ const COMBAT_MONSTER_ICON: Readonly<Record<string, string>> = {
|
||||
'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 {
|
||||
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];
|
||||
}
|
||||
|
||||