diff --git a/apps/api/src/combat/combat.service.spec.ts b/apps/api/src/combat/combat.service.spec.ts index 3fc0898..e78e5d5 100644 --- a/apps/api/src/combat/combat.service.spec.ts +++ b/apps/api/src/combat/combat.service.spec.ts @@ -3,6 +3,7 @@ import { CharacterCombatStatsService } from '../characters/character-combat-stat import { Character } from '../characters/entities/character.entity'; import { Hunt } from '../hunting/entities/hunt.entity'; import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity'; +import { HuntEncounterStatus } from '../hunting/hunt-encounter-status.enum'; import { HuntStatus } from '../hunting/hunt-status.enum'; import { MonsterDefinition } from '../monsters/entities/monster-definition.entity'; import { TravelService } from '../travel/travel.service'; @@ -221,7 +222,7 @@ function huntEncounter(overrides: Partial = {}): HuntEncounter { huntId: HUNT_ID, monsterDefinitionId: MONSTER_ID, position: 0, - consumedAt: null, + status: HuntEncounterStatus.AVAILABLE, createdAt: new Date('2026-08-18T09:00:00.000Z'), ...overrides, } as HuntEncounter; @@ -315,12 +316,14 @@ describe('CombatService', () => { }); }); - it('marks the encounter as consumed', async () => { + it('marks the encounter as IN_PROGRESS', async () => { const { dataSource, service } = createService(); await service.startCombat(CHARACTER_ID, ENCOUNTER_ID); - expect(dataSource.state.huntEncounters[0].consumedAt).not.toBeNull(); + expect(dataSource.state.huntEncounters[0].status).toBe( + HuntEncounterStatus.IN_PROGRESS, + ); }); it('rejects an unknown encounter id', async () => { @@ -332,10 +335,25 @@ describe('CombatService', () => { ); }); - it('rejects an already-consumed encounter, and does not create a second combat', async () => { + it('rejects an already-defeated encounter, and does not create a second combat', async () => { const state = createState({ huntEncounters: [ - huntEncounter({ consumedAt: new Date('2026-08-18T09:05:00.000Z') }), + huntEncounter({ status: HuntEncounterStatus.DEFEATED }), + ], + }); + const { dataSource, service } = createService({ state }); + + await expectCombatDomainError( + service.startCombat(CHARACTER_ID, ENCOUNTER_ID), + 'HUNT_ENCOUNTER_ALREADY_CONSUMED', + ); + expect(dataSource.state.combats).toHaveLength(0); + }); + + it('rejects an encounter whose fight is still IN_PROGRESS', async () => { + const state = createState({ + huntEncounters: [ + huntEncounter({ status: HuntEncounterStatus.IN_PROGRESS }), ], }); const { dataSource, service } = createService({ state }); @@ -518,6 +536,55 @@ describe('CombatService', () => { ); }); + it('marks the encounter DEFEATED when the fight is won', async () => { + const state = createState({ monsters: [monster({ maxHp: 10 })] }); + const { dataSource, service, combatId } = await startedCombat(state); + + await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); + + expect(dataSource.state.huntEncounters[0].status).toBe( + HuntEncounterStatus.DEFEATED, + ); + }); + + it('frees the encounter for another attempt when the fight is lost', async () => { + const state = createState({ characters: [character({ baseHp: 1 })] }); + const { dataSource, service, combatId } = await startedCombat(state); + + await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); + + expect(dataSource.state.huntEncounters[0].status).toBe( + HuntEncounterStatus.AVAILABLE, + ); + }); + + it('leaves the encounter IN_PROGRESS while the fight continues', async () => { + const { dataSource, service, combatId } = await startedCombat(); + + await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); + + expect(dataSource.state.huntEncounters[0].status).toBe( + HuntEncounterStatus.IN_PROGRESS, + ); + }); + + it('lets a lost encounter be fought again as a fresh combat', async () => { + const state = createState({ characters: [character({ baseHp: 1 })] }); + const { dataSource, service, combatId } = await startedCombat(state); + await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); + + const retry = await service.startCombat(CHARACTER_ID, ENCOUNTER_ID); + + expect(retry.id).not.toBe(combatId); + expect(retry.status).toBe('ACTIVE'); + expect(retry.round).toBe(1); + expect(retry.player.currentHp).toBe(retry.player.maxHp); + expect(dataSource.state.combats).toHaveLength(2); + expect(dataSource.state.huntEncounters[0].status).toBe( + HuntEncounterStatus.IN_PROGRESS, + ); + }); + it('rejects actions on an unknown combat id', async () => { const { service } = createService(); diff --git a/apps/api/src/combat/combat.service.ts b/apps/api/src/combat/combat.service.ts index 60eb4e0..4007618 100644 --- a/apps/api/src/combat/combat.service.ts +++ b/apps/api/src/combat/combat.service.ts @@ -4,6 +4,7 @@ import { CharacterCombatStatsService } from '../characters/character-combat-stat import { Character } from '../characters/entities/character.entity'; import { Hunt } from '../hunting/entities/hunt.entity'; import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity'; +import { HuntEncounterStatus } from '../hunting/hunt-encounter-status.enum'; import { HuntStatus } from '../hunting/hunt-status.enum'; import { MonsterDefinition } from '../monsters/entities/monster-definition.entity'; import { TravelService } from '../travel/travel.service'; @@ -93,7 +94,7 @@ export class CombatService { if (!encounter) { throw huntEncounterNotFound(); } - if (encounter.consumedAt) { + if (encounter.status !== HuntEncounterStatus.AVAILABLE) { throw huntEncounterAlreadyConsumed(); } @@ -143,7 +144,7 @@ export class CombatService { }); await combats.save(combat); - encounter.consumedAt = new Date(); + encounter.status = HuntEncounterStatus.IN_PROGRESS; await encounters.save(encounter); return this.toCombatDto(combat, character.name, monster, []); @@ -216,6 +217,11 @@ export class CombatService { combat.monsterCurrentHp = result.state.monster.currentHp; if (combat.status !== CombatStatus.ACTIVE) { combat.completedAt = new Date(); + await this.settleEncounter( + manager.getRepository(HuntEncounter), + combat.huntEncounterId, + combat.status, + ); } await combats.save(combat); @@ -252,6 +258,28 @@ export class CombatService { }); } + /** + * Records the fight's outcome on the encounter that spawned it. A win + * retires the encounter; a loss hands it back so the player can try again. + */ + private async settleEncounter( + encounters: Repository, + encounterId: string, + outcome: CombatStatus, + ): Promise { + const encounter = await encounters.findOneBy({ id: encounterId }); + if (!encounter) { + // combats.hunt_encounter_id is a RESTRICT FK; guaranteed to exist. + throw combatStateInvalid(); + } + + encounter.status = + outcome === CombatStatus.WON + ? HuntEncounterStatus.DEFEATED + : HuntEncounterStatus.AVAILABLE; + await encounters.save(encounter); + } + private async lockCharacter( characters: Repository, characterId: string, diff --git a/apps/api/src/combat/entities/combat.entity.ts b/apps/api/src/combat/entities/combat.entity.ts index 9f602e8..c767d18 100644 --- a/apps/api/src/combat/entities/combat.entity.ts +++ b/apps/api/src/combat/entities/combat.entity.ts @@ -23,7 +23,9 @@ export interface CombatPlayerState extends CombatCombatantState { } @Entity({ name: 'combats' }) -@Index('IDX_combats_hunt_encounter', ['huntEncounterId'], { unique: true }) +// Deliberately not unique: a lost fight frees the encounter to be retried, +// which creates a second combat row for the same encounter. +@Index('IDX_combats_hunt_encounter', ['huntEncounterId']) export class Combat { @PrimaryGeneratedColumn('uuid', { name: 'id' }) id!: string; diff --git a/apps/api/src/database/migrations/1788200000000-AddHuntEncounterStatus.ts b/apps/api/src/database/migrations/1788200000000-AddHuntEncounterStatus.ts new file mode 100644 index 0000000..3864249 --- /dev/null +++ b/apps/api/src/database/migrations/1788200000000-AddHuntEncounterStatus.ts @@ -0,0 +1,54 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddHuntEncounterStatus1788200000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + "CREATE TYPE \"hunt_encounter_status_enum\" AS ENUM ('AVAILABLE', 'IN_PROGRESS', 'DEFEATED')", + ); + await queryRunner.query(`ALTER TABLE "hunt_encounters" + ADD COLUMN "status" "hunt_encounter_status_enum" NOT NULL DEFAULT 'AVAILABLE'`); + + // consumed_at only recorded that a fight had started, so the outcome has + // to be read off the combat it spawned. The unique index this migration + // drops guarantees at most one such combat per encounter. + await queryRunner.query(`UPDATE "hunt_encounters" AS "encounter" + SET "status" = CASE "combat"."status" + WHEN 'WON' THEN 'DEFEATED'::"hunt_encounter_status_enum" + WHEN 'ACTIVE' THEN 'IN_PROGRESS'::"hunt_encounter_status_enum" + ELSE 'AVAILABLE'::"hunt_encounter_status_enum" + END + FROM "combats" AS "combat" + WHERE "combat"."hunt_encounter_id" = "encounter"."id"`); + + await queryRunner.query( + 'ALTER TABLE "hunt_encounters" DROP COLUMN "consumed_at"', + ); + + // A retried encounter gets a second combat row, so the index that kept + // them one-to-one has to go; one ACTIVE combat per character is still + // enforced by IDX_active_combat_per_character. + await queryRunner.query('DROP INDEX "IDX_combats_hunt_encounter"'); + await queryRunner.query( + 'CREATE INDEX "IDX_combats_hunt_encounter" ON "combats" ("hunt_encounter_id")', + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP INDEX "IDX_combats_hunt_encounter"'); + await queryRunner.query( + 'CREATE UNIQUE INDEX "IDX_combats_hunt_encounter" ON "combats" ("hunt_encounter_id")', + ); + + await queryRunner.query( + 'ALTER TABLE "hunt_encounters" ADD COLUMN "consumed_at" TIMESTAMP WITH TIME ZONE', + ); + await queryRunner.query(`UPDATE "hunt_encounters" + SET "consumed_at" = now() + WHERE "status" <> 'AVAILABLE'`); + + await queryRunner.query( + 'ALTER TABLE "hunt_encounters" DROP COLUMN "status"', + ); + await queryRunner.query('DROP TYPE "hunt_encounter_status_enum"'); + } +} diff --git a/apps/api/src/database/migrations/combat-system.migration.spec.ts b/apps/api/src/database/migrations/combat-system.migration.spec.ts index 373894f..160e27e 100644 --- a/apps/api/src/database/migrations/combat-system.migration.spec.ts +++ b/apps/api/src/database/migrations/combat-system.migration.spec.ts @@ -2,7 +2,6 @@ import 'reflect-metadata'; import { getMetadataArgsStorage } from 'typeorm'; import { Combat } from '../../combat/entities/combat.entity'; import { CombatEvent } from '../../combat/entities/combat-event.entity'; -import { HuntEncounter } from '../../hunting/entities/hunt-encounter.entity'; describe('combat system schema', () => { it('maps Combat and CombatEvent relations with the documented onDelete behavior', () => { @@ -28,17 +27,6 @@ describe('combat system schema', () => { ); }); - it('enforces one combat per hunt encounter via a unique index', () => { - const metadata = getMetadataArgsStorage(); - const index = metadata.indices.find( - (candidate) => candidate.target === Combat && candidate.columns?.includes('huntEncounterId'), - ); - - expect(index).toBeDefined(); - const indexMetadata = index as typeof index & { options?: { unique?: boolean }; unique?: boolean }; - expect(indexMetadata?.options?.unique ?? indexMetadata?.unique).toBe(true); - }); - it('enforces ordered, unique event sequencing per combat', () => { const metadata = getMetadataArgsStorage(); const index = metadata.indices.find( @@ -52,14 +40,4 @@ describe('combat system schema', () => { const indexMetadata = index as typeof index & { options?: { unique?: boolean }; unique?: boolean }; expect(indexMetadata?.options?.unique ?? indexMetadata?.unique).toBe(true); }); - - it('adds a nullable consumedAt column to hunt_encounters to prevent reuse', () => { - const metadata = getMetadataArgsStorage(); - const column = metadata.columns.find( - (candidate) => candidate.target === HuntEncounter && candidate.propertyName === 'consumedAt', - ); - - expect(column).toBeDefined(); - expect(column?.options.nullable).toBe(true); - }); }); diff --git a/apps/api/src/database/migrations/encounter-status.migration.spec.ts b/apps/api/src/database/migrations/encounter-status.migration.spec.ts new file mode 100644 index 0000000..633884a --- /dev/null +++ b/apps/api/src/database/migrations/encounter-status.migration.spec.ts @@ -0,0 +1,49 @@ +import 'reflect-metadata'; +import { getMetadataArgsStorage } from 'typeorm'; +import { Combat } from '../../combat/entities/combat.entity'; +import { HuntEncounter } from '../../hunting/entities/hunt-encounter.entity'; +import { HuntEncounterStatus } from '../../hunting/hunt-encounter-status.enum'; + +describe('encounter status schema', () => { + it('stores the encounter status as a non-nullable enum on hunt_encounters', () => { + const metadata = getMetadataArgsStorage(); + const column = metadata.columns.find( + (candidate) => + candidate.target === HuntEncounter && + candidate.propertyName === 'status', + ); + + expect(column).toBeDefined(); + expect(column?.options.type).toBe('enum'); + expect(column?.options.enum).toBe(HuntEncounterStatus); + expect(column?.options.enumName).toBe('hunt_encounter_status_enum'); + expect(column?.options.nullable).toBeFalsy(); + }); + + it('drops consumedAt, whose gate the encounter status replaces', () => { + const metadata = getMetadataArgsStorage(); + const column = metadata.columns.find( + (candidate) => + candidate.target === HuntEncounter && + candidate.propertyName === 'consumedAt', + ); + + expect(column).toBeUndefined(); + }); + + it('allows repeated combats per encounter so a lost fight can be retried', () => { + const metadata = getMetadataArgsStorage(); + const index = metadata.indices.find( + (candidate) => + candidate.target === Combat && + candidate.columns?.includes('huntEncounterId'), + ); + + expect(index).toBeDefined(); + const indexMetadata = index as typeof index & { + options?: { unique?: boolean }; + unique?: boolean; + }; + expect(indexMetadata?.options?.unique ?? indexMetadata?.unique).toBeFalsy(); + }); +}); diff --git a/apps/api/src/hunting/entities/hunt-encounter.entity.ts b/apps/api/src/hunting/entities/hunt-encounter.entity.ts index e6f0aa8..aa65dcc 100644 --- a/apps/api/src/hunting/entities/hunt-encounter.entity.ts +++ b/apps/api/src/hunting/entities/hunt-encounter.entity.ts @@ -7,6 +7,7 @@ import { PrimaryGeneratedColumn, } from 'typeorm'; import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity'; +import { HuntEncounterStatus } from '../hunt-encounter-status.enum'; import { Hunt } from './hunt.entity'; @Entity({ name: 'hunt_encounters' }) @@ -23,10 +24,16 @@ export class HuntEncounter { @Column({ name: 'position', type: 'integer' }) position!: number; - // Set when a Combat is successfully created from this encounter. Prevents - // one HuntEncounter from spawning more than one Combat (spec §7). - @Column({ name: 'consumed_at', type: 'timestamptz', nullable: true }) - consumedAt!: Date | null; + // Owned by the combat module, which advances it as fights start and end. + // DEFEATED and IN_PROGRESS both bar a new fight; a lost fight resets the + // encounter to AVAILABLE so the player can try again. + @Column({ + name: 'status', + type: 'enum', + enum: HuntEncounterStatus, + enumName: 'hunt_encounter_status_enum', + }) + status!: HuntEncounterStatus; @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt!: Date; diff --git a/apps/api/src/hunting/hunt-encounter-status.enum.ts b/apps/api/src/hunting/hunt-encounter-status.enum.ts new file mode 100644 index 0000000..736273b --- /dev/null +++ b/apps/api/src/hunting/hunt-encounter-status.enum.ts @@ -0,0 +1,5 @@ +export enum HuntEncounterStatus { + AVAILABLE = 'AVAILABLE', + IN_PROGRESS = 'IN_PROGRESS', + DEFEATED = 'DEFEATED', +} diff --git a/apps/api/src/hunting/hunting.controller.spec.ts b/apps/api/src/hunting/hunting.controller.spec.ts index 7cef911..0a9a5b9 100644 --- a/apps/api/src/hunting/hunting.controller.spec.ts +++ b/apps/api/src/hunting/hunting.controller.spec.ts @@ -10,15 +10,17 @@ import { HuntingService } from './hunting.service'; describe('HuntingController', () => { let app: INestApplication; const startHunt = jest.fn(); + const getActiveHunt = jest.fn(); beforeEach(async () => { startHunt.mockReset(); + getActiveHunt.mockReset(); const module = await Test.createTestingModule({ controllers: [HuntingController], providers: [ { provide: HuntingService, - useValue: { startHunt }, + useValue: { startHunt, getActiveHunt }, }, ], }).compile(); @@ -47,4 +49,42 @@ describe('HuntingController', () => { expect(startHunt).toHaveBeenCalledWith(DEMO_CHARACTER_ID); expect(response.body).toEqual(huntResult); }); + + it('serves the resumable hunt with its encounter statuses', async () => { + const huntResult = { + id: 'hunt-1', + location: { id: 'loc-1', key: 'burned-road', name: 'Verbrannte Strasse' }, + encounters: [ + { + id: 'encounter-1', + monster: { + key: 'ash-rat', + name: 'Aschenratte', + level: 1, + artworkPath: '/images/monsters/ash-rat.png', + }, + dangerRating: 'WEAK', + status: 'DEFEATED', + }, + ], + }; + getActiveHunt.mockResolvedValue(huntResult); + + const response = await request(app.getHttpServer()) + .get('/api/hunts/active') + .expect(200); + + expect(getActiveHunt).toHaveBeenCalledWith(DEMO_CHARACTER_ID); + expect(response.body).toEqual(huntResult); + }); + + it('serves an empty body when there is no resumable hunt', async () => { + getActiveHunt.mockResolvedValue(null); + + const response = await request(app.getHttpServer()) + .get('/api/hunts/active') + .expect(200); + + expect(response.body).toEqual({}); + }); }); diff --git a/apps/api/src/hunting/hunting.controller.ts b/apps/api/src/hunting/hunting.controller.ts index d23a863..d1fc31d 100644 --- a/apps/api/src/hunting/hunting.controller.ts +++ b/apps/api/src/hunting/hunting.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Post } from '@nestjs/common'; +import { Controller, Get, Post } from '@nestjs/common'; import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants'; import { HuntResultDto, HuntingService } from './hunting.service'; @@ -10,4 +10,9 @@ export class HuntingController { startHunt(): Promise { return this.huntingService.startHunt(DEMO_CHARACTER_ID); } + + @Get('active') + getActiveHunt(): Promise { + return this.huntingService.getActiveHunt(DEMO_CHARACTER_ID); + } } diff --git a/apps/api/src/hunting/hunting.service.spec.ts b/apps/api/src/hunting/hunting.service.spec.ts index 7d9783c..6f5d8b0 100644 --- a/apps/api/src/hunting/hunting.service.spec.ts +++ b/apps/api/src/hunting/hunting.service.spec.ts @@ -9,6 +9,7 @@ import { LocationDefinition } from '../world/entities/location-definition.entity import { DangerRating } from './danger-rating'; import { Hunt } from './entities/hunt.entity'; import { HuntEncounter } from './entities/hunt-encounter.entity'; +import { HuntEncounterStatus } from './hunt-encounter-status.enum'; import { HuntStatus } from './hunt-status.enum'; import { HuntingDomainError } from './hunting.errors'; import { HuntingService } from './hunting.service'; @@ -29,6 +30,15 @@ interface FakeState { huntEncounters: HuntEncounter[]; } +// `find` in the fake ignores `relations`, so fixtures attach the joined +// monster the way TypeORM would have hydrated it. +function withMonster( + encounter: HuntEncounter, + monster: MonsterDefinition, +): HuntEncounter { + return { ...encounter, monster } as HuntEncounter; +} + class FakeRepository { constructor( private readonly state: FakeState, @@ -56,10 +66,24 @@ class FakeRepository { ); } - find(options: { where: Partial }): Promise { - return Promise.resolve( - this.rows().filter((row) => this.matches(row, options.where)), + find(options: { + where: Partial; + order?: Partial>; + }): Promise { + const matched = this.rows().filter((row) => + this.matches(row, options.where), ); + const orderKey = options.order + ? (Object.keys(options.order)[0] as keyof T) + : undefined; + if (orderKey) { + const direction = options.order![orderKey] === 'DESC' ? -1 : 1; + matched.sort((a, b) => { + if (a[orderKey] === b[orderKey]) return 0; + return a[orderKey] > b[orderKey] ? direction : -direction; + }); + } + return Promise.resolve(matched); } create(values: Partial): T { @@ -567,4 +591,135 @@ describe('HuntingService', () => { expect(encounter.dangerRating).toBe(DangerRating.WEAK); } }); + + it('marks every freshly rolled encounter as AVAILABLE', async () => { + const monsterA = monsterDefinition( + MONSTER_A_ID, + 'aschenratte', + 'Aschenratte', + ); + const state = createState(); + state.characters[0].currentLocationId = HUNTING_LOCATION_ID; + state.characters[0].currentLocation = huntingLocation(); + state.locationMonsters = [ + locationMonster( + LOCATION_MONSTER_A_ID, + HUNTING_LOCATION_ID, + monsterA, + 100, + ), + ]; + const { dataSource, service } = createService({ + state, + randomSource: fakeRandomSource([0.1, 0.1, 0.1]), + }); + + const result = await service.startHunt(CHARACTER_ID); + + expect(result.encounters.map((encounter) => encounter.status)).toEqual([ + HuntEncounterStatus.AVAILABLE, + HuntEncounterStatus.AVAILABLE, + HuntEncounterStatus.AVAILABLE, + ]); + expect( + dataSource.state.huntEncounters.map((encounter) => encounter.status), + ).toEqual([ + HuntEncounterStatus.AVAILABLE, + HuntEncounterStatus.AVAILABLE, + HuntEncounterStatus.AVAILABLE, + ]); + }); + + describe('getActiveHunt', () => { + function activeHuntState( + statuses: HuntEncounterStatus[], + overrides: { huntStatus?: HuntStatus; huntLocationId?: string } = {}, + ) { + const monsterA = monsterDefinition( + MONSTER_A_ID, + 'aschenratte', + 'Aschenratte', + ); + const state = createState(); + state.characters[0].currentLocationId = HUNTING_LOCATION_ID; + state.characters[0].currentLocation = huntingLocation(); + state.hunts = [ + { + id: 'hunt-1', + characterId: CHARACTER_ID, + locationId: overrides.huntLocationId ?? HUNTING_LOCATION_ID, + status: overrides.huntStatus ?? HuntStatus.ACTIVE, + createdAt: new Date('2026-08-18T09:00:00.000Z'), + } as Hunt, + ]; + state.huntEncounters = statuses.map((status, position) => + withMonster( + { + id: `encounter-${position}`, + huntId: 'hunt-1', + monsterDefinitionId: MONSTER_A_ID, + position, + status, + createdAt: new Date('2026-08-18T09:00:00.000Z'), + } as HuntEncounter, + monsterA, + ), + ); + return state; + } + + it('returns null when the character has no active hunt', async () => { + const { service } = createService(); + + await expect(service.getActiveHunt(CHARACTER_ID)).resolves.toBeNull(); + }); + + it('returns null when the only hunt has been superseded', async () => { + const state = activeHuntState([HuntEncounterStatus.AVAILABLE], { + huntStatus: HuntStatus.SUPERSEDED, + }); + const { service } = createService({ state }); + + await expect(service.getActiveHunt(CHARACTER_ID)).resolves.toBeNull(); + }); + + it('returns the active hunt with the persisted status of each encounter', async () => { + const state = activeHuntState([ + HuntEncounterStatus.AVAILABLE, + HuntEncounterStatus.DEFEATED, + HuntEncounterStatus.IN_PROGRESS, + ]); + const { service } = createService({ state }); + + const result = await service.getActiveHunt(CHARACTER_ID); + + expect(result?.id).toBe('hunt-1'); + expect(result?.location).toEqual({ + id: HUNTING_LOCATION_ID, + key: 'burned-road', + name: 'Verbrannte Strasse', + }); + expect(result?.encounters.map((encounter) => encounter.status)).toEqual([ + HuntEncounterStatus.AVAILABLE, + HuntEncounterStatus.DEFEATED, + HuntEncounterStatus.IN_PROGRESS, + ]); + expect(result?.encounters.map((encounter) => encounter.id)).toEqual([ + 'encounter-0', + 'encounter-1', + 'encounter-2', + ]); + expect(result?.encounters[0].monster.key).toBe('aschenratte'); + expect(result?.encounters[0].dangerRating).toBeDefined(); + }); + + it('returns null once the character has left the hunt location', async () => { + const state = activeHuntState([HuntEncounterStatus.AVAILABLE], { + huntLocationId: SAFE_LOCATION_ID, + }); + const { service } = createService({ state }); + + await expect(service.getActiveHunt(CHARACTER_ID)).resolves.toBeNull(); + }); + }); }); diff --git a/apps/api/src/hunting/hunting.service.ts b/apps/api/src/hunting/hunting.service.ts index 55e71b7..cc9c3ed 100644 --- a/apps/api/src/hunting/hunting.service.ts +++ b/apps/api/src/hunting/hunting.service.ts @@ -9,6 +9,7 @@ import { TravelStatus } from '../travel/travel-status.enum'; import { calculateDangerRating, DangerRating } from './danger-rating'; import { Hunt } from './entities/hunt.entity'; import { HuntEncounter } from './entities/hunt-encounter.entity'; +import { HuntEncounterStatus } from './hunt-encounter-status.enum'; import { HuntStatus } from './hunt-status.enum'; import { characterNotFound, @@ -30,6 +31,7 @@ export interface HuntEncounterDto { id: string; monster: MonsterSummary; dangerRating: DangerRating; + status: HuntEncounterStatus; } export interface HuntResultDto { @@ -110,28 +112,11 @@ export class HuntingService { huntId: hunt.id, monsterDefinitionId: monster.id, position, + status: HuntEncounterStatus.AVAILABLE, }); await txEncounters.save(encounter); - const dangerRating = calculateDangerRating( - { attack: character.baseAttack, armor: 0, hp: character.baseHp }, - { - attack: monster.attack, - armor: monster.armor, - hp: monster.maxHp, - }, - ); - - encounterDtos.push({ - id: encounter.id, - monster: { - key: monster.key, - name: monster.name, - level: monster.level, - artworkPath: monster.artworkPath, - }, - dangerRating, - }); + encounterDtos.push(this.toEncounterDto(encounter, monster, character)); } return { @@ -142,6 +127,70 @@ export class HuntingService { }); } + /** + * The hunt the player can still act on, or null if there is none. A hunt + * is only resumable where it was rolled, so travelling away retires it and + * the player has to search the new area instead. + */ + async getActiveHunt(characterId: string): Promise { + const characters = this.dataSource.getRepository(Character); + const character = await characters.findOne({ + where: { id: characterId }, + relations: { currentLocation: true }, + }); + if (!character) { + throw characterNotFound(); + } + + const hunts = this.dataSource.getRepository(Hunt); + const hunt = await hunts.findOne({ + where: { + characterId, + status: HuntStatus.ACTIVE, + locationId: character.currentLocationId, + }, + }); + if (!hunt) { + return null; + } + + const huntEncounters = this.dataSource.getRepository(HuntEncounter); + const encounters = await huntEncounters.find({ + where: { huntId: hunt.id }, + relations: { monster: true }, + order: { position: 'ASC' }, + }); + + return { + id: hunt.id, + location: this.toLocationSummary(character.currentLocation), + encounters: encounters.map((encounter) => + this.toEncounterDto(encounter, encounter.monster, character), + ), + }; + } + + private toEncounterDto( + encounter: HuntEncounter, + monster: MonsterDefinition, + character: Character, + ): HuntEncounterDto { + return { + id: encounter.id, + monster: { + key: monster.key, + name: monster.name, + level: monster.level, + artworkPath: monster.artworkPath, + }, + dangerRating: calculateDangerRating( + { attack: character.baseAttack, armor: 0, hp: character.baseHp }, + { attack: monster.attack, armor: monster.armor, hp: monster.maxHp }, + ), + status: encounter.status, + }; + } + /** * Rolls `count` independent weighted picks from `pool`. Each slot walks * the pool in the order it was supplied, accumulating weight, and picks diff --git a/apps/web/public/assets/hud-elements/x.png b/apps/web/public/assets/hud-elements/x.png new file mode 100644 index 0000000..2652085 Binary files /dev/null and b/apps/web/public/assets/hud-elements/x.png differ diff --git a/apps/web/public/images/hud/runtime/defeated-mark-256.png b/apps/web/public/images/hud/runtime/defeated-mark-256.png new file mode 100644 index 0000000..b2ff2b7 Binary files /dev/null and b/apps/web/public/images/hud/runtime/defeated-mark-256.png differ diff --git a/apps/web/src/app/app.ts b/apps/web/src/app/app.ts index 372b06c..6350917 100644 --- a/apps/web/src/app/app.ts +++ b/apps/web/src/app/app.ts @@ -1,5 +1,6 @@ -import { Component } from '@angular/core'; -import { RouterOutlet } from '@angular/router'; +import { Component, OnInit, inject } from '@angular/core'; +import { Router, RouterOutlet } from '@angular/router'; +import { CombatStore } from './features/combat/combat.store'; @Component({ selector: 'app-root', @@ -7,4 +8,21 @@ import { RouterOutlet } from '@angular/router'; templateUrl: './app.html', styleUrl: './app.scss', }) -export class App {} +export class App implements OnInit { + private readonly combatStore = inject(CombatStore); + private readonly router = inject(Router); + + // A fight the server still holds open outlives the browser session, and + // leaving it is not something the player can do from anywhere else, so a + // fresh load resumes it rather than stranding them on the world map. + ngOnInit(): void { + void this.resumeRunningCombat(); + } + + private async resumeRunningCombat(): Promise { + const combat = await this.combatStore.loadActiveCombat(); + if (combat) { + void this.router.navigate(['/combat', combat.id]); + } + } +} diff --git a/apps/web/src/app/core/api/game-api.models.ts b/apps/web/src/app/core/api/game-api.models.ts index e7646fc..1802397 100644 --- a/apps/web/src/app/core/api/game-api.models.ts +++ b/apps/web/src/app/core/api/game-api.models.ts @@ -57,10 +57,13 @@ export interface MonsterSummary { artworkPath: string; } +export type HuntEncounterStatus = 'AVAILABLE' | 'IN_PROGRESS' | 'DEFEATED'; + export interface HuntEncounter { id: string; monster: MonsterSummary; dangerRating: DangerRating; + status: HuntEncounterStatus; } export interface HuntResult { 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 ae438f7..66c8b56 100644 --- a/apps/web/src/app/core/api/game-api.service.ts +++ b/apps/web/src/app/core/api/game-api.service.ts @@ -34,6 +34,10 @@ export class GameApiService { return this.http.post('/api/hunts', {}); } + getActiveHunt(): Observable { + return this.http.get('/api/hunts/active'); + } + startCombat(encounterId: string): Observable { return this.http.post(`/api/hunt-encounters/${encounterId}/attack`, {}); } diff --git a/apps/web/src/app/core/resume-combat.spec.ts b/apps/web/src/app/core/resume-combat.spec.ts new file mode 100644 index 0000000..e997214 --- /dev/null +++ b/apps/web/src/app/core/resume-combat.spec.ts @@ -0,0 +1,66 @@ +import { TestBed } from '@angular/core/testing'; +import { Router, provideRouter } from '@angular/router'; +import { vi } from 'vitest'; +import type { Combat } from './api/game-api.models'; +import { App } from '../app'; +import { CombatStore } from '../features/combat/combat.store'; + +const runningCombat: Combat = { + id: 'combat-running', + status: 'ACTIVE', + round: 4, + player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 62 }, + monster: { + key: 'road-bandit', + name: 'Straßenräuber', + level: 3, + maxHp: 75, + currentHp: 30, + artworkPath: '/images/enemies/RoadBandit.png', + }, + events: [], +}; + +describe('resuming an interrupted combat', () => { + let combatStore: { loadActiveCombat: ReturnType }; + let router: Router; + + async function bootstrap() { + await TestBed.configureTestingModule({ + imports: [App], + providers: [ + provideRouter([ + { path: 'world', children: [] }, + { path: 'combat/:combatId', children: [] }, + ]), + { provide: CombatStore, useValue: combatStore }, + ], + }).compileComponents(); + + router = TestBed.inject(Router); + vi.spyOn(router, 'navigate').mockResolvedValue(true); + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + await fixture.whenStable(); + return fixture; + } + + it('drops the player straight back into the fight they left running', async () => { + combatStore = { loadActiveCombat: vi.fn(() => Promise.resolve(runningCombat)) }; + + await bootstrap(); + + expect(combatStore.loadActiveCombat).toHaveBeenCalledOnce(); + expect(router.navigate).toHaveBeenCalledWith(['/combat', 'combat-running']); + }); + + it('leaves navigation alone when no fight is running', async () => { + combatStore = { loadActiveCombat: vi.fn(() => Promise.resolve(null)) }; + + await bootstrap(); + + expect(combatStore.loadActiveCombat).toHaveBeenCalledOnce(); + expect(router.navigate).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.html b/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.html index 63ab368..65f625f 100644 --- a/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.html +++ b/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.html @@ -1,4 +1,4 @@ -
+
@if (iconPath(); as icon) { @@ -13,6 +13,16 @@ loading="lazy" decoding="async" /> + + @if (defeated) { + Besiegt + }

{{ encounter.monster.name }}

@@ -22,5 +32,12 @@ - +
diff --git a/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.scss b/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.scss index c2542f3..d947f86 100644 --- a/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.scss +++ b/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.scss @@ -21,6 +21,16 @@ background-size: 100% 100%; } +/* A settled encounter is out of play, so the card recedes: the artwork loses + its colour and the whole frame dims. */ +.encounter-card--settled .encounter-card__artwork { + filter: grayscale(0.85) brightness(0.6); +} + +.encounter-card--settled { + opacity: 0.78; +} + /* ---------- crest ---------- */ .encounter-card__crest { @@ -54,6 +64,17 @@ place-items: end center; } +/* Sits over the artwork panel rather than the whole card, so the name and + the danger badge stay readable under it. */ +.encounter-card__defeated-mark { + position: absolute; + inset: 6%; + inline-size: 88%; + block-size: 88%; + object-fit: contain; + pointer-events: none; +} + .encounter-card__artwork { inline-size: 100%; block-size: 100%; @@ -126,15 +147,20 @@ text-shadow: 0 0.1rem 0.35rem rgb(0 0 0 / 0.85); } -.encounter-card__attack:hover { +.encounter-card__attack:enabled:hover { color: #f4ecda; box-shadow: inset 0 0 1.1rem rgb(150 200 245 / 0.5); } -.encounter-card__attack:active { +.encounter-card__attack:enabled:active { box-shadow: inset 0 0.15rem 0.7rem rgb(0 0 0 / 0.6); } +.encounter-card__attack:disabled { + color: rgb(214 206 190 / 0.45); + cursor: default; +} + .encounter-card__attack:focus-visible { outline: 2px solid var(--ar-blue); outline-offset: -3px; @@ -145,7 +171,7 @@ transition: filter var(--ar-motion-base); } - .encounter-card:hover { + .encounter-card:not(.encounter-card--settled):hover { filter: drop-shadow(0 0 0.9rem rgb(214 178 107 / 0.3)); } diff --git a/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.spec.ts b/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.spec.ts index b070354..f0c99a8 100644 --- a/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.spec.ts +++ b/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.spec.ts @@ -12,6 +12,7 @@ const dawnwolfEncounter: HuntEncounter = { artworkPath: '/images/enemies/Dawnwolf.png', }, dangerRating: 'MATCH', + status: 'AVAILABLE', }; const ashRatEncounter: HuntEncounter = { @@ -23,6 +24,7 @@ const ashRatEncounter: HuntEncounter = { artworkPath: '/images/monsters/ash-rat.png', }, dangerRating: 'WEAK', + status: 'AVAILABLE', }; function render(encounter: HuntEncounter): HTMLElement { @@ -89,4 +91,62 @@ describe('EncounterCardComponent', () => { expect(emitted).not.toHaveBeenCalledWith(dawnwolfEncounter.monster.key); expect(dawnwolfEncounter.id).not.toBe(dawnwolfEncounter.monster.key); }); + + it('leaves an available encounter unmarked and interactive', () => { + const element = render(dawnwolfEncounter); + + expect(element.querySelector('.encounter-card__defeated-mark')).toBeNull(); + expect(element.querySelector('.encounter-card')?.classList).not.toContain( + 'encounter-card--settled', + ); + expect( + element.querySelector('.encounter-card__attack')?.disabled, + ).toBe(false); + }); + + it('crosses out a defeated encounter and disables its attack', () => { + const element = render({ ...dawnwolfEncounter, status: 'DEFEATED' }); + const mark = element.querySelector('.encounter-card__defeated-mark'); + + expect(mark).not.toBeNull(); + expect(mark?.getAttribute('alt')).toBe('Besiegt'); + expect( + element.querySelector('.encounter-card__attack')?.disabled, + ).toBe(true); + }); + + it('drops the hover treatment once an encounter is settled', () => { + const defeated = render({ ...dawnwolfEncounter, status: 'DEFEATED' }); + const inProgress = render({ ...dawnwolfEncounter, status: 'IN_PROGRESS' }); + + expect(defeated.querySelector('.encounter-card')?.classList).toContain( + 'encounter-card--settled', + ); + expect(inProgress.querySelector('.encounter-card')?.classList).toContain( + 'encounter-card--settled', + ); + }); + + it('locks an in-progress encounter without crossing it out', () => { + const element = render({ ...dawnwolfEncounter, status: 'IN_PROGRESS' }); + + expect(element.querySelector('.encounter-card__defeated-mark')).toBeNull(); + expect( + element.querySelector('.encounter-card__attack')?.disabled, + ).toBe(true); + }); + + it('does not emit an attack for a defeated encounter', () => { + const fixture = TestBed.createComponent(EncounterCardComponent); + fixture.componentRef.setInput('encounter', { ...dawnwolfEncounter, status: 'DEFEATED' }); + fixture.detectChanges(); + + const emitted = vi.fn(); + fixture.componentInstance.attack.subscribe(emitted); + + const element = fixture.nativeElement as HTMLElement; + element.querySelector('.encounter-card__attack')?.click(); + + expect(emitted).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.ts b/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.ts index 4a51eee..05edb3a 100644 --- a/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.ts +++ b/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.ts @@ -3,6 +3,8 @@ import { HuntEncounter } from '../../../core/api/game-api.models'; import { monsterCutoutPath, monsterIconPath } from '../../../shared/monster-artwork'; import { DangerBadgeComponent } from '../../../shared/danger-badge/danger-badge.component'; +const DEFEATED_MARK = '/images/hud/runtime/defeated-mark-256.png'; + @Component({ selector: 'app-encounter-card', imports: [DangerBadgeComponent], @@ -13,7 +15,31 @@ export class EncounterCardComponent { @Input({ required: true }) encounter!: HuntEncounter; @Output() readonly attack = new EventEmitter(); + protected readonly defeatedMark = DEFEATED_MARK; + + protected get defeated(): boolean { + return this.encounter.status === 'DEFEATED'; + } + + // A cleared or already-running encounter cannot be attacked, so the card + // drops its hover invitation as well as the button. + protected get settled(): boolean { + return this.encounter.status !== 'AVAILABLE'; + } + + protected get actionLabel(): string { + if (this.defeated) { + return 'Besiegt'; + } + + return this.encounter.status === 'IN_PROGRESS' ? 'Im Kampf' : 'Angreifen'; + } + protected onAttack(): void { + if (this.settled) { + return; + } + this.attack.emit(this.encounter.id); } 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 b8c5963..3e5c1ba 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 @@ -46,6 +46,7 @@ const threeEncounterHunt: HuntResult = { id: 'encounter-1', monster: { key: 'ash-rat', name: 'Aschenratte', level: 1, artworkPath: '/images/enemies/AshRat.png' }, dangerRating: 'WEAK', + status: 'AVAILABLE', }, { id: 'encounter-2', @@ -56,11 +57,13 @@ const threeEncounterHunt: HuntResult = { artworkPath: '/images/enemies/RoadBandit.png', }, dangerRating: 'MATCH', + status: 'AVAILABLE', }, { id: 'encounter-3', monster: { key: 'ash-rat', name: 'Aschenratte', level: 1, artworkPath: '/images/enemies/AshRat.png' }, dangerRating: 'WEAK', + status: 'AVAILABLE', }, ], }; @@ -93,6 +96,7 @@ describe('HuntPageComponent', () => { encounters: () => HuntResult['encounters']; startHunt: ReturnType; refreshHunt: ReturnType; + loadActiveHunt: ReturnType; selectEncounter: ReturnType; }; let combatStore: { @@ -115,6 +119,7 @@ describe('HuntPageComponent', () => { encounters: () => currentHunt()?.encounters ?? [], startHunt: vi.fn(() => Promise.resolve()), refreshHunt: vi.fn(() => Promise.resolve()), + loadActiveHunt: vi.fn(() => Promise.resolve()), selectEncounter: vi.fn(), }; combatStore = { @@ -301,6 +306,75 @@ describe('HuntPageComponent', () => { expect(huntingStore.startHunt).not.toHaveBeenCalled(); }); + it('adopts the resumable hunt on page entry so cleared encounters stay marked', async () => { + await setup(burnedRoad); + + expect(huntingStore.loadActiveHunt).toHaveBeenCalledOnce(); + expect(huntingStore.startHunt).not.toHaveBeenCalled(); + }); + + it('marks a defeated encounter and takes away its attack action', async () => { + const clearedHunt: HuntResult = { + ...threeEncounterHunt, + encounters: [ + { ...threeEncounterHunt.encounters[0], status: 'DEFEATED' }, + threeEncounterHunt.encounters[1], + threeEncounterHunt.encounters[2], + ], + }; + const fixture = await setup(burnedRoad, clearedHunt); + const element = fixture.nativeElement as HTMLElement; + + const cards = element.querySelectorAll('app-encounter-card'); + expect(cards[0].querySelector('.encounter-card__defeated-mark')).not.toBeNull(); + expect(cards[1].querySelector('.encounter-card__defeated-mark')).toBeNull(); + + const attackButtons = Array.from( + element.querySelectorAll('.encounter-card__attack'), + ); + expect(attackButtons[0].disabled).toBe(true); + expect(attackButtons[1].disabled).toBe(false); + }); + + it('keeps an in-progress encounter unmarked but locked', async () => { + const fightingHunt: HuntResult = { + ...threeEncounterHunt, + encounters: [ + { ...threeEncounterHunt.encounters[0], status: 'IN_PROGRESS' }, + threeEncounterHunt.encounters[1], + threeEncounterHunt.encounters[2], + ], + }; + const fixture = await setup(burnedRoad, fightingHunt); + const element = fixture.nativeElement as HTMLElement; + + const cards = element.querySelectorAll('app-encounter-card'); + expect(cards[0].querySelector('.encounter-card__defeated-mark')).toBeNull(); + + const attackButtons = Array.from( + element.querySelectorAll('.encounter-card__attack'), + ); + expect(attackButtons[0].disabled).toBe(true); + }); + + it('does not start a combat from a defeated encounter card', async () => { + const clearedHunt: HuntResult = { + ...threeEncounterHunt, + encounters: [ + { ...threeEncounterHunt.encounters[0], status: 'DEFEATED' }, + threeEncounterHunt.encounters[1], + threeEncounterHunt.encounters[2], + ], + }; + const fixture = await setup(burnedRoad, clearedHunt); + const element = fixture.nativeElement as HTMLElement; + + element.querySelectorAll('.encounter-card__attack')[0].click(); + await Promise.resolve(); + + expect(combatStore.startCombat).not.toHaveBeenCalled(); + }); + it('loads the world state on init when no location has been loaded yet (direct navigation/hard refresh)', async () => { await setup(null); 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 74eadeb..6bdb95b 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 @@ -21,6 +21,11 @@ export class HuntPageComponent implements OnInit { if (this.worldStore.currentLocation() === null) { void this.worldStore.load(); } + + // The server owns which encounters are still open, so entering the page -- + // including on the way back from a fight -- takes its word over whatever + // roll is still in memory. + void this.huntingStore.loadActiveHunt(); } protected startHunt(): void { diff --git a/apps/web/src/app/features/hunting/hunting.store.spec.ts b/apps/web/src/app/features/hunting/hunting.store.spec.ts index 455bbe8..de5bc06 100644 --- a/apps/web/src/app/features/hunting/hunting.store.spec.ts +++ b/apps/web/src/app/features/hunting/hunting.store.spec.ts @@ -14,11 +14,13 @@ const huntResult: HuntResult = { id: 'encounter-1', monster: { key: 'wolf', name: 'Wolf', level: 1, artworkPath: '/images/enemies/Wolf.png' }, dangerRating: 'MATCH', + status: 'AVAILABLE', }, { id: 'encounter-2', monster: { key: 'bear', name: 'Bär', level: 3, artworkPath: '/images/enemies/Bear.png' }, dangerRating: 'STRONG', + status: 'DEFEATED', }, ], }; @@ -31,6 +33,7 @@ const refreshedHuntResult: HuntResult = { id: 'encounter-3', monster: { key: 'rat', name: 'Ratte', level: 1, artworkPath: '/images/enemies/Rat.png' }, dangerRating: 'WEAK', + status: 'AVAILABLE', }, ], }; @@ -38,12 +41,14 @@ const refreshedHuntResult: HuntResult = { describe('HuntingStore', () => { let api: { startHunt: ReturnType; + getActiveHunt: ReturnType; }; let store: HuntingStore; beforeEach(() => { api = { startHunt: vi.fn(() => of(huntResult)), + getActiveHunt: vi.fn(() => of(huntResult)), }; TestBed.configureTestingModule({ @@ -206,4 +211,44 @@ describe('HuntingStore', () => { expect(store.error()).toBe('Netzwerkfehler'); }); + + describe('loadActiveHunt', () => { + it('adopts the resumable hunt so returning players see the encounter statuses', async () => { + await store.loadActiveHunt(); + + expect(api.getActiveHunt).toHaveBeenCalledOnce(); + expect(api.startHunt).not.toHaveBeenCalled(); + expect(store.currentHunt()).toEqual(huntResult); + expect(store.encounters()[1].status).toBe('DEFEATED'); + expect(store.loading()).toBe(false); + expect(store.error()).toBeNull(); + }); + + it('leaves the page on its start screen when there is no resumable hunt', async () => { + api.getActiveHunt.mockReturnValue(of(null)); + + await store.loadActiveHunt(); + + expect(store.currentHunt()).toBeNull(); + expect(store.error()).toBeNull(); + }); + + it('replaces a stale hunt with the current server state', async () => { + await store.startHunt(); + api.getActiveHunt.mockReturnValue(of(refreshedHuntResult)); + + await store.loadActiveHunt(); + + expect(store.currentHunt()).toEqual(refreshedHuntResult); + }); + + it('surfaces a failed reload as an error and clears loading', async () => { + api.getActiveHunt.mockReturnValue(throwError(() => new Error('Netzwerkfehler'))); + + await store.loadActiveHunt(); + + expect(store.error()).toBe('Netzwerkfehler'); + expect(store.loading()).toBe(false); + }); + }); }); diff --git a/apps/web/src/app/features/hunting/hunting.store.ts b/apps/web/src/app/features/hunting/hunting.store.ts index a8606e3..a264883 100644 --- a/apps/web/src/app/features/hunting/hunting.store.ts +++ b/apps/web/src/app/features/hunting/hunting.store.ts @@ -50,6 +50,26 @@ export class HuntingStore { await this.startHunt(); } + /** + * Adopts the hunt the server still considers open, so a player coming back + * from a fight (or a fresh page load) sees which encounters they have + * already cleared instead of a stale in-memory roll. + */ + async loadActiveHunt(): Promise { + this.loadingState.set(true); + this.errorState.set(null); + + try { + const hunt = await firstValueFrom(this.api.getActiveHunt()); + this.currentHuntState.set(hunt); + this.selectedEncounterIdState.set(null); + } catch (error) { + this.errorState.set(this.toErrorMessage(error)); + } finally { + this.loadingState.set(false); + } + } + selectEncounter(encounterId: string): void { this.selectedEncounterIdState.set(encounterId); }