Merge branch 'worktree-playable-slice-0.5-first-upgrade'
# Conflicts: # apps/web/src/app/core/api/game-api.service.ts # apps/web/src/app/features/combat/combat-page/combat-page.component.html # apps/web/src/app/features/combat/combat-page/combat-page.component.scss # apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts # apps/web/src/app/features/combat/combat-page/combat-page.component.ts
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateEquipment1789000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Reuses the "equipment_slot_enum" type created by CreateLootAndRewards.
|
||||
await queryRunner.query(`CREATE TABLE "character_equipment" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"character_id" uuid NOT NULL,
|
||||
"slot" "equipment_slot_enum" NOT NULL,
|
||||
"character_item_id" uuid NOT NULL,
|
||||
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_character_equipment" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "FK_character_equipment_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||
CONSTRAINT "FK_character_equipment_character_item" FOREIGN KEY ("character_item_id") REFERENCES "character_items"("id") ON DELETE CASCADE ON UPDATE NO ACTION
|
||||
)`);
|
||||
await queryRunner.query(
|
||||
'CREATE UNIQUE INDEX "IDX_character_equipment_character_slot" ON "character_equipment" ("character_id", "slot")',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'CREATE UNIQUE INDEX "IDX_character_equipment_character_item" ON "character_equipment" ("character_item_id")',
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
'DROP INDEX "IDX_character_equipment_character_item"',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'DROP INDEX "IDX_character_equipment_character_slot"',
|
||||
);
|
||||
await queryRunner.query('DROP TABLE "character_equipment"');
|
||||
}
|
||||
}
|
||||
52
apps/api/src/database/migrations/equipment.migration.spec.ts
Normal file
52
apps/api/src/database/migrations/equipment.migration.spec.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import 'reflect-metadata';
|
||||
import { getMetadataArgsStorage } from 'typeorm';
|
||||
import { CharacterEquipment } from '../../equipment/entities/character-equipment.entity';
|
||||
|
||||
describe('character_equipment schema', () => {
|
||||
it('stores slot as a non-nullable equipment_slot_enum column', () => {
|
||||
const metadata = getMetadataArgsStorage();
|
||||
const column = metadata.columns.find(
|
||||
(candidate) =>
|
||||
candidate.target === CharacterEquipment && candidate.propertyName === 'slot',
|
||||
);
|
||||
|
||||
expect(column).toBeDefined();
|
||||
expect(column?.options.type).toBe('enum');
|
||||
expect(column?.options.enumName).toBe('equipment_slot_enum');
|
||||
expect(column?.options.nullable).toBeFalsy();
|
||||
});
|
||||
|
||||
it('enforces one equipped item per character per slot', () => {
|
||||
const metadata = getMetadataArgsStorage();
|
||||
const index = metadata.indices.find(
|
||||
(candidate) =>
|
||||
candidate.target === CharacterEquipment &&
|
||||
candidate.columns?.includes('characterId') &&
|
||||
candidate.columns?.includes('slot'),
|
||||
);
|
||||
|
||||
expect(index).toBeDefined();
|
||||
const indexMetadata = index as typeof index & {
|
||||
options?: { unique?: boolean };
|
||||
unique?: boolean;
|
||||
};
|
||||
expect(indexMetadata?.options?.unique ?? indexMetadata?.unique).toBe(true);
|
||||
});
|
||||
|
||||
it('forbids one CharacterItem from occupying more than one equipment slot', () => {
|
||||
const metadata = getMetadataArgsStorage();
|
||||
const index = metadata.indices.find(
|
||||
(candidate) =>
|
||||
candidate.target === CharacterEquipment &&
|
||||
candidate.columns?.length === 1 &&
|
||||
candidate.columns?.includes('characterItemId'),
|
||||
);
|
||||
|
||||
expect(index).toBeDefined();
|
||||
const indexMetadata = index as typeof index & {
|
||||
options?: { unique?: boolean };
|
||||
unique?: boolean;
|
||||
};
|
||||
expect(indexMetadata?.options?.unique ?? indexMetadata?.unique).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { Character } from '../../characters/entities/character.entity';
|
||||
import { CharacterEquipment } from '../../equipment/entities/character-equipment.entity';
|
||||
import { CharacterItem } from '../../items/entities/character-item.entity';
|
||||
import { ItemDefinition } from '../../items/entities/item-definition.entity';
|
||||
import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity';
|
||||
import { LootTable } from '../../loot/entities/loot-table.entity';
|
||||
@@ -7,6 +9,7 @@ import { LocationMonster } from '../../monsters/entities/location-monster.entity
|
||||
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
|
||||
import { LocationConnection } from '../../world/entities/location-connection.entity';
|
||||
import { LocationDefinition } from '../../world/entities/location-definition.entity';
|
||||
import { DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID } from '../../demo/demo-character.constants';
|
||||
import { ASH_RAT_LOOT_TABLE_ID, ITEM_IDS, ROAD_BANDIT_LOOT_TABLE_ID } from './item.constants';
|
||||
import { seedVisibleVerticalSlice } from './vertical-slice.seed';
|
||||
|
||||
@@ -76,6 +79,8 @@ function createDataSource(
|
||||
itemRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
lootTableRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
lootEntryRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
characterItemRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
characterEquipmentRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
): DataSource {
|
||||
return {
|
||||
getRepository: jest.fn((entity: unknown) => {
|
||||
@@ -87,6 +92,8 @@ function createDataSource(
|
||||
if (entity === ItemDefinition) return itemRepository;
|
||||
if (entity === LootTable) return lootTableRepository;
|
||||
if (entity === LootTableEntry) return lootEntryRepository;
|
||||
if (entity === CharacterItem) return characterItemRepository;
|
||||
if (entity === CharacterEquipment) return characterEquipmentRepository;
|
||||
|
||||
throw new Error('Unexpected repository');
|
||||
}),
|
||||
@@ -449,4 +456,125 @@ describe('seedVisibleVerticalSlice', () => {
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('seeds the starting sword as a real, equipped CharacterItem idempotently', async () => {
|
||||
const locationRepository = new InMemoryRepository();
|
||||
const connectionRepository = new InMemoryRepository();
|
||||
const characterRepository = new InMemoryRepository();
|
||||
const monsterRepository = new InMemoryRepository();
|
||||
const locationMonsterRepository = new InMemoryRepository();
|
||||
const characterItemRepository = new InMemoryRepository();
|
||||
const characterEquipmentRepository = new InMemoryRepository();
|
||||
const dataSource = createDataSource(
|
||||
locationRepository,
|
||||
connectionRepository,
|
||||
characterRepository,
|
||||
monsterRepository,
|
||||
locationMonsterRepository,
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
characterItemRepository,
|
||||
characterEquipmentRepository,
|
||||
);
|
||||
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
|
||||
expect(characterItemRepository.rows).toHaveLength(1);
|
||||
expect(characterItemRepository.rows[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
characterId: DEMO_CHARACTER_ID,
|
||||
itemDefinitionId: ITEM_IDS['worn-short-sword'],
|
||||
quantity: 1,
|
||||
}),
|
||||
);
|
||||
expect(characterEquipmentRepository.rows).toHaveLength(1);
|
||||
expect(characterEquipmentRepository.rows[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
characterId: DEMO_CHARACTER_ID,
|
||||
slot: 'WEAPON',
|
||||
characterItemId: DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('never re-equips the starting sword once the player has equipped different gear', async () => {
|
||||
const locationRepository = new InMemoryRepository();
|
||||
const connectionRepository = new InMemoryRepository();
|
||||
const characterRepository = new InMemoryRepository();
|
||||
const monsterRepository = new InMemoryRepository();
|
||||
const locationMonsterRepository = new InMemoryRepository();
|
||||
const characterItemRepository = new InMemoryRepository();
|
||||
const characterEquipmentRepository = new InMemoryRepository();
|
||||
const dataSource = createDataSource(
|
||||
locationRepository,
|
||||
connectionRepository,
|
||||
characterRepository,
|
||||
monsterRepository,
|
||||
locationMonsterRepository,
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
characterItemRepository,
|
||||
characterEquipmentRepository,
|
||||
);
|
||||
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
// Simulate the player having equipped earned loot instead.
|
||||
characterEquipmentRepository.rows[0]['characterItemId'] = 'earned-bandit-blade-item-id';
|
||||
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
|
||||
expect(characterEquipmentRepository.rows).toHaveLength(1);
|
||||
expect(characterEquipmentRepository.rows[0]['characterItemId']).toBe(
|
||||
'earned-bandit-blade-item-id',
|
||||
);
|
||||
expect(characterItemRepository.rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reuses a naturally-looted starting sword instead of inserting a duplicate CharacterItem', async () => {
|
||||
const locationRepository = new InMemoryRepository();
|
||||
const connectionRepository = new InMemoryRepository();
|
||||
const characterRepository = new InMemoryRepository();
|
||||
const monsterRepository = new InMemoryRepository();
|
||||
const locationMonsterRepository = new InMemoryRepository();
|
||||
const characterItemRepository = new InMemoryRepository();
|
||||
const characterEquipmentRepository = new InMemoryRepository();
|
||||
const dataSource = createDataSource(
|
||||
locationRepository,
|
||||
connectionRepository,
|
||||
characterRepository,
|
||||
monsterRepository,
|
||||
locationMonsterRepository,
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
characterItemRepository,
|
||||
characterEquipmentRepository,
|
||||
);
|
||||
|
||||
// Simulate the demo character having already looted a worn-short-sword
|
||||
// naturally, under a DB-generated id that differs from the seed's
|
||||
// stable literal constant.
|
||||
const naturallyLootedItemId = 'naturally-looted-sword-item-id';
|
||||
characterItemRepository.rows.push({
|
||||
id: naturallyLootedItemId,
|
||||
characterId: DEMO_CHARACTER_ID,
|
||||
itemDefinitionId: ITEM_IDS['worn-short-sword'],
|
||||
quantity: 1,
|
||||
});
|
||||
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
|
||||
expect(characterItemRepository.rows).toHaveLength(1);
|
||||
expect(characterEquipmentRepository.rows).toHaveLength(1);
|
||||
expect(characterEquipmentRepository.rows[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
characterId: DEMO_CHARACTER_ID,
|
||||
slot: 'WEAPON',
|
||||
characterItemId: naturallyLootedItemId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { DEMO_CHARACTER_ID } from '../../demo/demo-character.constants';
|
||||
import {
|
||||
DEMO_CHARACTER_ID,
|
||||
DEMO_CHARACTER_STARTING_WEAPON_EQUIPMENT_ID,
|
||||
DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID,
|
||||
} from '../../demo/demo-character.constants';
|
||||
import { Character } from '../../characters/entities/character.entity';
|
||||
import { CharacterEquipment } from '../../equipment/entities/character-equipment.entity';
|
||||
import { CharacterItem } from '../../items/entities/character-item.entity';
|
||||
import { EquipmentSlot } from '../../items/equipment-slot.enum';
|
||||
import { ItemDefinition } from '../../items/entities/item-definition.entity';
|
||||
import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity';
|
||||
import { LootTable } from '../../loot/entities/loot-table.entity';
|
||||
@@ -12,6 +19,7 @@ import { LocationDefinition } from '../../world/entities/location-definition.ent
|
||||
import { ITEM_DEFINITIONS, LOOT_TABLES, LOOT_TABLE_ENTRIES } from './item-content';
|
||||
import {
|
||||
ASH_RAT_LOOT_TABLE_ID,
|
||||
ITEM_IDS,
|
||||
ROAD_BANDIT_LOOT_TABLE_ID,
|
||||
} from './item.constants';
|
||||
import {
|
||||
@@ -238,4 +246,42 @@ export async function seedVisibleVerticalSlice(
|
||||
currentLocationId: southGateId,
|
||||
});
|
||||
}
|
||||
|
||||
const characterItemRepository = dataSource.getRepository(CharacterItem);
|
||||
const characterEquipmentRepository = dataSource.getRepository(CharacterEquipment);
|
||||
|
||||
// Starting loadout is weapon-only -- no starter armor piece exists in
|
||||
// content yet -- so the demo character's effective armor (sum of equipped
|
||||
// bonusArmor) is 0 until the player loots and equips bandit-hood (+3
|
||||
// armor). This is a deliberate tradeoff, not a bug: Slice 0.5 spec §19
|
||||
// says to preserve the existing demo balance "as closely as the
|
||||
// implemented content allows" and explicitly forbids fabricating a full
|
||||
// starter gear set just to hit the old hardcoded TEMPORARY_ARMOR = 6.
|
||||
const existingStartingSword = await characterItemRepository.findOneBy({
|
||||
characterId: DEMO_CHARACTER_ID,
|
||||
itemDefinitionId: ITEM_IDS['worn-short-sword'],
|
||||
});
|
||||
const startingSwordItemId =
|
||||
existingStartingSword?.id ?? DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID;
|
||||
if (!existingStartingSword) {
|
||||
await characterItemRepository.insert({
|
||||
id: DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID,
|
||||
characterId: DEMO_CHARACTER_ID,
|
||||
itemDefinitionId: ITEM_IDS['worn-short-sword'],
|
||||
quantity: 1,
|
||||
});
|
||||
}
|
||||
|
||||
const existingWeaponEquipment = await characterEquipmentRepository.findOneBy({
|
||||
characterId: DEMO_CHARACTER_ID,
|
||||
slot: EquipmentSlot.WEAPON,
|
||||
});
|
||||
if (!existingWeaponEquipment) {
|
||||
await characterEquipmentRepository.insert({
|
||||
id: DEMO_CHARACTER_STARTING_WEAPON_EQUIPMENT_ID,
|
||||
characterId: DEMO_CHARACTER_ID,
|
||||
slot: EquipmentSlot.WEAPON,
|
||||
characterItemId: startingSwordItemId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user