Merge branch 'master' into worktree-local-location-view

Brings in the encounter-status feature (cleared/resumed hunt encounters)
and its own independent Verwilderter Straßenhund / Verkohlter Plünderer
assets. Both branches added the same two monsters at the same time;
resolved by keeping master's asset set as canonical (images/combat/icons/,
images/combat/sprites/) rather than maintaining a parallel copy under
images/monsters/icons/ — dropped that directory and pointed the seed's
MonsterDefinition.iconPath, the local-view test fixtures and the frontend
icon lookup at the existing combat/icons paths instead. Kept this
branch's COMBAT_MONSTER_SCALE entries for the two monsters, since master
never added them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-20 16:03:59 +02:00
45 changed files with 889 additions and 88 deletions

View File

@@ -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"');
}
}

View File

@@ -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);
});
});

View File

@@ -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();
});
});

View File

@@ -188,14 +188,14 @@ describe('seedVisibleVerticalSlice', () => {
name: 'Verwilderter Straßenhund',
level: 1,
artworkPath: '/images/monsters/wild-road-dog.png',
iconPath: '/images/monsters/icons/wild-road-dog-128.png',
iconPath: '/images/combat/icons/wild-road-dog-128.png',
}),
expect.objectContaining({
key: 'charred-looter',
name: 'Verkohlter Plünderer',
level: 2,
artworkPath: '/images/monsters/charred-looter.png',
iconPath: '/images/monsters/icons/charred-looter-128.png',
iconPath: '/images/combat/icons/charred-looter-128.png',
}),
]),
);

View File

@@ -131,7 +131,7 @@ export async function seedVisibleVerticalSlice(
silverMin: 4,
silverMax: 7,
artworkPath: '/images/monsters/ash-rat.png',
iconPath: '/images/monsters/icons/ash-rat-128.png',
iconPath: '/images/combat/icons/ash-rat-128.png',
lootTableId: ASH_RAT_LOOT_TABLE_ID,
},
{
@@ -146,7 +146,7 @@ export async function seedVisibleVerticalSlice(
silverMin: 5,
silverMax: 9,
artworkPath: '/images/monsters/wild-road-dog.png',
iconPath: '/images/monsters/icons/wild-road-dog-128.png',
iconPath: '/images/combat/icons/wild-road-dog-128.png',
// Shares the beast table: both are scorched road animals that leave a
// pelt behind. A table of its own waits for content that differs.
lootTableId: ASH_RAT_LOOT_TABLE_ID,
@@ -163,7 +163,7 @@ export async function seedVisibleVerticalSlice(
silverMin: 9,
silverMax: 15,
artworkPath: '/images/monsters/road-bandit.png',
iconPath: '/images/monsters/icons/road-bandit-128.png',
iconPath: '/images/combat/icons/road-bandit-128.png',
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
},
{
@@ -178,7 +178,7 @@ export async function seedVisibleVerticalSlice(
silverMin: 12,
silverMax: 19,
artworkPath: '/images/monsters/charred-looter.png',
iconPath: '/images/monsters/icons/charred-looter-128.png',
iconPath: '/images/combat/icons/charred-looter-128.png',
// Shares the raider table: same gear, taken from the same caravans.
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
},