diff --git a/docs/superpowers/plans/2026-08-19-playable-slice-0.2-first-hunt.md b/docs/superpowers/plans/2026-08-19-playable-slice-0.2-first-hunt.md new file mode 100644 index 0000000..fb9500a --- /dev/null +++ b/docs/superpowers/plans/2026-08-19-playable-slice-0.2-first-hunt.md @@ -0,0 +1,685 @@ +# Playable Slice 0.2 – First Hunt Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. + +**Goal:** Extend the existing server-authoritative world/travel slice with a Hunting/Encounter boundary: a character standing at Verbrannte Straße can start a hunt, receive 2–3 server-generated encounters backed by persisted content, inspect them, and select one — establishing `HuntEncounter.id` as the sole handoff point into a future combat slice. Combat itself is explicitly out of scope. + +**Architecture:** Same npm-workspace modular monolith. New `monsters` and `hunting` Nest modules alongside `characters`/`world`/`travel`. All new gameplay decisions (pool resolution, weighted roll, danger rating) happen server-side in one transaction; Angular only requests, renders, and forwards a `HuntEncounter.id`. + +**Tech Stack:** Angular 22 (standalone components, signals), NestJS 11, TypeORM, PostgreSQL, REST under `/api`, npm Workspaces, Jest (API), Vitest (web). + +**Spec:** No separate spec file exists for this slice — the full spec is the user's request that produced this plan (transcribed into each task below verbatim where it gives exact values). Treat this plan's task text as the binding spec; `docs/Ashen_Realms_Vertical_Slice_World_Content_Design_V1.md` and `docs/Ashen_Realms_Balancing_Items_Loot_Design_V1.md` are secondary references only if a task explicitly says to consult them. + +## Global Constraints + +- Do not implement: combat, damage, combat actions, loot, XP progression, silver granting, inventory, equipment, item drops, quests, merchants, area currencies, bosses (gameplay behavior), authentication, travel ambushes. Do not introduce Nx, Turborepo, NgRx, GraphQL, CQRS, event sourcing, Redis, WebSockets, microservices, or background workers. +- `synchronize: false` always; every schema change is one checked-in migration. +- The client must never send `characterId`, `locationId`, `monsterId`, `weight`, or `dangerRating` in any request body. The server resolves the current demo character the same way `travel`/`world` already do (`DEMO_CHARACTER_ID` constant from `apps/api/src/demo/demo-character.constants.ts`, passed straight into the service — no guard/decorator). +- The frontend must never compute pool membership, random rolls, location validity, hunt availability, or danger rating. It requests, renders, and navigates. +- A future combat slice must be able to start from a persisted `HuntEncounter.id` alone. The client can never turn an arbitrary `monsterId` directly into combat — this is a security/domain boundary, not a convenience. +- Reuse existing conventions exactly (confirmed by inspection, see below) instead of inventing parallel abstractions: entity decorator style, migration raw-SQL style, seed idempotency style, domain-error-as-`HttpException` style, Angular signal-store style, the single shared `GameApiService` for all HTTP calls, and the existing SCSS design tokens in `apps/web/src/styles.scss`. +- Preserve all existing behavior and tests: `/world`, character loading, current location, Südtor von Graufurt, Verbrannte Straße, travel start/countdown/completion, return travel, the application shell, the existing visual design. Do not touch `apps/api/src/travel/**` or `apps/api/src/world/**` beyond the one explicitly listed addition in Task 5 (exposing the encounter pool's monster names on the existing current-location response). +- Do not implement Slice 0.3 (combat) just because the architecture makes it easy. `Angreifen` preserves a `HuntEncounter.id` and stops there. + +## Repository conventions confirmed by inspection (do not re-derive, just follow) + +- **Entities:** `@Entity({ name: 'snake_case_plural' })`; every column explicit via `@Column({ name: 'snake_case', type: '...' })`; UUID PK via `@PrimaryGeneratedColumn('uuid', { name: 'id' })`; timestamps via `@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })` / `@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })` — omit `updated_at` on append-only/child tables (`Travel` has no `updated_at`; follow the same rule for `Hunt`/`HuntEncounter`/`LocationMonster`). Relations always get **both** a plain `@Column({ type: 'uuid' })` FK id field **and** a parallel `@ManyToOne(() => Target, { onDelete: '...' }) @JoinColumn({ name: '...' })` object reference. Enums live in their own `*.enum.ts` file as a plain TS `enum`, referenced via `@Column({ type: 'enum', enum: X, enumName: 'x_enum' })`. Unique constraints are `@Index('IDX_name', ['field'], { unique: true })` at class level, never `@Column({ unique: true })`. +- **Migrations:** `apps/api/src/database/migrations/-PascalCaseDescription.ts`, class `PascalCaseDescription`, pure raw SQL via `queryRunner.query(...)` (no QueryRunner schema API), `down()` reverses every statement in exact opposite order. The existing migration already establishes the "only one active row per character" pattern via a **partial unique index** (`CREATE UNIQUE INDEX ... WHERE "status" = 'TRAVELLING'`) — reuse this exact mechanism for the one-active-hunt invariant. +- **Seed:** `apps/api/src/database/seeds/vertical-slice.seed.ts` (idempotent, extend it — do not create a second seed file), stable content IDs live in `apps/api/src/database/seeds/vertical-slice.constants.ts`. Locations: `findOneBy({key})` → `update` if found else `insert` (preserves DB-generated id across reseeds). Connections: `repository.upsert(rows, [conflictCol1, conflictCol2])`. The demo character is guard-inserted only, never overwritten. `LocationDefinition.huntingEnabled` **already exists and is already seeded** (`south-gate=false`, `burned-road=true`) — do not touch it. +- **No-auth character resolution:** controllers import `DEMO_CHARACTER_ID` directly and pass it to the service (see `travel.controller.ts`, `world.controller.ts`). Do the same in the new hunting controller. +- **Domain errors:** `apps/api/src/travel/travel.errors.ts` defines one `TravelDomainError extends HttpException`, constructed `new TravelDomainError(code, status, message)` with body `{ statusCode: status, code, message }`. There is no custom exception filter — Nest serializes this as-is, which is exactly the `{statusCode, code, message}` shape required here too. One factory function per error, exported individually. +- **Angular store:** `apps/web/src/app/features/world/world.store.ts` is an `@Injectable({ providedIn: 'root' })` class (not NgRx) holding private `signal()`s with public `.asReadonly()` getters, `loading`/`error` signals where `error` is a pre-translated German string produced by a private `toErrorMessage(error)` helper that checks `error instanceof HttpErrorResponse` (from `@angular/common/http`) **first**, looks up `error.error?.code` in a local error-code→message map, and only falls back to `error instanceof Error` for genuine non-HTTP errors. Async methods use `async/await` with `try/finally` toggling the loading signal. +- **Angular API client:** one shared `apps/web/src/app/core/api/game-api.service.ts` (`@Injectable({providedIn:'root'})`), one method per endpoint returning `Observable` via injected `HttpClient`; types live in one shared `apps/web/src/app/core/api/game-api.models.ts`. There is no per-feature API service in this repo — add to the shared one. +- **Shared UI:** no `shared/` directory exists yet and no Panel/Badge/Card base components exist — every feature hand-rolls its own SCSS following the same token vocabulary in `apps/web/src/styles.scss` (`--ar-bg`, `--ar-panel`, `--ar-panel-muted`, `--ar-border`, `--ar-border-highlight`, `--ar-text`, `--ar-text-muted`, `--ar-gold`, `--ar-blue`, `--ar-success`, `--ar-warning`, `--ar-danger`, `--ar-space-1..6`, `--ar-radius-sm/md`, `--ar-shadow-raised`, `--ar-motion-fast/base`). `apps/web/src/app/features/world/travel-panel.component.scss` is the best copy-template (bordered panel, gradient background, gold eyebrow label, Georgia serif headings, button gradient with hover/disabled states). `travel-panel` already renders danger as text + modifier class, never color alone — copy this pattern exactly for `DangerBadge`. +- **Navigation:** `apps/web/src/app/layout/side-navigation/side-navigation.component.html` — flat `