feat(hunting): show cleared encounters and resume interrupted fights
The hunt screen kept whatever roll was last in memory, so a player coming back from a fight saw every encounter as fresh. Encounters now carry their own status, which the combat module advances as fights start and end. - hunt_encounters.status replaces consumed_at, which only recorded that a fight had begun and could not distinguish a win from a loss - a lost fight hands the encounter back as AVAILABLE, so it can be retried; the unique index tying one combat to one encounter goes with it - GET /hunts/active serves the resumable hunt, which the hunt page adopts on entry rather than trusting its in-memory roll - defeated encounters are crossed out and lose their hover and attack action - a fresh page load rejoins a combat the server still holds open Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { CharacterCombatStatsService } from '../characters/character-combat-stat
|
|||||||
import { Character } from '../characters/entities/character.entity';
|
import { Character } from '../characters/entities/character.entity';
|
||||||
import { Hunt } from '../hunting/entities/hunt.entity';
|
import { Hunt } from '../hunting/entities/hunt.entity';
|
||||||
import { HuntEncounter } from '../hunting/entities/hunt-encounter.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 { HuntStatus } from '../hunting/hunt-status.enum';
|
||||||
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
|
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
|
||||||
import { TravelService } from '../travel/travel.service';
|
import { TravelService } from '../travel/travel.service';
|
||||||
@@ -221,7 +222,7 @@ function huntEncounter(overrides: Partial<HuntEncounter> = {}): HuntEncounter {
|
|||||||
huntId: HUNT_ID,
|
huntId: HUNT_ID,
|
||||||
monsterDefinitionId: MONSTER_ID,
|
monsterDefinitionId: MONSTER_ID,
|
||||||
position: 0,
|
position: 0,
|
||||||
consumedAt: null,
|
status: HuntEncounterStatus.AVAILABLE,
|
||||||
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||||
...overrides,
|
...overrides,
|
||||||
} as HuntEncounter;
|
} 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();
|
const { dataSource, service } = createService();
|
||||||
|
|
||||||
await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
|
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 () => {
|
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({
|
const state = createState({
|
||||||
huntEncounters: [
|
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 });
|
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 () => {
|
it('rejects actions on an unknown combat id', async () => {
|
||||||
const { service } = createService();
|
const { service } = createService();
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { CharacterCombatStatsService } from '../characters/character-combat-stat
|
|||||||
import { Character } from '../characters/entities/character.entity';
|
import { Character } from '../characters/entities/character.entity';
|
||||||
import { Hunt } from '../hunting/entities/hunt.entity';
|
import { Hunt } from '../hunting/entities/hunt.entity';
|
||||||
import { HuntEncounter } from '../hunting/entities/hunt-encounter.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 { HuntStatus } from '../hunting/hunt-status.enum';
|
||||||
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
|
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
|
||||||
import { TravelService } from '../travel/travel.service';
|
import { TravelService } from '../travel/travel.service';
|
||||||
@@ -93,7 +94,7 @@ export class CombatService {
|
|||||||
if (!encounter) {
|
if (!encounter) {
|
||||||
throw huntEncounterNotFound();
|
throw huntEncounterNotFound();
|
||||||
}
|
}
|
||||||
if (encounter.consumedAt) {
|
if (encounter.status !== HuntEncounterStatus.AVAILABLE) {
|
||||||
throw huntEncounterAlreadyConsumed();
|
throw huntEncounterAlreadyConsumed();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +144,7 @@ export class CombatService {
|
|||||||
});
|
});
|
||||||
await combats.save(combat);
|
await combats.save(combat);
|
||||||
|
|
||||||
encounter.consumedAt = new Date();
|
encounter.status = HuntEncounterStatus.IN_PROGRESS;
|
||||||
await encounters.save(encounter);
|
await encounters.save(encounter);
|
||||||
|
|
||||||
return this.toCombatDto(combat, character.name, monster, []);
|
return this.toCombatDto(combat, character.name, monster, []);
|
||||||
@@ -216,6 +217,11 @@ export class CombatService {
|
|||||||
combat.monsterCurrentHp = result.state.monster.currentHp;
|
combat.monsterCurrentHp = result.state.monster.currentHp;
|
||||||
if (combat.status !== CombatStatus.ACTIVE) {
|
if (combat.status !== CombatStatus.ACTIVE) {
|
||||||
combat.completedAt = new Date();
|
combat.completedAt = new Date();
|
||||||
|
await this.settleEncounter(
|
||||||
|
manager.getRepository(HuntEncounter),
|
||||||
|
combat.huntEncounterId,
|
||||||
|
combat.status,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await combats.save(combat);
|
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<HuntEncounter>,
|
||||||
|
encounterId: string,
|
||||||
|
outcome: CombatStatus,
|
||||||
|
): Promise<void> {
|
||||||
|
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(
|
private async lockCharacter(
|
||||||
characters: Repository<Character>,
|
characters: Repository<Character>,
|
||||||
characterId: string,
|
characterId: string,
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ export interface CombatPlayerState extends CombatCombatantState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Entity({ name: 'combats' })
|
@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 {
|
export class Combat {
|
||||||
@PrimaryGeneratedColumn('uuid', { name: 'id' })
|
@PrimaryGeneratedColumn('uuid', { name: 'id' })
|
||||||
id!: string;
|
id!: string;
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddHuntEncounterStatus1788200000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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"');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ import 'reflect-metadata';
|
|||||||
import { getMetadataArgsStorage } from 'typeorm';
|
import { getMetadataArgsStorage } from 'typeorm';
|
||||||
import { Combat } from '../../combat/entities/combat.entity';
|
import { Combat } from '../../combat/entities/combat.entity';
|
||||||
import { CombatEvent } from '../../combat/entities/combat-event.entity';
|
import { CombatEvent } from '../../combat/entities/combat-event.entity';
|
||||||
import { HuntEncounter } from '../../hunting/entities/hunt-encounter.entity';
|
|
||||||
|
|
||||||
describe('combat system schema', () => {
|
describe('combat system schema', () => {
|
||||||
it('maps Combat and CombatEvent relations with the documented onDelete behavior', () => {
|
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', () => {
|
it('enforces ordered, unique event sequencing per combat', () => {
|
||||||
const metadata = getMetadataArgsStorage();
|
const metadata = getMetadataArgsStorage();
|
||||||
const index = metadata.indices.find(
|
const index = metadata.indices.find(
|
||||||
@@ -52,14 +40,4 @@ describe('combat system schema', () => {
|
|||||||
const indexMetadata = index as typeof index & { options?: { unique?: boolean }; unique?: boolean };
|
const indexMetadata = index as typeof index & { options?: { unique?: boolean }; unique?: boolean };
|
||||||
expect(indexMetadata?.options?.unique ?? indexMetadata?.unique).toBe(true);
|
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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
|
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
|
||||||
|
import { HuntEncounterStatus } from '../hunt-encounter-status.enum';
|
||||||
import { Hunt } from './hunt.entity';
|
import { Hunt } from './hunt.entity';
|
||||||
|
|
||||||
@Entity({ name: 'hunt_encounters' })
|
@Entity({ name: 'hunt_encounters' })
|
||||||
@@ -23,10 +24,16 @@ export class HuntEncounter {
|
|||||||
@Column({ name: 'position', type: 'integer' })
|
@Column({ name: 'position', type: 'integer' })
|
||||||
position!: number;
|
position!: number;
|
||||||
|
|
||||||
// Set when a Combat is successfully created from this encounter. Prevents
|
// Owned by the combat module, which advances it as fights start and end.
|
||||||
// one HuntEncounter from spawning more than one Combat (spec §7).
|
// DEFEATED and IN_PROGRESS both bar a new fight; a lost fight resets the
|
||||||
@Column({ name: 'consumed_at', type: 'timestamptz', nullable: true })
|
// encounter to AVAILABLE so the player can try again.
|
||||||
consumedAt!: Date | null;
|
@Column({
|
||||||
|
name: 'status',
|
||||||
|
type: 'enum',
|
||||||
|
enum: HuntEncounterStatus,
|
||||||
|
enumName: 'hunt_encounter_status_enum',
|
||||||
|
})
|
||||||
|
status!: HuntEncounterStatus;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
|||||||
5
apps/api/src/hunting/hunt-encounter-status.enum.ts
Normal file
5
apps/api/src/hunting/hunt-encounter-status.enum.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export enum HuntEncounterStatus {
|
||||||
|
AVAILABLE = 'AVAILABLE',
|
||||||
|
IN_PROGRESS = 'IN_PROGRESS',
|
||||||
|
DEFEATED = 'DEFEATED',
|
||||||
|
}
|
||||||
@@ -10,15 +10,17 @@ import { HuntingService } from './hunting.service';
|
|||||||
describe('HuntingController', () => {
|
describe('HuntingController', () => {
|
||||||
let app: INestApplication<App>;
|
let app: INestApplication<App>;
|
||||||
const startHunt = jest.fn();
|
const startHunt = jest.fn();
|
||||||
|
const getActiveHunt = jest.fn();
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
startHunt.mockReset();
|
startHunt.mockReset();
|
||||||
|
getActiveHunt.mockReset();
|
||||||
const module = await Test.createTestingModule({
|
const module = await Test.createTestingModule({
|
||||||
controllers: [HuntingController],
|
controllers: [HuntingController],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: HuntingService,
|
provide: HuntingService,
|
||||||
useValue: { startHunt },
|
useValue: { startHunt, getActiveHunt },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
@@ -47,4 +49,42 @@ describe('HuntingController', () => {
|
|||||||
expect(startHunt).toHaveBeenCalledWith(DEMO_CHARACTER_ID);
|
expect(startHunt).toHaveBeenCalledWith(DEMO_CHARACTER_ID);
|
||||||
expect(response.body).toEqual(huntResult);
|
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({});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
|
||||||
import { HuntResultDto, HuntingService } from './hunting.service';
|
import { HuntResultDto, HuntingService } from './hunting.service';
|
||||||
|
|
||||||
@@ -10,4 +10,9 @@ export class HuntingController {
|
|||||||
startHunt(): Promise<HuntResultDto> {
|
startHunt(): Promise<HuntResultDto> {
|
||||||
return this.huntingService.startHunt(DEMO_CHARACTER_ID);
|
return this.huntingService.startHunt(DEMO_CHARACTER_ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('active')
|
||||||
|
getActiveHunt(): Promise<HuntResultDto | null> {
|
||||||
|
return this.huntingService.getActiveHunt(DEMO_CHARACTER_ID);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { LocationDefinition } from '../world/entities/location-definition.entity
|
|||||||
import { DangerRating } from './danger-rating';
|
import { DangerRating } from './danger-rating';
|
||||||
import { Hunt } from './entities/hunt.entity';
|
import { Hunt } from './entities/hunt.entity';
|
||||||
import { HuntEncounter } from './entities/hunt-encounter.entity';
|
import { HuntEncounter } from './entities/hunt-encounter.entity';
|
||||||
|
import { HuntEncounterStatus } from './hunt-encounter-status.enum';
|
||||||
import { HuntStatus } from './hunt-status.enum';
|
import { HuntStatus } from './hunt-status.enum';
|
||||||
import { HuntingDomainError } from './hunting.errors';
|
import { HuntingDomainError } from './hunting.errors';
|
||||||
import { HuntingService } from './hunting.service';
|
import { HuntingService } from './hunting.service';
|
||||||
@@ -29,6 +30,15 @@ interface FakeState {
|
|||||||
huntEncounters: HuntEncounter[];
|
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<T extends { id: string }> {
|
class FakeRepository<T extends { id: string }> {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly state: FakeState,
|
private readonly state: FakeState,
|
||||||
@@ -56,10 +66,24 @@ class FakeRepository<T extends { id: string }> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
find(options: { where: Partial<T> }): Promise<T[]> {
|
find(options: {
|
||||||
return Promise.resolve(
|
where: Partial<T>;
|
||||||
this.rows().filter((row) => this.matches(row, options.where)),
|
order?: Partial<Record<keyof T, 'ASC' | 'DESC'>>;
|
||||||
|
}): Promise<T[]> {
|
||||||
|
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>): T {
|
create(values: Partial<T>): T {
|
||||||
@@ -567,4 +591,135 @@ describe('HuntingService', () => {
|
|||||||
expect(encounter.dangerRating).toBe(DangerRating.WEAK);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { TravelStatus } from '../travel/travel-status.enum';
|
|||||||
import { calculateDangerRating, DangerRating } from './danger-rating';
|
import { calculateDangerRating, DangerRating } from './danger-rating';
|
||||||
import { Hunt } from './entities/hunt.entity';
|
import { Hunt } from './entities/hunt.entity';
|
||||||
import { HuntEncounter } from './entities/hunt-encounter.entity';
|
import { HuntEncounter } from './entities/hunt-encounter.entity';
|
||||||
|
import { HuntEncounterStatus } from './hunt-encounter-status.enum';
|
||||||
import { HuntStatus } from './hunt-status.enum';
|
import { HuntStatus } from './hunt-status.enum';
|
||||||
import {
|
import {
|
||||||
characterNotFound,
|
characterNotFound,
|
||||||
@@ -30,6 +31,7 @@ export interface HuntEncounterDto {
|
|||||||
id: string;
|
id: string;
|
||||||
monster: MonsterSummary;
|
monster: MonsterSummary;
|
||||||
dangerRating: DangerRating;
|
dangerRating: DangerRating;
|
||||||
|
status: HuntEncounterStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HuntResultDto {
|
export interface HuntResultDto {
|
||||||
@@ -110,28 +112,11 @@ export class HuntingService {
|
|||||||
huntId: hunt.id,
|
huntId: hunt.id,
|
||||||
monsterDefinitionId: monster.id,
|
monsterDefinitionId: monster.id,
|
||||||
position,
|
position,
|
||||||
|
status: HuntEncounterStatus.AVAILABLE,
|
||||||
});
|
});
|
||||||
await txEncounters.save(encounter);
|
await txEncounters.save(encounter);
|
||||||
|
|
||||||
const dangerRating = calculateDangerRating(
|
encounterDtos.push(this.toEncounterDto(encounter, monster, character));
|
||||||
{ 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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
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<HuntResultDto | null> {
|
||||||
|
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
|
* Rolls `count` independent weighted picks from `pool`. Each slot walks
|
||||||
* the pool in the order it was supplied, accumulating weight, and picks
|
* the pool in the order it was supplied, accumulating weight, and picks
|
||||||
|
|||||||
BIN
apps/web/public/assets/hud-elements/x.png
Normal file
BIN
apps/web/public/assets/hud-elements/x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
BIN
apps/web/public/images/hud/runtime/defeated-mark-256.png
Normal file
BIN
apps/web/public/images/hud/runtime/defeated-mark-256.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
@@ -1,5 +1,6 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
import { RouterOutlet } from '@angular/router';
|
import { Router, RouterOutlet } from '@angular/router';
|
||||||
|
import { CombatStore } from './features/combat/combat.store';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
@@ -7,4 +8,21 @@ import { RouterOutlet } from '@angular/router';
|
|||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.scss',
|
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<void> {
|
||||||
|
const combat = await this.combatStore.loadActiveCombat();
|
||||||
|
if (combat) {
|
||||||
|
void this.router.navigate(['/combat', combat.id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,10 +57,13 @@ export interface MonsterSummary {
|
|||||||
artworkPath: string;
|
artworkPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type HuntEncounterStatus = 'AVAILABLE' | 'IN_PROGRESS' | 'DEFEATED';
|
||||||
|
|
||||||
export interface HuntEncounter {
|
export interface HuntEncounter {
|
||||||
id: string;
|
id: string;
|
||||||
monster: MonsterSummary;
|
monster: MonsterSummary;
|
||||||
dangerRating: DangerRating;
|
dangerRating: DangerRating;
|
||||||
|
status: HuntEncounterStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HuntResult {
|
export interface HuntResult {
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ export class GameApiService {
|
|||||||
return this.http.post<HuntResult>('/api/hunts', {});
|
return this.http.post<HuntResult>('/api/hunts', {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getActiveHunt(): Observable<HuntResult | null> {
|
||||||
|
return this.http.get<HuntResult | null>('/api/hunts/active');
|
||||||
|
}
|
||||||
|
|
||||||
startCombat(encounterId: string): Observable<Combat> {
|
startCombat(encounterId: string): Observable<Combat> {
|
||||||
return this.http.post<Combat>(`/api/hunt-encounters/${encounterId}/attack`, {});
|
return this.http.post<Combat>(`/api/hunt-encounters/${encounterId}/attack`, {});
|
||||||
}
|
}
|
||||||
|
|||||||
66
apps/web/src/app/core/resume-combat.spec.ts
Normal file
66
apps/web/src/app/core/resume-combat.spec.ts
Normal file
@@ -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<typeof vi.fn> };
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<article class="encounter-card">
|
<article class="encounter-card" [class.encounter-card--settled]="settled">
|
||||||
<div class="encounter-card__crest">
|
<div class="encounter-card__crest">
|
||||||
@if (iconPath(); as icon) {
|
@if (iconPath(); as icon) {
|
||||||
<img class="encounter-card__crest-icon" [src]="icon" alt="" loading="lazy" decoding="async" />
|
<img class="encounter-card__crest-icon" [src]="icon" alt="" loading="lazy" decoding="async" />
|
||||||
@@ -13,6 +13,16 @@
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
@if (defeated) {
|
||||||
|
<img
|
||||||
|
class="encounter-card__defeated-mark"
|
||||||
|
[src]="defeatedMark"
|
||||||
|
alt="Besiegt"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 class="encounter-card__name">{{ encounter.monster.name }}</h3>
|
<h3 class="encounter-card__name">{{ encounter.monster.name }}</h3>
|
||||||
@@ -22,5 +32,12 @@
|
|||||||
<app-danger-badge class="encounter-card__danger" [rating]="encounter.dangerRating" />
|
<app-danger-badge class="encounter-card__danger" [rating]="encounter.dangerRating" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" class="encounter-card__attack" (click)="onAttack()">Angreifen</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="encounter-card__attack"
|
||||||
|
[disabled]="settled"
|
||||||
|
(click)="onAttack()"
|
||||||
|
>
|
||||||
|
{{ actionLabel }}
|
||||||
|
</button>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -21,6 +21,16 @@
|
|||||||
background-size: 100% 100%;
|
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 ---------- */
|
/* ---------- crest ---------- */
|
||||||
|
|
||||||
.encounter-card__crest {
|
.encounter-card__crest {
|
||||||
@@ -54,6 +64,17 @@
|
|||||||
place-items: end center;
|
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 {
|
.encounter-card__artwork {
|
||||||
inline-size: 100%;
|
inline-size: 100%;
|
||||||
block-size: 100%;
|
block-size: 100%;
|
||||||
@@ -126,15 +147,20 @@
|
|||||||
text-shadow: 0 0.1rem 0.35rem rgb(0 0 0 / 0.85);
|
text-shadow: 0 0.1rem 0.35rem rgb(0 0 0 / 0.85);
|
||||||
}
|
}
|
||||||
|
|
||||||
.encounter-card__attack:hover {
|
.encounter-card__attack:enabled:hover {
|
||||||
color: #f4ecda;
|
color: #f4ecda;
|
||||||
box-shadow: inset 0 0 1.1rem rgb(150 200 245 / 0.5);
|
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);
|
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 {
|
.encounter-card__attack:focus-visible {
|
||||||
outline: 2px solid var(--ar-blue);
|
outline: 2px solid var(--ar-blue);
|
||||||
outline-offset: -3px;
|
outline-offset: -3px;
|
||||||
@@ -145,7 +171,7 @@
|
|||||||
transition: filter var(--ar-motion-base);
|
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));
|
filter: drop-shadow(0 0 0.9rem rgb(214 178 107 / 0.3));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const dawnwolfEncounter: HuntEncounter = {
|
|||||||
artworkPath: '/images/enemies/Dawnwolf.png',
|
artworkPath: '/images/enemies/Dawnwolf.png',
|
||||||
},
|
},
|
||||||
dangerRating: 'MATCH',
|
dangerRating: 'MATCH',
|
||||||
|
status: 'AVAILABLE',
|
||||||
};
|
};
|
||||||
|
|
||||||
const ashRatEncounter: HuntEncounter = {
|
const ashRatEncounter: HuntEncounter = {
|
||||||
@@ -23,6 +24,7 @@ const ashRatEncounter: HuntEncounter = {
|
|||||||
artworkPath: '/images/monsters/ash-rat.png',
|
artworkPath: '/images/monsters/ash-rat.png',
|
||||||
},
|
},
|
||||||
dangerRating: 'WEAK',
|
dangerRating: 'WEAK',
|
||||||
|
status: 'AVAILABLE',
|
||||||
};
|
};
|
||||||
|
|
||||||
function render(encounter: HuntEncounter): HTMLElement {
|
function render(encounter: HuntEncounter): HTMLElement {
|
||||||
@@ -89,4 +91,62 @@ describe('EncounterCardComponent', () => {
|
|||||||
expect(emitted).not.toHaveBeenCalledWith(dawnwolfEncounter.monster.key);
|
expect(emitted).not.toHaveBeenCalledWith(dawnwolfEncounter.monster.key);
|
||||||
expect(dawnwolfEncounter.id).not.toBe(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<HTMLButtonElement>('.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<HTMLButtonElement>('.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<HTMLButtonElement>('.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<HTMLButtonElement>('.encounter-card__attack')?.click();
|
||||||
|
|
||||||
|
expect(emitted).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { HuntEncounter } from '../../../core/api/game-api.models';
|
|||||||
import { monsterCutoutPath, monsterIconPath } from '../../../shared/monster-artwork';
|
import { monsterCutoutPath, monsterIconPath } from '../../../shared/monster-artwork';
|
||||||
import { DangerBadgeComponent } from '../../../shared/danger-badge/danger-badge.component';
|
import { DangerBadgeComponent } from '../../../shared/danger-badge/danger-badge.component';
|
||||||
|
|
||||||
|
const DEFEATED_MARK = '/images/hud/runtime/defeated-mark-256.png';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-encounter-card',
|
selector: 'app-encounter-card',
|
||||||
imports: [DangerBadgeComponent],
|
imports: [DangerBadgeComponent],
|
||||||
@@ -13,7 +15,31 @@ export class EncounterCardComponent {
|
|||||||
@Input({ required: true }) encounter!: HuntEncounter;
|
@Input({ required: true }) encounter!: HuntEncounter;
|
||||||
@Output() readonly attack = new EventEmitter<string>();
|
@Output() readonly attack = new EventEmitter<string>();
|
||||||
|
|
||||||
|
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 {
|
protected onAttack(): void {
|
||||||
|
if (this.settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.attack.emit(this.encounter.id);
|
this.attack.emit(this.encounter.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ const threeEncounterHunt: HuntResult = {
|
|||||||
id: 'encounter-1',
|
id: 'encounter-1',
|
||||||
monster: { key: 'ash-rat', name: 'Aschenratte', level: 1, artworkPath: '/images/enemies/AshRat.png' },
|
monster: { key: 'ash-rat', name: 'Aschenratte', level: 1, artworkPath: '/images/enemies/AshRat.png' },
|
||||||
dangerRating: 'WEAK',
|
dangerRating: 'WEAK',
|
||||||
|
status: 'AVAILABLE',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'encounter-2',
|
id: 'encounter-2',
|
||||||
@@ -56,11 +57,13 @@ const threeEncounterHunt: HuntResult = {
|
|||||||
artworkPath: '/images/enemies/RoadBandit.png',
|
artworkPath: '/images/enemies/RoadBandit.png',
|
||||||
},
|
},
|
||||||
dangerRating: 'MATCH',
|
dangerRating: 'MATCH',
|
||||||
|
status: 'AVAILABLE',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'encounter-3',
|
id: 'encounter-3',
|
||||||
monster: { key: 'ash-rat', name: 'Aschenratte', level: 1, artworkPath: '/images/enemies/AshRat.png' },
|
monster: { key: 'ash-rat', name: 'Aschenratte', level: 1, artworkPath: '/images/enemies/AshRat.png' },
|
||||||
dangerRating: 'WEAK',
|
dangerRating: 'WEAK',
|
||||||
|
status: 'AVAILABLE',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -93,6 +96,7 @@ describe('HuntPageComponent', () => {
|
|||||||
encounters: () => HuntResult['encounters'];
|
encounters: () => HuntResult['encounters'];
|
||||||
startHunt: ReturnType<typeof vi.fn>;
|
startHunt: ReturnType<typeof vi.fn>;
|
||||||
refreshHunt: ReturnType<typeof vi.fn>;
|
refreshHunt: ReturnType<typeof vi.fn>;
|
||||||
|
loadActiveHunt: ReturnType<typeof vi.fn>;
|
||||||
selectEncounter: ReturnType<typeof vi.fn>;
|
selectEncounter: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
let combatStore: {
|
let combatStore: {
|
||||||
@@ -115,6 +119,7 @@ describe('HuntPageComponent', () => {
|
|||||||
encounters: () => currentHunt()?.encounters ?? [],
|
encounters: () => currentHunt()?.encounters ?? [],
|
||||||
startHunt: vi.fn(() => Promise.resolve()),
|
startHunt: vi.fn(() => Promise.resolve()),
|
||||||
refreshHunt: vi.fn(() => Promise.resolve()),
|
refreshHunt: vi.fn(() => Promise.resolve()),
|
||||||
|
loadActiveHunt: vi.fn(() => Promise.resolve()),
|
||||||
selectEncounter: vi.fn(),
|
selectEncounter: vi.fn(),
|
||||||
};
|
};
|
||||||
combatStore = {
|
combatStore = {
|
||||||
@@ -301,6 +306,75 @@ describe('HuntPageComponent', () => {
|
|||||||
expect(huntingStore.startHunt).not.toHaveBeenCalled();
|
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<HTMLButtonElement>('.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<HTMLButtonElement>('.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<HTMLButtonElement>('.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 () => {
|
it('loads the world state on init when no location has been loaded yet (direct navigation/hard refresh)', async () => {
|
||||||
await setup(null);
|
await setup(null);
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ export class HuntPageComponent implements OnInit {
|
|||||||
if (this.worldStore.currentLocation() === null) {
|
if (this.worldStore.currentLocation() === null) {
|
||||||
void this.worldStore.load();
|
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 {
|
protected startHunt(): void {
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ const huntResult: HuntResult = {
|
|||||||
id: 'encounter-1',
|
id: 'encounter-1',
|
||||||
monster: { key: 'wolf', name: 'Wolf', level: 1, artworkPath: '/images/enemies/Wolf.png' },
|
monster: { key: 'wolf', name: 'Wolf', level: 1, artworkPath: '/images/enemies/Wolf.png' },
|
||||||
dangerRating: 'MATCH',
|
dangerRating: 'MATCH',
|
||||||
|
status: 'AVAILABLE',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'encounter-2',
|
id: 'encounter-2',
|
||||||
monster: { key: 'bear', name: 'Bär', level: 3, artworkPath: '/images/enemies/Bear.png' },
|
monster: { key: 'bear', name: 'Bär', level: 3, artworkPath: '/images/enemies/Bear.png' },
|
||||||
dangerRating: 'STRONG',
|
dangerRating: 'STRONG',
|
||||||
|
status: 'DEFEATED',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -31,6 +33,7 @@ const refreshedHuntResult: HuntResult = {
|
|||||||
id: 'encounter-3',
|
id: 'encounter-3',
|
||||||
monster: { key: 'rat', name: 'Ratte', level: 1, artworkPath: '/images/enemies/Rat.png' },
|
monster: { key: 'rat', name: 'Ratte', level: 1, artworkPath: '/images/enemies/Rat.png' },
|
||||||
dangerRating: 'WEAK',
|
dangerRating: 'WEAK',
|
||||||
|
status: 'AVAILABLE',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -38,12 +41,14 @@ const refreshedHuntResult: HuntResult = {
|
|||||||
describe('HuntingStore', () => {
|
describe('HuntingStore', () => {
|
||||||
let api: {
|
let api: {
|
||||||
startHunt: ReturnType<typeof vi.fn>;
|
startHunt: ReturnType<typeof vi.fn>;
|
||||||
|
getActiveHunt: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
let store: HuntingStore;
|
let store: HuntingStore;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
api = {
|
api = {
|
||||||
startHunt: vi.fn(() => of(huntResult)),
|
startHunt: vi.fn(() => of(huntResult)),
|
||||||
|
getActiveHunt: vi.fn(() => of(huntResult)),
|
||||||
};
|
};
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
@@ -206,4 +211,44 @@ describe('HuntingStore', () => {
|
|||||||
|
|
||||||
expect(store.error()).toBe('Netzwerkfehler');
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -50,6 +50,26 @@ export class HuntingStore {
|
|||||||
await this.startHunt();
|
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<void> {
|
||||||
|
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 {
|
selectEncounter(encounterId: string): void {
|
||||||
this.selectedEncounterIdState.set(encounterId);
|
this.selectedEncounterIdState.set(encounterId);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user