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:
@@ -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 { 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user