From 68edc04ed67f6116b3c4fcf9ba6142466be545b7 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Wed, 19 Aug 2026 15:27:05 +0200 Subject: [PATCH] feat(combat): add combat domain enums, entities, and encounter consumption field --- apps/api/src/combat/combat-action.enum.ts | 6 ++ apps/api/src/combat/combat-event-type.enum.ts | 5 ++ apps/api/src/combat/combat-status.enum.ts | 5 ++ apps/api/src/combat/combatant.enum.ts | 4 + .../combat/entities/combat-event.entity.ts | 62 +++++++++++++ apps/api/src/combat/entities/combat.entity.ts | 89 +++++++++++++++++++ .../combat-system.migration.spec.ts | 65 ++++++++++++++ .../hunting/entities/hunt-encounter.entity.ts | 5 ++ 8 files changed, 241 insertions(+) create mode 100644 apps/api/src/combat/combat-action.enum.ts create mode 100644 apps/api/src/combat/combat-event-type.enum.ts create mode 100644 apps/api/src/combat/combat-status.enum.ts create mode 100644 apps/api/src/combat/combatant.enum.ts create mode 100644 apps/api/src/combat/entities/combat-event.entity.ts create mode 100644 apps/api/src/combat/entities/combat.entity.ts create mode 100644 apps/api/src/database/migrations/combat-system.migration.spec.ts diff --git a/apps/api/src/combat/combat-action.enum.ts b/apps/api/src/combat/combat-action.enum.ts new file mode 100644 index 0000000..1dadb45 --- /dev/null +++ b/apps/api/src/combat/combat-action.enum.ts @@ -0,0 +1,6 @@ +// Only ATTACK is implemented in Slice 0.3. Future slices add HEAVY_STRIKE, +// SHIELD_BASH, DEFEND, POTION, FLEE as real members with their own +// CombatEngineService cases — do not add them here until their behavior ships. +export enum CombatAction { + ATTACK = 'ATTACK', +} diff --git a/apps/api/src/combat/combat-event-type.enum.ts b/apps/api/src/combat/combat-event-type.enum.ts new file mode 100644 index 0000000..61294e1 --- /dev/null +++ b/apps/api/src/combat/combat-event-type.enum.ts @@ -0,0 +1,5 @@ +export enum CombatEventType { + DAMAGE = 'DAMAGE', + COMBAT_WON = 'COMBAT_WON', + COMBAT_LOST = 'COMBAT_LOST', +} diff --git a/apps/api/src/combat/combat-status.enum.ts b/apps/api/src/combat/combat-status.enum.ts new file mode 100644 index 0000000..16b321d --- /dev/null +++ b/apps/api/src/combat/combat-status.enum.ts @@ -0,0 +1,5 @@ +export enum CombatStatus { + ACTIVE = 'ACTIVE', + WON = 'WON', + LOST = 'LOST', +} diff --git a/apps/api/src/combat/combatant.enum.ts b/apps/api/src/combat/combatant.enum.ts new file mode 100644 index 0000000..db5a06f --- /dev/null +++ b/apps/api/src/combat/combatant.enum.ts @@ -0,0 +1,4 @@ +export enum Combatant { + PLAYER = 'PLAYER', + MONSTER = 'MONSTER', +} diff --git a/apps/api/src/combat/entities/combat-event.entity.ts b/apps/api/src/combat/entities/combat-event.entity.ts new file mode 100644 index 0000000..93bdf5a --- /dev/null +++ b/apps/api/src/combat/entities/combat-event.entity.ts @@ -0,0 +1,62 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { Combatant } from '../combatant.enum'; +import { CombatEventType } from '../combat-event-type.enum'; +import { Combat } from './combat.entity'; + +@Entity({ name: 'combat_events' }) +@Index('IDX_combat_events_combat_sequence', ['combatId', 'sequence'], { unique: true }) +export class CombatEvent { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ name: 'combat_id', type: 'uuid' }) + combatId!: string; + + @Column({ name: 'round', type: 'integer' }) + round!: number; + + @Column({ name: 'sequence', type: 'integer' }) + sequence!: number; + + @Column({ + name: 'type', + type: 'enum', + enum: CombatEventType, + enumName: 'combat_event_type_enum', + }) + type!: CombatEventType; + + @Column({ + name: 'source', + type: 'enum', + enum: Combatant, + enumName: 'combatant_enum', + }) + source!: Combatant; + + @Column({ + name: 'target', + type: 'enum', + enum: Combatant, + enumName: 'combatant_enum', + }) + target!: Combatant; + + @Column({ name: 'amount', type: 'integer', nullable: true }) + amount!: number | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @ManyToOne(() => Combat, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'combat_id' }) + combat!: Combat; +} diff --git a/apps/api/src/combat/entities/combat.entity.ts b/apps/api/src/combat/entities/combat.entity.ts new file mode 100644 index 0000000..9f602e8 --- /dev/null +++ b/apps/api/src/combat/entities/combat.entity.ts @@ -0,0 +1,89 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Character } from '../../characters/entities/character.entity'; +import { HuntEncounter } from '../../hunting/entities/hunt-encounter.entity'; +import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity'; +import { CombatStatus } from '../combat-status.enum'; + +export interface CombatCombatantState { + attack: number; + armor: number; +} + +export interface CombatPlayerState extends CombatCombatantState { + weaponDamage: number; +} + +@Entity({ name: 'combats' }) +@Index('IDX_combats_hunt_encounter', ['huntEncounterId'], { unique: true }) +export class Combat { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ name: 'character_id', type: 'uuid' }) + characterId!: string; + + @Column({ name: 'hunt_encounter_id', type: 'uuid' }) + huntEncounterId!: string; + + @Column({ name: 'monster_definition_id', type: 'uuid' }) + monsterDefinitionId!: string; + + @Column({ + name: 'status', + type: 'enum', + enum: CombatStatus, + enumName: 'combat_status_enum', + }) + status!: CombatStatus; + + @Column({ name: 'round', type: 'integer' }) + round!: number; + + @Column({ name: 'player_max_hp', type: 'integer' }) + playerMaxHp!: number; + + @Column({ name: 'player_current_hp', type: 'integer' }) + playerCurrentHp!: number; + + @Column({ name: 'monster_max_hp', type: 'integer' }) + monsterMaxHp!: number; + + @Column({ name: 'monster_current_hp', type: 'integer' }) + monsterCurrentHp!: number; + + @Column({ name: 'player_state', type: 'jsonb' }) + playerState!: CombatPlayerState; + + @Column({ name: 'monster_state', type: 'jsonb' }) + monsterState!: CombatCombatantState; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt!: Date | null; + + @ManyToOne(() => Character, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'character_id' }) + character!: Character; + + @ManyToOne(() => HuntEncounter, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'hunt_encounter_id' }) + huntEncounter!: HuntEncounter; + + @ManyToOne(() => MonsterDefinition, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'monster_definition_id' }) + monster!: MonsterDefinition; +} diff --git a/apps/api/src/database/migrations/combat-system.migration.spec.ts b/apps/api/src/database/migrations/combat-system.migration.spec.ts new file mode 100644 index 0000000..373894f --- /dev/null +++ b/apps/api/src/database/migrations/combat-system.migration.spec.ts @@ -0,0 +1,65 @@ +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', () => { + const metadata = getMetadataArgsStorage(); + + const relations = metadata.relations.filter( + (relation) => relation.target === Combat || relation.target === CombatEvent, + ); + + expect( + relations.map((relation) => ({ + onDelete: relation.options.onDelete, + propertyName: relation.propertyName, + target: relation.target, + })), + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'character', target: Combat }), + expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'huntEncounter', target: Combat }), + expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'monster', target: Combat }), + expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'combat', target: CombatEvent }), + ]), + ); + }); + + 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( + (candidate) => + candidate.target === CombatEvent && + candidate.columns?.includes('combatId') && + candidate.columns?.includes('sequence'), + ); + + expect(index).toBeDefined(); + 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); + }); +}); diff --git a/apps/api/src/hunting/entities/hunt-encounter.entity.ts b/apps/api/src/hunting/entities/hunt-encounter.entity.ts index 2a76fe4..e6f0aa8 100644 --- a/apps/api/src/hunting/entities/hunt-encounter.entity.ts +++ b/apps/api/src/hunting/entities/hunt-encounter.entity.ts @@ -23,6 +23,11 @@ export class HuntEncounter { @Column({ name: 'position', type: 'integer' }) position!: number; + // Set when a Combat is successfully created from this encounter. Prevents + // one HuntEncounter from spawning more than one Combat (spec §7). + @Column({ name: 'consumed_at', type: 'timestamptz', nullable: true }) + consumedAt!: Date | null; + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt!: Date;