diff --git a/apps/api/src/characters/entities/character.entity.ts b/apps/api/src/characters/entities/character.entity.ts index 5318267..803417e 100644 --- a/apps/api/src/characters/entities/character.entity.ts +++ b/apps/api/src/characters/entities/character.entity.ts @@ -35,6 +35,12 @@ export class Character { @Column({ name: 'current_hp', type: 'integer' }) currentHp!: number; + // `current_hp` is only exact while this is null (regeneration paused, e.g. + // mid-combat). Otherwise it's the HP as of this timestamp -- read it + // through CharacterVitalsService.effectiveHp(), never directly. + @Column({ name: 'hp_regen_since', type: 'timestamptz', nullable: true }) + hpRegenSince!: Date | null; + @Column({ name: 'current_location_id', type: 'uuid' }) currentLocationId!: string; diff --git a/apps/api/src/database/migrations/1792000000000-AddHpRegeneration.ts b/apps/api/src/database/migrations/1792000000000-AddHpRegeneration.ts new file mode 100644 index 0000000..65890b6 --- /dev/null +++ b/apps/api/src/database/migrations/1792000000000-AddHpRegeneration.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddHpRegeneration1792000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'ALTER TABLE "characters" ADD COLUMN "hp_regen_since" TIMESTAMP WITH TIME ZONE', + ); + + // Existing characters start regenerating immediately from their current + // HP. A character whose fight is still ACTIVE keeps regeneration paused + // until that fight resolves, matching the "no combat-time regen" rule + // (persistent-hp-and-regeneration design, R4) -- this migration must not + // gift them free healing mid-fight. + await queryRunner.query('UPDATE "characters" SET "hp_regen_since" = now()'); + await queryRunner.query(`UPDATE "characters" AS "character" + SET "hp_regen_since" = NULL + FROM "combats" AS "combat" + WHERE "combat"."character_id" = "character"."id" + AND "combat"."status" = 'ACTIVE'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE "characters" DROP COLUMN "hp_regen_since"'); + } +} diff --git a/apps/api/src/database/migrations/add-hp-regeneration.migration.spec.ts b/apps/api/src/database/migrations/add-hp-regeneration.migration.spec.ts new file mode 100644 index 0000000..109fbbe --- /dev/null +++ b/apps/api/src/database/migrations/add-hp-regeneration.migration.spec.ts @@ -0,0 +1,17 @@ +import 'reflect-metadata'; +import { getMetadataArgsStorage } from 'typeorm'; +import { Character } from '../../characters/entities/character.entity'; + +describe('characters.hp_regen_since schema', () => { + it('stores the regeneration anchor as a nullable timestamptz', () => { + const metadata = getMetadataArgsStorage(); + const column = metadata.columns.find( + (candidate) => + candidate.target === Character && candidate.propertyName === 'hpRegenSince', + ); + + expect(column).toBeDefined(); + expect(column?.options.type).toBe('timestamptz'); + expect(column?.options.nullable).toBe(true); + }); +});