diff --git a/apps/api/src/characters/entities/character.entity.ts b/apps/api/src/characters/entities/character.entity.ts new file mode 100644 index 0000000..f8ca986 --- /dev/null +++ b/apps/api/src/characters/entities/character.entity.ts @@ -0,0 +1,47 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { LocationDefinition } from '../../world/entities/location-definition.entity'; + +@Entity({ name: 'characters' }) +export class Character { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ name: 'name', type: 'varchar', length: 150 }) + name!: string; + + @Column({ name: 'level', type: 'integer' }) + level!: number; + + @Column({ name: 'experience', type: 'integer' }) + experience!: number; + + @Column({ name: 'base_hp', type: 'integer' }) + baseHp!: number; + + @Column({ name: 'base_attack', type: 'integer' }) + baseAttack!: number; + + @Column({ name: 'current_hp', type: 'integer' }) + currentHp!: number; + + @Column({ name: 'current_location_id', type: 'uuid' }) + currentLocationId!: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; + + @ManyToOne(() => LocationDefinition, (location) => location.characters) + @JoinColumn({ name: 'current_location_id' }) + currentLocation!: LocationDefinition; +} diff --git a/apps/api/src/database/migrations/1787072400000-CreateVisibleVerticalSlice.ts b/apps/api/src/database/migrations/1787072400000-CreateVisibleVerticalSlice.ts new file mode 100644 index 0000000..f9f884d --- /dev/null +++ b/apps/api/src/database/migrations/1787072400000-CreateVisibleVerticalSlice.ts @@ -0,0 +1,112 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateVisibleVerticalSlice1787072400000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'); + await queryRunner.query(`CREATE TABLE "location_definitions" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "key" character varying(100) NOT NULL, + "name" character varying(150) NOT NULL, + "description" text NOT NULL, + "region_key" character varying(100) NOT NULL, + "min_recommended_level" integer NOT NULL, + "max_recommended_level" integer NOT NULL, + "danger_level" integer NOT NULL, + "is_safe" boolean NOT NULL, + "hunting_enabled" boolean NOT NULL, + "artwork_path" character varying(255) NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_location_definitions" PRIMARY KEY ("id") + )`); + await queryRunner.query( + 'CREATE UNIQUE INDEX "IDX_location_definitions_key" ON "location_definitions" ("key")', + ); + await queryRunner.query(`CREATE TABLE "characters" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "name" character varying(150) NOT NULL, + "level" integer NOT NULL, + "experience" integer NOT NULL, + "base_hp" integer NOT NULL, + "base_attack" integer NOT NULL, + "current_hp" integer NOT NULL, + "current_location_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_characters" PRIMARY KEY ("id"), + CONSTRAINT "FK_characters_current_location" FOREIGN KEY ("current_location_id") REFERENCES "location_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION + )`); + await queryRunner.query( + 'CREATE INDEX "IDX_characters_current_location" ON "characters" ("current_location_id")', + ); + await queryRunner.query(`CREATE TABLE "location_connections" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "from_location_id" uuid NOT NULL, + "to_location_id" uuid NOT NULL, + "travel_duration_seconds" integer NOT NULL, + "ambush_chance" numeric(5,4) NOT NULL, + "enabled" boolean NOT NULL, + CONSTRAINT "PK_location_connections" PRIMARY KEY ("id"), + CONSTRAINT "FK_location_connections_from_location" FOREIGN KEY ("from_location_id") REFERENCES "location_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION, + CONSTRAINT "FK_location_connections_to_location" FOREIGN KEY ("to_location_id") REFERENCES "location_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION + )`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_location_connection_direction" + ON "location_connections" ("from_location_id", "to_location_id")`); + await queryRunner.query( + 'CREATE INDEX "IDX_location_connections_from_location" ON "location_connections" ("from_location_id")', + ); + await queryRunner.query( + 'CREATE INDEX "IDX_location_connections_to_location" ON "location_connections" ("to_location_id")', + ); + await queryRunner.query( + "CREATE TYPE \"travel_status_enum\" AS ENUM ('TRAVELLING', 'COMPLETED')", + ); + await queryRunner.query(`CREATE TABLE "travels" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "character_id" uuid NOT NULL, + "origin_location_id" uuid NOT NULL, + "target_location_id" uuid NOT NULL, + "started_at" TIMESTAMP WITH TIME ZONE NOT NULL, + "arrives_at" TIMESTAMP WITH TIME ZONE NOT NULL, + "status" "travel_status_enum" NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_travels" PRIMARY KEY ("id"), + CONSTRAINT "FK_travels_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE RESTRICT ON UPDATE NO ACTION, + CONSTRAINT "FK_travels_origin_location" FOREIGN KEY ("origin_location_id") REFERENCES "location_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION, + CONSTRAINT "FK_travels_target_location" FOREIGN KEY ("target_location_id") REFERENCES "location_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION + )`); + await queryRunner.query( + 'CREATE INDEX "IDX_travels_character" ON "travels" ("character_id")', + ); + await queryRunner.query( + 'CREATE INDEX "IDX_travels_origin_location" ON "travels" ("origin_location_id")', + ); + await queryRunner.query( + 'CREATE INDEX "IDX_travels_target_location" ON "travels" ("target_location_id")', + ); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_active_travel_per_character" + ON "travels" ("character_id") + WHERE "status" = 'TRAVELLING'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP INDEX "IDX_active_travel_per_character"'); + await queryRunner.query('DROP INDEX "IDX_travels_target_location"'); + await queryRunner.query('DROP INDEX "IDX_travels_origin_location"'); + await queryRunner.query('DROP INDEX "IDX_travels_character"'); + await queryRunner.query('DROP TABLE "travels"'); + await queryRunner.query( + 'DROP INDEX "IDX_location_connections_to_location"', + ); + await queryRunner.query( + 'DROP INDEX "IDX_location_connections_from_location"', + ); + await queryRunner.query('DROP INDEX "IDX_location_connection_direction"'); + await queryRunner.query('DROP TABLE "location_connections"'); + await queryRunner.query('DROP INDEX "IDX_characters_current_location"'); + await queryRunner.query('DROP TABLE "characters"'); + await queryRunner.query('DROP INDEX "IDX_location_definitions_key"'); + await queryRunner.query('DROP TABLE "location_definitions"'); + await queryRunner.query('DROP TYPE "travel_status_enum"'); + } +} diff --git a/apps/api/src/database/migrations/visible-vertical-slice.migration.spec.ts b/apps/api/src/database/migrations/visible-vertical-slice.migration.spec.ts new file mode 100644 index 0000000..08c2f88 --- /dev/null +++ b/apps/api/src/database/migrations/visible-vertical-slice.migration.spec.ts @@ -0,0 +1,67 @@ +import 'reflect-metadata'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { getMetadataArgsStorage } from 'typeorm'; +import { Character } from '../../characters/entities/character.entity'; +import { Travel } from '../../travel/entities/travel.entity'; +import { LocationConnection } from '../../world/entities/location-connection.entity'; +import { LocationDefinition } from '../../world/entities/location-definition.entity'; + +describe('visible vertical slice schema', () => { + it('maps the location key and relationship foreign keys explicitly', () => { + const metadata = getMetadataArgsStorage(); + const locationIndex = metadata.indices.find((index) => { + const metadataIndex = index as typeof index & { + options?: { unique?: boolean }; + unique?: boolean; + }; + + return ( + index.target === LocationDefinition && + index.columns?.includes('key') && + (metadataIndex.options?.unique ?? metadataIndex.unique) === true + ); + }); + + expect(locationIndex).toBeDefined(); + + const joinColumns = metadata.joinColumns + .filter((joinColumn) => + [Character, LocationConnection, Travel].includes( + joinColumn.target as typeof Character, + ), + ) + .map((joinColumn) => joinColumn.name); + + expect(joinColumns).toEqual( + expect.arrayContaining([ + 'current_location_id', + 'from_location_id', + 'to_location_id', + 'character_id', + 'origin_location_id', + 'target_location_id', + ]), + ); + }); + + it('creates the complete schema with explicit reversible SQL', () => { + const migrationText = readFileSync( + join(__dirname, '1787072400000-CreateVisibleVerticalSlice.ts'), + 'utf8', + ); + + expect(migrationText).toContain('CREATE TABLE "location_definitions"'); + expect(migrationText).toContain('CREATE TABLE "characters"'); + expect(migrationText).toContain('CREATE TABLE "location_connections"'); + expect(migrationText).toContain('CREATE TABLE "travels"'); + expect(migrationText).toContain( + 'CREATE UNIQUE INDEX "IDX_location_connection_direction"', + ); + expect(migrationText).toContain( + 'CREATE UNIQUE INDEX "IDX_active_travel_per_character"', + ); + expect(migrationText).toContain('DROP TYPE "travel_status_enum"'); + expect(migrationText).not.toContain('synchronize'); + }); +}); diff --git a/apps/api/src/travel/entities/travel.entity.ts b/apps/api/src/travel/entities/travel.entity.ts new file mode 100644 index 0000000..1029f3d --- /dev/null +++ b/apps/api/src/travel/entities/travel.entity.ts @@ -0,0 +1,55 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { Character } from '../../characters/entities/character.entity'; +import { LocationDefinition } from '../../world/entities/location-definition.entity'; +import { TravelStatus } from '../travel-status.enum'; + +@Entity({ name: 'travels' }) +export class Travel { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ name: 'character_id', type: 'uuid' }) + characterId!: string; + + @Column({ name: 'origin_location_id', type: 'uuid' }) + originLocationId!: string; + + @Column({ name: 'target_location_id', type: 'uuid' }) + targetLocationId!: string; + + @Column({ name: 'started_at', type: 'timestamptz' }) + startedAt!: Date; + + @Column({ name: 'arrives_at', type: 'timestamptz' }) + arrivesAt!: Date; + + @Column({ + name: 'status', + type: 'enum', + enum: TravelStatus, + enumName: 'travel_status_enum', + }) + status!: TravelStatus; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @ManyToOne(() => Character) + @JoinColumn({ name: 'character_id' }) + character!: Character; + + @ManyToOne(() => LocationDefinition) + @JoinColumn({ name: 'origin_location_id' }) + originLocation!: LocationDefinition; + + @ManyToOne(() => LocationDefinition) + @JoinColumn({ name: 'target_location_id' }) + targetLocation!: LocationDefinition; +} diff --git a/apps/api/src/travel/travel-status.enum.ts b/apps/api/src/travel/travel-status.enum.ts new file mode 100644 index 0000000..1f5c087 --- /dev/null +++ b/apps/api/src/travel/travel-status.enum.ts @@ -0,0 +1,4 @@ +export enum TravelStatus { + TRAVELLING = 'TRAVELLING', + COMPLETED = 'COMPLETED', +} diff --git a/apps/api/src/world/entities/location-connection.entity.ts b/apps/api/src/world/entities/location-connection.entity.ts new file mode 100644 index 0000000..a2432b6 --- /dev/null +++ b/apps/api/src/world/entities/location-connection.entity.ts @@ -0,0 +1,51 @@ +import { + Column, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { LocationDefinition } from './location-definition.entity'; + +@Entity({ name: 'location_connections' }) +@Index( + 'IDX_location_connection_direction', + ['fromLocationId', 'toLocationId'], + { + unique: true, + }, +) +export class LocationConnection { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ name: 'from_location_id', type: 'uuid' }) + fromLocationId!: string; + + @Column({ name: 'to_location_id', type: 'uuid' }) + toLocationId!: string; + + @Column({ name: 'travel_duration_seconds', type: 'integer' }) + travelDurationSeconds!: number; + + @Column({ name: 'ambush_chance', type: 'numeric', precision: 5, scale: 4 }) + ambushChance!: string; + + @Column({ name: 'enabled', type: 'boolean' }) + enabled!: boolean; + + @ManyToOne( + () => LocationDefinition, + (location) => location.outgoingConnections, + ) + @JoinColumn({ name: 'from_location_id' }) + fromLocation!: LocationDefinition; + + @ManyToOne( + () => LocationDefinition, + (location) => location.incomingConnections, + ) + @JoinColumn({ name: 'to_location_id' }) + toLocation!: LocationDefinition; +} diff --git a/apps/api/src/world/entities/location-definition.entity.ts b/apps/api/src/world/entities/location-definition.entity.ts new file mode 100644 index 0000000..b6e86a2 --- /dev/null +++ b/apps/api/src/world/entities/location-definition.entity.ts @@ -0,0 +1,63 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Character } from '../../characters/entities/character.entity'; +import { LocationConnection } from './location-connection.entity'; + +@Entity({ name: 'location_definitions' }) +@Index('IDX_location_definitions_key', ['key'], { unique: true }) +export class LocationDefinition { + @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: 'region_key', type: 'varchar', length: 100 }) + regionKey!: string; + + @Column({ name: 'min_recommended_level', type: 'integer' }) + minRecommendedLevel!: number; + + @Column({ name: 'max_recommended_level', type: 'integer' }) + maxRecommendedLevel!: number; + + @Column({ name: 'danger_level', type: 'integer' }) + dangerLevel!: number; + + @Column({ name: 'is_safe', type: 'boolean' }) + isSafe!: boolean; + + @Column({ name: 'hunting_enabled', type: 'boolean' }) + huntingEnabled!: boolean; + + @Column({ name: 'artwork_path', type: 'varchar', length: 255 }) + artworkPath!: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; + + @OneToMany(() => Character, (character) => character.currentLocation) + characters!: Character[]; + + @OneToMany(() => LocationConnection, (connection) => connection.fromLocation) + outgoingConnections!: LocationConnection[]; + + @OneToMany(() => LocationConnection, (connection) => connection.toLocation) + incomingConnections!: LocationConnection[]; +}