feat: add world travel database schema
This commit is contained in:
47
apps/api/src/characters/entities/character.entity.ts
Normal file
47
apps/api/src/characters/entities/character.entity.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateVisibleVerticalSlice1787072400000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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"');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
55
apps/api/src/travel/entities/travel.entity.ts
Normal file
55
apps/api/src/travel/entities/travel.entity.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
4
apps/api/src/travel/travel-status.enum.ts
Normal file
4
apps/api/src/travel/travel-status.enum.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export enum TravelStatus {
|
||||||
|
TRAVELLING = 'TRAVELLING',
|
||||||
|
COMPLETED = 'COMPLETED',
|
||||||
|
}
|
||||||
51
apps/api/src/world/entities/location-connection.entity.ts
Normal file
51
apps/api/src/world/entities/location-connection.entity.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
63
apps/api/src/world/entities/location-definition.entity.ts
Normal file
63
apps/api/src/world/entities/location-definition.entity.ts
Normal file
@@ -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[];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user