diff --git a/apps/api/src/characters/entities/character.entity.ts b/apps/api/src/characters/entities/character.entity.ts index 19faf68..5318267 100644 --- a/apps/api/src/characters/entities/character.entity.ts +++ b/apps/api/src/characters/entities/character.entity.ts @@ -23,6 +23,9 @@ export class Character { @Column({ name: 'experience', type: 'integer' }) experience!: number; + @Column({ name: 'silver', type: 'integer' }) + silver!: number; + @Column({ name: 'base_hp', type: 'integer' }) baseHp!: number; diff --git a/apps/api/src/combat/combat.service.spec.ts b/apps/api/src/combat/combat.service.spec.ts index 3fc0898..bb8587b 100644 --- a/apps/api/src/combat/combat.service.spec.ts +++ b/apps/api/src/combat/combat.service.spec.ts @@ -173,6 +173,7 @@ function character(overrides: Partial = {}): Character { name: 'Aric Duskwalker', level: 1, experience: 0, + silver: 0, baseHp: 100, baseAttack: 6, currentHp: 100, diff --git a/apps/api/src/database/migrations/loot-and-rewards.migration.spec.ts b/apps/api/src/database/migrations/loot-and-rewards.migration.spec.ts new file mode 100644 index 0000000..aceba34 --- /dev/null +++ b/apps/api/src/database/migrations/loot-and-rewards.migration.spec.ts @@ -0,0 +1,97 @@ +import 'reflect-metadata'; +import { getMetadataArgsStorage } from 'typeorm'; +import { Character } from '../../characters/entities/character.entity'; +import { CharacterItem } from '../../items/entities/character-item.entity'; +import { ItemDefinition } from '../../items/entities/item-definition.entity'; +import { LootTable } from '../../loot/entities/loot-table.entity'; +import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity'; +import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity'; +import { CombatReward } from '../../rewards/entities/combat-reward.entity'; +import { CombatRewardItem } from '../../rewards/entities/combat-reward-item.entity'; + +function uniqueIndexFor(target: unknown, columns: string[]) { + const index = getMetadataArgsStorage().indices.find( + (candidate) => + candidate.target === target && + columns.every((column) => candidate.columns?.includes(column)), + ); + const indexMetadata = index as typeof index & { + options?: { unique?: boolean }; + unique?: boolean; + }; + return indexMetadata?.options?.unique ?? indexMetadata?.unique; +} + +describe('loot and rewards schema', () => { + it('gives every combat at most one reward record', () => { + expect(uniqueIndexFor(CombatReward, ['combatId'])).toBe(true); + }); + + it('keeps one stack per character per item definition', () => { + expect(uniqueIndexFor(CharacterItem, ['characterId', 'itemDefinitionId'])).toBe(true); + }); + + it('keeps loot-table content keys and entry positions unique', () => { + expect(uniqueIndexFor(ItemDefinition, ['key'])).toBe(true); + expect(uniqueIndexFor(LootTable, ['key'])).toBe(true); + expect(uniqueIndexFor(LootTableEntry, ['lootTableId', 'position'])).toBe(true); + expect(uniqueIndexFor(LootTableEntry, ['lootTableId', 'itemDefinitionId'])).toBe(true); + }); + + it('maps reward and loot relations with the documented onDelete behavior', () => { + const relations = getMetadataArgsStorage().relations.filter((relation) => + [CombatReward, CombatRewardItem, CharacterItem, LootTableEntry, MonsterDefinition].includes( + relation.target as never, + ), + ); + + expect( + relations.map((relation) => ({ + onDelete: relation.options.onDelete, + propertyName: relation.propertyName, + target: relation.target, + })), + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'combat', target: CombatReward }), + expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'character', target: CombatReward }), + expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'combatReward', target: CombatRewardItem }), + expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'characterItem', target: CombatRewardItem }), + expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'itemDefinition', target: CombatRewardItem }), + expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'character', target: CharacterItem }), + expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'itemDefinition', target: CharacterItem }), + expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'lootTable', target: LootTableEntry }), + expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'itemDefinition', target: LootTableEntry }), + expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'lootTable', target: MonsterDefinition }), + ]), + ); + }); + + it('adds the character silver column and the nullable monster loot table link', () => { + const columns = getMetadataArgsStorage().columns; + + const silver = columns.find( + (candidate) => candidate.target === Character && candidate.propertyName === 'silver', + ); + expect(silver).toBeDefined(); + expect(silver?.options.type).toBe('integer'); + + const lootTableId = columns.find( + (candidate) => + candidate.target === MonsterDefinition && candidate.propertyName === 'lootTableId', + ); + expect(lootTableId).toBeDefined(); + expect(lootTableId?.options.nullable).toBe(true); + }); + + it('stores drop chance as a numeric column so probabilities stay data-driven', () => { + const dropChance = getMetadataArgsStorage().columns.find( + (candidate) => + candidate.target === LootTableEntry && candidate.propertyName === 'dropChance', + ); + + expect(dropChance?.options.type).toBe('numeric'); + expect(dropChance?.options.precision).toBe(5); + expect(dropChance?.options.scale).toBe(4); + }); +}); diff --git a/apps/api/src/hunting/hunting.service.spec.ts b/apps/api/src/hunting/hunting.service.spec.ts index 21138d9..57f6762 100644 --- a/apps/api/src/hunting/hunting.service.spec.ts +++ b/apps/api/src/hunting/hunting.service.spec.ts @@ -211,6 +211,7 @@ function character(currentLocation: LocationDefinition): Character { name: 'Aric Duskwalker', level: 1, experience: 0, + silver: 0, baseHp: 100, baseAttack: 6, currentHp: 100, diff --git a/apps/api/src/items/entities/character-item.entity.ts b/apps/api/src/items/entities/character-item.entity.ts new file mode 100644 index 0000000..41a536a --- /dev/null +++ b/apps/api/src/items/entities/character-item.entity.ts @@ -0,0 +1,51 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Character } from '../../characters/entities/character.entity'; +import { ItemDefinition } from './item-definition.entity'; + +/** + * One stack of one item definition owned by one character. + * + * Duplicate drops increment `quantity` (spec §28 allows duplicates and forbids + * duplicate protection). Slice 0.5 equips a `CharacterItem.id`, never an + * `ItemDefinition.id`. + */ +@Entity({ name: 'character_items' }) +@Index('IDX_character_items_character_item', ['characterId', 'itemDefinitionId'], { + unique: true, +}) +export class CharacterItem { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ name: 'character_id', type: 'uuid' }) + characterId!: string; + + @Column({ name: 'item_definition_id', type: 'uuid' }) + itemDefinitionId!: string; + + @Column({ name: 'quantity', type: 'integer' }) + quantity!: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; + + @ManyToOne(() => Character, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'character_id' }) + character!: Character; + + @ManyToOne(() => ItemDefinition, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'item_definition_id' }) + itemDefinition!: ItemDefinition; +} diff --git a/apps/api/src/items/entities/item-definition.entity.ts b/apps/api/src/items/entities/item-definition.entity.ts new file mode 100644 index 0000000..c89adc8 --- /dev/null +++ b/apps/api/src/items/entities/item-definition.entity.ts @@ -0,0 +1,74 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { EquipmentSlot } from '../equipment-slot.enum'; +import { ItemRarity } from '../item-rarity.enum'; +import { ItemType } from '../item-type.enum'; + +@Entity({ name: 'item_definitions' }) +@Index('IDX_item_definitions_key', ['key'], { unique: true }) +export class ItemDefinition { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ name: 'key', type: 'varchar', length: 100 }) + key!: string; + + @Column({ name: 'name', type: 'varchar', length: 150 }) + name!: string; + + @Column({ name: 'description', type: 'text' }) + description!: string; + + @Column({ name: 'type', type: 'enum', enum: ItemType, enumName: 'item_type_enum' }) + type!: ItemType; + + @Column({ + name: 'equipment_slot', + type: 'enum', + enum: EquipmentSlot, + enumName: 'equipment_slot_enum', + nullable: true, + }) + equipmentSlot!: EquipmentSlot | null; + + @Column({ name: 'rarity', type: 'enum', enum: ItemRarity, enumName: 'item_rarity_enum' }) + rarity!: ItemRarity; + + @Column({ name: 'tier', type: 'integer' }) + tier!: number; + + @Column({ name: 'required_level', type: 'integer' }) + requiredLevel!: number; + + @Column({ name: 'weapon_damage', type: 'integer' }) + weaponDamage!: number; + + @Column({ name: 'bonus_hp', type: 'integer' }) + bonusHp!: number; + + @Column({ name: 'bonus_attack', type: 'integer' }) + bonusAttack!: number; + + @Column({ name: 'bonus_armor', type: 'integer' }) + bonusArmor!: number; + + // Always 0 in Slice 0.4: there are no merchants, and the balancing doc's + // Grenzmarken table lists purchase prices, not sell prices. + @Column({ name: 'sell_price', type: 'integer' }) + sellPrice!: number; + + @Column({ name: 'icon_path', type: 'varchar', length: 255 }) + iconPath!: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; +} diff --git a/apps/api/src/items/equipment-slot.enum.ts b/apps/api/src/items/equipment-slot.enum.ts new file mode 100644 index 0000000..c5596db --- /dev/null +++ b/apps/api/src/items/equipment-slot.enum.ts @@ -0,0 +1,10 @@ +// Slice 0.4 only stores the slot as content data. Slice 0.5 makes it functional. +export enum EquipmentSlot { + WEAPON = 'WEAPON', + HEAD = 'HEAD', + CHEST = 'CHEST', + HANDS = 'HANDS', + LEGS = 'LEGS', + FEET = 'FEET', + AMULET = 'AMULET', +} diff --git a/apps/api/src/items/item-rarity.enum.ts b/apps/api/src/items/item-rarity.enum.ts new file mode 100644 index 0000000..877f9ee --- /dev/null +++ b/apps/api/src/items/item-rarity.enum.ts @@ -0,0 +1,7 @@ +// Mirrors docs/Ashen_Realms_Balancing_Items_Loot_Design_V1.md §19: +// Gewöhnlich / Selten / Episch. German labels live in the frontend. +export enum ItemRarity { + COMMON = 'COMMON', + RARE = 'RARE', + EPIC = 'EPIC', +} diff --git a/apps/api/src/items/item-type.enum.ts b/apps/api/src/items/item-type.enum.ts new file mode 100644 index 0000000..9050b87 --- /dev/null +++ b/apps/api/src/items/item-type.enum.ts @@ -0,0 +1,6 @@ +export enum ItemType { + WEAPON = 'WEAPON', + ARMOR = 'ARMOR', + MATERIAL = 'MATERIAL', + CONSUMABLE = 'CONSUMABLE', +} diff --git a/apps/api/src/loot/entities/loot-table-entry.entity.ts b/apps/api/src/loot/entities/loot-table-entry.entity.ts new file mode 100644 index 0000000..91b3c5f --- /dev/null +++ b/apps/api/src/loot/entities/loot-table-entry.entity.ts @@ -0,0 +1,63 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { ItemDefinition } from '../../items/entities/item-definition.entity'; +import { LootTable } from './loot-table.entity'; + +/** + * One independently rolled drop (spec §17). + * + * `position` fixes the roll order so injected randoms are predictable in tests; + * it is content ordering, not priority. A guaranteed drop is simply + * `dropChance = 1.0000` — no extra mechanism needed (spec §14). + */ +@Entity({ name: 'loot_table_entries' }) +@Index('IDX_loot_table_entries_table_position', ['lootTableId', 'position'], { unique: true }) +@Index('IDX_loot_table_entries_table_item', ['lootTableId', 'itemDefinitionId'], { unique: true }) +export class LootTableEntry { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ name: 'loot_table_id', type: 'uuid' }) + lootTableId!: string; + + @Column({ name: 'item_definition_id', type: 'uuid' }) + itemDefinitionId!: string; + + @Column({ name: 'position', type: 'integer' }) + position!: number; + + // PostgreSQL numeric arrives as a string, like LocationConnection.ambushChance. + @Column({ name: 'drop_chance', type: 'numeric', precision: 5, scale: 4 }) + dropChance!: string; + + @Column({ name: 'min_quantity', type: 'integer' }) + minQuantity!: number; + + @Column({ name: 'max_quantity', type: 'integer' }) + maxQuantity!: number; + + @Column({ name: 'enabled', type: 'boolean' }) + enabled!: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; + + @ManyToOne(() => LootTable, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'loot_table_id' }) + lootTable!: LootTable; + + @ManyToOne(() => ItemDefinition, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'item_definition_id' }) + itemDefinition!: ItemDefinition; +} diff --git a/apps/api/src/loot/entities/loot-table.entity.ts b/apps/api/src/loot/entities/loot-table.entity.ts new file mode 100644 index 0000000..82dfcae --- /dev/null +++ b/apps/api/src/loot/entities/loot-table.entity.ts @@ -0,0 +1,27 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity({ name: 'loot_tables' }) +@Index('IDX_loot_tables_key', ['key'], { unique: true }) +export class LootTable { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ name: 'key', type: 'varchar', length: 100 }) + key!: string; + + @Column({ name: 'name', type: 'varchar', length: 150 }) + name!: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; +} diff --git a/apps/api/src/monsters/entities/monster-definition.entity.ts b/apps/api/src/monsters/entities/monster-definition.entity.ts index 2b953ee..5bf3286 100644 --- a/apps/api/src/monsters/entities/monster-definition.entity.ts +++ b/apps/api/src/monsters/entities/monster-definition.entity.ts @@ -3,9 +3,12 @@ import { CreateDateColumn, Entity, Index, + JoinColumn, + ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; +import { LootTable } from '../../loot/entities/loot-table.entity'; @Entity({ name: 'monster_definitions' }) @Index('IDX_monster_definitions_key', ['key'], { unique: true }) @@ -43,9 +46,16 @@ export class MonsterDefinition { @Column({ name: 'artwork_path', type: 'varchar', length: 255 }) artworkPath!: string; + @Column({ name: 'loot_table_id', type: 'uuid', nullable: true }) + lootTableId!: string | null; + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt!: Date; @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) updatedAt!: Date; + + @ManyToOne(() => LootTable, { onDelete: 'RESTRICT', nullable: true }) + @JoinColumn({ name: 'loot_table_id' }) + lootTable!: LootTable | null; } diff --git a/apps/api/src/rewards/entities/combat-reward-item.entity.ts b/apps/api/src/rewards/entities/combat-reward-item.entity.ts new file mode 100644 index 0000000..ec862c8 --- /dev/null +++ b/apps/api/src/rewards/entities/combat-reward-item.entity.ts @@ -0,0 +1,56 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { CharacterItem } from '../../items/entities/character-item.entity'; +import { ItemDefinition } from '../../items/entities/item-definition.entity'; +import { CombatReward } from './combat-reward.entity'; + +/** + * What this specific combat dropped. + * + * Needed because `CharacterItem.quantity` is a running stack total: after a + * duplicate drop it no longer says how much *this* victory granted, and a + * refreshed reward screen must replay the original result (spec §25, §48). + */ +@Entity({ name: 'combat_reward_items' }) +@Index('IDX_combat_reward_items_reward', ['combatRewardId']) +@Index('IDX_combat_reward_items_reward_item', ['combatRewardId', 'itemDefinitionId'], { + unique: true, +}) +export class CombatRewardItem { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ name: 'combat_reward_id', type: 'uuid' }) + combatRewardId!: string; + + @Column({ name: 'character_item_id', type: 'uuid' }) + characterItemId!: string; + + @Column({ name: 'item_definition_id', type: 'uuid' }) + itemDefinitionId!: string; + + @Column({ name: 'quantity', type: 'integer' }) + quantity!: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @ManyToOne(() => CombatReward, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'combat_reward_id' }) + combatReward!: CombatReward; + + @ManyToOne(() => CharacterItem, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'character_item_id' }) + characterItem!: CharacterItem; + + @ManyToOne(() => ItemDefinition, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'item_definition_id' }) + itemDefinition!: ItemDefinition; +} diff --git a/apps/api/src/rewards/entities/combat-reward.entity.ts b/apps/api/src/rewards/entities/combat-reward.entity.ts new file mode 100644 index 0000000..ff467c0 --- /dev/null +++ b/apps/api/src/rewards/entities/combat-reward.entity.ts @@ -0,0 +1,48 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { Character } from '../../characters/entities/character.entity'; +import { Combat } from '../../combat/entities/combat.entity'; + +/** + * Proof that one combat has already been rewarded (spec §8). + * + * The unique index on `combatId` is the database half of the idempotency + * invariant; `CombatRewardService` is the service half. + */ +@Entity({ name: 'combat_rewards' }) +@Index('IDX_combat_rewards_combat', ['combatId'], { unique: true }) +@Index('IDX_combat_rewards_character', ['characterId']) +export class CombatReward { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ name: 'combat_id', type: 'uuid' }) + combatId!: string; + + @Column({ name: 'character_id', type: 'uuid' }) + characterId!: string; + + @Column({ name: 'experience_granted', type: 'integer' }) + experienceGranted!: number; + + @Column({ name: 'silver_granted', type: 'integer' }) + silverGranted!: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @ManyToOne(() => Combat, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'combat_id' }) + combat!: Combat; + + @ManyToOne(() => Character, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'character_id' }) + character!: Character; +} diff --git a/apps/api/src/travel/travel.service.spec.ts b/apps/api/src/travel/travel.service.spec.ts index d6996f0..dbfa6d1 100644 --- a/apps/api/src/travel/travel.service.spec.ts +++ b/apps/api/src/travel/travel.service.spec.ts @@ -179,6 +179,7 @@ function createState(): FakeState { name: 'Aric Duskwalker', level: 1, experience: 0, + silver: 0, baseHp: 100, baseAttack: 6, currentHp: 100,