T001–T006: Phase 1 setup (workspace, Biome, TS, Vitest, layer boundary enforcement)
This commit is contained in:
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.log
|
||||||
|
.env*
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
*.tmp
|
||||||
|
*.swp
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
coverage/
|
||||||
|
*.tsbuildinfo
|
||||||
@@ -1,50 +1,145 @@
|
|||||||
# [PROJECT_NAME] Constitution
|
<!--
|
||||||
<!-- Example: Spec Constitution, TaskFlow Constitution, etc. -->
|
Sync Impact Report
|
||||||
|
───────────────────
|
||||||
|
Version change: 1.0.2 → 1.0.3 (PATCH — add merge-gate rule)
|
||||||
|
Modified sections:
|
||||||
|
- Development Workflow: added automated-checks merge gate
|
||||||
|
Templates requiring updates:
|
||||||
|
- .specify/templates/plan-template.md ✅ no update needed
|
||||||
|
- .specify/templates/spec-template.md ✅ no update needed
|
||||||
|
- .specify/templates/tasks-template.md ✅ no update needed
|
||||||
|
Follow-up TODOs: none
|
||||||
|
-->
|
||||||
|
# Encounter Console Constitution
|
||||||
|
|
||||||
## Core Principles
|
## Core Principles
|
||||||
|
|
||||||
### [PRINCIPLE_1_NAME]
|
### I. Deterministic Domain Core
|
||||||
<!-- Example: I. Library-First -->
|
|
||||||
[PRINCIPLE_1_DESCRIPTION]
|
|
||||||
<!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries -->
|
|
||||||
|
|
||||||
### [PRINCIPLE_2_NAME]
|
All domain logic MUST be implemented as pure state transitions.
|
||||||
<!-- Example: II. CLI Interface -->
|
Given the same input state and action, the output state MUST be
|
||||||
[PRINCIPLE_2_DESCRIPTION]
|
identical across runs. Side effects (I/O, randomness, clocks) MUST
|
||||||
<!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats -->
|
be injected at the boundary, never sourced inside the domain layer.
|
||||||
|
|
||||||
### [PRINCIPLE_3_NAME]
|
- State transitions MUST be expressed as pure functions.
|
||||||
<!-- Example: III. Test-First (NON-NEGOTIABLE) -->
|
- Random values (e.g., dice rolls) MUST be passed in as resolved
|
||||||
[PRINCIPLE_3_DESCRIPTION]
|
inputs, not generated within the domain.
|
||||||
<!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced -->
|
- Domain functions MUST NOT depend on wall-clock time, network, or
|
||||||
|
filesystem.
|
||||||
|
|
||||||
### [PRINCIPLE_4_NAME]
|
### II. Layered Architecture
|
||||||
<!-- Example: IV. Integration Testing -->
|
|
||||||
[PRINCIPLE_4_DESCRIPTION]
|
|
||||||
<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas -->
|
|
||||||
|
|
||||||
### [PRINCIPLE_5_NAME]
|
The codebase MUST be organized into four layers with strict
|
||||||
<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity -->
|
dependency direction:
|
||||||
[PRINCIPLE_5_DESCRIPTION]
|
|
||||||
<!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles -->
|
|
||||||
|
|
||||||
## [SECTION_2_NAME]
|
1. **Domain** — pure types, state transitions, validation rules.
|
||||||
<!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. -->
|
No imports from other layers.
|
||||||
|
2. **Application** — orchestrates domain operations, manages
|
||||||
|
workflows, coordinates use cases. Application defines port
|
||||||
|
interfaces that Adapters implement. May import Domain only.
|
||||||
|
3. **Adapters** — I/O, persistence, UI rendering, external APIs.
|
||||||
|
May import Application and Domain.
|
||||||
|
4. **Agent** — AI-assisted features (suggestions, analysis).
|
||||||
|
May import Application and Domain as read-only consumers.
|
||||||
|
|
||||||
[SECTION_2_CONTENT]
|
A module in an inner layer MUST NOT import from an outer layer.
|
||||||
<!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. -->
|
|
||||||
|
|
||||||
## [SECTION_3_NAME]
|
### III. Agent Boundary
|
||||||
<!-- Example: Development Workflow, Review Process, Quality Gates, etc. -->
|
|
||||||
|
|
||||||
[SECTION_3_CONTENT]
|
The agent layer MAY read domain events and current state. The agent
|
||||||
<!-- Example: Code review requirements, testing gates, deployment approval process, etc. -->
|
MAY produce suggestions, annotations, or recommendations. The agent
|
||||||
|
MUST NOT mutate domain state directly. All agent-originated changes
|
||||||
|
MUST flow through the Application layer as explicit user-confirmed
|
||||||
|
commands.
|
||||||
|
|
||||||
|
- Agent output MUST be clearly labeled as suggestions.
|
||||||
|
- No silent or automatic application of agent recommendations.
|
||||||
|
|
||||||
|
### IV. Clarification-First
|
||||||
|
|
||||||
|
Before making any non-trivial assumption during specification,
|
||||||
|
planning, or implementation, the agent MUST surface a clarification
|
||||||
|
question to the user. "Non-trivial" means any decision that would
|
||||||
|
alter observable behavior, data model shape, or public API surface.
|
||||||
|
The agent MUST also ask when multiple valid interpretations exist,
|
||||||
|
when a choice would affect architectural layering, or when scope
|
||||||
|
would expand beyond the current spec. The agent MUST NOT silently
|
||||||
|
choose among valid alternatives.
|
||||||
|
|
||||||
|
### V. Escalation Gates
|
||||||
|
|
||||||
|
Any feature, requirement, or scope change not present in the current
|
||||||
|
spec MUST be rejected at implementation time until the spec is
|
||||||
|
explicitly updated. The workflow is:
|
||||||
|
|
||||||
|
1. Identify the out-of-scope item.
|
||||||
|
2. Document it as a proposal.
|
||||||
|
3. Update the spec via `/speckit.specify` or `/speckit.clarify`.
|
||||||
|
4. Only then proceed with implementation.
|
||||||
|
|
||||||
|
### VI. MVP Baseline Language
|
||||||
|
|
||||||
|
Constraints in this constitution and in specs MUST use MVP baseline
|
||||||
|
language ("MVP baseline does not include X") rather than permanent
|
||||||
|
architectural bans ("X is forbidden"). This preserves the option to
|
||||||
|
add capabilities in future iterations without constitutional
|
||||||
|
amendment. The current MVP baseline is local-first and single-user;
|
||||||
|
this is a starting scope, not a permanent restriction.
|
||||||
|
|
||||||
|
### VII. No Gameplay Rules in Constitution
|
||||||
|
|
||||||
|
This constitution MUST NOT contain concrete gameplay mechanics,
|
||||||
|
rule-system specifics, or encounter resolution logic. Such details
|
||||||
|
belong in feature specs. The constitution governs process,
|
||||||
|
architecture, and quality — not product behavior.
|
||||||
|
|
||||||
|
## Scope Constraints
|
||||||
|
|
||||||
|
- The Encounter Console's primary focus is initiative tracking and
|
||||||
|
encounter state management. Adjacent capabilities (e.g., richer
|
||||||
|
game-engine features) are not in the MVP baseline but may be
|
||||||
|
added via spec updates in future iterations.
|
||||||
|
- Technology choices, UI framework, and storage mechanism are
|
||||||
|
spec-level decisions, not constitutional mandates.
|
||||||
|
- Testing strategy (unit, integration, contract) is determined per
|
||||||
|
feature spec; the constitution requires only that domain logic
|
||||||
|
remains testable via pure-function assertions.
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
- No change may be merged unless all automated checks (tests and
|
||||||
|
static analysis as defined by the project) pass.
|
||||||
|
- Every feature begins with a spec (`/speckit.specify`).
|
||||||
|
- Implementation follows the plan → tasks → implement pipeline.
|
||||||
|
- Domain logic MUST be testable without mocks for external systems.
|
||||||
|
- Long-running or multi-step state transitions SHOULD be verifiable
|
||||||
|
through reproducible event logs or snapshot-style tests.
|
||||||
|
- Commits SHOULD be atomic and map to individual tasks where
|
||||||
|
practical.
|
||||||
|
- Layer boundary compliance MUST be verified by automated import
|
||||||
|
rules or architectural tests. Agent-assisted or manual review MAY
|
||||||
|
supplement but not replace automated checks.
|
||||||
|
|
||||||
## Governance
|
## Governance
|
||||||
<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan -->
|
|
||||||
|
|
||||||
[GOVERNANCE_RULES]
|
This constitution is the highest-authority document for the
|
||||||
<!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance -->
|
Encounter Console project. All specs, plans, and implementations
|
||||||
|
MUST comply with its principles.
|
||||||
|
|
||||||
**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE]
|
**Amendment procedure**:
|
||||||
<!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 -->
|
1. Propose change with rationale.
|
||||||
|
2. Update constitution via `/speckit.constitution`.
|
||||||
|
3. Verify downstream template compatibility (Sync Impact Report).
|
||||||
|
4. A human maintainer MUST review and commit the update before
|
||||||
|
ratification takes effect.
|
||||||
|
5. Commit with version bump.
|
||||||
|
|
||||||
|
**Versioning policy**: MAJOR.MINOR.PATCH per semantic versioning.
|
||||||
|
- MAJOR: principle removal or backward-incompatible redefinition.
|
||||||
|
- MINOR: new principle or materially expanded guidance.
|
||||||
|
- PATCH: clarification, typo, or non-semantic refinement.
|
||||||
|
|
||||||
|
**Compliance review**: Every spec and plan MUST include a
|
||||||
|
Constitution Check section validating adherence to all principles.
|
||||||
|
|
||||||
|
**Version**: 1.0.3 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-03
|
||||||
|
|||||||
12
apps/web/index.html
Normal file
12
apps/web/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Initiative Tracker</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
23
apps/web/package.json
Normal file
23
apps/web/package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "web",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --build && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@initiative/application": "workspace:*",
|
||||||
|
"@initiative/domain": "workspace:*",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"vite": "^6.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
3
apps/web/src/App.tsx
Normal file
3
apps/web/src/App.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function App() {
|
||||||
|
return <div>Initiative Tracker</div>;
|
||||||
|
}
|
||||||
12
apps/web/src/main.tsx
Normal file
12
apps/web/src/main.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { App } from "./App";
|
||||||
|
|
||||||
|
const root = document.getElementById("root");
|
||||||
|
if (root) {
|
||||||
|
createRoot(root).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
|
}
|
||||||
14
apps/web/tsconfig.json
Normal file
14
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [
|
||||||
|
{ "path": "../../packages/domain" },
|
||||||
|
{ "path": "../../packages/application" }
|
||||||
|
]
|
||||||
|
}
|
||||||
6
apps/web/vite.config.ts
Normal file
6
apps/web/vite.config.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
});
|
||||||
33
biome.json
Normal file
33
biome.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
|
||||||
|
"files": {
|
||||||
|
"includes": [
|
||||||
|
"**",
|
||||||
|
"!**/dist/**",
|
||||||
|
"!.claude/**",
|
||||||
|
"!.specify/**",
|
||||||
|
"!specs/**"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"assist": {
|
||||||
|
"enabled": true,
|
||||||
|
"actions": {
|
||||||
|
"source": {
|
||||||
|
"organizeImports": {
|
||||||
|
"level": "on"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "tab",
|
||||||
|
"lineWidth": 80
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
package.json
Normal file
19
package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"private": true,
|
||||||
|
"packageManager": "pnpm@10.6.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "2.0.0",
|
||||||
|
"typescript": "^5.8.0",
|
||||||
|
"vitest": "^3.0.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"format": "biome format --write .",
|
||||||
|
"format:check": "biome format .",
|
||||||
|
"lint": "biome lint .",
|
||||||
|
"lint:fix": "biome lint --write .",
|
||||||
|
"typecheck": "tsc --build",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"check": "biome check . && tsc --build && vitest run"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
packages/application/package.json
Normal file
11
packages/application/package.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "@initiative/application",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./src/index.ts",
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"dependencies": {
|
||||||
|
"@initiative/domain": "workspace:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
packages/application/src/index.ts
Normal file
1
packages/application/src/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
10
packages/application/tsconfig.json
Normal file
10
packages/application/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "../domain" }]
|
||||||
|
}
|
||||||
8
packages/domain/package.json
Normal file
8
packages/domain/package.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "@initiative/domain",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./src/index.ts",
|
||||||
|
"types": "./src/index.ts"
|
||||||
|
}
|
||||||
16
packages/domain/src/__tests__/layer-boundaries.test.ts
Normal file
16
packages/domain/src/__tests__/layer-boundaries.test.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { checkLayerBoundaries } from "../../../../scripts/check-layer-boundaries.mjs";
|
||||||
|
|
||||||
|
describe("layer boundaries", () => {
|
||||||
|
it("domain and application packages have no forbidden imports", () => {
|
||||||
|
const violations = checkLayerBoundaries();
|
||||||
|
if (violations.length > 0) {
|
||||||
|
const messages = violations.map(
|
||||||
|
(v) =>
|
||||||
|
`${v.file}:${v.line} imports "${v.importPath}" (forbidden: ${v.forbidden})`,
|
||||||
|
);
|
||||||
|
throw new Error(`Layer boundary violations:\n ${messages.join("\n ")}`);
|
||||||
|
}
|
||||||
|
expect(violations).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
1
packages/domain/src/index.ts
Normal file
1
packages/domain/src/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
10
packages/domain/tsconfig.json
Normal file
10
packages/domain/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["src/__tests__"]
|
||||||
|
}
|
||||||
1550
pnpm-lock.yaml
generated
Normal file
1550
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
packages:
|
||||||
|
- "packages/*"
|
||||||
|
- "apps/*"
|
||||||
109
scripts/check-layer-boundaries.mjs
Normal file
109
scripts/check-layer-boundaries.mjs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { readdirSync, readFileSync } from "node:fs";
|
||||||
|
import { join, relative } from "node:path";
|
||||||
|
|
||||||
|
const ROOT = new URL("..", import.meta.url).pathname.replace(/\/$/, "");
|
||||||
|
|
||||||
|
const FORBIDDEN = {
|
||||||
|
"packages/domain/src": [
|
||||||
|
"@initiative/application",
|
||||||
|
"apps/",
|
||||||
|
"react",
|
||||||
|
"react-dom",
|
||||||
|
"vite",
|
||||||
|
],
|
||||||
|
"packages/application/src": ["apps/", "react", "react-dom", "vite"],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Directories to skip when collecting files */
|
||||||
|
const SKIP_DIRS = new Set(["__tests__", "node_modules"]);
|
||||||
|
|
||||||
|
/** @param {string} dir */
|
||||||
|
function collectTsFiles(dir) {
|
||||||
|
/** @type {string[]} */
|
||||||
|
const results = [];
|
||||||
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (!SKIP_DIRS.has(entry.name)) {
|
||||||
|
results.push(...collectTsFiles(full));
|
||||||
|
}
|
||||||
|
} else if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
|
||||||
|
results.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an import path matches a forbidden module.
|
||||||
|
* "vite" should match "vite" and "vite/foo" but not "vitest".
|
||||||
|
* @param {string} importPath
|
||||||
|
* @param {string} forbidden
|
||||||
|
*/
|
||||||
|
function matchesForbidden(importPath, forbidden) {
|
||||||
|
if (forbidden.endsWith("/")) {
|
||||||
|
return importPath.startsWith(forbidden);
|
||||||
|
}
|
||||||
|
return importPath === forbidden || importPath.startsWith(`${forbidden}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {{ file: string, line: number, importPath: string, forbidden: string }[]} */
|
||||||
|
export function checkLayerBoundaries() {
|
||||||
|
/** @type {{ file: string, line: number, importPath: string, forbidden: string }[]} */
|
||||||
|
const violations = [];
|
||||||
|
|
||||||
|
for (const [srcDir, forbidden] of Object.entries(FORBIDDEN)) {
|
||||||
|
const absDir = join(ROOT, srcDir);
|
||||||
|
let files;
|
||||||
|
try {
|
||||||
|
files = collectTsFiles(absDir);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const content = readFileSync(file, "utf-8");
|
||||||
|
const lines = content.split("\n");
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
const match = line.match(
|
||||||
|
/(?:import|from)\s+["']([^"']+)["']|require\s*\(\s*["']([^"']+)["']\s*\)/,
|
||||||
|
);
|
||||||
|
if (!match) continue;
|
||||||
|
const importPath = match[1] || match[2];
|
||||||
|
for (const f of forbidden) {
|
||||||
|
if (matchesForbidden(importPath, f)) {
|
||||||
|
violations.push({
|
||||||
|
file: relative(ROOT, file),
|
||||||
|
line: i + 1,
|
||||||
|
importPath,
|
||||||
|
forbidden: f,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return violations;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run as CLI if invoked directly
|
||||||
|
if (
|
||||||
|
process.argv[1] &&
|
||||||
|
(process.argv[1].endsWith("check-layer-boundaries.mjs") ||
|
||||||
|
process.argv[1] === new URL(import.meta.url).pathname)
|
||||||
|
) {
|
||||||
|
const violations = checkLayerBoundaries();
|
||||||
|
if (violations.length > 0) {
|
||||||
|
console.error("Layer boundary violations found:");
|
||||||
|
for (const v of violations) {
|
||||||
|
console.error(
|
||||||
|
` ${v.file}:${v.line} — imports "${v.importPath}" (forbidden: ${v.forbidden})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.log("No layer boundary violations found.");
|
||||||
|
}
|
||||||
|
}
|
||||||
349
specs/001-advance-turn/plan.md
Normal file
349
specs/001-advance-turn/plan.md
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
# Implementation Plan: Advance Turn
|
||||||
|
|
||||||
|
**Branch**: `001-advance-turn` | **Date**: 2026-03-03 | **Spec**: [spec.md](./spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/001-advance-turn/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Implement the AdvanceTurn domain operation as a pure function that
|
||||||
|
transitions an Encounter to the next combatant, wrapping rounds and
|
||||||
|
emitting TurnAdvanced / RoundAdvanced domain events. Stand up the
|
||||||
|
pnpm monorepo skeleton, Biome tooling, and Vitest test harness so
|
||||||
|
that all constitution merge-gate requirements are satisfied from the
|
||||||
|
first commit.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Node**: 22 LTS (pinned via `.nvmrc`)
|
||||||
|
**Language/Version**: TypeScript 5.8 (strict mode)
|
||||||
|
**Primary Dependencies**: React 19 (pin to major; minor upgrades
|
||||||
|
allowed), Vite 6.2
|
||||||
|
**Storage**: In-memory only (MVP baseline)
|
||||||
|
**Testing**: Vitest 3.0
|
||||||
|
**Lint/Format**: Biome 2.0.0 (exact version, single tool — no
|
||||||
|
Prettier, no ESLint)
|
||||||
|
**Package Manager**: pnpm 10.6 (pinned via `packageManager` field
|
||||||
|
in root `package.json`)
|
||||||
|
**Target Platform**: Static web app (modern browsers)
|
||||||
|
**Project Type**: Monorepo — library packages + web app
|
||||||
|
**Performance Goals**: N/A (walking skeleton)
|
||||||
|
**Constraints**: Domain package must have zero React/Vite imports
|
||||||
|
**Scale/Scope**: Single feature, ~5 source files, ~1 test file
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| I. Deterministic Domain Core | PASS | `advanceTurn` is a pure function; no I/O, randomness, or clocks |
|
||||||
|
| II. Layered Architecture | PASS | `packages/domain` → `packages/application` → `apps/web`; strict dependency direction enforced by automated import check |
|
||||||
|
| III. Agent Boundary | N/A | Agent layer out of scope for this feature |
|
||||||
|
| IV. Clarification-First | PASS | No ambiguous decisions remain; spec fully clarified |
|
||||||
|
| V. Escalation Gates | PASS | Scope strictly limited to spec; out-of-scope items listed |
|
||||||
|
| VI. MVP Baseline Language | PASS | No permanent bans; "MVP baseline does not include" used |
|
||||||
|
| VII. No Gameplay Rules | PASS | No gameplay mechanics in plan or constitution |
|
||||||
|
| Merge Gate | PASS | `pnpm check` script runs format, lint, typecheck, test |
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/001-advance-turn/
|
||||||
|
├── spec.md
|
||||||
|
└── plan.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
packages/
|
||||||
|
├── domain/
|
||||||
|
│ ├── package.json
|
||||||
|
│ ├── tsconfig.json
|
||||||
|
│ └── src/
|
||||||
|
│ ├── types.ts # CombatantId, Combatant, Encounter
|
||||||
|
│ ├── events.ts # TurnAdvanced, RoundAdvanced, DomainEvent
|
||||||
|
│ ├── advance-turn.ts # advanceTurn pure function
|
||||||
|
│ └── index.ts # public barrel export
|
||||||
|
├── application/
|
||||||
|
│ ├── package.json
|
||||||
|
│ ├── tsconfig.json
|
||||||
|
│ └── src/
|
||||||
|
│ ├── ports.ts # EncounterStore port interface
|
||||||
|
│ ├── advance-turn-use-case.ts
|
||||||
|
│ └── index.ts
|
||||||
|
apps/
|
||||||
|
└── web/
|
||||||
|
├── package.json
|
||||||
|
├── tsconfig.json
|
||||||
|
├── vite.config.ts
|
||||||
|
├── index.html
|
||||||
|
└── src/
|
||||||
|
├── main.tsx
|
||||||
|
├── App.tsx
|
||||||
|
└── hooks/
|
||||||
|
└── use-encounter.ts
|
||||||
|
|
||||||
|
# Testing (co-located with domain package)
|
||||||
|
packages/domain/
|
||||||
|
└── src/
|
||||||
|
└── __tests__/
|
||||||
|
└── advance-turn.test.ts
|
||||||
|
|
||||||
|
# Root config
|
||||||
|
├── .nvmrc # pins Node 22
|
||||||
|
├── pnpm-workspace.yaml
|
||||||
|
├── biome.json
|
||||||
|
├── tsconfig.base.json
|
||||||
|
└── package.json # packageManager field pins pnpm; root scripts
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: pnpm workspace monorepo with two packages
|
||||||
|
(`domain`, `application`) and one app (`web`). Domain is
|
||||||
|
framework-agnostic TypeScript. Application imports domain only.
|
||||||
|
Web app (React + Vite) imports both.
|
||||||
|
|
||||||
|
## Tooling & Merge Gate
|
||||||
|
|
||||||
|
### Scripts (root package.json)
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"format": "biome format --write .",
|
||||||
|
"format:check": "biome format .",
|
||||||
|
"lint": "biome lint .",
|
||||||
|
"lint:fix": "biome lint --write .",
|
||||||
|
"typecheck": "tsc --build",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"check": "biome check . && tsc --build && vitest run"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`pnpm check` is the single merge gate: format + lint + typecheck +
|
||||||
|
test. The layer boundary check runs as a Vitest test (see below),
|
||||||
|
so it executes as part of `vitest run` — no separate script needed.
|
||||||
|
|
||||||
|
### Layer Boundary Enforcement
|
||||||
|
|
||||||
|
Biome does not natively support cross-package import restrictions.
|
||||||
|
A lightweight `scripts/check-layer-boundaries.mjs` script will:
|
||||||
|
|
||||||
|
1. Scan `packages/domain/src/**/*.ts` — assert zero imports from
|
||||||
|
`@initiative/application`, `apps/`, `react`, `vite`.
|
||||||
|
2. Scan `packages/application/src/**/*.ts` — assert zero imports
|
||||||
|
from `apps/`, `react`, `vite`.
|
||||||
|
3. Exit non-zero on violation with a clear error message.
|
||||||
|
|
||||||
|
This script is invoked by a Vitest test
|
||||||
|
(`packages/domain/src/__tests__/layer-boundaries.test.ts`) so it
|
||||||
|
runs automatically as part of `vitest run` inside `pnpm check`.
|
||||||
|
No separate `check:layer` script is needed — the layer boundary
|
||||||
|
check is guaranteed to execute on every merge-gate run.
|
||||||
|
|
||||||
|
### Biome Configuration (biome.json)
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
|
||||||
|
"organizeImports": { "enabled": true },
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "tab",
|
||||||
|
"lineWidth": 80
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### TypeScript Configuration
|
||||||
|
|
||||||
|
- `tsconfig.base.json` at root: strict mode, composite projects,
|
||||||
|
path aliases (`@initiative/domain`, `@initiative/application`).
|
||||||
|
- Each package extends `tsconfig.base.json` with its own
|
||||||
|
`include`/`references`.
|
||||||
|
- `apps/web/tsconfig.json` references both packages.
|
||||||
|
|
||||||
|
## Milestones
|
||||||
|
|
||||||
|
### Milestone 1: Tooling & Domain (walking skeleton)
|
||||||
|
|
||||||
|
Stand up monorepo, Biome, Vitest, TypeScript project references,
|
||||||
|
and implement the complete AdvanceTurn domain logic with all tests.
|
||||||
|
|
||||||
|
**Exit criteria**: `pnpm check` passes. All 8 acceptance scenarios
|
||||||
|
green. Layer boundary check green. No React or Vite dependencies in
|
||||||
|
`packages/domain` or `packages/application`.
|
||||||
|
|
||||||
|
### Milestone 2: Application + Minimal Web Shell
|
||||||
|
|
||||||
|
Wire up the application use case and a minimal React UI that
|
||||||
|
displays the encounter state and has a single "Next Turn" button.
|
||||||
|
|
||||||
|
**Exit criteria**: `pnpm check` passes. Clicking "Next Turn" in the
|
||||||
|
browser advances the turn with correct round wrapping. The app
|
||||||
|
builds with `vite build`.
|
||||||
|
|
||||||
|
## Task List
|
||||||
|
|
||||||
|
### Phase 1: Setup (Milestone 1)
|
||||||
|
|
||||||
|
- [X] **T001** Initialize pnpm workspace and root config
|
||||||
|
- Create `pnpm-workspace.yaml` listing `packages/*` and `apps/*`
|
||||||
|
- Create `.nvmrc` pinning Node 22
|
||||||
|
- Create root `package.json` with `packageManager` field pinning
|
||||||
|
pnpm 10.6 and scripts (check, test, lint, format, typecheck)
|
||||||
|
- Create `biome.json` at root
|
||||||
|
- Create `tsconfig.base.json` (strict, composite, path aliases)
|
||||||
|
- **Acceptance**: `pnpm install` succeeds; `biome check .` runs
|
||||||
|
without config errors
|
||||||
|
|
||||||
|
- [X] **T002** [P] Create `packages/domain` package skeleton
|
||||||
|
- `package.json` (name: `@initiative/domain`, no dependencies)
|
||||||
|
- `tsconfig.json` extending base, composite: true
|
||||||
|
- Empty `src/index.ts`
|
||||||
|
- **Acceptance**: `tsc --build packages/domain` succeeds
|
||||||
|
|
||||||
|
- [X] **T003** [P] Create `packages/application` package skeleton
|
||||||
|
- `package.json` (name: `@initiative/application`,
|
||||||
|
depends on `@initiative/domain`)
|
||||||
|
- `tsconfig.json` extending base, references domain
|
||||||
|
- Empty `src/index.ts`
|
||||||
|
- **Acceptance**: `tsc --build packages/application` succeeds
|
||||||
|
|
||||||
|
- [X] **T004** [P] Create `apps/web` package skeleton
|
||||||
|
- `package.json` with React, Vite, depends on both packages
|
||||||
|
- `tsconfig.json` referencing both packages
|
||||||
|
- `vite.config.ts` (minimal)
|
||||||
|
- `index.html` + `src/main.tsx` + `src/App.tsx` (placeholder)
|
||||||
|
- **Acceptance**: `pnpm --filter web dev` starts; `vite build`
|
||||||
|
succeeds
|
||||||
|
|
||||||
|
- [X] **T005** Configure Vitest
|
||||||
|
- Add `vitest` as root dev dependency
|
||||||
|
- Create `vitest.config.ts` at root (workspace mode) or per
|
||||||
|
package as needed
|
||||||
|
- Verify `pnpm test` runs (0 tests, exits clean)
|
||||||
|
- **Acceptance**: `pnpm test` exits 0
|
||||||
|
|
||||||
|
- [X] **T006** Create layer boundary check script
|
||||||
|
- `scripts/check-layer-boundaries.mjs`: scans domain and
|
||||||
|
application source for forbidden imports
|
||||||
|
- `packages/domain/src/__tests__/layer-boundaries.test.ts`:
|
||||||
|
wraps the script as a Vitest test
|
||||||
|
- **Acceptance**: test passes on clean skeleton; fails if a
|
||||||
|
forbidden import is manually added (verify, then remove)
|
||||||
|
|
||||||
|
### Phase 2: Domain Implementation (Milestone 1)
|
||||||
|
|
||||||
|
- [ ] **T007** Define domain types in `packages/domain/src/types.ts`
|
||||||
|
- `CombatantId` (branded string or opaque type)
|
||||||
|
- `Combatant` (carries a CombatantId)
|
||||||
|
- `Encounter` (combatants array, activeIndex, roundNumber)
|
||||||
|
- Factory function `createEncounter` that validates INV-1, INV-2,
|
||||||
|
INV-3
|
||||||
|
- **Acceptance**: types compile; `createEncounter([])` returns
|
||||||
|
error; `createEncounter([a])` returns valid Encounter
|
||||||
|
|
||||||
|
- [ ] **T008** [P] Define domain events in
|
||||||
|
`packages/domain/src/events.ts`
|
||||||
|
- `TurnAdvanced { previousCombatantId, newCombatantId,
|
||||||
|
roundNumber }`
|
||||||
|
- `RoundAdvanced { newRoundNumber }`
|
||||||
|
- `DomainEvent = TurnAdvanced | RoundAdvanced`
|
||||||
|
- **Acceptance**: types compile; events are plain data (no
|
||||||
|
classes with methods)
|
||||||
|
|
||||||
|
- [ ] **T009** Implement `advanceTurn` in
|
||||||
|
`packages/domain/src/advance-turn.ts`
|
||||||
|
- Signature: `(encounter: Encounter) =>
|
||||||
|
{ encounter: Encounter; events: DomainEvent[] } | DomainError`
|
||||||
|
- Implements FR-001 through FR-005
|
||||||
|
- Returns error for empty combatant list (INV-1)
|
||||||
|
- Emits TurnAdvanced on every call (INV-5)
|
||||||
|
- Emits TurnAdvanced then RoundAdvanced on wrap (event order
|
||||||
|
contract)
|
||||||
|
- **Acceptance**: compiles; satisfies type contract
|
||||||
|
|
||||||
|
- [ ] **T010** Write tests for all 8 acceptance scenarios +
|
||||||
|
invariants in
|
||||||
|
`packages/domain/src/__tests__/advance-turn.test.ts`
|
||||||
|
- Scenarios 1–8 from spec (Given/When/Then)
|
||||||
|
- INV-1: empty encounter rejected
|
||||||
|
- INV-2: activeIndex always in bounds (property check across
|
||||||
|
scenarios)
|
||||||
|
- INV-3: roundNumber never decreases
|
||||||
|
- INV-4: determinism — same input produces same output (call
|
||||||
|
twice, assert deep equal)
|
||||||
|
- INV-5: every success emits at least TurnAdvanced
|
||||||
|
- Event ordering: on wrap, events array is
|
||||||
|
[TurnAdvanced, RoundAdvanced] in that order
|
||||||
|
- **Acceptance**: `pnpm test` — all tests green; `pnpm check` —
|
||||||
|
full pipeline green
|
||||||
|
|
||||||
|
- [ ] **T011** Export public API from `packages/domain/src/index.ts`
|
||||||
|
- Re-export types, events, `advanceTurn`, `createEncounter`
|
||||||
|
- **Acceptance**: consuming packages can
|
||||||
|
`import { advanceTurn } from "@initiative/domain"`
|
||||||
|
|
||||||
|
**Milestone 1 checkpoint**: `pnpm check` passes (format + lint +
|
||||||
|
typecheck + test + layer boundaries). All 8 scenarios + invariants
|
||||||
|
green.
|
||||||
|
|
||||||
|
### Phase 3: Application + Web Shell (Milestone 2)
|
||||||
|
|
||||||
|
- [ ] **T012** Define port interface in
|
||||||
|
`packages/application/src/ports.ts`
|
||||||
|
- `EncounterStore` port: `get(): Encounter`, `save(e: Encounter)`
|
||||||
|
- **Acceptance**: compiles; no imports from adapters or React
|
||||||
|
|
||||||
|
- [ ] **T013** Implement `AdvanceTurnUseCase` in
|
||||||
|
`packages/application/src/advance-turn-use-case.ts`
|
||||||
|
- Accepts `EncounterStore` port
|
||||||
|
- Calls `advanceTurn` from domain, saves result, returns events
|
||||||
|
- **Acceptance**: compiles; imports only from `@initiative/domain`
|
||||||
|
and local ports
|
||||||
|
|
||||||
|
- [ ] **T014** Export public API from
|
||||||
|
`packages/application/src/index.ts`
|
||||||
|
- Re-export use case and port types
|
||||||
|
- **Acceptance**: consuming app can import from
|
||||||
|
`@initiative/application`
|
||||||
|
|
||||||
|
- [ ] **T015** Implement `useEncounter` hook in
|
||||||
|
`apps/web/src/hooks/use-encounter.ts`
|
||||||
|
- In-memory implementation of `EncounterStore` port (React state)
|
||||||
|
- Exposes current encounter state + `advanceTurn` action
|
||||||
|
- Initializes with a hardcoded 3-combatant encounter for demo
|
||||||
|
- **Acceptance**: hook compiles; usable in a React component
|
||||||
|
|
||||||
|
- [ ] **T016** Wire up `App.tsx`
|
||||||
|
- Display: current combatant name, round number, combatant list
|
||||||
|
with active indicator
|
||||||
|
- Single "Next Turn" button calling the use case
|
||||||
|
- Display emitted events (optional, for demo clarity)
|
||||||
|
- **Acceptance**: `vite build` succeeds; clicking "Next Turn"
|
||||||
|
cycles through combatants and increments rounds correctly
|
||||||
|
|
||||||
|
**Milestone 2 checkpoint**: `pnpm check` passes. App runs in
|
||||||
|
browser. Full round-trip from button click → domain pure function →
|
||||||
|
UI update verified manually.
|
||||||
|
|
||||||
|
## Risks & Open Questions
|
||||||
|
|
||||||
|
| # | Item | Severity | Mitigation |
|
||||||
|
|---|------|----------|------------|
|
||||||
|
| 1 | pnpm workspace + TypeScript project references can have path resolution quirks with Vite | Low | Use `vite-tsconfig-paths` plugin if needed; test early in T004 |
|
||||||
|
| 2 | Biome config format may change across versions | Low | Pinned to exact 2.0.0; `$schema` in config validates structure |
|
||||||
|
| 3 | Layer boundary script is a lightweight grep — not a full architectural fitness function | Low | Sufficient for walking skeleton; can upgrade to a Biome plugin or `dependency-cruiser` later if needed |
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
No constitution violations. No complexity justifications needed.
|
||||||
160
specs/001-advance-turn/spec.md
Normal file
160
specs/001-advance-turn/spec.md
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
# Feature Specification: Advance Turn
|
||||||
|
|
||||||
|
**Feature Branch**: `001-advance-turn`
|
||||||
|
**Created**: 2026-03-03
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: Walking-skeleton domain feature — deterministic turn advancement
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Advance Turn (Priority: P1)
|
||||||
|
|
||||||
|
A game master running an encounter advances the turn to the next
|
||||||
|
combatant in initiative order. When the last combatant in the round
|
||||||
|
finishes, the round number increments and play wraps to the first
|
||||||
|
combatant.
|
||||||
|
|
||||||
|
**Why this priority**: This is the irreducible core of an initiative
|
||||||
|
tracker. Without turn advancement, no other feature has meaning.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested as a pure state transition
|
||||||
|
with no I/O, persistence, or UI. Given an Encounter value and an
|
||||||
|
AdvanceTurn action, assert the resulting Encounter value and emitted
|
||||||
|
domain events.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an encounter with combatants [A, B, C], activeIndex 0,
|
||||||
|
roundNumber 1,
|
||||||
|
**When** AdvanceTurn,
|
||||||
|
**Then** activeIndex is 1, roundNumber is 1,
|
||||||
|
and a TurnAdvanced event is emitted with
|
||||||
|
previousCombatantId A, newCombatantId B, roundNumber 1.
|
||||||
|
|
||||||
|
2. **Given** an encounter with combatants [A, B, C], activeIndex 1,
|
||||||
|
roundNumber 1,
|
||||||
|
**When** AdvanceTurn,
|
||||||
|
**Then** activeIndex is 2, roundNumber is 1,
|
||||||
|
and a TurnAdvanced event is emitted with
|
||||||
|
previousCombatantId B, newCombatantId C, roundNumber 1.
|
||||||
|
|
||||||
|
3. **Given** an encounter with combatants [A, B, C], activeIndex 2,
|
||||||
|
roundNumber 1,
|
||||||
|
**When** AdvanceTurn,
|
||||||
|
**Then** activeIndex is 0, roundNumber is 2,
|
||||||
|
and events are emitted in order: TurnAdvanced
|
||||||
|
(previousCombatantId C, newCombatantId A, roundNumber 2) then
|
||||||
|
RoundAdvanced (newRoundNumber 2).
|
||||||
|
|
||||||
|
4. **Given** an encounter with combatants [A, B, C], activeIndex 2,
|
||||||
|
roundNumber 5,
|
||||||
|
**When** AdvanceTurn,
|
||||||
|
**Then** activeIndex is 0, roundNumber is 6,
|
||||||
|
and events are emitted in order: TurnAdvanced then RoundAdvanced
|
||||||
|
(verifies round increment is not hardcoded to 2).
|
||||||
|
|
||||||
|
5. **Given** an encounter with a single combatant [A], activeIndex 0,
|
||||||
|
roundNumber 1,
|
||||||
|
**When** AdvanceTurn,
|
||||||
|
**Then** activeIndex is 0, roundNumber is 2,
|
||||||
|
and events are emitted in order: TurnAdvanced
|
||||||
|
(previousCombatantId A, newCombatantId A, roundNumber 2) then
|
||||||
|
RoundAdvanced (newRoundNumber 2).
|
||||||
|
|
||||||
|
6. **Given** an encounter with combatants [A, B], activeIndex 0,
|
||||||
|
roundNumber 1,
|
||||||
|
**When** AdvanceTurn is applied twice in sequence,
|
||||||
|
**Then** after the first: activeIndex 1, roundNumber 1;
|
||||||
|
after the second: activeIndex 0, roundNumber 2.
|
||||||
|
|
||||||
|
7. **Given** an encounter with an empty combatant list,
|
||||||
|
**When** AdvanceTurn,
|
||||||
|
**Then** the operation MUST fail with an invalid-encounter error.
|
||||||
|
No events are emitted. State is unchanged.
|
||||||
|
|
||||||
|
8. **Given** an encounter with combatants [A, B, C], activeIndex 0,
|
||||||
|
roundNumber 1,
|
||||||
|
**When** AdvanceTurn is applied three times,
|
||||||
|
**Then** the encounter completes a full round cycle:
|
||||||
|
activeIndex returns to 0 and roundNumber is 2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- Empty combatant list: AdvanceTurn MUST reject with an error.
|
||||||
|
- Single combatant: every advance wraps and increments the round.
|
||||||
|
- Large round numbers: no overflow or special-case behavior; round
|
||||||
|
increments uniformly.
|
||||||
|
|
||||||
|
## Domain Model *(mandatory)*
|
||||||
|
|
||||||
|
### Key Entities
|
||||||
|
|
||||||
|
- **Combatant**: An identified participant in the encounter. For this
|
||||||
|
feature, a combatant is an opaque identity (e.g., a name or id).
|
||||||
|
The MVP baseline does not include HP, conditions, or stats.
|
||||||
|
- **Encounter**: The aggregate root. Contains an ordered list of
|
||||||
|
combatants (pre-sorted by initiative), an activeIndex pointing to
|
||||||
|
the current combatant, and a roundNumber (positive integer,
|
||||||
|
starting at 1).
|
||||||
|
|
||||||
|
### Domain Events
|
||||||
|
|
||||||
|
- **TurnAdvanced**: Emitted on every successful AdvanceTurn.
|
||||||
|
Carries: previousCombatantId, newCombatantId, roundNumber.
|
||||||
|
- **RoundAdvanced**: Emitted when activeIndex wraps past the last
|
||||||
|
combatant. Carries: newRoundNumber.
|
||||||
|
|
||||||
|
When a round boundary is crossed, both TurnAdvanced and
|
||||||
|
RoundAdvanced MUST be emitted in that order (TurnAdvanced first).
|
||||||
|
This emission order is part of the observable domain contract and
|
||||||
|
MUST be verified by tests.
|
||||||
|
|
||||||
|
### Invariants
|
||||||
|
|
||||||
|
- **INV-1**: An encounter MUST have at least one combatant.
|
||||||
|
Operations on an empty encounter MUST fail.
|
||||||
|
- **INV-2**: activeIndex MUST always satisfy
|
||||||
|
0 <= activeIndex < len(combatants).
|
||||||
|
- **INV-3**: roundNumber MUST be a positive integer (>= 1) and MUST
|
||||||
|
only increase (never decrease or reset).
|
||||||
|
- **INV-4**: AdvanceTurn MUST be a pure function of the current
|
||||||
|
encounter state. Given identical input, output MUST be identical.
|
||||||
|
- **INV-5**: Every successful AdvanceTurn MUST emit at least one
|
||||||
|
domain event (TurnAdvanced). No silent state changes.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: The domain MUST expose an AdvanceTurn operation that
|
||||||
|
accepts an Encounter and returns the next Encounter state plus
|
||||||
|
emitted domain events.
|
||||||
|
- **FR-002**: AdvanceTurn MUST increment activeIndex by 1, wrapping
|
||||||
|
to 0 when past the last combatant.
|
||||||
|
- **FR-003**: When activeIndex wraps to 0, roundNumber MUST
|
||||||
|
increment by 1.
|
||||||
|
- **FR-004**: AdvanceTurn on an empty encounter MUST return an error
|
||||||
|
without modifying state or emitting events.
|
||||||
|
- **FR-005**: Domain events MUST be returned as values from the
|
||||||
|
operation, not dispatched via side effects.
|
||||||
|
|
||||||
|
### Out of Scope (MVP baseline does not include)
|
||||||
|
|
||||||
|
- Initiative rolling or combatant ordering logic
|
||||||
|
- Hit points, damage, conditions, or status effects
|
||||||
|
- Adding or removing combatants mid-encounter
|
||||||
|
- Persistence, serialization, or storage
|
||||||
|
- UI, CLI, or any adapter layer
|
||||||
|
- Agent behavior or suggestions
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: All 8 acceptance scenarios pass as deterministic,
|
||||||
|
pure-function tests with no I/O dependencies.
|
||||||
|
- **SC-002**: Invariants INV-1 through INV-5 are verified by tests.
|
||||||
|
- **SC-003**: The domain module has zero imports from application,
|
||||||
|
adapter, or agent layers (layer boundary compliance).
|
||||||
128
specs/001-advance-turn/tasks.md
Normal file
128
specs/001-advance-turn/tasks.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# Tasks: Advance Turn
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/001-advance-turn/`
|
||||||
|
**Prerequisites**: plan.md (required), spec.md (required)
|
||||||
|
|
||||||
|
**Organization**: Tasks follow the phased structure from plan.md. There is only one user story (US1 — Advance Turn, P1), so phases map directly to the plan's milestones.
|
||||||
|
|
||||||
|
## Format: `[ID] [P?] [Story?] Description`
|
||||||
|
|
||||||
|
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||||
|
- **[US1]**: User Story 1 — Advance Turn
|
||||||
|
- Exact file paths included in every task
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Setup (Milestone 1 — Tooling)
|
||||||
|
|
||||||
|
**Purpose**: Initialize pnpm monorepo, Biome, TypeScript, Vitest, and layer boundary enforcement
|
||||||
|
|
||||||
|
- [X] T001 Initialize pnpm workspace and root config — create `pnpm-workspace.yaml`, `.nvmrc` (Node 22), root `package.json` (with `packageManager` pinning pnpm 10.6 and scripts: check, test, lint, format, typecheck), `biome.json`, and `tsconfig.base.json` (strict, composite, path aliases)
|
||||||
|
- [X] T002 [P] Create `packages/domain` package skeleton — `packages/domain/package.json` (`@initiative/domain`, no deps), `packages/domain/tsconfig.json` (extends base, composite), empty `packages/domain/src/index.ts`
|
||||||
|
- [X] T003 [P] Create `packages/application` package skeleton — `packages/application/package.json` (`@initiative/application`, depends on `@initiative/domain`), `packages/application/tsconfig.json` (extends base, references domain), empty `packages/application/src/index.ts`
|
||||||
|
- [X] T004 [P] Create `apps/web` package skeleton — `apps/web/package.json` (React 19, Vite 6.2, depends on both packages), `apps/web/tsconfig.json`, `apps/web/vite.config.ts`, `apps/web/index.html`, `apps/web/src/main.tsx`, `apps/web/src/App.tsx` (placeholder)
|
||||||
|
- [X] T005 Configure Vitest — add `vitest` as root dev dependency, create `vitest.config.ts` at root (workspace mode or per-package), verify `pnpm test` exits 0
|
||||||
|
- [X] T006 Create layer boundary check — `scripts/check-layer-boundaries.mjs` (scans domain/application for forbidden imports) and `packages/domain/src/__tests__/layer-boundaries.test.ts` (wraps script as Vitest test)
|
||||||
|
|
||||||
|
**Checkpoint**: `pnpm install` succeeds, `biome check .` runs, `tsc --build` compiles, `pnpm test` exits 0 with layer boundary test green.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Domain Implementation — User Story 1: Advance Turn (Priority: P1) (Milestone 1)
|
||||||
|
|
||||||
|
**Goal**: Implement the complete AdvanceTurn domain logic as a pure function with all 8 acceptance scenarios and invariant tests.
|
||||||
|
|
||||||
|
**Independent Test**: Pure state transition — given an Encounter value and AdvanceTurn action, assert resulting Encounter and emitted domain events. No I/O, persistence, or UI needed.
|
||||||
|
|
||||||
|
- [ ] T007 [US1] Define domain types in `packages/domain/src/types.ts` — `CombatantId` (branded/opaque), `Combatant`, `Encounter` (combatants, activeIndex, roundNumber), factory `createEncounter` enforcing INV-1, INV-2, INV-3
|
||||||
|
- [ ] T008 [P] [US1] Define domain events in `packages/domain/src/events.ts` — `TurnAdvanced`, `RoundAdvanced`, `DomainEvent` union (plain data, no classes)
|
||||||
|
- [ ] T009 [US1] Implement `advanceTurn` in `packages/domain/src/advance-turn.ts` — pure function `(Encounter) => { encounter, events } | DomainError`, implements FR-001 through FR-005
|
||||||
|
- [ ] T010 [US1] Write tests for all 8 acceptance scenarios + invariants in `packages/domain/src/__tests__/advance-turn.test.ts` — scenarios 1–8, INV-1 through INV-5, event ordering on round wrap
|
||||||
|
- [ ] T011 [US1] Export public API from `packages/domain/src/index.ts` — re-export types, events, `advanceTurn`, `createEncounter`
|
||||||
|
|
||||||
|
**Checkpoint (Milestone 1)**: `pnpm check` passes (format + lint + typecheck + test + layer boundaries). All 8 scenarios + invariants green. No React/Vite imports in domain or application.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Application + Web Shell (Milestone 2)
|
||||||
|
|
||||||
|
**Goal**: Wire up the application use case and minimal React UI with a "Next Turn" button.
|
||||||
|
|
||||||
|
- [ ] T012 Define port interface in `packages/application/src/ports.ts` — `EncounterStore` port: `get(): Encounter`, `save(e: Encounter)`
|
||||||
|
- [ ] T013 Implement `AdvanceTurnUseCase` in `packages/application/src/advance-turn-use-case.ts` — accepts `EncounterStore`, calls `advanceTurn`, saves result, returns events
|
||||||
|
- [ ] T014 Export public API from `packages/application/src/index.ts` — re-export use case and port types
|
||||||
|
- [ ] T015 Implement `useEncounter` hook in `apps/web/src/hooks/use-encounter.ts` — in-memory `EncounterStore` via React state, exposes encounter state + `advanceTurn` action, hardcoded 3-combatant demo
|
||||||
|
- [ ] T016 Wire up `apps/web/src/App.tsx` — display current combatant, round number, combatant list with active indicator, "Next Turn" button, emitted events
|
||||||
|
|
||||||
|
**Checkpoint (Milestone 2)**: `pnpm check` passes. `vite build` succeeds. Clicking "Next Turn" cycles combatants and increments rounds correctly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Phase 1 (Setup)**: No dependencies — start immediately
|
||||||
|
- **Phase 2 (Domain)**: Depends on Phase 1 completion
|
||||||
|
- **Phase 3 (App + Web)**: Depends on Phase 2 completion (needs domain types and `advanceTurn`)
|
||||||
|
|
||||||
|
### Within Phase 1
|
||||||
|
|
||||||
|
- T001 must complete first (workspace and root config)
|
||||||
|
- T002, T003, T004 can run in parallel [P] after T001
|
||||||
|
- T005 depends on T001 (needs root package.json)
|
||||||
|
- T006 depends on T002 and T005 (needs domain package + Vitest)
|
||||||
|
|
||||||
|
### Within Phase 2
|
||||||
|
|
||||||
|
- T007 must complete first (types needed by everything)
|
||||||
|
- T008 can run in parallel [P] with T007 (events are independent types)
|
||||||
|
- T009 depends on T007 and T008 (uses types and events)
|
||||||
|
- T010 depends on T009 (tests the implementation)
|
||||||
|
- T011 depends on T007, T008, T009 (exports all public API)
|
||||||
|
|
||||||
|
### Within Phase 3
|
||||||
|
|
||||||
|
- T012 first (port interface)
|
||||||
|
- T013 depends on T012 (uses port)
|
||||||
|
- T014 depends on T013 (exports use case)
|
||||||
|
- T015 depends on T014 (uses application layer)
|
||||||
|
- T016 depends on T015 (uses hook)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Opportunities
|
||||||
|
|
||||||
|
```text
|
||||||
|
# After T001 completes:
|
||||||
|
T002, T003, T004 — all package skeletons in parallel
|
||||||
|
|
||||||
|
# After T007 starts:
|
||||||
|
T008 — domain events can be written in parallel with types
|
||||||
|
|
||||||
|
# Independent stories: only one user story (US1), so parallelism is within-phase only
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (Milestone 1)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Setup (T001–T006)
|
||||||
|
2. Complete Phase 2: Domain (T007–T011)
|
||||||
|
3. **STOP and VALIDATE**: `pnpm check` passes, all 8 scenarios green
|
||||||
|
|
||||||
|
### Full Feature (Milestone 2)
|
||||||
|
|
||||||
|
4. Complete Phase 3: App + Web Shell (T012–T016)
|
||||||
|
5. **VALIDATE**: `pnpm check` passes, app runs in browser
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All task IDs (T001–T016) match plan.md — no scope expansion
|
||||||
|
- Single user story (US1: Advance Turn) — no cross-story dependencies
|
||||||
|
- Tests (T010) are included as specified in plan.md and spec.md
|
||||||
|
- Domain package must have zero React/Vite imports (enforced by T006)
|
||||||
17
tsconfig.base.json
Normal file
17
tsconfig.base.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"composite": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"verbatimModuleSyntax": true
|
||||||
|
}
|
||||||
|
}
|
||||||
8
tsconfig.json
Normal file
8
tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "packages/domain" },
|
||||||
|
{ "path": "packages/application" },
|
||||||
|
{ "path": "apps/web" }
|
||||||
|
]
|
||||||
|
}
|
||||||
8
vitest.config.ts
Normal file
8
vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ["packages/*/src/**/*.test.ts"],
|
||||||
|
passWithNoTests: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user