diff --git a/.env.example b/.env.example index 1ef675e..e8b42e6 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,10 @@ +# Copy this file to `.env` (repo root) and adjust as needed. See README.md +# for the full local setup workflow (install, migrate, seed, run api/web). NODE_ENV=development PORT=3000 +# Requires a reachable PostgreSQL server with a database named `ashen_realms` +# owned by this user/password (or your own local equivalent connection string). DATABASE_URL=postgresql://ashen:ashen@localhost:5432/ashen_realms JWT_ACCESS_SECRET=change-me diff --git a/README.md b/README.md new file mode 100644 index 0000000..eae5a61 --- /dev/null +++ b/README.md @@ -0,0 +1,162 @@ +# Ashen Realms + +A dark-fantasy browser RPG built as an npm-workspace modular monolith: + +- `apps/web` — Angular 22 single-page application +- `apps/api` — NestJS 11 + TypeORM + PostgreSQL API +- `packages/*` — reserved shared boundaries (currently unused) + +This README documents the first visible vertical slice: a server-authoritative +world/travel loop between two locations (Südtor von Graufurt and Verbrannte +Straße) for one demo character, with no login required. + +## Prerequisites + +- Node.js and npm (workspace-aware npm, matching the versions used by CI/your + toolchain). +- A reachable PostgreSQL server (PostgreSQL 13 or newer — the schema uses the + built-in `gen_random_uuid()`, which needs no extensions) with a database + named `ashen_realms`. Create it once, e.g.: + + ```powershell + psql -h localhost -U postgres -c "CREATE DATABASE ashen_realms;" + psql -h localhost -U postgres -c "CREATE USER ashen WITH PASSWORD 'ashen';" + psql -h localhost -U postgres -c "GRANT ALL PRIVILEGES ON DATABASE ashen_realms TO ashen;" + ``` + + Adjust user/password/host to match your own local PostgreSQL instance; just + keep `DATABASE_URL` (below) pointed at it. + +## Local setup + +Run from the repository root: + +```powershell +npm install +Copy-Item .env.example .env +npm run db:migrate +npm run db:seed +npm run dev:api +npm run dev:web +``` + +Run `dev:api` and `dev:web` in separate terminals — both watch and keep +running. Once both are up: + +- API: `http://localhost:3000/api/...` (see routes below) +- Web: `http://localhost:4200`, which proxies `/api/*` requests to + `http://localhost:3000` in development (see `apps/web/proxy.conf.json`) + +Open `http://localhost:4200/world` to see Aric Duskwalker at the Südtor von +Graufurt and travel to the Verbrannte Straße and back. + +### Configuration (`.env`) + +`.env.example` (repo root) documents every variable. Copy it to `.env` at the +repo root — `.env` is git-ignored and must never be committed. The important +one for local setup is: + +``` +DATABASE_URL=postgresql://ashen:ashen@localhost:5432/ashen_realms +``` + +This is the default local PostgreSQL connection string: database name +`ashen_realms`, default port `5432`. Edit it if your local PostgreSQL uses a +different user, password, host, or port. All API workspace scripts +(`db:migrate`, `db:seed`, `dev:api`, and `apps/api`'s own `test:e2e`) resolve +this `.env` from the repository root regardless of which workspace directory +npm runs the underlying command in. + +`synchronize` is always `false`; schema changes only happen through the +checked-in migration at +`apps/api/src/database/migrations/1787072400000-CreateVisibleVerticalSlice.ts`. +The seed (`apps/api/src/database/seeds/vertical-slice.seed.ts`) is idempotent: +running `npm run db:seed` multiple times upserts the same two locations, two +connections, and one demo character without creating duplicates or resetting +the character's current (live) location. + +### Ports + +| Service | Port | Notes | +|---|---|---| +| API (NestJS) | `3000` | All routes are under `/api` (e.g. `/api/health`). | +| Web (Angular dev server) | `4200` | Proxies `/api/*` to the API in development. | + +## Available routes (first slice) + +``` +GET /api/health +GET /api/characters/me +GET /api/world/current-location +GET /api/travel/current +POST /api/travel { "targetLocationId": "" } +``` + +`POST /api/travel` accepts exactly `targetLocationId`; the global validation +pipe rejects any other property (e.g. a client-supplied `arrivesAt`) with +`400 Bad Request`, since `arrivesAt` is always server-derived. + +## Scripts + +Run these from the repository root unless noted otherwise. + +| Script | Description | +|---|---| +| `npm run dev:web` | Start the Angular dev server (port 4200). | +| `npm run dev:api` | Start the NestJS API in watch mode (port 3000). | +| `npm run build:web` | Production build of the Angular app. | +| `npm run build:api` | Production build of the NestJS app. | +| `npm run build` | Both builds. | +| `npm test` | Unit tests for every workspace. | +| `npm run test:e2e` | API end-to-end/smoke tests (see below). | +| `npm run db:migrate` | Run pending TypeORM migrations against `DATABASE_URL`. | +| `npm run db:revert` | Revert the last migration. | +| `npm run db:seed` | Run the idempotent demo-content seed. | + +## Testing + +Unit tests never require a database: + +```powershell +npm test --workspace=@ashen-realms/api -- --runInBand +``` + +The API end-to-end smoke test (`apps/api/test/visible-slice.e2e-spec.ts`) +always asserts `GET /api/health`. When `DATABASE_URL` is set and reachable, +it additionally boots the real `AppModule` (real database, entities, and +controllers) and asserts the seeded character/world responses and the +`arrivesAt` validation rejection. Those database-backed assertions are +skipped — not failed — when no database is configured, so `npm run test:e2e` +is safe to run without any local PostgreSQL setup too: + +```powershell +npm run test:e2e --workspace=@ashen-realms/api -- --runInBand +``` + +Both builds: + +```powershell +npm run build:api +npm run build:web +``` + +## Known limitations of this first vertical slice + +This slice deliberately excludes (per +`docs/superpowers/specs/2026-08-18-first-visible-vertical-slice-design.md`): + +- Authentication, user accounts, JWTs, or any login/registration flow — there + is exactly one hardcoded demo character (Aric Duskwalker). +- Hunting, combat, loot, inventory, equipment, quests, merchants, and + currencies. +- Realtime features: WebSockets, chat, guilds, CMS, object storage, or a + multi-service/microservice architecture. +- Ambush resolution: `ambushChance` is stored on each connection and surfaced + only as a coarse `LOW`/`HIGH` danger rating; it is never rolled. +- Production containerization or NestJS static hosting. +- Client-side clock skew can shift how the travel countdown *displays*, but + arrival is always confirmed by the server (`GET /api/travel/current` / + `GET /api/world/current-location`), never inferred locally. + +Only two locations and one directed pair of connections exist +(`south-gate` ⇄ `burned-road`), each with a fixed 10-second travel duration. diff --git a/apps/api/src/database/data-source.ts b/apps/api/src/database/data-source.ts index 97c1156..c5b2179 100644 --- a/apps/api/src/database/data-source.ts +++ b/apps/api/src/database/data-source.ts @@ -1,6 +1,14 @@ -import 'dotenv/config'; +import { config } from 'dotenv'; +import { resolve } from 'path'; import { DataSource } from 'typeorm'; +// npm workspace scripts (`db:migrate`, `db:seed`) run with this file's +// workspace (`apps/api`) as the current working directory, not the repo +// root where the documented `.env` lives. Resolve it explicitly so the +// documented `npm run db:migrate` / `npm run db:seed` flow works regardless +// of the caller's working directory. +config({ path: resolve(__dirname, '../../../../.env') }); + const databaseUrl = process.env.DATABASE_URL; if (!databaseUrl?.trim()) { @@ -11,6 +19,6 @@ export const AppDataSource = new DataSource({ type: 'postgres', url: databaseUrl, entities: [__dirname + '/../**/*.entity.{ts,js}'], - migrations: [__dirname + '/migrations/*.{ts,js}'], + migrations: [__dirname + '/migrations/[0-9]*.{ts,js}'], synchronize: false, }); diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index ed54b1e..5e3d6c9 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,7 +1,15 @@ +import { config } from 'dotenv'; +import { resolve } from 'path'; import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { configureApplication } from './app.config'; +// `dev:api` runs via the `apps/api` npm workspace, so process.cwd() is +// `apps/api`, not the repo root where the documented `.env` lives. Resolve +// it explicitly so `npm run dev:api` picks up `DATABASE_URL` after +// `Copy-Item .env.example .env` as documented in the README. +config({ path: resolve(__dirname, '..', '..', '..', '.env') }); + async function bootstrap() { const app = await NestFactory.create(AppModule); configureApplication(app); diff --git a/apps/api/test/jest-e2e.json b/apps/api/test/jest-e2e.json index e9d912f..0ad9c60 100644 --- a/apps/api/test/jest-e2e.json +++ b/apps/api/test/jest-e2e.json @@ -5,5 +5,6 @@ "testRegex": ".e2e-spec.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" - } + }, + "testTimeout": 15000 } diff --git a/apps/api/test/visible-slice.e2e-spec.ts b/apps/api/test/visible-slice.e2e-spec.ts new file mode 100644 index 0000000..dfed8c6 --- /dev/null +++ b/apps/api/test/visible-slice.e2e-spec.ts @@ -0,0 +1,156 @@ +import { config } from 'dotenv'; +import { resolve } from 'path'; +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, Module } from '@nestjs/common'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { AppModule } from './../src/app.module'; +import { CharactersModule } from './../src/characters/characters.module'; +import { DatabaseModule } from './../src/database/database.module'; +import { configureApplication } from './../src/app.config'; +import { TravelModule } from './../src/travel/travel.module'; +import { WorldModule } from './../src/world/world.module'; +import { DEMO_CHARACTER_ID } from './../src/demo/demo-character.constants'; +import { BURNED_ROAD_ID } from './../src/database/seeds/vertical-slice.constants'; + +// `npm run test:e2e` runs from the `apps/api` workspace, so process.cwd() +// is `apps/api`, not the repo root where the documented `.env` lives. +// Load it the same way `main.ts`/`data-source.ts` do, without overriding a +// `DATABASE_URL` a developer or CI already exported. +config({ path: resolve(__dirname, '../../../.env') }); + +@Module({}) +class TestDatabaseModule {} + +@Module({}) +class TestCharactersModule {} + +@Module({}) +class TestTravelModule {} + +@Module({}) +class TestWorldModule {} + +describe('Visible vertical slice smoke (e2e)', () => { + describe('without a developer database', () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }) + .overrideModule(DatabaseModule) + .useModule(TestDatabaseModule) + .overrideModule(CharactersModule) + .useModule(TestCharactersModule) + .overrideModule(TravelModule) + .useModule(TestTravelModule) + .overrideModule(WorldModule) + .useModule(TestWorldModule) + .compile(); + + app = moduleFixture.createNestApplication(); + configureApplication(app); + await app.init(); + }); + + afterEach(async () => { + await app?.close(); + }); + + it('GET /api/health returns ok', () => { + return request(app.getHttpServer()) + .get('/api/health') + .expect(200) + .expect({ status: 'ok' }); + }); + }); + + // These assertions exercise the real DatabaseModule, TypeORM entities and + // the seeded demo character/world data, so they only run when a reachable + // PostgreSQL database is configured (see README.md for local setup). + const describeWithDatabase = process.env.DATABASE_URL + ? describe + : describe.skip; + + describeWithDatabase('against a migrated and seeded database', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + configureApplication(app); + await app.init(); + }); + + afterAll(async () => { + await app?.close(); + }); + + it('GET /api/health returns ok', () => { + return request(app.getHttpServer()) + .get('/api/health') + .expect(200) + .expect({ status: 'ok' }); + }); + + it('GET /api/characters/me returns the seeded demo character', async () => { + const response = await request(app.getHttpServer()) + .get('/api/characters/me') + .expect(200); + const body = response.body as { + id: string; + name: string; + currentLocation: { id: string; key: string; name: string }; + }; + + expect(body).toMatchObject({ + id: DEMO_CHARACTER_ID, + name: 'Aric Duskwalker', + }); + expect(typeof body.currentLocation.id).toBe('string'); + expect(typeof body.currentLocation.key).toBe('string'); + expect(typeof body.currentLocation.name).toBe('string'); + }); + + it('GET /api/world/current-location returns the character location with its connections', async () => { + const response = await request(app.getHttpServer()) + .get('/api/world/current-location') + .expect(200); + const body = response.body as { + id: string; + key: string; + name: string; + connections: unknown[]; + }; + + expect(typeof body.id).toBe('string'); + expect(typeof body.key).toBe('string'); + expect(typeof body.name).toBe('string'); + expect(Array.isArray(body.connections)).toBe(true); + expect(body.connections.length).toBeGreaterThan(0); + }); + + it('GET /api/travel/current returns a known travel status', async () => { + const response = await request(app.getHttpServer()) + .get('/api/travel/current') + .expect(200); + const body = response.body as { status: string }; + + expect(['IDLE', 'TRAVELLING', 'COMPLETED']).toContain(body.status); + }); + + it('POST /api/travel rejects a body carrying a non-whitelisted arrivesAt field', () => { + return request(app.getHttpServer()) + .post('/api/travel') + .send({ + targetLocationId: BURNED_ROAD_ID, + arrivesAt: new Date().toISOString(), + }) + .expect(400); + }); + }); +}); diff --git a/package.json b/package.json index 81708fa..55e9f7b 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "build:api": "npm run build --workspace=@ashen-realms/api", "build": "npm run build:web && npm run build:api", "test": "npm run test --workspaces --if-present", + "test:e2e": "npm run test:e2e --workspace=@ashen-realms/api --", "db:migrate": "npm run typeorm --workspace=@ashen-realms/api -- migration:run -d src/database/data-source.ts", "db:revert": "npm run typeorm --workspace=@ashen-realms/api -- migration:revert -d src/database/data-source.ts", "db:seed": "npm run seed --workspace=@ashen-realms/api"