code.pablomurad.com

the-last-glyph

Turn-based narrative RPG about a world that lost its names, drawn entirely in emoji

first commit
author
Pablo Murad
date
commit
8ffb9343d32ed634108c6303ddc0ff812f791e14
changes
99 files +13429 −0
@@ -0,0 +1,45 @@+# Dependencies
+node_modules/
+
+# Build output
+dist/
+
+# Vite / Vitest cache
+.vite/
+*.local
+
+# TypeScript
+*.tsbuildinfo
+
+# Test coverage
+coverage/
+
+# Environment variables
+.env
+.env.*
+!.env.example
+
+# Logs
+logs/
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+
+# OS files
+.DS_Store
+Thumbs.db
+Desktop.ini
+
+# Editor / IDE
+.vscode/*
+!.vscode/extensions.json
+!.vscode/settings.json
+.idea/
+*.swp
+*.swo
+*~
+
+# Cursor
+.cursor/
@@ -0,0 +1,26 @@+# The Last Glyph
+
+A dark, melancholic turn-based narrative RPG about a world that lost its names. Built with Vite + TypeScript, DOM rendering, and emoji as the entire visual language — no frameworks, no runtime dependencies.
+
+## Run
+
+```bash
+npm install
+npm run dev      # local dev server
+npm run build    # production build to dist/
+npm run preview  # preview the production build
+```
+
+## Checks
+
+```bash
+npm run typecheck  # tsc --noEmit
+npm run test       # vitest run
+```
+
+
+## Authorship
+
+Created by Pablo Murad — pablomurad@pm.me
+
+Copyright © 2026 Pablo Murad. All rights reserved.
@@ -0,0 +1,14 @@+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <meta name="description" content="The Last Glyph — a dark, melancholic turn-based narrative RPG about a world that lost its names." />
+    <meta name="author" content="Pablo Murad" />
+    <title>The Last Glyph</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>
@@ -0,0 +1,1476 @@+{
+  "name": "the-last-glyph",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "the-last-glyph",
+      "version": "0.1.0",
+      "devDependencies": {
+        "typescript": "^5.6.3",
+        "vite": "^5.4.11",
+        "vitest": "^2.1.8"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+      "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+      "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+      "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+      "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+      "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+      "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+      "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+      "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+      "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+      "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+      "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+      "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+      "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+      "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+      "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+      "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+      "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+      "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+      "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+      "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+      "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+      "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+      "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@rollup/rollup-android-arm-eabi": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz",
+      "integrity": "sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-android-arm64": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.3.tgz",
+      "integrity": "sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-arm64": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.3.tgz",
+      "integrity": "sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-x64": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.3.tgz",
+      "integrity": "sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-arm64": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.3.tgz",
+      "integrity": "sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-x64": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.3.tgz",
+      "integrity": "sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.3.tgz",
+      "integrity": "sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.3.tgz",
+      "integrity": "sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-gnu": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.3.tgz",
+      "integrity": "sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-musl": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.3.tgz",
+      "integrity": "sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-gnu": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.3.tgz",
+      "integrity": "sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-musl": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.3.tgz",
+      "integrity": "sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.3.tgz",
+      "integrity": "sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-musl": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.3.tgz",
+      "integrity": "sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.3.tgz",
+      "integrity": "sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-musl": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.3.tgz",
+      "integrity": "sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-s390x-gnu": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.3.tgz",
+      "integrity": "sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-gnu": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.3.tgz",
+      "integrity": "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-musl": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.3.tgz",
+      "integrity": "sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-openbsd-x64": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.3.tgz",
+      "integrity": "sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-openharmony-arm64": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.3.tgz",
+      "integrity": "sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-arm64-msvc": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.3.tgz",
+      "integrity": "sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-ia32-msvc": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.3.tgz",
+      "integrity": "sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-gnu": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.3.tgz",
+      "integrity": "sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-msvc": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.3.tgz",
+      "integrity": "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.9",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+      "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@vitest/expect": {
+      "version": "2.1.9",
+      "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
+      "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/spy": "2.1.9",
+        "@vitest/utils": "2.1.9",
+        "chai": "^5.1.2",
+        "tinyrainbow": "^1.2.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/mocker": {
+      "version": "2.1.9",
+      "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz",
+      "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/spy": "2.1.9",
+        "estree-walker": "^3.0.3",
+        "magic-string": "^0.30.12"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      },
+      "peerDependencies": {
+        "msw": "^2.4.9",
+        "vite": "^5.0.0"
+      },
+      "peerDependenciesMeta": {
+        "msw": {
+          "optional": true
+        },
+        "vite": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@vitest/pretty-format": {
+      "version": "2.1.9",
+      "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz",
+      "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tinyrainbow": "^1.2.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/runner": {
+      "version": "2.1.9",
+      "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz",
+      "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/utils": "2.1.9",
+        "pathe": "^1.1.2"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/snapshot": {
+      "version": "2.1.9",
+      "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz",
+      "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/pretty-format": "2.1.9",
+        "magic-string": "^0.30.12",
+        "pathe": "^1.1.2"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/spy": {
+      "version": "2.1.9",
+      "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz",
+      "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tinyspy": "^3.0.2"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/utils": {
+      "version": "2.1.9",
+      "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz",
+      "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/pretty-format": "2.1.9",
+        "loupe": "^3.1.2",
+        "tinyrainbow": "^1.2.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/assertion-error": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+      "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/cac": {
+      "version": "6.7.14",
+      "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+      "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/chai": {
+      "version": "5.3.3",
+      "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
+      "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "assertion-error": "^2.0.1",
+        "check-error": "^2.1.1",
+        "deep-eql": "^5.0.1",
+        "loupe": "^3.1.0",
+        "pathval": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/check-error": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
+      "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 16"
+      }
+    },
+    "node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/deep-eql": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
+      "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/es-module-lexer": {
+      "version": "1.7.0",
+      "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+      "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/esbuild": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+      "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.21.5",
+        "@esbuild/android-arm": "0.21.5",
+        "@esbuild/android-arm64": "0.21.5",
+        "@esbuild/android-x64": "0.21.5",
+        "@esbuild/darwin-arm64": "0.21.5",
+        "@esbuild/darwin-x64": "0.21.5",
+        "@esbuild/freebsd-arm64": "0.21.5",
+        "@esbuild/freebsd-x64": "0.21.5",
+        "@esbuild/linux-arm": "0.21.5",
+        "@esbuild/linux-arm64": "0.21.5",
+        "@esbuild/linux-ia32": "0.21.5",
+        "@esbuild/linux-loong64": "0.21.5",
+        "@esbuild/linux-mips64el": "0.21.5",
+        "@esbuild/linux-ppc64": "0.21.5",
+        "@esbuild/linux-riscv64": "0.21.5",
+        "@esbuild/linux-s390x": "0.21.5",
+        "@esbuild/linux-x64": "0.21.5",
+        "@esbuild/netbsd-x64": "0.21.5",
+        "@esbuild/openbsd-x64": "0.21.5",
+        "@esbuild/sunos-x64": "0.21.5",
+        "@esbuild/win32-arm64": "0.21.5",
+        "@esbuild/win32-ia32": "0.21.5",
+        "@esbuild/win32-x64": "0.21.5"
+      }
+    },
+    "node_modules/estree-walker": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+      "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "^1.0.0"
+      }
+    },
+    "node_modules/expect-type": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+      "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=12.0.0"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/loupe": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
+      "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/magic-string": {
+      "version": "0.30.21",
+      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+      "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.5"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.16",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
+      "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/pathe": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
+      "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/pathval": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
+      "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 14.16"
+      }
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/postcss": {
+      "version": "8.5.25",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
+      "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.16",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/rollup": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.3.tgz",
+      "integrity": "sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "1.0.9"
+      },
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
+      "engines": {
+        "node": ">=18.0.0",
+        "npm": ">=8.0.0"
+      },
+      "optionalDependencies": {
+        "@rollup/rollup-android-arm-eabi": "4.62.3",
+        "@rollup/rollup-android-arm64": "4.62.3",
+        "@rollup/rollup-darwin-arm64": "4.62.3",
+        "@rollup/rollup-darwin-x64": "4.62.3",
+        "@rollup/rollup-freebsd-arm64": "4.62.3",
+        "@rollup/rollup-freebsd-x64": "4.62.3",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.62.3",
+        "@rollup/rollup-linux-arm-musleabihf": "4.62.3",
+        "@rollup/rollup-linux-arm64-gnu": "4.62.3",
+        "@rollup/rollup-linux-arm64-musl": "4.62.3",
+        "@rollup/rollup-linux-loong64-gnu": "4.62.3",
+        "@rollup/rollup-linux-loong64-musl": "4.62.3",
+        "@rollup/rollup-linux-ppc64-gnu": "4.62.3",
+        "@rollup/rollup-linux-ppc64-musl": "4.62.3",
+        "@rollup/rollup-linux-riscv64-gnu": "4.62.3",
+        "@rollup/rollup-linux-riscv64-musl": "4.62.3",
+        "@rollup/rollup-linux-s390x-gnu": "4.62.3",
+        "@rollup/rollup-linux-x64-gnu": "4.62.3",
+        "@rollup/rollup-linux-x64-musl": "4.62.3",
+        "@rollup/rollup-openbsd-x64": "4.62.3",
+        "@rollup/rollup-openharmony-arm64": "4.62.3",
+        "@rollup/rollup-win32-arm64-msvc": "4.62.3",
+        "@rollup/rollup-win32-ia32-msvc": "4.62.3",
+        "@rollup/rollup-win32-x64-gnu": "4.62.3",
+        "@rollup/rollup-win32-x64-msvc": "4.62.3",
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/siginfo": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+      "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/stackback": {
+      "version": "0.0.2",
+      "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+      "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/std-env": {
+      "version": "3.10.0",
+      "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+      "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/tinybench": {
+      "version": "2.9.0",
+      "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+      "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/tinyexec": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
+      "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/tinypool": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
+      "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      }
+    },
+    "node_modules/tinyrainbow": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz",
+      "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/tinyspy": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
+      "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/typescript": {
+      "version": "5.9.3",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/vite": {
+      "version": "5.4.21",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+      "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "^0.21.3",
+        "postcss": "^8.4.43",
+        "rollup": "^4.20.0"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^18.0.0 || >=20.0.0",
+        "less": "*",
+        "lightningcss": "^1.21.0",
+        "sass": "*",
+        "sass-embedded": "*",
+        "stylus": "*",
+        "sugarss": "*",
+        "terser": "^5.4.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "lightningcss": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vite-node": {
+      "version": "2.1.9",
+      "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz",
+      "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "cac": "^6.7.14",
+        "debug": "^4.3.7",
+        "es-module-lexer": "^1.5.4",
+        "pathe": "^1.1.2",
+        "vite": "^5.0.0"
+      },
+      "bin": {
+        "vite-node": "vite-node.mjs"
+      },
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/vitest": {
+      "version": "2.1.9",
+      "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz",
+      "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/expect": "2.1.9",
+        "@vitest/mocker": "2.1.9",
+        "@vitest/pretty-format": "^2.1.9",
+        "@vitest/runner": "2.1.9",
+        "@vitest/snapshot": "2.1.9",
+        "@vitest/spy": "2.1.9",
+        "@vitest/utils": "2.1.9",
+        "chai": "^5.1.2",
+        "debug": "^4.3.7",
+        "expect-type": "^1.1.0",
+        "magic-string": "^0.30.12",
+        "pathe": "^1.1.2",
+        "std-env": "^3.8.0",
+        "tinybench": "^2.9.0",
+        "tinyexec": "^0.3.1",
+        "tinypool": "^1.0.1",
+        "tinyrainbow": "^1.2.0",
+        "vite": "^5.0.0",
+        "vite-node": "2.1.9",
+        "why-is-node-running": "^2.3.0"
+      },
+      "bin": {
+        "vitest": "vitest.mjs"
+      },
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      },
+      "peerDependencies": {
+        "@edge-runtime/vm": "*",
+        "@types/node": "^18.0.0 || >=20.0.0",
+        "@vitest/browser": "2.1.9",
+        "@vitest/ui": "2.1.9",
+        "happy-dom": "*",
+        "jsdom": "*"
+      },
+      "peerDependenciesMeta": {
+        "@edge-runtime/vm": {
+          "optional": true
+        },
+        "@types/node": {
+          "optional": true
+        },
+        "@vitest/browser": {
+          "optional": true
+        },
+        "@vitest/ui": {
+          "optional": true
+        },
+        "happy-dom": {
+          "optional": true
+        },
+        "jsdom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/why-is-node-running": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+      "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "siginfo": "^2.0.0",
+        "stackback": "0.0.2"
+      },
+      "bin": {
+        "why-is-node-running": "cli.js"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    }
+  }
+}
@@ -0,0 +1,20 @@+{
+  "name": "the-last-glyph",
+  "version": "0.1.0",
+  "private": true,
+  "description": "The Last Glyph — a dark, melancholic turn-based narrative RPG about a world that lost its names.",
+  "author": "Pablo Murad <pablomurad@pm.me>",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vite build",
+    "preview": "vite preview",
+    "typecheck": "tsc --noEmit",
+    "test": "vitest run"
+  },
+  "devDependencies": {
+    "typescript": "^5.6.3",
+    "vite": "^5.4.11",
+    "vitest": "^2.1.8"
+  }
+}
@@ -0,0 +1,24 @@+type Handler<T> = (payload: T) => void;
+
+/** Tiny typed emitter. All cross-module communication goes through here. */
+export class EventBus<Events extends Record<string, unknown>> {
+  private handlers: { [K in keyof Events]?: Set<Handler<Events[K]>> } = {};
+
+  on<K extends keyof Events>(event: K, handler: Handler<Events[K]>): () => void {
+    const set = (this.handlers[event] ??= new Set<Handler<Events[K]>>());
+    set.add(handler);
+    return () => {
+      set.delete(handler);
+    };
+  }
+
+  emit<K extends keyof Events>(event: K, payload: Events[K]): void {
+    const set = this.handlers[event];
+    if (!set) return;
+    for (const handler of [...set]) handler(payload);
+  }
+
+  clear(): void {
+    this.handlers = {};
+  }
+}
@@ -0,0 +1,1575 @@+import { EventBus } from './EventBus';
+import { Rng } from './Rng';
+import { TurnManager } from './TurnManager';
+import { InputManager, type InputAction, type PanelId } from './InputManager';
+import { SaveManager, validateSave } from './SaveManager';
+import {
+  AUTOSAVE_SLOT,
+  COMBAT_JOIN_RADIUS,
+  PLAYER_MOVE_RANGE,
+  SAVE_SLOTS,
+  STORAGE_KEYS,
+  TURN_COSTS,
+} from './constants';
+import {
+  allActors,
+  findActor,
+  livingHostiles,
+  type ActorState,
+  type GameState,
+  type Settings,
+  DEFAULT_SETTINGS,
+} from './GameState';
+import type { GameEvents } from './events';
+import { DIRECTION_VECTORS, addPos, manhattan, posKey, type GridPos } from './types';
+import { MapManager } from '../world/MapManager';
+import type { ExitDef, InteractableDef, MapDefinition } from '../world/MapDefinition';
+import { occupantAt } from '../world/Collision';
+import { findPath, reachableSet } from '../world/Pathfinding';
+import { MovementSystem } from '../systems/MovementSystem';
+import {
+  inspectEnemy,
+  performAttack,
+  performCalm,
+  performDefend,
+  performPush,
+  type AttackOutcome,
+} from '../systems/CombatSystem';
+import {
+  applyCondition,
+  hasCondition,
+  speedOf,
+  tickConditions,
+} from '../systems/ConditionSystem';
+import { canUseAbility, grantUnlocks, spendAbilityFocus } from '../systems/AbilitySystem';
+import {
+  equipArmor,
+  equipWeapon,
+  unequipArmor,
+  unequipWeapon,
+  useConsumable,
+} from '../systems/InventorySystem';
+import { DialogueSystem, applyEffect } from '../systems/DialogueSystem';
+import type { DialogueNode } from '../data/dialogues/types';
+import { EffectSystem } from '../systems/EffectSystem';
+import { decideEnemyAction } from '../systems/EnemyAI';
+import { validTargets } from '../systems/Targeting';
+import { hearEcho } from '../systems/EchoSystem';
+import { storeCurrentMap, takeMapState } from '../systems/MapTransitions';
+import { aggroRangeOf } from '../systems/EnemyAI';
+import { startQuest, advanceQuest } from '../systems/QuestSystem';
+import { Renderer } from '../rendering/Renderer';
+import { EffectRenderer } from '../rendering/EffectRenderer';
+import { AnimationQueue } from '../rendering/AnimationQueue';
+import { ScreenManager } from '../ui/ScreenManager';
+import { HUD, type ActionBarEntry, type TurnOrderEntry } from '../ui/HUD';
+import { DialogueView } from '../ui/DialogueView';
+import { InventoryView } from '../ui/InventoryView';
+import { CharacterView } from '../ui/CharacterView';
+import { JournalView } from '../ui/JournalView';
+import { EchoView } from '../ui/EchoView';
+import { SavesView, type SlotEntry } from '../ui/SavesView';
+import {
+  buildCreditsScreen,
+  buildGlyphGalleryScreen,
+  buildNewGameScreen,
+  buildPauseOverlay,
+  buildSettingsScreen,
+  buildTitleScreen,
+  type TitleScreen,
+} from '../ui/screens';
+import { createEnemyState, createNewGameState, createNpcState } from '../entities/EntityFactory';
+import type { ActorSpawn } from '../world/MapDefinition';
+import { ALL_MAPS } from '../data/maps';
+import { dialogueTree } from '../data/dialogues';
+import { abilityDef, ABILITY_IDS } from '../data/abilities';
+import { itemDef } from '../data/items';
+import { enemyDef } from '../data/enemies';
+import { conditionDef } from '../data/conditions';
+import { CHARACTERS, CHARACTER_IDS } from '../data/characters';
+import { FLAGS, MEMORY_EVENTS } from '../data/flags';
+import { QUEST_IDS } from '../data/quests';
+import { GLYPH_IDS, GLYPHS } from '../data/glyphs';
+import { validateContent } from '../utils/validation';
+import { el } from '../utils/dom';
+
+const GLYPH_LOOKUP = new Map(GLYPHS.map((g) => [g.id, g.glyph]));
+
+function glyphOf(id: string): string {
+  return GLYPH_LOOKUP.get(id) ?? '✨';
+}
+
+interface TargetingState {
+  abilityId: string;
+  targets: ActorState[];
+  index: number;
+}
+
+/**
+ * Orchestrator: owns the one GameState, wires input → systems → renderer,
+ * and runs the deterministic turn loop. DOM reflects state; state is truth.
+ */
+export class Game {
+  private readonly bus = new EventBus<GameEvents>();
+  private readonly input = new InputManager();
+  private readonly saves: SaveManager;
+  private readonly screens = new ScreenManager();
+  private readonly mapManager = new MapManager();
+  private readonly turnManager = new TurnManager();
+  private readonly effects = new EffectSystem();
+  private readonly dialogue = new DialogueSystem();
+  private rng = new Rng(Date.now() >>> 0);
+  private settings: Settings;
+  private readonly devMode: boolean;
+
+  private state: GameState | null = null;
+  private renderer: Renderer | null = null;
+  private effectRenderer: EffectRenderer | null = null;
+  private animations: AnimationQueue | null = null;
+  private movement: MovementSystem | null = null;
+  private hud: HUD | null = null;
+  private dialogueView: DialogueView | null = null;
+  private inventoryView: InventoryView | null = null;
+  private characterView: CharacterView | null = null;
+  private journalView: JournalView | null = null;
+  private echoView: EchoView | null = null;
+  private savesView: SavesView | null = null;
+  private pause: ReturnType<typeof buildPauseOverlay> | null = null;
+  private titleScreen: TitleScreen | null = null;
+  private playerTurnActive = false;
+  private playtimeTimer: number | null = null;
+  private openPanel: PanelId | null = null;
+  private itemChooserOpen = false;
+  private targeting: TargetingState | null = null;
+  private actionBarIds: string[] = [];
+  private echoDoneCallback: (() => void) | null = null;
+
+  constructor(private readonly root: HTMLElement) {
+    this.saves = new SaveManager(window.localStorage);
+    this.devMode =
+      new URLSearchParams(window.location.search).has('dev') ||
+      window.location.hash === '#/gallery';
+    this.settings = this.loadSettings();
+  }
+
+  boot(): void {
+    for (const map of ALL_MAPS) this.mapManager.register(map);
+    if (this.devMode) {
+      for (const problem of validateContent(ALL_MAPS)) {
+        console.error(`[content] ${problem}`);
+      }
+    }
+    this.buildMenuScreens();
+    this.input.attach((action) => this.handleInput(action));
+    this.applySettings(this.settings);
+    this.screens.show('title');
+    if (window.location.hash === '#/gallery') this.screens.show('gallery');
+    window.addEventListener('resize', () => {
+      if (this.state && this.renderer) this.renderer.centerCameraOn(this.state.player.pos, false);
+    });
+  }
+
+  // ---- menu screens ------------------------------------------------------
+
+  private buildMenuScreens(): void {
+    const menuRoot = el('div', 'menu-root');
+    this.root.append(menuRoot);
+
+    this.titleScreen = buildTitleScreen({
+      onNewGame: () => this.screens.show('newGame'),
+      onContinue: () => this.loadSlot(AUTOSAVE_SLOT),
+      onSaves: () => this.showSaves(),
+      onSettings: () => this.screens.show('settings'),
+      onCredits: () => this.screens.show('credits'),
+      onGallery: () => this.screens.show('gallery'),
+      devMode: this.devMode,
+    });
+    this.titleScreen.setHasSave(this.saves.hasSave(AUTOSAVE_SLOT));
+
+    const newGame = buildNewGameScreen({
+      onStart: (variantId) => this.newGame(variantId),
+      onBack: () => this.screens.show('title'),
+    });
+    const settings = buildSettingsScreen({
+      settings: this.settings,
+      onApply: (next) => this.applySettings(next),
+      onBack: () => this.screens.show('title'),
+    });
+    const credits = buildCreditsScreen(() => this.screens.show('title'));
+    const gallery = buildGlyphGalleryScreen(() => this.screens.show('title'));
+
+    const gameScreen = el('div', 'game-screen');
+
+    this.screens.register('title', this.titleScreen.element);
+    this.screens.register('newGame', newGame);
+    this.screens.register('settings', settings);
+    this.screens.register('credits', credits);
+    this.screens.register('gallery', gallery);
+    this.screens.register('game', gameScreen);
+
+    for (const id of ['title', 'newGame', 'settings', 'credits', 'gallery'] as const) {
+      menuRoot.append(this.screens.element(id));
+    }
+    this.root.append(gameScreen);
+  }
+
+  private ensureGameScreenBuilt(): void {
+    if (this.renderer) return;
+    const gameScreen = this.screens.element('game');
+
+    this.animations = new AnimationQueue((busy) => this.onAnimationBusy(busy));
+    this.renderer = new Renderer(gameScreen);
+    this.effectRenderer = new EffectRenderer(
+      this.renderer,
+      this.animations,
+      () => this.settings.reducedMotion,
+    );
+    this.effects.connect((request) => {
+      if (request.kind === 'shake') this.renderer?.shake(this.settings.screenShake);
+      else this.effectRenderer?.handle(request);
+    });
+    this.hud = new HUD(gameScreen);
+    this.hud.onAction((actionId) => this.playerCombatAction(actionId));
+    this.bus.on('ui:toast', ({ text }) => this.hud?.toast(text));
+    this.dialogueView = new DialogueView(gameScreen);
+    this.inventoryView = new InventoryView(gameScreen);
+    this.characterView = new CharacterView(gameScreen);
+    this.journalView = new JournalView(gameScreen);
+    this.echoView = new EchoView(gameScreen);
+    this.pause = buildPauseOverlay({
+      onResume: () => this.closePause(),
+      onSaves: () => {
+        this.pause?.hide();
+        this.showSaves();
+      },
+      onQuit: () => this.quitToTitle(),
+    });
+    gameScreen.append(this.pause.element);
+    this.savesView = new SavesView(this.root);
+
+    this.movement = new MovementSystem({
+      getState: () => this.requireState(),
+      getMap: () => this.currentMap(),
+      renderer: this.renderer,
+      animations: this.animations,
+      effects: this.effects,
+    });
+
+    this.renderer.setMotionOptions(this.settings.reducedMotion, this.settings.animationSpeed);
+    this.input.enableTilePicking(this.renderer.viewportEl, (x, y) =>
+      this.renderer?.pickTile(x, y) ?? null,
+    );
+  }
+
+  // ---- game lifecycle ----------------------------------------------------
+
+  private newGame(variantId: string): void {
+    const map = this.mapManager.get('graveClearing');
+    this.state = createNewGameState({
+      variantId,
+      mapId: map.id,
+      region: map.region,
+      objective: map.objective,
+      playerSpawn: map.playerSpawn,
+      settings: this.settings,
+    });
+    this.rng = new Rng(this.state.rngState);
+    this.populateMapActors(map);
+    this.startPlay();
+    this.showNarration(
+      [
+        'You wake with dirt in your mouth and no name in your head.',
+        'Above you: a tree that died before you did. Below: a grave too shallow to keep you.',
+        'The world gave up its names to hide from what listens. Yours went with the rest. Almost.',
+      ],
+      () => this.toast('WASD or arrow keys to walk. Enter to interact. Click a tile to walk there.'),
+    );
+  }
+
+  private showNarration(lines: string[], onDone: () => void): void {
+    this.echoDoneCallback = onDone;
+    this.echoView?.show(
+      { id: 'narration_prologue', glyph: '🪦', title: 'Prologue — The Grave', lines },
+      false,
+      () => this.finishEcho(),
+    );
+  }
+
+  private loadSlot(slot: string): void {
+    const loaded = this.saves.load(slot);
+    if (!loaded) {
+      this.toast('Could not load that save.');
+      return;
+    }
+    this.adoptLoadedState(loaded);
+    this.savesView?.hide();
+    this.startPlay();
+  }
+
+  /** Loaded saves always resume in exploration, hostiles calmed down. */
+  private adoptLoadedState(state: GameState): void {
+    state.mode = 'exploration';
+    for (const actor of state.actors) actor.aggroed = false;
+    this.state = state;
+    this.rng = new Rng(state.rngState);
+    this.targeting = null;
+    this.playerTurnActive = false;
+  }
+
+  private startPlay(): void {
+    this.ensureGameScreenBuilt();
+    this.enterCurrentMap();
+    this.screens.show('game');
+    this.startPlaytimeClock();
+    this.autosave();
+  }
+
+  private populateMapActors(map: MapDefinition): void {
+    const state = this.requireState();
+    state.actors = [...map.npcs, ...map.enemies]
+      .filter((spawn) => this.spawnVisible(spawn))
+      .map((spawn) => this.createSpawnActor(spawn));
+  }
+
+  private spawnKeyOf(spawn: ActorSpawn): string {
+    return `${spawn.defId}@${spawn.pos.x},${spawn.pos.y}`;
+  }
+
+  private spawnVisible(spawn: ActorSpawn): boolean {
+    const flags = this.requireState().flags;
+    if (spawn.requiresFlag && !flags[spawn.requiresFlag]) return false;
+    if (spawn.hiddenFlag && flags[spawn.hiddenFlag]) return false;
+    return true;
+  }
+
+  private createSpawnActor(spawn: ActorSpawn): ActorState {
+    const key = this.spawnKeyOf(spawn);
+    return spawn.defId in CHARACTERS
+      ? createNpcState(spawn.defId, spawn.pos, key)
+      : createEnemyState(spawn.defId, spawn.pos, spawn.patrol, key);
+  }
+
+  /**
+   * Sync actors with world flags and effect-driven changes: spawn actors
+   * whose requiresFlag just turned true, remove hidden/forgotten ones, and
+   * drop renderer elements for actors removed from state (removeActors).
+   */
+  private reconcileActors(): void {
+    const state = this.requireState();
+    const map = this.currentMap();
+    for (const spawn of [...map.npcs, ...map.enemies]) {
+      const key = this.spawnKeyOf(spawn);
+      const existing = state.actors.findIndex((a) => a.spawnKey === key);
+      if (spawn.hiddenFlag && state.flags[spawn.hiddenFlag] && existing >= 0) {
+        this.requireRenderer().removeActor(state.actors[existing].id);
+        state.actors.splice(existing, 1);
+        continue;
+      }
+      if (this.spawnVisible(spawn) && existing < 0) {
+        const actor = this.createSpawnActor(spawn);
+        state.actors.push(actor);
+        this.requireRenderer().spawnActor(actor);
+      }
+    }
+    for (const id of this.requireRenderer().actorIds()) {
+      if (id !== state.player.id && !state.actors.some((a) => a.id === id)) {
+        this.requireRenderer().removeActor(id);
+      }
+    }
+  }
+
+  private enterCurrentMap(): void {
+    const state = this.requireState();
+    const renderer = this.requireRenderer();
+    const map = this.currentMap();
+    state.region = map.region;
+    if (map.onEnterQuest) startQuest(state, map.onEnterQuest);
+    renderer.renderMap(map);
+    for (const actor of allActors(state)) renderer.spawnActor(actor);
+    this.reconcileActors();
+    renderer.centerCameraOn(state.player.pos, false);
+    this.refreshHud();
+    this.input.unlock();
+  }
+
+  private transitionTo(exit: ExitDef): void {
+    const state = this.requireState();
+    storeCurrentMap(state);
+    const stored = takeMapState(state, exit.toMapId);
+    state.mapId = exit.toMapId;
+    if (stored) {
+      state.actors = stored.actors;
+      state.consumedInteractables = stored.consumedInteractables;
+    } else {
+      this.populateMapActors(this.mapManager.get(exit.toMapId));
+      state.consumedInteractables = [];
+    }
+    state.player.pos = { ...exit.toPos };
+    this.enterCurrentMap();
+    this.toast(exit.label);
+    this.autosave();
+  }
+
+  private checkExit(): void {
+    const state = this.requireState();
+    const exit = this.currentMap().exits.find(
+      (e) => e.pos.x === state.player.pos.x && e.pos.y === state.player.pos.y,
+    );
+    if (exit) this.transitionTo(exit);
+  }
+
+  private startPlaytimeClock(): void {
+    if (this.playtimeTimer !== null) window.clearInterval(this.playtimeTimer);
+    this.playtimeTimer = window.setInterval(() => {
+      if (this.state && this.screens.currentId === 'game' && !this.pause?.visible()) {
+        this.state.playtimeSeconds += 1;
+      }
+    }, 1000);
+  }
+
+  // ---- input -------------------------------------------------------------
+
+  private handleInput(action: InputAction): void {
+    if (this.savesView?.visible && this.screens.currentId !== 'game') {
+      if (action.type === 'cancel') this.savesView.hide();
+      return;
+    }
+    if (!this.state) return;
+    if (this.echoView?.visible) {
+      if (action.type === 'interact' || action.type === 'choice') this.echoView.advance();
+      else if (action.type === 'cancel') this.finishEcho();
+      return;
+    }
+    if (this.itemChooserOpen) {
+      if (action.type === 'cancel') this.cancelItemChooser();
+      return;
+    }
+    if (this.openPanel) {
+      if (action.type === 'cancel') this.closePanels();
+      return;
+    }
+    if (this.savesView?.visible) {
+      if (action.type === 'cancel') this.savesView.hide();
+      return;
+    }
+    if (this.pause?.visible()) {
+      if (action.type === 'cancel' || action.type === 'pause') this.closePause();
+      return;
+    }
+    if (action.type === 'openPanel') {
+      this.openPanelView(action.panel);
+      return;
+    }
+    if (action.type === 'pause') {
+      this.openPause();
+      return;
+    }
+    switch (this.state.mode) {
+      case 'dialogue':
+        this.handleDialogueInput(action);
+        break;
+      case 'exploration':
+        this.handleExplorationInput(action);
+        break;
+      case 'combat':
+        this.handleCombatInput(action);
+        break;
+    }
+  }
+
+  private handleDialogueInput(action: InputAction): void {
+    if (!this.dialogueView) return;
+    if (action.type === 'choice') this.dialogueView.pickByIndex(action.index);
+    else if (action.type === 'interact') this.dialogueView.skipOrAdvance();
+    else if (action.type === 'cancel') this.endDialogue();
+  }
+
+  private handleExplorationInput(action: InputAction): void {
+    if (action.type === 'cancel') {
+      this.openPause();
+      return;
+    }
+    if (action.type === 'interact') {
+      this.interact();
+      return;
+    }
+    if (action.type === 'move') {
+      void this.explorationStep(action.direction);
+      return;
+    }
+    if (action.type === 'tileTap') {
+      void this.explorationTapMove(action.pos);
+    }
+  }
+
+  private handleCombatInput(action: InputAction): void {
+    if (this.targeting) {
+      if (action.type === 'cancel') this.cancelTargeting();
+      else if (action.type === 'interact') this.confirmTargeting();
+      else if (action.type === 'move') {
+        this.cycleTarget(action.direction === 'left' || action.direction === 'up' ? -1 : 1);
+      } else if (action.type === 'tileTap') this.tapTarget(action.pos);
+      return;
+    }
+    if (action.type === 'cancel') {
+      this.openPause();
+      return;
+    }
+    if (!this.playerTurnActive) return; // nothing hostile acts while the player decides
+    if (action.type === 'move') {
+      void this.combatStep(action.direction);
+      return;
+    }
+    if (action.type === 'tileTap') {
+      void this.combatTapMove(action.pos);
+      return;
+    }
+    if (action.type === 'interact') {
+      this.playerCombatAction(ABILITY_IDS.strike);
+      return;
+    }
+    if (action.type === 'choice') {
+      const picked = this.actionBarIds[action.index];
+      if (picked) this.playerCombatAction(picked);
+    }
+  }
+
+  // ---- exploration -------------------------------------------------------
+
+  private async explorationStep(direction: keyof typeof DIRECTION_VECTORS): Promise<void> {
+    const state = this.requireState();
+    const movement = this.requireMovement();
+    const player = state.player;
+    player.facing = direction;
+    const to = addPos(player.pos, DIRECTION_VECTORS[direction]);
+    const occupantId = occupantAt(movement.occupancy(player.id), to);
+
+    if (occupantId) {
+      const occupant = findActor(state, occupantId);
+      if (occupant?.kind === 'enemy' && occupant.alive && occupant.faction === 'hostile') {
+        this.resolveTargetedAbility(occupant, ABILITY_IDS.strike);
+        if (this.state?.mode === 'exploration') this.checkAggro();
+        return;
+      }
+      if (occupant?.kind === 'npc') {
+        this.startDialogueWith(occupant);
+        return;
+      }
+      return;
+    }
+    const moved = await movement.stepActor(player, to);
+    if (!moved || !this.state) return;
+    this.requireRenderer().centerCameraOn(player.pos, true);
+    if (this.state.mode === 'exploration') {
+      this.checkExit();
+      if (this.state.mode === 'exploration') this.checkAggro();
+    }
+  }
+
+  private async explorationTapMove(pos: GridPos): Promise<void> {
+    const state = this.requireState();
+    const movement = this.requireMovement();
+    const player = state.player;
+    const occupancy = movement.occupancy(player.id);
+    const occupantId = occupantAt(occupancy, pos);
+    const path = findPath(this.currentMap(), occupancy, player.pos, pos, {
+      ignoreOccupantId: player.id,
+      allowOccupiedGoal: true,
+    });
+    if (!path) return;
+    const steps = occupantId ? path.slice(0, -1) : path;
+    await movement.walkPath(player, steps);
+    if (!this.state) return;
+    this.requireRenderer().centerCameraOn(player.pos, true);
+    if (occupantId) {
+      const occupant = findActor(state, occupantId);
+      if (occupant?.kind === 'npc' && manhattan(player.pos, occupant.pos) === 1) {
+        this.startDialogueWith(occupant);
+        return;
+      }
+      if (occupant?.kind === 'enemy' && occupant.faction === 'hostile' && manhattan(player.pos, occupant.pos) === 1) {
+        this.resolveTargetedAbility(occupant, ABILITY_IDS.strike);
+      }
+    }
+    if (this.state?.mode === 'exploration') {
+      this.checkExit();
+      if (this.state.mode === 'exploration') this.checkAggro();
+    }
+  }
+
+  private interact(): void {
+    const state = this.requireState();
+    const player = state.player;
+    const ahead = addPos(player.pos, DIRECTION_VECTORS[player.facing]);
+    const candidates = [ahead, ...Object.values(DIRECTION_VECTORS).map((v) => addPos(player.pos, v))];
+    for (const pos of candidates) {
+      const npc = state.actors.find((a) => a.kind === 'npc' && a.alive && a.pos.x === pos.x && a.pos.y === pos.y);
+      if (npc) {
+        this.startDialogueWith(npc);
+        return;
+      }
+      const interactable = this.currentMap().interactables.find((i) => i.pos.x === pos.x && i.pos.y === pos.y);
+      if (interactable) {
+        this.runInteractable(interactable);
+        return;
+      }
+    }
+  }
+
+  private runInteractable(interactable: InteractableDef): void {
+    const state = this.requireState();
+    const action = interactable.action;
+    if (action.type === 'inspect') {
+      this.toast(action.text);
+      for (const effect of action.effects ?? []) applyEffect(effect, state);
+      this.checkAbilityUnlocks();
+      this.refreshHud();
+      return;
+    }
+    if (action.type === 'echo') {
+      this.openEcho(action.echoId);
+      return;
+    }
+    if (action.type === 'item') {
+      if (state.consumedInteractables.includes(interactable.id)) {
+        this.toast(action.afterText);
+        return;
+      }
+      state.consumedInteractables.push(interactable.id);
+      state.flags[action.onceFlag] = true;
+      applyEffect({ type: 'giveItem', itemId: action.itemId }, state);
+      const def = itemDef(action.itemId);
+      if (def.type === 'weapon') {
+        equipWeapon(state, def.id);
+        this.respawnPlayer();
+        this.toast(`Took and equipped ${def.name}.`);
+      } else if (def.type === 'armor') {
+        equipArmor(state, def.id);
+        this.toast(`Took and put on ${def.name}.`);
+      } else {
+        this.toast(`Took ${def.name}.`);
+      }
+      this.refreshHud();
+      return;
+    }
+    const tree = dialogueTree(action.dialogueId);
+    if (tree) this.openDialogue(tree.id);
+  }
+
+  private checkAggro(): void {
+    const state = this.requireState();
+    const player = state.player;
+    let aggroedAny = false;
+    for (const actor of state.actors) {
+      if (actor.kind !== 'enemy' || !actor.alive || actor.faction !== 'hostile' || actor.aggroed) {
+        continue;
+      }
+      if (manhattan(actor.pos, player.pos) <= aggroRangeOf(actor)) {
+        actor.aggroed = true;
+        aggroedAny = true;
+      }
+    }
+    if (aggroedAny) this.startCombat();
+  }
+
+  // ---- combat ------------------------------------------------------------
+
+  private startCombat(): void {
+    const state = this.requireState();
+    state.mode = 'combat';
+    this.turnManager.clear();
+    this.turnManager.addActor(state.player.id, speedOf(state.player));
+    const engaged: string[] = [];
+    for (const actor of livingHostiles(state)) {
+      if (actor.aggroed || manhattan(actor.pos, state.player.pos) <= COMBAT_JOIN_RADIUS) {
+        actor.aggroed = true;
+        this.turnManager.addActor(actor.id, speedOf(actor));
+        engaged.push(actor.id);
+      }
+    }
+    this.bus.emit('combat:started', { enemyIds: engaged });
+    this.showActionBar(true);
+    this.refreshTurnOrder(null);
+    void this.runTurns();
+  }
+
+  private endCombat(reason: 'cleared' | 'calmed' | 'died'): void {
+    const state = this.requireState();
+    state.mode = 'exploration';
+    this.playerTurnActive = false;
+    this.targeting = null;
+    this.turnManager.clear();
+    this.requireRenderer().clearHighlights();
+    this.hud?.setActionBar(null, false);
+    this.hud?.updateTurnOrder([]);
+    this.bus.emit('combat:ended', { reason });
+    this.input.unlock();
+    if (reason !== 'died') this.autosave();
+  }
+
+  private combatActive(): boolean {
+    const state = this.requireState();
+    return livingHostiles(state).some(
+      (a) => a.aggroed && manhattan(a.pos, state.player.pos) <= COMBAT_JOIN_RADIUS + 4,
+    );
+  }
+
+  private async runTurns(): Promise<void> {
+    const state = this.requireState();
+    while (this.state && state.mode === 'combat') {
+      if (!this.combatActive()) {
+        this.endCombat('cleared');
+        return;
+      }
+      const actorId = this.turnManager.advance();
+      const actor = findActor(state, actorId);
+      if (!actor || !actor.alive) {
+        this.turnManager.removeActor(actorId);
+        continue;
+      }
+      this.turnManager.setSpeed(actorId, speedOf(actor));
+      const skip = this.tickTurnStart(actor);
+      this.refreshTurnOrder(actorId);
+      if (this.state !== state || state.mode !== 'combat') return;
+      if (!actor.alive) {
+        this.turnManager.removeActor(actorId);
+        continue;
+      }
+      if (skip) {
+        this.turnManager.commit(actorId, TURN_COSTS.wait);
+        continue;
+      }
+      if (actor.id === state.player.id) {
+        this.beginPlayerTurn();
+        return; // resumes via endPlayerTurn
+      }
+      await this.runEnemyTurn(actor);
+    }
+  }
+
+  /** Turn-start conditions; returns true when the actor loses its action. */
+  private tickTurnStart(actor: ActorState): boolean {
+    const tick = tickConditions(actor);
+    if (tick.damage > 0) {
+      this.effects.damageNumber(actor.pos, tick.damage);
+      if (actor.kind === 'player') this.effects.shake();
+    }
+    for (const expired of tick.expired) {
+      this.effects.word(actor.pos, `${conditionDef(expired).name} faded`);
+    }
+    this.requireRenderer().refreshActor(actor);
+    if (!actor.alive) {
+      this.handleDeath(actor);
+      return true;
+    }
+    if (tick.preventsAction) {
+      this.effects.word(actor.pos, 'Frozen');
+      return true;
+    }
+    if (hasCondition(actor, 'forgotten') && this.rng.chance(conditionDef('forgotten').skipChance ?? 0)) {
+      this.effects.word(actor.pos, 'Lost');
+      return true;
+    }
+    return false;
+  }
+
+  private beginPlayerTurn(): void {
+    const state = this.requireState();
+    this.playerTurnActive = true;
+    const reachable = reachableSet(
+      this.currentMap(),
+      this.requireMovement().occupancy(state.player.id),
+      state.player.pos,
+      PLAYER_MOVE_RANGE,
+    );
+    this.requireRenderer().showReachable(reachable);
+    this.showActionBar(false);
+    this.refreshHud();
+    if (!this.animations?.busy) this.input.unlock();
+  }
+
+  private endPlayerTurn(cost: number): void {
+    this.playerTurnActive = false;
+    this.targeting = null;
+    this.requireRenderer().clearHighlights();
+    this.showActionBar(true);
+    this.input.lock();
+    const state = this.requireState();
+    this.turnManager.commit(state.player.id, cost);
+    this.refreshHud();
+    void this.runTurns();
+  }
+
+  private showActionBar(disabled: boolean): void {
+    const entries = this.buildActionBar();
+    this.actionBarIds = entries.map((entry) => entry.id);
+    this.hud?.setActionBar(entries, disabled);
+  }
+
+  private buildActionBar(): ActionBarEntry[] {
+    const state = this.requireState();
+    const entries: ActionBarEntry[] = state.player.abilityIds.map((id) => ({
+      id,
+      label: abilityDef(id).name,
+    }));
+    entries.push(
+      { id: 'defend', label: 'Defend' },
+      { id: 'calm', label: 'Calm' },
+      { id: 'item', label: 'Item' },
+      { id: 'wait', label: 'Wait' },
+    );
+    return entries;
+  }
+
+  private async combatStep(direction: keyof typeof DIRECTION_VECTORS): Promise<void> {
+    const state = this.requireState();
+    const player = state.player;
+    player.facing = direction;
+    const to = addPos(player.pos, DIRECTION_VECTORS[direction]);
+    const occupantId = occupantAt(this.requireMovement().occupancy(player.id), to);
+    if (occupantId) {
+      const occupant = findActor(state, occupantId);
+      if (occupant?.kind === 'enemy' && occupant.faction === 'hostile' && occupant.alive) {
+        this.resolveTargetedAbility(occupant, ABILITY_IDS.strike);
+      }
+      return;
+    }
+    const moved = await this.requireMovement().stepActor(player, to);
+    if (moved && this.playerTurnActive) {
+      this.requireRenderer().centerCameraOn(player.pos, true);
+      this.endPlayerTurn(TURN_COSTS.move);
+    }
+  }
+
+  private async combatTapMove(pos: GridPos): Promise<void> {
+    const state = this.requireState();
+    const player = state.player;
+    const occupancy = this.requireMovement().occupancy(player.id);
+    const occupantId = occupantAt(occupancy, pos);
+    if (occupantId) {
+      const occupant = findActor(state, occupantId);
+      if (
+        occupant?.kind === 'enemy' &&
+        occupant.faction === 'hostile' &&
+        occupant.alive &&
+        manhattan(player.pos, occupant.pos) === 1
+      ) {
+        this.resolveTargetedAbility(occupant, ABILITY_IDS.strike);
+      }
+      return;
+    }
+    const reachable = reachableSet(this.currentMap(), occupancy, player.pos, PLAYER_MOVE_RANGE);
+    if (!reachable.has(posKey(pos))) return;
+    const path = findPath(this.currentMap(), occupancy, player.pos, pos, {
+      ignoreOccupantId: player.id,
+    });
+    if (!path) return;
+    await this.requireMovement().walkPath(player, path);
+    if (this.playerTurnActive && this.state?.mode === 'combat') {
+      this.requireRenderer().centerCameraOn(player.pos, true);
+      this.endPlayerTurn(TURN_COSTS.move);
+    }
+  }
+
+  playerCombatAction(actionId: string): void {
+    const state = this.state;
+    if (!state || state.mode !== 'combat' || !this.playerTurnActive) return;
+    if (this.targeting) {
+      this.toast('Choose a target — Esc to cancel.');
+      return;
+    }
+    const player = state.player;
+    switch (actionId) {
+      case 'defend':
+        performDefend(player);
+        this.effects.impact(player.pos, glyphOf(GLYPH_IDS.fxShield));
+        this.effects.word(player.pos, 'Guarded');
+        this.requireRenderer().refreshActor(player);
+        this.endPlayerTurn(TURN_COSTS.defend);
+        return;
+      case 'wait':
+        this.endPlayerTurn(TURN_COSTS.wait);
+        return;
+      case 'calm': {
+        const target = this.nearestHostile(2, true);
+        if (!target) {
+          this.toast('Nothing close enough to calm.');
+          return;
+        }
+        this.playerCalm(target);
+        return;
+      }
+      case 'item':
+        this.openItemChooser();
+        return;
+      default:
+        this.beginAbilityUse(actionId);
+    }
+  }
+
+  private beginAbilityUse(abilityId: string): void {
+    const state = this.requireState();
+    const ability = abilityDef(abilityId);
+    const check = canUseAbility(state.player, ability);
+    if (!check.ok) {
+      this.toast(check.reason ?? 'Cannot use that now.');
+      return;
+    }
+    if (ability.range === 0) {
+      this.resolveSelfAbility(abilityId);
+      return;
+    }
+    const targets = validTargets(this.currentMap(), state.actors, state.player, ability);
+    if (targets.length === 0) {
+      this.toast(ability.range > 1 ? 'No target in range and line of sight.' : 'No target in range.');
+      return;
+    }
+    this.enterTargeting(abilityId, targets);
+  }
+
+  // ---- targeting mode ----------------------------------------------------
+
+  private enterTargeting(abilityId: string, targets: ActorState[]): void {
+    this.targeting = { abilityId, targets, index: 0 };
+    this.renderTargeting();
+    this.hud?.setActionBar(this.buildActionBar(), true);
+  }
+
+  private renderTargeting(): void {
+    const targeting = this.targeting;
+    if (!targeting) return;
+    const selected = targeting.targets[targeting.index];
+    this.requireRenderer().showTargets(
+      targeting.targets.map((t) => t.pos),
+      selected?.pos ?? null,
+    );
+  }
+
+  private cycleTarget(delta: number): void {
+    const targeting = this.targeting;
+    if (!targeting) return;
+    targeting.index = (targeting.index + delta + targeting.targets.length) % targeting.targets.length;
+    this.renderTargeting();
+  }
+
+  private tapTarget(pos: GridPos): void {
+    const targeting = this.targeting;
+    if (!targeting) return;
+    const hit = targeting.targets.find((t) => t.pos.x === pos.x && t.pos.y === pos.y);
+    if (hit) this.confirmTargeting(hit.id);
+  }
+
+  private confirmTargeting(targetId?: string): void {
+    const targeting = this.targeting;
+    if (!targeting) return;
+    const target = targetId
+      ? targeting.targets.find((t) => t.id === targetId)
+      : targeting.targets[targeting.index];
+    this.targeting = null;
+    this.requireRenderer().clearHighlights();
+    if (target) this.resolveTargetedAbility(target, targeting.abilityId);
+  }
+
+  private cancelTargeting(): void {
+    this.targeting = null;
+    this.requireRenderer().clearHighlights();
+    this.showActionBar(false);
+    this.toast('Targeting cancelled.');
+  }
+
+  // ---- ability resolution -------------------------------------------------
+
+  private resolveSelfAbility(abilityId: string): void {
+    const state = this.requireState();
+    const ability = abilityDef(abilityId);
+    spendAbilityFocus(state.player, abilityId);
+    if (ability.cleanses) {
+      state.player.conditions = [];
+      this.effects.word(state.player.pos, 'Cleansed');
+    }
+    if (ability.heals) {
+      const healed = Math.min(ability.heals, state.player.maxHp - state.player.hp);
+      state.player.hp += healed;
+      if (healed > 0) this.effects.healNumber(state.player.pos, healed);
+    }
+    if (ability.selfCondition) {
+      applyCondition(state.player, ability.selfCondition.id, ability.selfCondition.duration);
+      this.effects.word(state.player.pos, conditionDef(ability.selfCondition.id).name);
+    }
+    this.effects.impact(state.player.pos, ability.effectGlyph);
+    this.requireRenderer().refreshActor(state.player);
+    this.tendencyBump(ability.tendency);
+    if (state.mode === 'combat' && this.playerTurnActive) {
+      this.endPlayerTurn(TURN_COSTS[ability.costKind]);
+    }
+  }
+
+  /** Targeted ability on an enemy: damage, conditions, push, reveal. */
+  private resolveTargetedAbility(target: ActorState, abilityId: string): void {
+    const state = this.requireState();
+    const ability = abilityDef(abilityId);
+    const check = canUseAbility(state.player, ability);
+    if (!check.ok) {
+      this.toast(check.reason ?? 'Cannot use that now.');
+      return;
+    }
+    spendAbilityFocus(state.player, abilityId);
+
+    if (ability.noDamage) {
+      if (ability.reveals && target.kind === 'enemy') {
+        this.toast(inspectEnemy(target));
+      }
+      if (ability.appliesCondition) {
+        applyCondition(target, ability.appliesCondition.id, ability.appliesCondition.duration);
+        this.effects.word(target.pos, conditionDef(ability.appliesCondition.id).name);
+        this.requireRenderer().refreshActor(target);
+      }
+      if (ability.selfCondition) {
+        applyCondition(state.player, ability.selfCondition.id, ability.selfCondition.duration);
+      }
+      this.effects.impact(target.pos, ability.effectGlyph);
+    } else {
+      const outcome = performAttack(state.player, target, this.rng, allActors(state), {
+        mult: ability.damageMult,
+        appliesCondition: ability.appliesCondition,
+        selfCondition: ability.selfCondition,
+      });
+      void this.effectRenderer?.attackSequence(state.player.id, target.pos, ability.effectGlyph);
+      this.applyAttackOutcome(outcome);
+      if (ability.push && target.alive) {
+        const push = performPush(state.player, target, (pos) =>
+          this.requireMovement().checkStep(target, pos).ok,
+        );
+        if (push.moved) {
+          this.effects.word(target.pos, 'Pushed');
+          void this.animations?.enqueue(() => this.requireRenderer().placeActor(target, true));
+        } else {
+          this.effects.word(target.pos, 'Staggered');
+          this.requireRenderer().refreshActor(target);
+        }
+      }
+    }
+    this.tendencyBump(ability.tendency);
+    if (state.mode === 'combat' && this.playerTurnActive) {
+      this.endPlayerTurn(TURN_COSTS[ability.costKind]);
+    }
+  }
+
+  private nearestHostile(maxRange: number, calmableOnly = false): ActorState | null {
+    const state = this.requireState();
+    let best: ActorState | null = null;
+    for (const actor of livingHostiles(state)) {
+      if (!this.turnManager.has(actor.id)) continue;
+      if (calmableOnly && !enemyDef(actor.defId).calmable) continue;
+      const distance = manhattan(actor.pos, state.player.pos);
+      if (distance > maxRange) continue;
+      if (!best || distance < manhattan(best.pos, state.player.pos)) best = actor;
+    }
+    return best;
+  }
+
+  private playerCalm(target: ActorState): void {
+    const state = this.requireState();
+    const calmed = performCalm(state.player, target, state.tendencies.empathy, this.rng);
+    if (calmed) {
+      this.effects.word(target.pos, 'Calmed');
+      this.effects.impact(target.pos, glyphOf(GLYPH_IDS.fxSparkle));
+      this.turnManager.removeActor(target.id);
+      state.flags[FLAGS.calmedWolf] = true;
+      if (target.defId === 'ashWolf') advanceQuest(state, QUEST_IDS.prologueAwakening);
+      this.tendencyBump('empathy');
+    } else {
+      this.effects.word(target.pos, 'Resisted');
+    }
+    this.endPlayerTurn(TURN_COSTS.attack);
+  }
+
+  private openItemChooser(): void {
+    const state = this.requireState();
+    this.itemChooserOpen = true;
+    this.inventoryView?.showChooser(
+      state,
+      (itemId) => {
+        this.itemChooserOpen = false;
+        this.inventoryView?.hide();
+        const result = useConsumable(state, itemId);
+        if (!result.ok) {
+          this.toast(result.message);
+          return;
+        }
+        const def = itemDef(itemId);
+        if (def.healAmount) this.effects.healNumber(state.player.pos, def.healAmount);
+        this.requireRenderer().refreshActor(state.player);
+        this.refreshHud();
+        this.endPlayerTurn(TURN_COSTS.item);
+      },
+      () => this.cancelItemChooser(),
+    );
+  }
+
+  private cancelItemChooser(): void {
+    this.itemChooserOpen = false;
+    this.inventoryView?.hide();
+  }
+
+  private async runEnemyTurn(enemy: ActorState): Promise<void> {
+    const state = this.requireState();
+    const intent = decideEnemyAction(enemy, {
+      map: this.currentMap(),
+      actors: allActors(state),
+      player: state.player,
+      flags: state.flags,
+      rng: this.rng,
+    });
+    let cost: number = TURN_COSTS.wait;
+    if (intent.type === 'move') {
+      await this.requireMovement().walkPath(enemy, intent.path);
+      cost = TURN_COSTS.move;
+    } else if (intent.type === 'attack' || intent.type === 'rangedAttack') {
+      const target = findActor(state, intent.targetId);
+      if (target && target.alive) {
+        const effectGlyph =
+          intent.type === 'rangedAttack' ? glyphOf(GLYPH_IDS.fxArrow) : glyphOf(GLYPH_IDS.fxSlash);
+        if (intent.type === 'rangedAttack') {
+          await this.effectRenderer?.rangedSequence(target.pos);
+        } else {
+          await this.effectRenderer?.attackSequence(enemy.id, target.pos, effectGlyph);
+        }
+        const inflicts = enemy.kind === 'enemy' ? enemyDef(enemy.defId).appliesCondition : undefined;
+        const outcome = performAttack(enemy, target, this.rng, allActors(state), {
+          appliesCondition: inflicts,
+        });
+        this.applyAttackOutcome(outcome);
+        cost = TURN_COSTS.attack;
+      }
+    } else if (intent.type === 'guard') {
+      performDefend(enemy);
+      this.effects.word(enemy.pos, 'Guarded');
+      this.requireRenderer().refreshActor(enemy);
+      cost = TURN_COSTS.defend;
+    } else if (this.animations) {
+      await this.animations.wait(150, this.settings.reducedMotion);
+    }
+    if (this.state?.mode === 'combat') {
+      this.turnManager.commit(enemy.id, cost);
+    }
+  }
+
+  private applyAttackOutcome(outcome: AttackOutcome): void {
+    const state = this.requireState();
+    const defender = findActor(state, outcome.defenderId);
+    if (!defender) return;
+    this.effects.damageNumber(defender.pos, outcome.damage);
+    if (defender.kind === 'player') this.effects.shake();
+    for (const applied of outcome.conditionsApplied) {
+      this.effects.word(defender.pos, conditionDef(applied).name);
+    }
+    for (const broken of outcome.brokenConditions) {
+      this.effects.word(defender.pos, `${conditionDef(broken).name} broke`);
+    }
+    this.requireRenderer().refreshActor(defender);
+    const attacker = findActor(state, outcome.attackerId);
+    if (attacker) this.requireRenderer().refreshActor(attacker);
+    if (outcome.killed) this.handleDeath(defender);
+    this.refreshHud();
+  }
+
+  private handleDeath(actor: ActorState): void {
+    const state = this.requireState();
+    this.turnManager.removeActor(actor.id);
+    if (actor.kind === 'player') {
+      this.playerDeath();
+      return;
+    }
+    // Canon characters are not killed at 0 hp — they yield, and it is remembered.
+    const profile = actor.kind === 'npc' ? CHARACTERS[actor.defId]?.combat : undefined;
+    if (profile?.yields) {
+      actor.alive = true;
+      actor.hp = 1;
+      actor.faction = 'neutral';
+      actor.aggroed = false;
+      actor.conditions = [];
+      this.effects.word(actor.pos, 'Yields');
+      if (actor.defId === CHARACTER_IDS.mira) {
+        state.flags[FLAGS.miraDefeated] = true;
+        applyEffect({ type: 'addMemory', npcId: CHARACTER_IDS.mira, event: MEMORY_EVENTS.foughtMira }, state);
+        applyEffect({ type: 'changeNpcAttitude', npcId: CHARACTER_IDS.mira, attitude: 'rival' }, state);
+        applyEffect({ type: 'advanceQuest', questId: QUEST_IDS.glasswoodShard }, state);
+      }
+      this.requireRenderer().refreshActor(actor);
+      return;
+    }
+    if (actor.kind === 'enemy' && actor.defId === 'ashWolf') {
+      state.flags[FLAGS.killedWolf] = true;
+      advanceQuest(state, QUEST_IDS.prologueAwakening);
+    }
+    this.requireRenderer().refreshActor(actor);
+  }
+
+  private playerDeath(): void {
+    const state = this.requireState();
+    const player = state.player;
+    this.toast('The grave remembers you. It gives you back.');
+    player.alive = true;
+    player.hp = player.maxHp;
+    player.focus = Math.max(1, player.focus);
+    player.conditions = [];
+    player.pos = { ...this.currentMap().playerSpawn };
+    for (const actor of state.actors) actor.aggroed = false;
+    this.requireRenderer().placeActor(player, false);
+    this.requireRenderer().refreshActor(player);
+    this.requireRenderer().centerCameraOn(player.pos, false);
+    if (state.mode === 'combat') this.endCombat('died');
+  }
+
+  private tendencyBump(axis: 'resolve' | 'insight' | 'empathy' | undefined): void {
+    if (!axis || !this.state) return;
+    this.state.tendencies[axis] += 1;
+    this.checkAbilityUnlocks();
+  }
+
+  private checkAbilityUnlocks(): void {
+    const state = this.state;
+    if (!state) return;
+    for (const abilityId of grantUnlocks(state)) {
+      this.toast(`New ability: ${abilityDef(abilityId).name}`);
+      this.effects.word(state.player.pos, 'New ability');
+    }
+  }
+
+  // ---- dialogue ----------------------------------------------------------
+
+  private startDialogueWith(npc: ActorState): void {
+    const dialogueId = CHARACTERS[npc.defId]?.dialogueId;
+    if (!dialogueId) {
+      this.toast(`${npc.name} has nothing to say.`);
+      return;
+    }
+    const state = this.requireState();
+    if (!state.metCharacters.includes(npc.defId)) {
+      state.metCharacters.push(npc.defId);
+    }
+    this.openDialogue(dialogueId);
+  }
+
+  private openDialogue(treeId: string): void {
+    const state = this.requireState();
+    const tree = dialogueTree(treeId);
+    if (!tree) return;
+    const node = this.dialogue.start(tree, state);
+    if (!node) {
+      this.toast('Nothing more to say.');
+      return;
+    }
+    state.mode = 'dialogue';
+    this.showDialogueNode(node);
+  }
+
+  private showDialogueNode(node: DialogueNode): void {
+    const state = this.requireState();
+    const choices = this.dialogue.availableChoices(node, state);
+    this.dialogueView?.show(node, choices, {
+      onChoice: (choiceId) => {
+        const next = this.dialogue.choose(choiceId, state);
+        if (next) this.showDialogueNode(next);
+        else this.endDialogue();
+      },
+      onAdvance: () => {
+        const next = this.dialogue.advance(state);
+        if (next) this.showDialogueNode(next);
+        else this.endDialogue();
+      },
+      onEnd: () => this.endDialogue(),
+    });
+    this.refreshHud();
+  }
+
+  private endDialogue(): void {
+    const state = this.requireState();
+    this.dialogue.end();
+    this.dialogueView?.hide();
+    if (state.mode === 'dialogue') state.mode = 'exploration';
+    this.reconcileActors();
+    this.checkAbilityUnlocks();
+    this.refreshHud();
+    this.autosave();
+    // A dialogue may have turned an NPC hostile — combat starts now.
+    if (state.mode === 'exploration' && livingHostiles(state).some((a) => a.aggroed)) {
+      this.startCombat();
+    }
+  }
+
+  // ---- name echoes --------------------------------------------------------
+
+  private openEcho(echoId: string): void {
+    const state = this.requireState();
+    const result = hearEcho(state, echoId);
+    this.echoDoneCallback = () => {
+      if (result.grantedAbility) {
+        this.toast(`New ability: ${abilityDef(result.grantedAbility).name}`);
+      }
+      this.refreshHud();
+      this.autosave();
+    };
+    this.echoView?.show(result.def, result.alreadyHeard, () => this.finishEcho());
+  }
+
+  private finishEcho(): void {
+    this.echoView?.hide();
+    const done = this.echoDoneCallback;
+    this.echoDoneCallback = null;
+    done?.();
+  }
+
+  // ---- panels (inventory / character / journal) --------------------------
+
+  private openPanelView(panel: PanelId): void {
+    const state = this.requireState();
+    if (this.openPanel || this.targeting) return;
+    if (panel === 'inventory' && state.mode === 'combat') {
+      this.toast('No time to rummage — use the Item action.');
+      return;
+    }
+    if (panel === 'inventory') {
+      this.inventoryView?.show(state, {
+        onEquip: (itemId) => this.panelEquip(itemId),
+        onUnequip: (slot) => this.panelUnequip(slot),
+        onUse: (itemId) => this.panelUse(itemId),
+        onClose: () => this.closePanels(),
+      });
+    } else if (panel === 'character') {
+      this.characterView?.show(state, () => this.closePanels());
+    } else {
+      this.journalView?.show(state, () => this.closePanels());
+    }
+    this.openPanel = panel;
+  }
+
+  private closePanels(): void {
+    this.openPanel = null;
+    this.inventoryView?.hide();
+    this.characterView?.hide();
+    this.journalView?.hide();
+  }
+
+  private refreshOpenPanel(): void {
+    const panel = this.openPanel;
+    this.closePanels();
+    if (panel) this.openPanelView(panel);
+  }
+
+  private panelEquip(itemId: string): void {
+    const state = this.requireState();
+    const def = itemDef(itemId);
+    const result = def.type === 'weapon' ? equipWeapon(state, itemId) : equipArmor(state, itemId);
+    this.toast(result.message);
+    if (result.ok && def.type === 'weapon') this.respawnPlayer();
+    this.refreshHud();
+    this.refreshOpenPanel();
+  }
+
+  private panelUnequip(slot: 'weapon' | 'armor'): void {
+    const state = this.requireState();
+    const result = slot === 'weapon' ? unequipWeapon(state) : unequipArmor(state);
+    this.toast(result.message);
+    if (result.ok && slot === 'weapon') this.respawnPlayer();
+    this.refreshHud();
+    this.refreshOpenPanel();
+  }
+
+  private panelUse(itemId: string): void {
+    const state = this.requireState();
+    const result = useConsumable(state, itemId);
+    this.toast(result.message);
+    if (result.ok) {
+      const def = itemDef(itemId);
+      if (def.healAmount) this.effects.healNumber(state.player.pos, def.healAmount);
+      this.requireRenderer().refreshActor(state.player);
+    }
+    this.refreshHud();
+    this.refreshOpenPanel();
+  }
+
+  /** Redraw the player element so equipment badges update. */
+  private respawnPlayer(): void {
+    const state = this.requireState();
+    this.requireRenderer().spawnActor(state.player);
+  }
+
+  // ---- pause / saves -----------------------------------------------------
+
+  private openPause(): void {
+    if (this.state?.mode === 'dialogue') return;
+    this.pause?.show();
+  }
+
+  private closePause(): void {
+    this.pause?.hide();
+  }
+
+  private showSaves(): void {
+    this.savesView?.show({
+      entries: () => this.slotEntries(),
+      canSaveNow: () => !!this.state && !this.animations?.busy && !this.targeting,
+      onLoad: (slot) => this.loadSlot(slot),
+      onSave: (slot) => this.saveToSlot(slot),
+      onDelete: (slot) => this.saves.delete(slot),
+      onExport: (slot) => this.exportSlot(slot),
+      onImportJson: (json) => this.importJson(json),
+      onClose: () => this.savesView?.hide(),
+    });
+  }
+
+  private slotEntries(): SlotEntry[] {
+    return [
+      {
+        slot: AUTOSAVE_SLOT,
+        label: 'Autosave',
+        info: this.saves.readInfo(AUTOSAVE_SLOT),
+        readOnly: true,
+      },
+      ...SAVE_SLOTS.map((slot) => ({
+        slot,
+        label: slot === 'manual' ? 'Manual' : `Slot ${slot.replace('slot', '')}`,
+        info: this.saves.readInfo(slot),
+        readOnly: false,
+      })),
+    ];
+  }
+
+  private saveToSlot(slot: string): void {
+    if (!this.state) return;
+    this.state.rngState = this.rng.getState();
+    const ok = this.saves.save(slot, this.state);
+    this.toast(ok ? 'Saved.' : 'Save failed.');
+  }
+
+  private autosave(): void {
+    if (!this.state || this.animations?.busy) return;
+    this.state.rngState = this.rng.getState();
+    this.saves.save(AUTOSAVE_SLOT, this.state);
+    this.titleScreen?.setHasSave(true);
+  }
+
+  private exportSlot(slot: string): void {
+    const raw = this.saves.readRaw(slot);
+    if (!raw) return;
+    const blob = new Blob([raw], { type: 'application/json' });
+    const url = URL.createObjectURL(blob);
+    const anchor = el('a');
+    anchor.href = url;
+    anchor.download = `the-last-glyph-${slot}.json`;
+    anchor.click();
+    URL.revokeObjectURL(url);
+  }
+
+  /** Returns an error message, or null on success. */
+  private importJson(json: string): string | null {
+    let parsed: unknown;
+    try {
+      parsed = JSON.parse(json);
+    } catch {
+      return 'That file is not valid JSON.';
+    }
+    const file = validateSave(parsed);
+    if (!file) {
+      return 'That file is not a valid The Last Glyph save (wrong version or corrupted).';
+    }
+    this.adoptLoadedState(file.data);
+    this.savesView?.hide();
+    this.pause?.hide();
+    this.startPlay();
+    return null;
+  }
+
+  private quitToTitle(): void {
+    this.autosave();
+    this.pause?.hide();
+    this.savesView?.hide();
+    if (this.playtimeTimer !== null) {
+      window.clearInterval(this.playtimeTimer);
+      this.playtimeTimer = null;
+    }
+    this.screens.show('title');
+  }
+
+  // ---- settings / misc ---------------------------------------------------
+
+  private loadSettings(): Settings {
+    try {
+      const raw = window.localStorage.getItem(STORAGE_KEYS.settings);
+      if (raw) return { ...DEFAULT_SETTINGS, ...(JSON.parse(raw) as Partial<Settings>) };
+    } catch {
+      // malformed settings fall through to defaults
+    }
+    const prefersReduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
+    return { ...DEFAULT_SETTINGS, reducedMotion: prefersReduced };
+  }
+
+  private applySettings(settings: Settings): void {
+    this.settings = settings;
+    if (this.state) this.state.settings = settings;
+    try {
+      window.localStorage.setItem(STORAGE_KEYS.settings, JSON.stringify(settings));
+    } catch {
+      // storage full or blocked — settings just won't persist
+    }
+    document.body.classList.toggle('reduced-motion', settings.reducedMotion);
+    document.documentElement.style.setProperty('--text-scale', String(settings.textScale));
+    this.renderer?.setMotionOptions(settings.reducedMotion, settings.animationSpeed);
+  }
+
+  private onAnimationBusy(busy: boolean): void {
+    this.bus.emit('animation:busy', { busy });
+    if (busy) {
+      this.input.lock();
+    } else if (this.state && (this.state.mode !== 'combat' || this.playerTurnActive)) {
+      this.input.unlock();
+    }
+  }
+
+  private refreshHud(): void {
+    if (this.state) this.hud?.update(this.state);
+  }
+
+  private refreshTurnOrder(activeId: string | null): void {
+    const state = this.requireState();
+    if (state.mode !== 'combat') {
+      this.hud?.updateTurnOrder([]);
+      return;
+    }
+    const entries: TurnOrderEntry[] = this.turnManager.peekOrder(6).map((actorId, index) => {
+      const actor = findActor(state, actorId);
+      return {
+        actorId,
+        name: actor?.kind === 'player' ? 'You' : (actor?.name ?? actorId),
+        glyph: actor?.appearance.bodyGlyph ?? '❔',
+        active: activeId !== null ? actorId === activeId : index === 0,
+      };
+    });
+    this.hud?.updateTurnOrder(entries);
+  }
+
+  private toast(text: string): void {
+    this.bus.emit('ui:toast', { text });
+  }
+
+  private requireState(): GameState {
+    if (!this.state) throw new Error('No active game state');
+    return this.state;
+  }
+
+  private requireRenderer(): Renderer {
+    if (!this.renderer) throw new Error('Game screen not built');
+    return this.renderer;
+  }
+
+  private requireMovement(): MovementSystem {
+    if (!this.movement) throw new Error('Game screen not built');
+    return this.movement;
+  }
+
+  private currentMap(): MapDefinition {
+    return this.mapManager.get(this.requireState().mapId);
+  }
+}
@@ -0,0 +1,141 @@+import type {
+  ActiveCondition,
+  ActorAppearance,
+  ActorStats,
+  AiType,
+  Direction,
+  Faction,
+  GridPos,
+} from './types';
+
+export type ActorKind = 'player' | 'npc' | 'enemy';
+
+export interface ActorState {
+  id: string;
+  kind: ActorKind;
+  defId: string;
+  name: string;
+  appearance: ActorAppearance;
+  pos: GridPos;
+  hp: number;
+  maxHp: number;
+  focus: number;
+  maxFocus: number;
+  stats: ActorStats;
+  conditions: ActiveCondition[];
+  alive: boolean;
+  faction: Faction;
+  aiType?: AiType;
+  patrol?: GridPos[];
+  patrolIndex?: number;
+  /** Rust machines cycle a predictable routine; this is the cycle position. */
+  routineStep?: number;
+  aggroed?: boolean;
+  /** `${defId}@${x},${y}` of the map spawn that created this actor. */
+  spawnKey?: string;
+}
+
+export interface PlayerState extends ActorState {
+  kind: 'player';
+  appearanceVariantId: string;
+  abilityIds: string[];
+  equippedWeaponId: string | null;
+  equippedArmorId: string | null;
+  facing: Direction;
+}
+
+export interface InventoryState {
+  /** itemId -> quantity */
+  items: Record<string, number>;
+}
+
+export type QuestStageStatus = 'active' | 'completed';
+
+export interface QuestState {
+  id: string;
+  status: QuestStageStatus;
+  stageIndex: number;
+}
+
+export interface Tendencies {
+  resolve: number;
+  insight: number;
+  empathy: number;
+}
+
+export interface Settings {
+  reducedMotion: boolean;
+  /** Multiplier on animation durations: 0.5 fast, 1 normal, 2 slow. */
+  animationSpeed: number;
+  screenShake: boolean;
+  textScale: number;
+}
+
+export const DEFAULT_SETTINGS: Settings = {
+  reducedMotion: false,
+  animationSpeed: 1,
+  screenShake: true,
+  textScale: 1,
+};
+
+export type GameMode = 'exploration' | 'combat' | 'dialogue';
+
+/** Per-map state persisted when the player leaves a map. */
+export interface PersistedMapState {
+  actors: ActorState[];
+  consumedInteractables: string[];
+}
+
+/** The single explicit game-state structure. DOM reflects this; never the reverse. */
+export interface GameState {
+  version: number;
+  mode: GameMode;
+  mapId: string;
+  region: string;
+  objective: string;
+  player: PlayerState;
+  /** Non-player actors on the current map. */
+  actors: ActorState[];
+  /** mapId -> state of maps the player has left (dead stay dead, moved stay moved). */
+  mapStates: Record<string, PersistedMapState>;
+  flags: Record<string, boolean>;
+  /** npcId -> remembered event ids (event-based memory, not points). */
+  npcMemories: Record<string, string[]>;
+  /** npcId -> attitude label (hostile/wary/friendly/...), set by dialogue. */
+  npcAttitudes: Record<string, string>;
+  quests: Record<string, QuestState>;
+  inventory: InventoryState;
+  tendencies: Tendencies;
+  /** Lore entry ids discovered (journal). */
+  discoveries: string[];
+  /** Character ids the protagonist has met (journal). */
+  metCharacters: string[];
+  /** Name Shard ids acquired (0–4, journal + endings). */
+  shards: string[];
+  /** Name Echo object ids already heard (one-time). */
+  echoesHeard: string[];
+  /** Route ids opened by dialogue/quests. */
+  routes: string[];
+  /** region -> outcome label, for Act II regional decisions. */
+  regionalOutcomes: Record<string, string>;
+  settings: Settings;
+  playtimeSeconds: number;
+  rngState: number;
+  /** Dialogue node ids already shown (for once-nodes). */
+  dialogueSeen: string[];
+  /** Interactable ids already consumed on the current map. */
+  consumedInteractables: string[];
+}
+
+export function allActors(state: GameState): ActorState[] {
+  return [state.player, ...state.actors];
+}
+
+export function findActor(state: GameState, actorId: string): ActorState | null {
+  if (state.player.id === actorId) return state.player;
+  return state.actors.find((a) => a.id === actorId) ?? null;
+}
+
+export function livingHostiles(state: GameState): ActorState[] {
+  return state.actors.filter((a) => a.alive && a.faction === 'hostile');
+}
@@ -0,0 +1,126 @@+import type { Direction, GridPos } from './types';
+
+export type PanelId = 'inventory' | 'journal' | 'character';
+
+export type InputAction =
+  | { type: 'move'; direction: Direction }
+  | { type: 'interact' }
+  | { type: 'cancel' }
+  | { type: 'choice'; index: number }
+  | { type: 'tileTap'; pos: GridPos }
+  | { type: 'openPanel'; panel: PanelId }
+  | { type: 'pause' };
+
+export type InputHandler = (action: InputAction) => void;
+
+const KEY_DIRECTIONS: Record<string, Direction> = {
+  w: 'up',
+  W: 'up',
+  ArrowUp: 'up',
+  s: 'down',
+  S: 'down',
+  ArrowDown: 'down',
+  a: 'left',
+  A: 'left',
+  ArrowLeft: 'left',
+  d: 'right',
+  D: 'right',
+  ArrowRight: 'right',
+};
+
+/**
+ * Keyboard + pointer input. Locked while animations resolve — the handler
+ * never sees input during that window.
+ */
+export class InputManager {
+  private handler: InputHandler | null = null;
+  private locked = false;
+  private tilePicker: ((clientX: number, clientY: number) => GridPos | null) | null = null;
+  private pointerTarget: HTMLElement | null = null;
+
+  private readonly onKeyDown = (event: KeyboardEvent): void => {
+    if (!this.handler || this.locked) return;
+    if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
+      return;
+    }
+    const direction = KEY_DIRECTIONS[event.key];
+    if (direction) {
+      event.preventDefault();
+      this.handler({ type: 'move', direction });
+      return;
+    }
+    if (event.key === 'Enter' || event.key === ' ') {
+      event.preventDefault();
+      this.handler({ type: 'interact' });
+      return;
+    }
+    if (event.key === 'Escape') {
+      event.preventDefault();
+      this.handler({ type: 'cancel' });
+      return;
+    }
+    if (event.key === 'p' || event.key === 'P') {
+      this.handler({ type: 'pause' });
+      return;
+    }
+    if (event.key === 'i' || event.key === 'I') {
+      this.handler({ type: 'openPanel', panel: 'inventory' });
+      return;
+    }
+    if (event.key === 'j' || event.key === 'J') {
+      this.handler({ type: 'openPanel', panel: 'journal' });
+      return;
+    }
+    if (event.key === 'c' || event.key === 'C') {
+      this.handler({ type: 'openPanel', panel: 'character' });
+      return;
+    }
+    const digit = Number(event.key);
+    if (Number.isInteger(digit) && digit >= 1 && digit <= 9) {
+      this.handler({ type: 'choice', index: digit - 1 });
+    }
+  };
+
+  private readonly onPointerDown = (event: PointerEvent): void => {
+    if (!this.handler || this.locked || !this.tilePicker) return;
+    const pos = this.tilePicker(event.clientX, event.clientY);
+    if (pos) this.handler({ type: 'tileTap', pos });
+  };
+
+  attach(handler: InputHandler): void {
+    this.handler = handler;
+    window.addEventListener('keydown', this.onKeyDown);
+  }
+
+  detach(): void {
+    this.handler = null;
+    window.removeEventListener('keydown', this.onKeyDown);
+    this.enableTilePicking(null, null);
+  }
+
+  enableTilePicking(
+    target: HTMLElement | null,
+    picker: ((clientX: number, clientY: number) => GridPos | null) | null,
+  ): void {
+    if (this.pointerTarget) {
+      this.pointerTarget.removeEventListener('pointerdown', this.onPointerDown);
+    }
+    this.pointerTarget = target;
+    this.tilePicker = picker;
+    if (target && picker) {
+      target.addEventListener('pointerdown', this.onPointerDown);
+    }
+  }
+
+  lock(): void {
+    this.locked = true;
+  }
+
+  unlock(): void {
+    this.locked = false;
+  }
+
+  get isLocked(): boolean {
+    return this.locked;
+  }
+}
@@ -0,0 +1,37 @@+// mulberry32: small, fast, deterministic. The whole game derives randomness
+// from one instance so a saved seed reproduces the same outcomes.
+export class Rng {
+  private state: number;
+
+  constructor(seed: number) {
+    this.state = seed >>> 0;
+  }
+
+  next(): number {
+    this.state = (this.state + 0x6d2b79f5) >>> 0;
+    let t = this.state;
+    t = Math.imul(t ^ (t >>> 15), t | 1);
+    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
+    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+  }
+
+  int(minInclusive: number, maxInclusive: number): number {
+    return minInclusive + Math.floor(this.next() * (maxInclusive - minInclusive + 1));
+  }
+
+  chance(probability: number): boolean {
+    return this.next() < probability;
+  }
+
+  pick<T>(items: readonly T[]): T {
+    return items[Math.floor(this.next() * items.length)];
+  }
+
+  getState(): number {
+    return this.state;
+  }
+
+  setState(state: number): void {
+    this.state = state >>> 0;
+  }
+}
@@ -0,0 +1,184 @@+import { SAVE_VERSION, STORAGE_KEYS } from './constants';
+import type { GameState } from './GameState';
+
+/** Minimal storage interface so saves are testable outside the browser. */
+export interface KeyValueStorage {
+  getItem(key: string): string | null;
+  setItem(key: string, value: string): void;
+  removeItem(key: string): void;
+}
+
+export interface SaveFile {
+  version: number;
+  slot: string;
+  savedAt: string;
+  playtimeSeconds: number;
+  data: GameState;
+}
+
+export function serializeState(state: GameState, slot: string): SaveFile {
+  return {
+    version: SAVE_VERSION,
+    slot,
+    savedAt: new Date().toISOString(),
+    playtimeSeconds: state.playtimeSeconds,
+    data: state,
+  };
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function isNumber(value: unknown): value is number {
+  return typeof value === 'number' && Number.isFinite(value);
+}
+
+function isString(value: unknown): value is string {
+  return typeof value === 'string';
+}
+
+function isBooleanRecord(value: unknown): value is Record<string, boolean> {
+  return isRecord(value) && Object.values(value).every((v) => typeof v === 'boolean');
+}
+
+function validateActor(actor: unknown): boolean {
+  if (!isRecord(actor)) return false;
+  return (
+    isString(actor.id) &&
+    isString(actor.name) &&
+    isRecord(actor.pos) &&
+    isNumber(actor.pos.x) &&
+    isNumber(actor.pos.y) &&
+    isNumber(actor.hp) &&
+    isNumber(actor.maxHp) &&
+    isNumber(actor.focus) &&
+    isNumber(actor.maxFocus) &&
+    isRecord(actor.stats) &&
+    isNumber(actor.stats.attack) &&
+    isNumber(actor.stats.defense) &&
+    isNumber(actor.stats.speed) &&
+    isNumber(actor.stats.insight) &&
+    isRecord(actor.appearance) &&
+    isString(actor.appearance.bodyGlyph) &&
+    Array.isArray(actor.conditions) &&
+    typeof actor.alive === 'boolean' &&
+    (actor.faction === 'player' || actor.faction === 'neutral' || actor.faction === 'hostile')
+  );
+}
+
+function isStringArray(value: unknown): value is string[] {
+  return Array.isArray(value) && value.every((v) => typeof v === 'string');
+}
+
+function isStringRecord(value: unknown): value is Record<string, string> {
+  return isRecord(value) && Object.values(value).every((v) => typeof v === 'string');
+}
+
+function validateMapStates(value: unknown): boolean {
+  if (!isRecord(value)) return false;
+  return Object.values(value).every(
+    (entry) =>
+      isRecord(entry) &&
+      Array.isArray(entry.actors) &&
+      entry.actors.every(validateActor) &&
+      isStringArray(entry.consumedInteractables),
+  );
+}
+
+/** Structural validation. Returns the save or null — never throws. */
+export function validateSave(raw: unknown): SaveFile | null {
+  if (!isRecord(raw)) return null;
+  if (raw.version !== SAVE_VERSION) return null;
+  if (!isString(raw.slot) || !isString(raw.savedAt)) return null;
+  const data = raw.data;
+  if (!isRecord(data)) return null;
+  if (data.version !== SAVE_VERSION) return null;
+  if (!isString(data.mapId) || !isString(data.region) || !isString(data.objective)) return null;
+  if (data.mode !== 'exploration' && data.mode !== 'combat' && data.mode !== 'dialogue') return null;
+  if (!validateActor(data.player)) return null;
+  const player = data.player as Record<string, unknown>;
+  if (!(player.equippedWeaponId === null || isString(player.equippedWeaponId))) return null;
+  if (!(player.equippedArmorId === null || isString(player.equippedArmorId))) return null;
+  if (!Array.isArray(data.actors) || !data.actors.every(validateActor)) return null;
+  if (!validateMapStates(data.mapStates)) return null;
+  if (!isBooleanRecord(data.flags)) return null;
+  if (!isRecord(data.npcMemories)) return null;
+  if (!isStringRecord(data.npcAttitudes)) return null;
+  if (!isRecord(data.quests)) return null;
+  if (!isRecord(data.inventory) || !isRecord(data.inventory.items)) return null;
+  if (!isRecord(data.tendencies)) return null;
+  if (!isNumber(data.tendencies.resolve) || !isNumber(data.tendencies.insight)) return null;
+  if (!isNumber(data.tendencies.empathy)) return null;
+  if (!isStringArray(data.discoveries) || !isStringArray(data.metCharacters)) return null;
+  if (!isStringArray(data.shards) || !isStringArray(data.echoesHeard)) return null;
+  if (!isStringArray(data.routes)) return null;
+  if (!isStringRecord(data.regionalOutcomes)) return null;
+  if (!isRecord(data.settings)) return null;
+  if (!isNumber(data.playtimeSeconds) || !isNumber(data.rngState)) return null;
+  if (!Array.isArray(data.dialogueSeen) || !Array.isArray(data.consumedInteractables)) return null;
+  return raw as unknown as SaveFile;
+}
+
+export class SaveManager {
+  constructor(private readonly storage: KeyValueStorage) {}
+
+  save(slot: string, state: GameState): boolean {
+    try {
+      const file = serializeState(state, slot);
+      this.storage.setItem(STORAGE_KEYS.saveSlot(slot), JSON.stringify(file));
+      return true;
+    } catch {
+      return false; // quota or serialization failure — never crash on save
+    }
+  }
+
+  load(slot: string): GameState | null {
+    const raw = this.storage.getItem(STORAGE_KEYS.saveSlot(slot));
+    if (raw === null) return null;
+    try {
+      const file = validateSave(JSON.parse(raw));
+      return file ? file.data : null;
+    } catch {
+      return null;
+    }
+  }
+
+  hasSave(slot: string): boolean {
+    return this.storage.getItem(STORAGE_KEYS.saveSlot(slot)) !== null;
+  }
+
+  delete(slot: string): void {
+    this.storage.removeItem(STORAGE_KEYS.saveSlot(slot));
+  }
+
+  /** Raw stored JSON for a slot (used by export), or null. */
+  readRaw(slot: string): string | null {
+    return this.storage.getItem(STORAGE_KEYS.saveSlot(slot));
+  }
+
+  /** Validated save metadata for slot listings; null when absent or corrupt. */
+  readInfo(slot: string): SaveFile | null {
+    const raw = this.readRaw(slot);
+    if (raw === null) return null;
+    try {
+      return validateSave(JSON.parse(raw));
+    } catch {
+      return null;
+    }
+  }
+
+  export(state: GameState, slot: string): string {
+    return JSON.stringify(serializeState(state, slot), null, 2);
+  }
+
+  /** Malformed imports must not crash: parse + validate, return null on failure. */
+  import(json: string): GameState | null {
+    try {
+      const file = validateSave(JSON.parse(json));
+      return file ? file.data : null;
+    } catch {
+      return null;
+    }
+  }
+}
@@ -0,0 +1,94 @@+import { READINESS_THRESHOLD, TURN_COSTS } from './constants';
+
+export interface TurnEntry {
+  actorId: string;
+  speed: number;
+  readiness: number;
+}
+
+/**
+ * Deterministic readiness model. Actors accumulate readiness by speed; when
+ * an actor reaches the threshold it acts and spends the action's cost.
+ * Ties break on actor id, so the same inputs always yield the same order.
+ */
+export class TurnManager {
+  private entries = new Map<string, TurnEntry>();
+
+  addActor(actorId: string, speed: number, initialReadiness = 0): void {
+    this.entries.set(actorId, { actorId, speed, readiness: initialReadiness });
+  }
+
+  removeActor(actorId: string): void {
+    this.entries.delete(actorId);
+  }
+
+  setSpeed(actorId: string, speed: number): void {
+    const entry = this.entries.get(actorId);
+    if (entry) entry.speed = speed;
+  }
+
+  has(actorId: string): boolean {
+    return this.entries.has(actorId);
+  }
+
+  clear(): void {
+    this.entries.clear();
+  }
+
+  private static pickReady(entries: Iterable<TurnEntry>): string | null {
+    let best: TurnEntry | null = null;
+    for (const entry of entries) {
+      if (entry.readiness < READINESS_THRESHOLD) continue;
+      if (
+        best === null ||
+        entry.readiness > best.readiness ||
+        (entry.readiness === best.readiness && entry.actorId < best.actorId)
+      ) {
+        best = entry;
+      }
+    }
+    return best ? best.actorId : null;
+  }
+
+  /** Accumulates readiness until someone can act; returns that actor. */
+  advance(): string {
+    let ready = TurnManager.pickReady(this.entries.values());
+    let guard = 0;
+    while (ready === null) {
+      if (guard++ > 10000) throw new Error('TurnManager: no actor can ever act');
+      for (const entry of this.entries.values()) {
+        entry.readiness += entry.speed;
+      }
+      ready = TurnManager.pickReady(this.entries.values());
+    }
+    return ready;
+  }
+
+  /** Spends readiness for the action just taken. */
+  commit(actorId: string, cost: number): void {
+    const entry = this.entries.get(actorId);
+    if (!entry) return;
+    entry.readiness = Math.max(0, entry.readiness - cost);
+  }
+
+  /**
+   * Projected turn order for the HUD. Assumes a standard attack cost for
+   * future turns; deterministic given the same entries.
+   */
+  peekOrder(count: number): string[] {
+    const sim = [...this.entries.values()].map((e) => ({ ...e }));
+    const order: string[] = [];
+    let guard = 0;
+    while (order.length < count && sim.length > 0 && guard++ < 1000) {
+      let ready = TurnManager.pickReady(sim);
+      while (ready === null) {
+        for (const entry of sim) entry.readiness += entry.speed;
+        ready = TurnManager.pickReady(sim);
+      }
+      order.push(ready);
+      const entry = sim.find((e) => e.actorId === ready);
+      if (entry) entry.readiness = Math.max(0, entry.readiness - TURN_COSTS.attack);
+    }
+    return order;
+  }
+}
@@ -0,0 +1,36 @@+export const TILE_SIZE = 48;
+
+export const SAVE_VERSION = 2;
+
+export const STORAGE_KEYS = {
+  settings: 'tlg:settings',
+  saveSlot: (slot: string) => `tlg:save:${slot}`,
+} as const;
+
+export const AUTOSAVE_SLOT = 'auto';
+
+/** Player-facing save slots (autosave lives outside this list). */
+export const SAVE_SLOTS = ['manual', 'slot1', 'slot2', 'slot3'] as const;
+
+// Deterministic turn model: actors accumulate readiness by speed; acting
+// spends readiness. Threshold to act is 100.
+export const READINESS_THRESHOLD = 100;
+
+export const TURN_COSTS = {
+  move: 100,
+  attack: 100,
+  defend: 80,
+  item: 80,
+  heavy: 140,
+  powerful: 160,
+  wait: 100,
+} as const;
+
+export type TurnCostKind = keyof typeof TURN_COSTS;
+
+export const MOVE_ANIMATION_MS = 180;
+export const MOVE_ANIMATION_MIN_MS = 140;
+export const MOVE_ANIMATION_MAX_MS = 240;
+
+export const PLAYER_MOVE_RANGE = 3;
+export const COMBAT_JOIN_RADIUS = 6;
@@ -0,0 +1,6 @@+export interface GameEvents extends Record<string, unknown> {
+  'ui:toast': { text: string };
+  'combat:started': { enemyIds: string[] };
+  'combat:ended': { reason: 'cleared' | 'calmed' | 'died' };
+  'animation:busy': { busy: boolean };
+}
@@ -0,0 +1,73 @@+export interface GridPos {
+  x: number;
+  y: number;
+}
+
+export type Direction = 'up' | 'down' | 'left' | 'right';
+
+export const DIRECTION_VECTORS: Record<Direction, GridPos> = {
+  up: { x: 0, y: -1 },
+  down: { x: 0, y: 1 },
+  left: { x: -1, y: 0 },
+  right: { x: 1, y: 0 },
+};
+
+export function posKey(pos: GridPos): string {
+  return `${pos.x},${pos.y}`;
+}
+
+export function addPos(a: GridPos, b: GridPos): GridPos {
+  return { x: a.x + b.x, y: a.y + b.y };
+}
+
+export function samePos(a: GridPos, b: GridPos): boolean {
+  return a.x === b.x && a.y === b.y;
+}
+
+export function manhattan(a: GridPos, b: GridPos): number {
+  return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
+}
+
+export function chebyshev(a: GridPos, b: GridPos): number {
+  return Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y));
+}
+
+/** Visual identity: body is the character; the rest are badges. */
+export interface ActorAppearance {
+  bodyGlyph: string;
+  roleGlyph?: string;
+  equipmentGlyph?: string;
+  statusGlyph?: string;
+  factionGlyph?: string;
+}
+
+export interface ActorStats {
+  attack: number;
+  defense: number;
+  speed: number;
+  insight: number;
+}
+
+export type Faction = 'player' | 'neutral' | 'hostile';
+
+export type AiType = 'wolf' | 'hollowSoldier' | 'archer' | 'rustMachine' | 'crownSoldier' | 'mireLurker';
+
+export type ConditionId =
+  | 'bleeding'
+  | 'burning'
+  | 'frozen'
+  | 'staggered'
+  | 'afraid'
+  | 'guarded'
+  | 'silenced'
+  | 'forgotten'
+  | 'remembered'
+  | 'unstable';
+
+export interface ActiveCondition {
+  id: ConditionId;
+  remaining: number;
+}
+
+/** Movement animation personality, per archetype. */
+export type MoveArchetype = 'default' | 'heavy' | 'light' | 'irregular' | 'hop';
@@ -0,0 +1,226 @@+import type { ConditionId } from '../core/types';
+import type { TurnCostKind } from '../core/constants';
+import { GLYPH_IDS, GLYPHS } from './glyphs';
+
+const GLYPH_LOOKUP = new Map(GLYPHS.map((g) => [g.id, g.glyph]));
+
+function glyphOf(id: string): string {
+  const glyph = GLYPH_LOOKUP.get(id);
+  if (!glyph) throw new Error(`Unknown glyph id: ${id}`);
+  return glyph;
+}
+
+export type TendencyAxis = 'resolve' | 'insight' | 'empathy';
+
+export interface AbilityUnlock {
+  axis: TendencyAxis;
+  threshold: number;
+}
+
+export interface AbilityDef {
+  id: string;
+  name: string;
+  glyph: string;
+  description: string;
+  costKind: TurnCostKind;
+  damageMult: number;
+  /** 0 = self-targeted. Ranged abilities (range > 1) require line of sight. */
+  range: number;
+  focusCost: number;
+  appliesCondition?: { id: ConditionId; duration: number };
+  selfCondition?: { id: ConditionId; duration: number };
+  tendency?: TendencyAxis;
+  effectGlyph: string;
+  /** Tendency threshold that unlocks this ability. Absent = starting ability. */
+  unlock?: AbilityUnlock;
+  /** Deals no damage; only conditions/specials apply. */
+  noDamage?: boolean;
+  /** Pushes the target one tile away (Staggered instead if blocked). */
+  push?: boolean;
+  /** Removes all conditions from the user. */
+  cleanses?: boolean;
+  /** Heals the user. */
+  heals?: number;
+  /** Shows the target's inspect text (Insight path). */
+  reveals?: boolean;
+}
+
+export const ABILITY_IDS = {
+  strike: 'strike',
+  heavyBlow: 'heavyBlow',
+  nameEcho: 'nameEcho',
+  guardStance: 'guardStance',
+  pushBack: 'pushBack',
+  ironResolve: 'ironResolve',
+  scrutinize: 'scrutinize',
+  disrupt: 'disrupt',
+  restore: 'restore',
+  remember: 'remember',
+} as const;
+
+export const ABILITIES: Record<string, AbilityDef> = {
+  [ABILITY_IDS.strike]: {
+    id: ABILITY_IDS.strike,
+    name: 'Strike',
+    glyph: glyphOf(GLYPH_IDS.fxSlash),
+    description: 'A plain, honest blow. Melee range.',
+    costKind: 'attack',
+    damageMult: 1,
+    range: 1,
+    focusCost: 0,
+    effectGlyph: glyphOf(GLYPH_IDS.fxSlash),
+  },
+  [ABILITY_IDS.heavyBlow]: {
+    id: ABILITY_IDS.heavyBlow,
+    name: 'Heavy Blow',
+    glyph: glyphOf(GLYPH_IDS.fxImpact),
+    description: 'A committed swing that staggers the target. Slower to recover.',
+    costKind: 'heavy',
+    damageMult: 1.6,
+    range: 1,
+    focusCost: 0,
+    appliesCondition: { id: 'staggered', duration: 1 },
+    tendency: 'resolve',
+    effectGlyph: glyphOf(GLYPH_IDS.fxImpact),
+  },
+  [ABILITY_IDS.nameEcho]: {
+    id: ABILITY_IDS.nameEcho,
+    name: 'Name Echo',
+    glyph: glyphOf(GLYPH_IDS.fxSparkle),
+    description:
+      'Hear the fragment of a true name. Damages at range (needs line of sight), anchors you (Remembered) and unsettles the target (Afraid).',
+    costKind: 'powerful',
+    damageMult: 1.2,
+    range: 3,
+    focusCost: 2,
+    appliesCondition: { id: 'afraid', duration: 2 },
+    selfCondition: { id: 'remembered', duration: 2 },
+    tendency: 'insight',
+    effectGlyph: glyphOf(GLYPH_IDS.fxSparkle),
+  },
+  [ABILITY_IDS.guardStance]: {
+    id: ABILITY_IDS.guardStance,
+    name: 'Guard Stance',
+    glyph: glyphOf(GLYPH_IDS.fxShield),
+    description: 'Brace behind your guard (Guarded). Quick recovery.',
+    costKind: 'defend',
+    damageMult: 0,
+    range: 0,
+    focusCost: 0,
+    selfCondition: { id: 'guarded', duration: 1 },
+    tendency: 'resolve',
+    effectGlyph: glyphOf(GLYPH_IDS.fxShield),
+  },
+  // — Tendency unlocks. Resolve: guard/push/resist. Insight: inspect/reveal/disrupt.
+  // Empathy: calm/remember/restore. Mixed approaches never lock anything out.
+  [ABILITY_IDS.pushBack]: {
+    id: ABILITY_IDS.pushBack,
+    name: 'Push Back',
+    glyph: glyphOf(GLYPH_IDS.fxGust),
+    description: 'Shove the target one tile away. If it cannot move, it is Staggered.',
+    costKind: 'attack',
+    damageMult: 0.5,
+    range: 1,
+    focusCost: 0,
+    push: true,
+    tendency: 'resolve',
+    unlock: { axis: 'resolve', threshold: 3 },
+    effectGlyph: glyphOf(GLYPH_IDS.fxGust),
+  },
+  [ABILITY_IDS.ironResolve]: {
+    id: ABILITY_IDS.ironResolve,
+    name: 'Iron Resolve',
+    glyph: glyphOf(GLYPH_IDS.fxShield),
+    description: 'Shake off every condition and brace (Guarded).',
+    costKind: 'defend',
+    damageMult: 0,
+    range: 0,
+    focusCost: 1,
+    cleanses: true,
+    selfCondition: { id: 'guarded', duration: 1 },
+    tendency: 'resolve',
+    unlock: { axis: 'resolve', threshold: 6 },
+    effectGlyph: glyphOf(GLYPH_IDS.fxShield),
+  },
+  [ABILITY_IDS.scrutinize]: {
+    id: ABILITY_IDS.scrutinize,
+    name: 'Scrutinize',
+    glyph: glyphOf(GLYPH_IDS.uiEye),
+    description: 'Read an enemy at range (needs line of sight). Reveals it — and its weakness (Unstable).',
+    costKind: 'item',
+    damageMult: 0,
+    range: 4,
+    focusCost: 0,
+    noDamage: true,
+    reveals: true,
+    appliesCondition: { id: 'unstable', duration: 2 },
+    tendency: 'insight',
+    unlock: { axis: 'insight', threshold: 3 },
+    effectGlyph: glyphOf(GLYPH_IDS.uiEye),
+  },
+  [ABILITY_IDS.disrupt]: {
+    id: ABILITY_IDS.disrupt,
+    name: 'Disrupt',
+    glyph: glyphOf(GLYPH_IDS.fxSwirl),
+    description: 'Unravel how the target sees itself: Silenced and Staggered at range.',
+    costKind: 'heavy',
+    damageMult: 0.8,
+    range: 3,
+    focusCost: 2,
+    appliesCondition: { id: 'silenced', duration: 2 },
+    tendency: 'insight',
+    unlock: { axis: 'insight', threshold: 6 },
+    effectGlyph: glyphOf(GLYPH_IDS.fxSwirl),
+  },
+  [ABILITY_IDS.restore]: {
+    id: ABILITY_IDS.restore,
+    name: 'Restore',
+    glyph: glyphOf(GLYPH_IDS.fxSparkle),
+    description: 'Bind your own wounds with attention. Heals 6 health.',
+    costKind: 'item',
+    damageMult: 0,
+    range: 0,
+    focusCost: 2,
+    noDamage: true,
+    heals: 6,
+    tendency: 'empathy',
+    unlock: { axis: 'empathy', threshold: 3 },
+    effectGlyph: glyphOf(GLYPH_IDS.fxSparkle),
+  },
+  [ABILITY_IDS.remember]: {
+    id: ABILITY_IDS.remember,
+    name: 'Remember',
+    glyph: glyphOf(GLYPH_IDS.fxSparkle),
+    description: 'Hold on to who you were a moment ago: cleanse conditions and become Remembered.',
+    costKind: 'powerful',
+    damageMult: 0,
+    range: 0,
+    focusCost: 2,
+    noDamage: true,
+    cleanses: true,
+    selfCondition: { id: 'remembered', duration: 2 },
+    tendency: 'empathy',
+    unlock: { axis: 'empathy', threshold: 6 },
+    effectGlyph: glyphOf(GLYPH_IDS.fxSparkle),
+  },
+};
+
+export function abilityDef(id: string): AbilityDef {
+  const def = ABILITIES[id];
+  if (!def) throw new Error(`Unknown ability id: ${id}`);
+  return def;
+}
+
+export const STARTING_ABILITIES: string[] = [
+  ABILITY_IDS.strike,
+  ABILITY_IDS.heavyBlow,
+  ABILITY_IDS.nameEcho,
+  ABILITY_IDS.guardStance,
+];
+
+/** All tendency-gated abilities, for unlock checks and journal progress. */
+export function tendencyUnlocks(): { abilityId: string; unlock: AbilityUnlock }[] {
+  return Object.values(ABILITIES)
+    .filter((def) => def.unlock !== undefined)
+    .map((def) => ({ abilityId: def.id, unlock: def.unlock as AbilityUnlock }));
+}
@@ -0,0 +1,230 @@+import type { ActorAppearance, ActorStats, AiType, MoveArchetype } from '../core/types';
+import { GLYPH_IDS, GLYPHS } from './glyphs';
+
+const GLYPHS_LOOKUP = new Map(GLYPHS.map((g) => [g.id, g.glyph]));
+
+function glyphOf(id: string): string {
+  const glyph = GLYPHS_LOOKUP.get(id);
+  if (!glyph) throw new Error(`Unknown glyph id: ${id}`);
+  return glyph;
+}
+
+/** Combat data for a named character who can be fought (and can yield). */
+export interface CombatProfile {
+  hp: number;
+  focus: number;
+  stats: ActorStats;
+  aiType: AiType;
+  aggroRange: number;
+  moveRange: number;
+  attackRange: number;
+  /** Canon characters are not killed at 0 hp — they yield instead. */
+  yields: boolean;
+}
+
+export interface CharacterDef {
+  id: string;
+  name: string;
+  title: string;
+  /** One-line journal impression once met. */
+  impression: string;
+  appearance: ActorAppearance;
+  dialogueId?: string;
+  moveArchetype?: MoveArchetype;
+  combat?: CombatProfile;
+}
+
+export const CHARACTER_IDS = {
+  crow: 'crow',
+  mira: 'mira',
+  rook: 'rook',
+  vale: 'vale',
+  hollowKing: 'hollowKing',
+  marshKing: 'marshKing',
+  tam: 'tam',
+  mae: 'mae',
+  fen: 'fen',
+  liora: 'liora',
+  brann: 'brann',
+  wenna: 'wenna',
+  sedge: 'sedge',
+  bellMother: 'bellMother',
+  heartMirror: 'heartMirror',
+} as const;
+
+// Canonical appearances — major characters are fixed by docs/NARRATIVE_CANON.md.
+export const CHARACTERS: Record<string, CharacterDef> = {
+  [CHARACTER_IDS.crow]: {
+    id: CHARACTER_IDS.crow,
+    name: 'Black Crow',
+    title: 'A voice that remembers',
+    impression: 'A crow that talks like it has read the end of the book.',
+    appearance: { bodyGlyph: glyphOf(GLYPH_IDS.crow) },
+    dialogueId: 'crow_prologue',
+    moveArchetype: 'light',
+  },
+  [CHARACTER_IDS.mira]: {
+    id: CHARACTER_IDS.mira,
+    name: 'Mira',
+    title: 'Ranger of the Glasswood',
+    impression: 'A ranger who trusts trees more than people. Maybe rightly.',
+    appearance: {
+      bodyGlyph: glyphOf(GLYPH_IDS.miraBody),
+      roleGlyph: glyphOf(GLYPH_IDS.miraRole),
+    },
+    dialogueId: 'mira_standoff',
+    moveArchetype: 'light',
+    combat: {
+      hp: 22,
+      focus: 6,
+      stats: { attack: 6, defense: 2, speed: 6, insight: 5 },
+      aiType: 'archer',
+      aggroRange: 1,
+      moveRange: 2,
+      attackRange: 4,
+      yields: true,
+    },
+  },
+  [CHARACTER_IDS.rook]: {
+    id: CHARACTER_IDS.rook,
+    name: 'Captain Rook',
+    title: 'Officer of the Crown',
+    impression: 'An officer who confuses protection with control, and knows it somewhere.',
+    appearance: {
+      bodyGlyph: glyphOf(GLYPH_IDS.rookBody),
+      roleGlyph: glyphOf(GLYPH_IDS.rookRole),
+      equipmentGlyph: glyphOf(GLYPH_IDS.rookEquipment),
+    },
+    moveArchetype: 'heavy',
+  },
+  [CHARACTER_IDS.vale]: {
+    id: CHARACTER_IDS.vale,
+    name: 'Sister Vale',
+    title: 'Caretaker of the drowned letters',
+    impression: 'She reads letters addressed to the drowned. Honest in a way that unsettles.',
+    appearance: {
+      bodyGlyph: glyphOf(GLYPH_IDS.valeBody),
+      roleGlyph: glyphOf(GLYPH_IDS.valeRole),
+    },
+  },
+  [CHARACTER_IDS.hollowKing]: {
+    id: CHARACTER_IDS.hollowKing,
+    name: 'The Hollow King',
+    title: 'What is left of Aldren',
+    impression: 'An old man wearing a crown and an absence.',
+    appearance: {
+      bodyGlyph: glyphOf(GLYPH_IDS.hollowKingBody),
+      roleGlyph: glyphOf(GLYPH_IDS.hollowKingRole),
+      statusGlyph: glyphOf(GLYPH_IDS.hollowKingStatus),
+    },
+    moveArchetype: 'irregular',
+  },
+  [CHARACTER_IDS.marshKing]: {
+    id: CHARACTER_IDS.marshKing,
+    name: 'The Marsh King',
+    title: 'Ruler of the Mire of Bells',
+    impression: 'A frog, a king, or the memory of several kings. Possibly all three.',
+    appearance: {
+      bodyGlyph: glyphOf(GLYPH_IDS.marshKingBody),
+      roleGlyph: glyphOf(GLYPH_IDS.marshKingRole),
+    },
+    dialogueId: 'marsh_king',
+    moveArchetype: 'hop',
+  },
+  [CHARACTER_IDS.tam]: {
+    id: CHARACTER_IDS.tam,
+    name: 'Tam',
+    title: 'Baker of Wayrest',
+    impression: 'You remember him. That has to count for something.',
+    appearance: { bodyGlyph: glyphOf(GLYPH_IDS.tamBody) },
+    dialogueId: 'tam_wayrest',
+  },
+  [CHARACTER_IDS.mae]: {
+    id: CHARACTER_IDS.mae,
+    name: 'Mae',
+    title: 'Elder of Wayrest',
+    impression: 'Kind eyes, careful words. She feels the hole where a person used to be.',
+    appearance: { bodyGlyph: glyphOf(GLYPH_IDS.maeBody) },
+    dialogueId: 'mae_wayrest',
+  },
+  [CHARACTER_IDS.fen]: {
+    id: CHARACTER_IDS.fen,
+    name: 'Fen',
+    title: 'Child of Wayrest',
+    impression: 'Loud, quick, and braver than the village deserves.',
+    appearance: { bodyGlyph: glyphOf(GLYPH_IDS.fenBody) },
+    dialogueId: 'fen_wayrest',
+  },
+  [CHARACTER_IDS.liora]: {
+    id: CHARACTER_IDS.liora,
+    name: 'Liora',
+    title: 'Glasswood child',
+    impression: 'She visits her mother in a mirror. Which mother is the question.',
+    appearance: { bodyGlyph: glyphOf(GLYPH_IDS.lioraBody) },
+    dialogueId: 'liora_glasswood',
+  },
+  [CHARACTER_IDS.brann]: {
+    id: CHARACTER_IDS.brann,
+    name: 'Brann',
+    title: 'Warden of the Glasswood',
+    impression: 'An old warden who fails, mostly, at keeping children from looking too long.',
+    appearance: { bodyGlyph: glyphOf(GLYPH_IDS.brannBody) },
+    dialogueId: 'brann_glasswood',
+  },
+  [CHARACTER_IDS.wenna]: {
+    id: CHARACTER_IDS.wenna,
+    name: 'Wenna',
+    title: 'Elder of the Mire',
+    impression: 'She keeps the village’s kind lies the way others keep lanterns.',
+    appearance: { bodyGlyph: glyphOf(GLYPH_IDS.wennaBody) },
+    dialogueId: 'wenna_mire',
+  },
+  [CHARACTER_IDS.sedge]: {
+    id: CHARACTER_IDS.sedge,
+    name: 'Sedge',
+    title: 'Mire child',
+    impression: 'Walks the planks without looking. Asks the questions adults stopped asking.',
+    appearance: { bodyGlyph: glyphOf(GLYPH_IDS.sedgeBody) },
+    dialogueId: 'sedge_mire',
+  },
+  [CHARACTER_IDS.bellMother]: {
+    id: CHARACTER_IDS.bellMother,
+    name: 'The Bell-Mother',
+    title: 'Or Old Sorrow, depending who is asked',
+    impression: 'A woman being someone because the village needs her to be.',
+    appearance: { bodyGlyph: glyphOf(GLYPH_IDS.bellMotherBody) },
+    dialogueId: 'bell_mother',
+  },
+  [CHARACTER_IDS.heartMirror]: {
+    id: CHARACTER_IDS.heartMirror,
+    name: 'The Heart Mirror',
+    title: 'It shows what it chooses',
+    impression: 'A mirror that keeps both versions and asks you to pick one.',
+    appearance: { bodyGlyph: glyphOf(GLYPH_IDS.heartMirrorBody) },
+    dialogueId: 'heart_mirror',
+  },
+};
+
+// Curated, purely cosmetic protagonist variants (canon: small set of human
+// emoji with different skin tones/presentations; no stat or dialogue effect).
+export interface PlayerVariant {
+  id: string;
+  bodyGlyph: string;
+  label: string;
+}
+
+export const PLAYER_VARIANTS: PlayerVariant[] = [
+  { id: 'neutral', bodyGlyph: glyphOf(GLYPH_IDS.playerNeutral), label: 'Unmarked' },
+  { id: 'light', bodyGlyph: glyphOf(GLYPH_IDS.playerLight), label: 'Pale' },
+  { id: 'medium', bodyGlyph: glyphOf(GLYPH_IDS.playerMedium), label: 'Bronzed' },
+  { id: 'dark', bodyGlyph: glyphOf(GLYPH_IDS.playerDark), label: 'Deep-toned' },
+  { id: 'woman', bodyGlyph: glyphOf(GLYPH_IDS.playerWoman), label: 'Woman' },
+  { id: 'man', bodyGlyph: glyphOf(GLYPH_IDS.playerMan), label: 'Man' },
+  { id: 'redhair', bodyGlyph: glyphOf(GLYPH_IDS.playerRedHair), label: 'Red-haired' },
+  { id: 'curly', bodyGlyph: glyphOf(GLYPH_IDS.playerCurly), label: 'Curly-haired' },
+  { id: 'elder', bodyGlyph: glyphOf(GLYPH_IDS.playerElder), label: 'Weathered' },
+];
+
+export function playerVariantById(id: string): PlayerVariant {
+  return PLAYER_VARIANTS.find((v) => v.id === id) ?? PLAYER_VARIANTS[0];
+}
@@ -0,0 +1,126 @@+import type { ConditionId } from '../core/types';
+import { GLYPH_IDS, GLYPHS } from './glyphs';
+
+const GLYPH_LOOKUP = new Map(GLYPHS.map((g) => [g.id, g.glyph]));
+
+function glyphOf(id: string): string {
+  const glyph = GLYPH_LOOKUP.get(id);
+  if (!glyph) throw new Error(`Unknown glyph id: ${id}`);
+  return glyph;
+}
+
+export interface ConditionDef {
+  id: ConditionId;
+  name: string;
+  glyph: string;
+  tooltip: string;
+  defaultDuration: number;
+  /** HP lost at the start of the afflicted actor's turn. */
+  tickDamage?: number;
+  /** Focus gained (negative = lost) at turn start. */
+  tickFocus?: number;
+  speedMult?: number;
+  attackMult?: number;
+  defenseMult?: number;
+  /** Multiplier on damage the afflicted actor receives. */
+  incomingMult?: number;
+  preventsAction?: boolean;
+  preventsAbilities?: boolean;
+  breaksOnHit?: boolean;
+  /** Chance per turn that the afflicted actor loses its action. */
+  skipChance?: number;
+}
+
+// The binding condition set from DESIGN.md — each has a mechanical effect,
+// a status glyph, a tooltip and a duration.
+export const CONDITIONS: Record<ConditionId, ConditionDef> = {
+  bleeding: {
+    id: 'bleeding',
+    name: 'Bleeding',
+    glyph: glyphOf(GLYPH_IDS.condBleeding),
+    tooltip: 'Loses 2 health at the start of each turn.',
+    defaultDuration: 3,
+    tickDamage: 2,
+  },
+  burning: {
+    id: 'burning',
+    name: 'Burning',
+    glyph: glyphOf(GLYPH_IDS.condBurning),
+    tooltip: 'Loses 3 health at the start of each turn. Water extinguishes it.',
+    defaultDuration: 2,
+    tickDamage: 3,
+  },
+  frozen: {
+    id: 'frozen',
+    name: 'Frozen',
+    glyph: glyphOf(GLYPH_IDS.condFrozen),
+    tooltip: 'Cannot move or act. Breaks when struck.',
+    defaultDuration: 1,
+    speedMult: 0,
+    preventsAction: true,
+    breaksOnHit: true,
+  },
+  staggered: {
+    id: 'staggered',
+    name: 'Staggered',
+    glyph: glyphOf(GLYPH_IDS.condStaggered),
+    tooltip: 'Off balance. Speed halved.',
+    defaultDuration: 1,
+    speedMult: 0.5,
+  },
+  afraid: {
+    id: 'afraid',
+    name: 'Afraid',
+    glyph: glyphOf(GLYPH_IDS.condAfraid),
+    tooltip: 'Attack reduced by 25%. May flee.',
+    defaultDuration: 2,
+    attackMult: 0.75,
+  },
+  guarded: {
+    id: 'guarded',
+    name: 'Guarded',
+    glyph: glyphOf(GLYPH_IDS.condGuarded),
+    tooltip: 'Braced. Defense increased by 50% until next turn.',
+    defaultDuration: 1,
+    defenseMult: 1.5,
+  },
+  silenced: {
+    id: 'silenced',
+    name: 'Silenced',
+    glyph: glyphOf(GLYPH_IDS.condSilenced),
+    tooltip: 'Cannot use abilities.',
+    defaultDuration: 2,
+    preventsAbilities: true,
+  },
+  forgotten: {
+    id: 'forgotten',
+    name: 'Forgotten',
+    glyph: glyphOf(GLYPH_IDS.condForgotten),
+    tooltip: 'Slipping out of memory. Loses 1 focus per turn; may lose its action.',
+    defaultDuration: 3,
+    tickFocus: -1,
+    skipChance: 0.25,
+  },
+  remembered: {
+    id: 'remembered',
+    name: 'Remembered',
+    glyph: glyphOf(GLYPH_IDS.condRemembered),
+    tooltip: 'Anchored. Attack and defense increased by 25%; regains 1 focus per turn.',
+    defaultDuration: 2,
+    attackMult: 1.25,
+    defenseMult: 1.25,
+    tickFocus: 1,
+  },
+  unstable: {
+    id: 'unstable',
+    name: 'Unstable',
+    glyph: glyphOf(GLYPH_IDS.condUnstable),
+    tooltip: 'Glyph flickering. Takes 25% more damage.',
+    defaultDuration: 3,
+    incomingMult: 1.25,
+  },
+};
+
+export function conditionDef(id: ConditionId): ConditionDef {
+  return CONDITIONS[id];
+}
@@ -0,0 +1,93 @@+import type { DialogueTree } from './types';
+import { CHARACTER_IDS } from '../characters';
+import { FLAGS, MEMORY_EVENTS } from '../flags';
+import { ITEM_IDS } from '../items';
+import { QUEST_IDS } from '../quests';
+
+// Prologue: the Black Crow finds the protagonist climbing out of the grave.
+export const CROW_PROLOGUE: DialogueTree = {
+  id: 'crow_prologue',  startNodeId: 'start',
+  nodes: {
+    start: {
+      id: 'start',
+      speakerId: CHARACTER_IDS.crow,
+      once: true,
+      text: 'Up already? Good. The grave was only a suggestion.',
+      effects: [
+        { type: 'setFlag', flag: FLAGS.metCrow, value: true },
+        { type: 'addMemory', npcId: CHARACTER_IDS.crow, event: MEMORY_EVENTS.metCrow },
+        { type: 'advanceQuest', questId: QUEST_IDS.prologueAwakening },
+      ],
+      choices: [
+        {
+          id: 'who',
+          text: 'Who are you?',
+          next: 'who',
+          effects: [{ type: 'tendency', axis: 'insight', amount: 1 }],
+        },
+        {
+          id: 'where',
+          text: 'Where am I?',
+          next: 'where',
+        },
+        {
+          id: 'silence',
+          text: '',
+          silent: true,
+          next: 'silence',
+          effects: [
+            { type: 'tendency', axis: 'resolve', amount: 1 },
+            { type: 'addMemory', npcId: CHARACTER_IDS.crow, event: MEMORY_EVENTS.choseSilence },
+          ],
+        },
+      ],
+    },
+    who: {
+      id: 'who',
+      speakerId: CHARACTER_IDS.crow,
+      text: 'A crow. Black, if you want to be precise. I remember things, which around here makes me a library with wings.',
+      next: 'gift',
+    },
+    where: {
+      id: 'where',
+      speakerId: CHARACTER_IDS.crow,
+      text: 'A clearing that forgot its name, like everything else. The trees are dead. The grave is yours. Try not to use it twice.',
+      next: 'gift',
+    },
+    silence: {
+      id: 'silence',
+      speakerId: CHARACTER_IDS.crow,
+      text: 'Quiet. Good. The world listens harder than it should.',
+      next: 'gift',
+    },
+    gift: {
+      id: 'gift',
+      speakerId: CHARACTER_IDS.crow,
+      once: true,
+      text: 'Take this. A feather. When you hear a name that is not yours, hold on to something that is.',
+      effects: [
+        { type: 'giveItem', itemId: ITEM_IDS.crowFeather, quantity: 1 },
+        { type: 'addMemory', npcId: CHARACTER_IDS.crow, event: MEMORY_EVENTS.acceptedFeather },
+        { type: 'tendency', axis: 'empathy', amount: 1 },
+        { type: 'objective', text: 'The black crow is waiting by the dead tree. Speak to it.' },
+      ],
+      choices: [
+        { id: 'thanks', text: 'Thank it.', next: 'wolves' },
+        { id: 'pocket', text: 'Pocket it without a word.', next: 'wolves' },
+      ],
+    },
+    wolves: {
+      id: 'wolves',
+      speakerId: CHARACTER_IDS.crow,
+      text: 'One more thing. The wolves smell the grave on you. They are hungry, not evil. Remember the difference — it will matter.',
+      effects: [{ type: 'advanceQuest', questId: QUEST_IDS.prologueAwakening }],
+      choices: [{ id: 'end', text: 'Understood.' }],
+    },
+    repeat: {
+      id: 'repeat',
+      speakerId: CHARACTER_IDS.crow,
+      text: 'Still breathing? Keep it that way. The wolves are the least of it.',
+      choices: [{ id: 'end', text: 'Leave.' }],
+    },
+  },
+};
@@ -0,0 +1,348 @@+import type { DialogueTree } from './types';
+import { CHARACTER_IDS } from '../characters';
+import { FLAGS, MEMORY_EVENTS } from '../flags';
+import { QUEST_IDS } from '../quests';
+
+// The Glasswood: Mira's standoff (talk-down or combat), the warden camp, and
+// the Heart Mirror — the first Name Shard, earned by deciding what a memory
+// deserves. No branch is the kind one.
+
+export const MIRA_STANDOFF: DialogueTree = {
+  id: 'mira_standoff',
+  startNodeId: 'start',
+  startVariants: [
+    { nodeId: 'afterPeace', conditions: [{ type: 'flag', flag: FLAGS.miraPeaceful }] },
+    { nodeId: 'afterFight', conditions: [{ type: 'flag', flag: FLAGS.miraDefeated }] },
+  ],
+  nodes: {
+    start: {
+      id: 'start',
+      speakerId: CHARACTER_IDS.mira,
+      once: true,
+      text: 'Don’t. Move. One flicker — that’s all I need. The Crown pays for unstable Glyphs, and you, stranger, flicker like a candle in a cave.',
+      effects: [
+        { type: 'advanceQuest', questId: QUEST_IDS.glasswoodShard },
+        { type: 'advanceQuest', questId: QUEST_IDS.theForgottenWayrest },
+      ],
+      choices: [
+        {
+          id: 'unwritten',
+          text: 'I’m not unstable. I’m… unwritten.',
+          next: 'unwritten',
+          effects: [{ type: 'tendency', axis: 'insight', amount: 1 }],
+        },
+        { id: 'silence', text: '', silent: true, next: 'assess', effects: [{ type: 'tendency', axis: 'resolve', amount: 1 }] },
+        { id: 'shoot', text: 'Then shoot.', next: 'tense' },
+      ],
+    },
+    unwritten: {
+      id: 'unwritten',
+      speakerId: CHARACTER_IDS.mira,
+      text: 'Unwritten. Everyone here is half-erased, but you — you’re a blank page that walks. Give me one reason not to take you in.',
+      choices: [
+        {
+          id: 'talkdown',
+          text: 'The wolves outside Wayrest are alive because of me. Ask the road.',
+          next: 'talkdown',
+          conditions: [
+            { type: 'memory', npcId: 'protagonist', event: MEMORY_EVENTS.calmedWolf },
+          ],
+        },
+        {
+          id: 'talkdown2',
+          text: 'Because you’d rather know what I am than sell what I am.',
+          next: 'talkdown',
+          conditions: [{ type: 'tendency', axis: 'empathy', min: 2 }],
+        },
+        { id: 'catch', text: 'You’d have to catch me first.', next: 'tense' },
+      ],
+    },
+    assess: {
+      id: 'assess',
+      speakerId: CHARACTER_IDS.mira,
+      text: 'Quiet. Good. Answers lie; silence just stands there. …The wolves didn’t eat you, and you don’t beg. Maybe you’re worth more breathing than traded.',
+      next: 'talkdown',
+    },
+    talkdown: {
+      id: 'talkdown',
+      speakerId: CHARACTER_IDS.mira,
+      text: 'Lower your hands, then. I’m Mira. I watch these trees — and lately the trees watch back. Walk ahead of me where I can see you.',
+      effects: [
+        { type: 'setFlag', flag: FLAGS.miraPeaceful, value: true },
+        { type: 'addMemory', npcId: CHARACTER_IDS.mira, event: MEMORY_EVENTS.miraStandoffPeaceful },
+        { type: 'changeNpcAttitude', npcId: CHARACTER_IDS.mira, attitude: 'rival' },
+        { type: 'advanceQuest', questId: QUEST_IDS.glasswoodShard },
+      ],
+      next: 'resolved',
+    },
+    tense: {
+      id: 'tense',
+      speakerId: CHARACTER_IDS.mira,
+      text: 'Then we do this the Crown’s way. Nothing personal — I have a brother’s mistake to pay for.',
+      effects: [
+        { type: 'turnHostile', defId: CHARACTER_IDS.mira },
+        { type: 'addMemory', npcId: CHARACTER_IDS.mira, event: MEMORY_EVENTS.miraStandoffViolent },
+      ],
+      choices: [{ id: 'fight', text: '(Fight.)' }],
+    },
+    resolved: {
+      id: 'resolved',
+      speakerId: CHARACTER_IDS.mira,
+      text: 'The mirrors will show you things. Some true. Some true-ish. Don’t thank me — I haven’t decided about you yet.',
+      choices: [
+        { id: 'mirrors', text: 'Tell me about the mirrors.', next: 'mirrors' },
+        { id: 'her', text: 'Tell me about you.', next: 'her' },
+        { id: 'end', text: 'Leave.' },
+      ],
+    },
+    mirrors: {
+      id: 'mirrors',
+      speakerId: CHARACTER_IDS.mira,
+      text: 'They reflect memories instead of light. Real ones, stolen ones, invented ones. The Glasswood doesn’t care which you believe. Neither should you — that’s how it gets in.',
+      next: 'resolved2',
+    },
+    her: {
+      id: 'her',
+      speakerId: CHARACTER_IDS.mira,
+      text: 'I had a brother. The Crown offered him a Glyph — clean, assigned, legal. He took it. He forgot us. Now he wears the shield and hunts people like you, and he does not grieve, because grief would contradict.',
+      effects: [
+        { type: 'startQuest', questId: QUEST_IDS.miraBrother },
+        { type: 'addMemory', npcId: CHARACTER_IDS.mira, event: MEMORY_EVENTS.miraToldYouAboutBrother },
+        { type: 'discovery', loreId: 'lore_crownGlyphs' },
+      ],
+      next: 'resolved2',
+    },
+    resolved2: {
+      id: 'resolved2',
+      speakerId: CHARACTER_IDS.mira,
+      text: 'Go on, then. The heart of the wood is north. Try to leave as one person.',
+      choices: [{ id: 'end', text: 'Leave.' }],
+    },
+    afterPeace: {
+      id: 'afterPeace',
+      speakerId: CHARACTER_IDS.mira,
+      text: 'Still one person? Good. The wood hasn’t decided about you either. Watch the mirrors — don’t let them watch you first.',
+      choices: [{ id: 'end', text: 'Leave.' }],
+    },
+    afterFight: {
+      id: 'afterFight',
+      speakerId: CHARACTER_IDS.mira,
+      text: 'You hit hard for a blank page. We’re square — you didn’t kill me when you could have. Don’t make me regret noticing.',
+      choices: [
+        { id: 'brother', text: 'Why did you mention a brother, before?', next: 'her' },
+        { id: 'end', text: 'Leave.' },
+      ],
+    },
+  },
+};
+
+export const LIORA_GLASSWOOD: DialogueTree = {
+  id: 'liora_glasswood',
+  startNodeId: 'start',
+  startVariants: [
+    { nodeId: 'restored', conditions: [{ type: 'flag', flag: FLAGS.glasswoodMemoryRestored }] },
+    { nodeId: 'letLie', conditions: [{ type: 'flag', flag: FLAGS.glasswoodMemoryLeftLie }] },
+  ],
+  nodes: {
+    start: {
+      id: 'start',
+      speakerId: CHARACTER_IDS.liora,
+      once: true,
+      text: 'You can see them too, can’t you? The people in the mirrors. My mama lives in the big one, at the heart. Brann says she doesn’t. Brann says mama is—',
+      choices: [
+        { id: 'show', text: 'What does the mirror show you?', next: 'show' },
+        { id: 'careful', text: 'Brann might be right to worry.', next: 'careful' },
+        { id: 'silence', text: '', silent: true, next: 'silence' },
+      ],
+    },
+    show: {
+      id: 'show',
+      speakerId: CHARACTER_IDS.liora,
+      text: 'Mama braiding my hair. Mama singing the bell-song. Mama walking to the Crown’s wagon with her head up. …The mirror says I can have one of them back. I don’t know which is real.',
+      effects: [{ type: 'addMemory', npcId: 'protagonist', event: MEMORY_EVENTS.lioraToldYou }],
+      next: 'end',
+    },
+    careful: {
+      id: 'careful',
+      speakerId: CHARACTER_IDS.liora,
+      text: 'I know. That’s why I only look on Sundays. …There are no Sundays anymore. I look every day.',
+      next: 'end',
+    },
+    silence: {
+      id: 'silence',
+      speakerId: CHARACTER_IDS.liora,
+      text: 'It’s okay. Mama didn’t talk much either, near the end. Or mirror-mama didn’t. One of them.',
+      next: 'end',
+    },
+    restored: {
+      id: 'restored',
+      speakerId: CHARACTER_IDS.liora,
+      text: 'It hurts. It hurts and it’s MINE. She didn’t smile at the wagon. She was afraid, and she went anyway, and she sang so I wouldn’t know. …Thank you. I think.',
+      next: 'end',
+    },
+    letLie: {
+      id: 'letLie',
+      speakerId: CHARACTER_IDS.liora,
+      text: 'Mama sang to me yesterday. The mirror kept her brave. You did that, didn’t you? …The other mothers in the mirrors look so cold.',
+      next: 'end',
+    },
+    end: {
+      id: 'end',
+      speakerId: CHARACTER_IDS.liora,
+      text: 'I should go. Brann worries when the mirrors hold me too long.',
+      choices: [{ id: 'bye', text: 'Leave.' }],
+    },
+    repeat: {
+      id: 'repeat',
+      speakerId: CHARACTER_IDS.liora,
+      text: 'The mirrors are showing me a wolf today. A wolf with a crown. Make it make sense.',
+      choices: [{ id: 'bye', text: 'Leave.' }],
+    },
+  },
+};
+
+export const BRANN_GLASSWOOD: DialogueTree = {
+  id: 'brann_glasswood',
+  startNodeId: 'start',
+  startVariants: [
+    { nodeId: 'restored', conditions: [{ type: 'flag', flag: FLAGS.glasswoodMemoryRestored }] },
+    { nodeId: 'letLie', conditions: [{ type: 'flag', flag: FLAGS.glasswoodMemoryLeftLie }] },
+  ],
+  nodes: {
+    start: {
+      id: 'start',
+      speakerId: CHARACTER_IDS.brann,
+      once: true,
+      text: 'Another traveler the road didn’t keep. Sit — the ground’s honest here, even if the trees aren’t.',
+      choices: [
+        { id: 'mirrors', text: 'What are the mirrors, really?', next: 'mirrors' },
+        { id: 'who', text: 'Who are you?', next: 'who' },
+        { id: 'silence', text: '', silent: true, next: 'silence' },
+      ],
+    },
+    mirrors: {
+      id: 'mirrors',
+      speakerId: CHARACTER_IDS.brann,
+      text: 'The Glasswood grows mirrors instead of leaves. Shows you your memories — and other people’s, and ones nobody ever had. I’ve watched travelers smash them and travelers kneel to them. Both leave changed.',
+      next: 'end',
+    },
+    who: {
+      id: 'who',
+      speakerId: CHARACTER_IDS.brann,
+      text: 'Brann. I was a warden here when the trees were just trees. Now I’m an old man who tells children not to look too long. I fail, mostly.',
+      next: 'end',
+    },
+    silence: {
+      id: 'silence',
+      speakerId: CHARACTER_IDS.brann,
+      text: 'Nothing to say? Good. Words are how the wood learns you.',
+      next: 'end',
+    },
+    restored: {
+      id: 'restored',
+      speakerId: CHARACTER_IDS.brann,
+      text: 'You gave the girl her mother back — the real one, teeth and all. The wood is quieter today. Whether you were cruel or kind, I’ll decide in a year.',
+      next: 'end',
+    },
+    letLie: {
+      id: 'letLie',
+      speakerId: CHARACTER_IDS.brann,
+      text: 'You let her keep the warm version. The mirrors hum easier now. I hope you can carry the cold one for her — someone has to, or it goes looking for a home.',
+      next: 'end',
+    },
+    end: {
+      id: 'end',
+      speakerId: CHARACTER_IDS.brann,
+      text: 'Watch for wolves between the trunks. And for the Crown’s man at the east edge — he’s looking for someone, and it isn’t you. Yet.',
+      choices: [{ id: 'bye', text: 'Leave.' }],
+    },
+    repeat: {
+      id: 'repeat',
+      speakerId: CHARACTER_IDS.brann,
+      text: 'Still walking? Good. The wood settles for the ones who keep moving.',
+      choices: [{ id: 'bye', text: 'Leave.' }],
+    },
+  },
+};
+
+export const HEART_MIRROR: DialogueTree = {
+  id: 'heart_mirror',
+  startNodeId: 'start',
+  startVariants: [
+    { nodeId: 'afterTruth', conditions: [{ type: 'flag', flag: FLAGS.glasswoodMemoryRestored }] },
+    { nodeId: 'afterLie', conditions: [{ type: 'flag', flag: FLAGS.glasswoodMemoryLeftLie }] },
+  ],
+  nodes: {
+    start: {
+      id: 'start',
+      speakerId: CHARACTER_IDS.heartMirror,
+      once: true,
+      text: 'LOOK. // A woman walks to the Crown’s wagon, head high. A child watches from the trees. This is the memory the girl prays to.',
+      choices: [
+        { id: 'watch', text: 'Watch.', next: 'watch' },
+        { id: 'silence', text: '', silent: true, next: 'watch' },
+      ],
+    },
+    watch: {
+      id: 'watch',
+      speakerId: CHARACTER_IDS.heartMirror,
+      text: 'LOOK CLOSER. // Her head is not high. Her hands shake. She sings the bell-song so the child hears courage instead of fear. The mirror keeps both versions. It will give one to the girl. Choose.',
+      choices: [
+        { id: 'truth', text: 'Give her the truth — afraid, and brave anyway.', next: 'truth' },
+        { id: 'lie', text: 'Let her keep the brave mother. The lie is kinder.', next: 'lie' },
+      ],
+    },
+    truth: {
+      id: 'truth',
+      speakerId: CHARACTER_IDS.heartMirror,
+      text: 'THE TRUTH, THEN. // The mirror cracks like thin ice. Behind you, far off, a child gasps — grief with a shape, finally. Real. Heavy. Hers.',
+      effects: [
+        { type: 'gainShard', shardId: 'shard_glasswood' },
+        { type: 'regionalOutcome', region: 'The Glasswood', outcome: 'restored' },
+        { type: 'setFlag', flag: FLAGS.glasswoodMemoryRestored, value: true },
+        { type: 'addMemory', npcId: 'protagonist', event: MEMORY_EVENTS.restoredFamilyMemory },
+        { type: 'tendency', axis: 'resolve', amount: 1 },
+        { type: 'advanceQuest', questId: QUEST_IDS.glasswoodShard },
+      ],
+      next: 'truth2',
+    },
+    lie: {
+      id: 'lie',
+      speakerId: CHARACTER_IDS.heartMirror,
+      text: 'THE KIND LIE, THEN. // The mirror smooths. The brave mother stays, head high forever. The child will sleep tonight. The truth stays in the wood — with you, colder for being carried alone.',
+      effects: [
+        { type: 'gainShard', shardId: 'shard_glasswood' },
+        { type: 'regionalOutcome', region: 'The Glasswood', outcome: 'letLie' },
+        { type: 'setFlag', flag: FLAGS.glasswoodMemoryLeftLie, value: true },
+        { type: 'addMemory', npcId: 'protagonist', event: MEMORY_EVENTS.letMemoryLie },
+        { type: 'tendency', axis: 'empathy', amount: 1 },
+        { type: 'advanceQuest', questId: QUEST_IDS.glasswoodShard },
+      ],
+      next: 'lie2',
+    },
+    truth2: {
+      id: 'truth2',
+      speakerId: CHARACTER_IDS.heartMirror,
+      text: 'A shard of the name “Glasswood” settles into your chest like a splinter of ice. First of four. The wood remembers what you chose — it always does.',
+      choices: [{ id: 'end', text: 'Step back.' }],
+    },
+    lie2: {
+      id: 'lie2',
+      speakerId: CHARACTER_IDS.heartMirror,
+      text: 'A shard of the name “Glasswood” settles into your chest like a warm coal. First of four. The wood remembers what you chose — it always does.',
+      choices: [{ id: 'end', text: 'Step back.' }],
+    },
+    afterTruth: {
+      id: 'afterTruth',
+      speakerId: CHARACTER_IDS.heartMirror,
+      text: 'The mirror shows the woman as she was: afraid, singing, walking. It does not show the child anymore. She is done watching.',
+      choices: [{ id: 'end', text: 'Step back.' }],
+    },
+    afterLie: {
+      id: 'afterLie',
+      speakerId: CHARACTER_IDS.heartMirror,
+      text: 'The mirror shows the woman, head high, forever. Only you can see her hands shake.',
+      choices: [{ id: 'end', text: 'Step back.' }],
+    },
+  },
+};
@@ -0,0 +1,25 @@+import type { DialogueTree } from './types';
+import { CROW_PROLOGUE } from './crowPrologue';
+import { TAM_WAYREST, MAE_WAYREST, FEN_WAYREST } from './wayrest';
+import { MIRA_STANDOFF, LIORA_GLASSWOOD, BRANN_GLASSWOOD, HEART_MIRROR } from './glasswood';
+import { WENNA_MIRE, SEDGE_MIRE, BELL_MOTHER, MARSH_KING } from './mire';
+
+// Aggregate registry of all dialogue trees. Content phases add theirs here.
+export const DIALOGUES: Record<string, DialogueTree> = {
+  [CROW_PROLOGUE.id]: CROW_PROLOGUE,
+  [TAM_WAYREST.id]: TAM_WAYREST,
+  [MAE_WAYREST.id]: MAE_WAYREST,
+  [FEN_WAYREST.id]: FEN_WAYREST,
+  [MIRA_STANDOFF.id]: MIRA_STANDOFF,
+  [LIORA_GLASSWOOD.id]: LIORA_GLASSWOOD,
+  [BRANN_GLASSWOOD.id]: BRANN_GLASSWOOD,
+  [HEART_MIRROR.id]: HEART_MIRROR,
+  [WENNA_MIRE.id]: WENNA_MIRE,
+  [SEDGE_MIRE.id]: SEDGE_MIRE,
+  [BELL_MOTHER.id]: BELL_MOTHER,
+  [MARSH_KING.id]: MARSH_KING,
+};
+
+export function dialogueTree(id: string): DialogueTree | null {
+  return DIALOGUES[id] ?? null;
+}
@@ -0,0 +1,310 @@+import type { DialogueTree } from './types';
+import { CHARACTER_IDS } from '../characters';
+import { FLAGS, MEMORY_EVENTS } from '../flags';
+import { QUEST_IDS } from '../quests';
+
+// The Mire of Bells: silent bells, a village that agreed on a name, and a
+// king who is not sure which of his memories were his. Comic surface, real
+// pathos — and the second shard, decided, never fetched.
+
+export const WENNA_MIRE: DialogueTree = {
+  id: 'wenna_mire',
+  startNodeId: 'start',
+  startVariants: [
+    { nodeId: 'affirmed', conditions: [{ type: 'flag', flag: FLAGS.bellMotherAffirmed }] },
+    { nodeId: 'truthed', conditions: [{ type: 'flag', flag: FLAGS.bellMotherTruthed }] },
+  ],
+  nodes: {
+    start: {
+      id: 'start',
+      speakerId: CHARACTER_IDS.wenna,
+      once: true,
+      text: 'Mind the third plank — it floats more than it walks. You’re headed to the throne? The King expects someone. He always expects someone.',
+      choices: [
+        { id: 'bells', text: 'Why are the bells silent?', next: 'bells' },
+        { id: 'bellmother', text: 'Who is the old woman by the bell?', next: 'bellmother' },
+        { id: 'silence', text: '', silent: true, next: 'silence' },
+      ],
+    },
+    bells: {
+      id: 'bells',
+      speakerId: CHARACTER_IDS.wenna,
+      text: 'Silent years now. Ring one anyway — the things in the water remember hating the sound. Old habit keeps them honest, like most of what we do.',
+      effects: [{ type: 'advanceQuest', questId: QUEST_IDS.mireShard }],
+      next: 'end',
+    },
+    bellmother: {
+      id: 'bellmother',
+      speakerId: CHARACTER_IDS.wenna,
+      text: 'Old Sorrow sits by the great bell. We agreed — all of us — that she is the Bell-Mother. She was no one, after the flood took her name. Now she is someone. It keeps her fed. It keeps us kind. Don’t you go un-agreeing it.',
+      next: 'end',
+    },
+    silence: {
+      id: 'silence',
+      speakerId: CHARACTER_IDS.wenna,
+      text: 'Quiet as a bell, you. You’ll fit in here. That is not entirely a compliment.',
+      next: 'end',
+    },
+    affirmed: {
+      id: 'affirmed',
+      speakerId: CHARACTER_IDS.wenna,
+      text: 'You heard? The King himself says she is the Bell-Mother. Official. Royal. The whole Mire sleeps easier. Even the water sounds pleased, in its way.',
+      next: 'end',
+    },
+    truthed: {
+      id: 'truthed',
+      speakerId: CHARACTER_IDS.wenna,
+      text: 'She knows she is Sorrow now. She still rings at dusk. I don’t know if you freed her or robbed her, and I’ve stopped asking her, because she doesn’t know either.',
+      next: 'end',
+    },
+    end: {
+      id: 'end',
+      speakerId: CHARACTER_IDS.wenna,
+      text: 'Up the planks with you. And if the King asks about the bucket — say nothing.',
+      choices: [{ id: 'bye', text: 'Leave.' }],
+    },
+    repeat: {
+      id: 'repeat',
+      speakerId: CHARACTER_IDS.wenna,
+      text: 'Still dry? Good. Keep it that way.',
+      choices: [{ id: 'bye', text: 'Leave.' }],
+    },
+  },
+};
+
+export const SEDGE_MIRE: DialogueTree = {
+  id: 'sedge_mire',
+  startNodeId: 'start',
+  startVariants: [
+    { nodeId: 'affirmed', conditions: [{ type: 'flag', flag: FLAGS.bellMotherAffirmed }] },
+    { nodeId: 'truthed', conditions: [{ type: 'flag', flag: FLAGS.bellMotherTruthed }] },
+  ],
+  nodes: {
+    start: {
+      id: 'start',
+      speakerId: CHARACTER_IDS.sedge,
+      once: true,
+      text: 'I can walk the planks without looking. Watch— oh. You’re not here to watch. Everyone’s here to ask the King something.',
+      choices: [
+        { id: 'ask', text: 'What would you ask him?', next: 'ask' },
+        { id: 'silence', text: '', silent: true, next: 'silence' },
+      ],
+    },
+    ask: {
+      id: 'ask',
+      speakerId: CHARACTER_IDS.sedge,
+      text: 'If frogs dream. The Bell-Mother says they dream of being princes. Da says they dream of flies. One of them is lying and I need to know which.',
+      next: 'end',
+    },
+    silence: {
+      id: 'silence',
+      speakerId: CHARACTER_IDS.sedge,
+      text: '…You’re no fun. The King is fun. He has a bucket.',
+      next: 'end',
+    },
+    affirmed: {
+      id: 'affirmed',
+      speakerId: CHARACTER_IDS.sedge,
+      text: 'She IS the Bell-Mother. The King said, and kings know. So the frogs dream of princes. I win.',
+      next: 'end',
+    },
+    truthed: {
+      id: 'truthed',
+      speakerId: CHARACTER_IDS.sedge,
+      text: 'Wenna says the Bell-Mother is just Sorrow now. I liked her better as a mother. Is that bad? Don’t tell her I said it.',
+      next: 'end',
+    },
+    end: {
+      id: 'end',
+      speakerId: CHARACTER_IDS.sedge,
+      text: 'If you see a frog with a crown, bow. He gets sad otherwise.',
+      choices: [{ id: 'bye', text: 'Leave.' }],
+    },
+    repeat: {
+      id: 'repeat',
+      speakerId: CHARACTER_IDS.sedge,
+      text: 'Watch me cross the planks this time. Watch! …You’re not watching.',
+      choices: [{ id: 'bye', text: 'Leave.' }],
+    },
+  },
+};
+
+export const BELL_MOTHER: DialogueTree = {
+  id: 'bell_mother',
+  startNodeId: 'start',
+  startVariants: [
+    { nodeId: 'affirmed', conditions: [{ type: 'flag', flag: FLAGS.bellMotherAffirmed }] },
+    { nodeId: 'truthed', conditions: [{ type: 'flag', flag: FLAGS.bellMotherTruthed }] },
+  ],
+  nodes: {
+    start: {
+      id: 'start',
+      speakerId: CHARACTER_IDS.bellMother,
+      once: true,
+      text: 'Ah — there you are. You’ve come about the bell rotation. Third bell at dusk, dear, and mind the heron.',
+      choices: [
+        { id: 'whoami', text: 'Who do you think I am?', next: 'whoami' },
+        {
+          id: 'playalong',
+          text: 'Third bell at dusk. Understood.',
+          next: 'playalong',
+          effects: [
+            { type: 'addMemory', npcId: CHARACTER_IDS.bellMother, event: MEMORY_EVENTS.playedAlongWithBellMother },
+            { type: 'tendency', axis: 'empathy', amount: 1 },
+          ],
+        },
+        { id: 'silence', text: '', silent: true, next: 'silence' },
+      ],
+    },
+    whoami: {
+      id: 'whoami',
+      speakerId: CHARACTER_IDS.bellMother,
+      text: 'Why, you’re… you’re… Oh. Oh, no. You’re not. I’m doing it again. The King says I’m the Bell-Mother, and the village says so, and some days I am, dear. Some days I’m just Sorrow.',
+      next: 'end',
+    },
+    playalong: {
+      id: 'playalong',
+      speakerId: CHARACTER_IDS.bellMother,
+      text: 'Good dear. The bells like punctuality. They’re silent, but they’re particular.',
+      next: 'end',
+    },
+    silence: {
+      id: 'silence',
+      speakerId: CHARACTER_IDS.bellMother,
+      text: 'Tired, dear? Sit. The water doesn’t bite. The things under it do, but only on weekdays.',
+      next: 'end',
+    },
+    affirmed: {
+      id: 'affirmed',
+      speakerId: CHARACTER_IDS.bellMother,
+      text: 'Third bell at dusk! The King confirmed it. I keep the bells. I am the Bell-Mother. It is good to be someone, dear. You have no idea.',
+      next: 'end',
+    },
+    truthed: {
+      id: 'truthed',
+      speakerId: CHARACTER_IDS.bellMother,
+      text: 'Sorrow. My name is Sorrow — no bell, no mother. …I still ring it at dusk. Someone should, and I know how. Isn’t that odd, dear? Isn’t that something?',
+      next: 'end',
+    },
+    end: {
+      id: 'end',
+      speakerId: CHARACTER_IDS.bellMother,
+      text: 'Mind the heron, dear. It remembers being a person, some days.',
+      choices: [{ id: 'bye', text: 'Leave.' }],
+    },
+    repeat: {
+      id: 'repeat',
+      speakerId: CHARACTER_IDS.bellMother,
+      text: 'Back again, dear? The bells missed you. They didn’t say so, but I could tell.',
+      choices: [{ id: 'bye', text: 'Leave.' }],
+    },
+  },
+};
+
+export const MARSH_KING: DialogueTree = {
+  id: 'marsh_king',
+  startNodeId: 'start',
+  startVariants: [
+    { nodeId: 'repeatDecided', conditions: [{ type: 'flag', flag: FLAGS.bellMotherAffirmed }] },
+    { nodeId: 'repeatDecided', conditions: [{ type: 'flag', flag: FLAGS.bellMotherTruthed }] },
+    { nodeId: 'repeatDecided', conditions: [{ type: 'flag', flag: FLAGS.mireBothWays }] },
+  ],
+  nodes: {
+    start: {
+      id: 'start',
+      speakerId: CHARACTER_IDS.marshKing,
+      once: true,
+      text: 'HALT. State business. …Do you have the ceremonial bucket?',
+      effects: [
+        { type: 'setFlag', flag: FLAGS.metMarshKing, value: true },
+        { type: 'advanceQuest', questId: QUEST_IDS.mireShard },
+      ],
+      choices: [
+        { id: 'nobucket', text: '…No?', next: 'nobucket' },
+        { id: 'silence', text: '', silent: true, next: 'silenceKing' },
+      ],
+    },
+    nobucket: {
+      id: 'nobucket',
+      speakerId: CHARACTER_IDS.marshKing,
+      text: 'Then YOU are the ceremonial bucket. Kneel. …That was a joke. We are not all ceremony here in the Mire. Sometimes we are almost a person.',
+      next: 'ask',
+    },
+    silenceKing: {
+      id: 'silenceKing',
+      speakerId: CHARACTER_IDS.marshKing,
+      text: 'No bucket AND no words. You would make an excellent regent.',
+      next: 'ask',
+    },
+    ask: {
+      id: 'ask',
+      speakerId: CHARACTER_IDS.marshKing,
+      text: 'They say a nameless one walked out of a grave, deciding what things mean. We should know. We have been so many things. We remember being a king on a white horse. We remember being a queen who drowned the horses. We remember being the horse. Which of those was us?',
+      choices: [
+        { id: 'affirm', text: 'All of them. A role held long enough becomes true.', next: 'affirm' },
+        { id: 'truth', text: 'None of them. A worn memory is not a name.', next: 'truth' },
+        { id: 'weighs', text: '', silent: true, next: 'weighs' },
+      ],
+    },
+    affirm: {
+      id: 'affirm',
+      speakerId: CHARACTER_IDS.marshKing,
+      text: 'THEN IT IS SETTLED. We are the Marsh King, wholly, officially, by unanimous vote of everyone who remembers being us. …You cannot know what a relief that is. And hear our decree: the old woman is the Bell-Mother, because we all say she is, and saying so is the kindest true thing in this swamp.',
+      effects: [
+        { type: 'gainShard', shardId: 'shard_mire' },
+        { type: 'regionalOutcome', region: 'The Mire of Bells', outcome: 'affirmed' },
+        { type: 'setFlag', flag: FLAGS.bellMotherAffirmed, value: true },
+        { type: 'addMemory', npcId: CHARACTER_IDS.marshKing, event: MEMORY_EVENTS.affirmedAcceptedIdentity },
+        { type: 'tendency', axis: 'empathy', amount: 1 },
+        { type: 'advanceQuest', questId: QUEST_IDS.mireShard },
+      ],
+      next: 'shard',
+    },
+    truth: {
+      id: 'truth',
+      speakerId: CHARACTER_IDS.marshKing,
+      text: '…Yes. We suspected. The white horse was too well-lit to be real. Then hear our decree: the old woman by the bell is SORROW, and the village will say so — and she will ring at dusk anyway, because she knows how. Truth, then duty. In that order.',
+      effects: [
+        { type: 'gainShard', shardId: 'shard_mire' },
+        { type: 'regionalOutcome', region: 'The Mire of Bells', outcome: 'truth' },
+        { type: 'setFlag', flag: FLAGS.bellMotherTruthed, value: true },
+        { type: 'addMemory', npcId: CHARACTER_IDS.marshKing, event: MEMORY_EVENTS.toldTruthInMire },
+        { type: 'tendency', axis: 'insight', amount: 1 },
+        { type: 'advanceQuest', questId: QUEST_IDS.mireShard },
+      ],
+      next: 'shard',
+    },
+    weighs: {
+      id: 'weighs',
+      speakerId: CHARACTER_IDS.marshKing,
+      text: 'Silent again. Hm. We will take that as “both”, which is what we secretly believe and never say at banquets. The old woman stays Bell-Mother AND Sorrow. The Mire is good at both. It has had practice.',
+      effects: [
+        { type: 'gainShard', shardId: 'shard_mire' },
+        { type: 'regionalOutcome', region: 'The Mire of Bells', outcome: 'both' },
+        { type: 'setFlag', flag: FLAGS.mireBothWays, value: true },
+        { type: 'addMemory', npcId: CHARACTER_IDS.marshKing, event: MEMORY_EVENTS.choseBothInMire },
+        { type: 'tendency', axis: 'insight', amount: 1 },
+        { type: 'advanceQuest', questId: QUEST_IDS.mireShard },
+      ],
+      next: 'shard',
+    },
+    shard: {
+      id: 'shard',
+      speakerId: CHARACTER_IDS.marshKing,
+      text: 'Take this — a shard of the name of this place. We held it for someone who could answer. The other regions will be harder. The Crown will be harder. Go carefully, unwritten one.',
+      choices: [{ id: 'end', text: 'Bow. Obviously.' }],
+    },
+    repeatDecided: {
+      id: 'repeatDecided',
+      speakerId: CHARACTER_IDS.marshKing,
+      text: 'The bucket is still empty. We keep it that way on purpose. Probably. Return when you have more shards, and we will tell you what the white horse was wearing. It matters. Or it doesn’t.',
+      choices: [{ id: 'end', text: 'Leave.' }],
+    },
+    repeat: {
+      id: 'repeat',
+      speakerId: CHARACTER_IDS.marshKing,
+      text: 'The throne receives you again. The bucket situation is unchanged.',
+      choices: [{ id: 'end', text: 'Leave.' }],
+    },
+  },
+};
@@ -0,0 +1,72 @@+// Dialogue data format — binding for all later content phases.
+// Trees live in src/data/dialogues/, never inside UI components.
+
+export type TendencyAxis = 'resolve' | 'insight' | 'empathy';
+
+export type StatAxis = 'attack' | 'defense' | 'speed' | 'insight';
+
+export type DialogueCondition =
+  | { type: 'flag'; flag: string; value?: boolean }
+  | { type: 'item'; itemId: string }
+  | { type: 'memory'; npcId: string; event: string }
+  | { type: 'tendency'; axis: TendencyAxis; min: number }
+  | { type: 'stat'; stat: StatAxis; min: number }
+  | { type: 'quest'; questId: string; stage: 'inactive' | 'active' | 'completed' }
+  | { type: 'shard'; shardId: string };
+
+export type DialogueEffect =
+  | { type: 'setFlag'; flag: string; value: boolean }
+  | { type: 'giveItem'; itemId: string; quantity?: number }
+  | { type: 'takeItem'; itemId: string; quantity?: number }
+  | { type: 'startQuest'; questId: string }
+  | { type: 'advanceQuest'; questId: string }
+  | { type: 'completeQuest'; questId: string }
+  | { type: 'addMemory'; npcId: string; event: string }
+  | { type: 'changeNpcAttitude'; npcId: string; attitude: string }
+  | { type: 'tendency'; axis: TendencyAxis; amount: number }
+  | { type: 'heal'; amount: number }
+  | { type: 'objective'; text: string }
+  | { type: 'discovery'; loreId: string }
+  | { type: 'unlockAbility'; abilityId: string }
+  | { type: 'openRoute'; routeId: string }
+  | { type: 'regionalOutcome'; region: string; outcome: string }
+  | { type: 'gainShard'; shardId: string }
+  /** Removes every actor with this defId from the current map (the world forgets them). */
+  | { type: 'removeActors'; defId: string }
+  /** Turns an NPC hostile and aggroed — a dialogue that tips into combat. */
+  | { type: 'turnHostile'; defId: string };
+
+export interface DialogueChoice {
+  id: string;
+  text: string;
+  next?: string;
+  conditions?: DialogueCondition[];
+  effects?: DialogueEffect[];
+  /** Silence is a valid choice: renders as "Say nothing." */
+  silent?: boolean;
+}
+
+export interface DialogueNode {
+  id: string;
+  speakerId: string;
+  text: string;
+  choices?: DialogueChoice[];
+  effects?: DialogueEffect[];
+  next?: string;
+  /** A once-node already seen is skipped on later conversations. */
+  once?: boolean;
+}
+
+/** Conditional conversation opener: first variant whose conditions all pass
+    wins; otherwise startNodeId is used. */
+export interface DialogueStartVariant {
+  nodeId: string;
+  conditions: DialogueCondition[];
+}
+
+export interface DialogueTree {
+  id: string;
+  startNodeId: string;
+  startVariants?: DialogueStartVariant[];
+  nodes: Record<string, DialogueNode>;
+}
@@ -0,0 +1,201 @@+import type { DialogueTree } from './types';
+import { CHARACTER_IDS } from '../characters';
+import { FLAGS, MEMORY_EVENTS } from '../flags';
+import { ITEM_IDS } from '../items';
+import { QUEST_IDS } from '../quests';
+
+// Wayrest. The prologue's central horror, played quietly: a person is
+// forgotten mid-sentence, and only the protagonist keeps him.
+
+export const TAM_WAYREST: DialogueTree = {
+  id: 'tam_wayrest',
+  startNodeId: 'start',
+  nodes: {
+    start: {
+      id: 'start',
+      speakerId: CHARACTER_IDS.tam,
+      once: true,
+      text: 'Morning! Or what’s left of it. Bread’s grey but it’s warm — the oven still believes in itself.',
+      choices: [
+        { id: 'who', text: 'Who are you?', next: 'who' },
+        { id: 'bread', text: 'That bread smells better than it looks.', next: 'bread' },
+        { id: 'silence', text: '', silent: true, next: 'silence' },
+      ],
+    },
+    who: {
+      id: 'who',
+      speakerId: CHARACTER_IDS.tam,
+      text: 'Tam. I bake. That’s the whole of it — a man is what he does before breakfast, my mother used to say. She’d know. She was breakfast.',
+      next: 'end',
+    },
+    bread: {
+      id: 'bread',
+      speakerId: CHARACTER_IDS.tam,
+      once: true,
+      text: 'Grey loaf, no charge. You look like you climbed out of something. Bread helps. Bread always helps.',
+      effects: [{ type: 'giveItem', itemId: ITEM_IDS.mossBread, quantity: 2 }],
+      next: 'end',
+    },
+    silence: {
+      id: 'silence',
+      speakerId: CHARACTER_IDS.tam,
+      text: 'Not a talker. That’s fine. Dough doesn’t talk either, and look what it becomes.',
+      next: 'end',
+    },
+    end: {
+      id: 'end',
+      speakerId: CHARACTER_IDS.tam,
+      text: 'Come back whenever. I’m always here. Where else would I be?',
+      choices: [{ id: 'bye', text: 'Leave.' }],
+    },
+    repeat: {
+      id: 'repeat',
+      speakerId: CHARACTER_IDS.tam,
+      text: 'Back again? The oven’s still believing.',
+      choices: [{ id: 'bye', text: 'Leave.' }],
+    },
+  },
+};
+
+export const MAE_WAYREST: DialogueTree = {
+  id: 'mae_wayrest',
+  startNodeId: 'start',
+  startVariants: [{ nodeId: 'afterForget', conditions: [{ type: 'flag', flag: FLAGS.wayrestForgot }] }],
+  nodes: {
+    start: {
+      id: 'start',
+      speakerId: CHARACTER_IDS.mae,
+      once: true,
+      text: 'Well. A new face, and not one of the Crown’s, thank the silence. You picked a poor week to visit, love.',
+      choices: [
+        { id: 'wrong', text: 'What’s wrong with the week?', next: 'lives' },
+        { id: 'who', text: 'Who lives here?', next: 'lives' },
+        { id: 'silence', text: '', silent: true, next: 'silence' },
+      ],
+    },
+    silence: {
+      id: 'silence',
+      speakerId: CHARACTER_IDS.mae,
+      text: 'Hm. Quiet one. The quiet ones last longer, here. Ask your questions anyway — I can hear the shape of them.',
+      next: 'lives',
+    },
+    lives: {
+      id: 'lives',
+      speakerId: CHARACTER_IDS.mae,
+      once: true,
+      text: 'Few enough of us. Fen, who is louder than the flood. And old Tam, who bakes grey bread and sings to his oven so the dough won’t be afraid—',
+      effects: [
+        { type: 'removeActors', defId: CHARACTER_IDS.tam },
+        { type: 'setFlag', flag: FLAGS.wayrestForgot, value: true },
+        { type: 'advanceQuest', questId: QUEST_IDS.theForgottenWayrest },
+        { type: 'discovery', loreId: 'lore_forgetting' },
+      ],
+      choices: [
+        { id: 'insist', text: 'Tam. The baker. I spoke to him an hour ago.', next: 'insist' },
+        { id: 'slip', text: '…No one. Go on.', next: 'slip' },
+      ],
+    },
+    insist: {
+      id: 'insist',
+      speakerId: CHARACTER_IDS.mae,
+      text: 'Tam? …There’s no Tam here, love. There has never been a Tam. You’re white as candle wax — sit down before you fall.',
+      effects: [
+        { type: 'setFlag', flag: FLAGS.insistedTamExisted, value: true },
+        { type: 'addMemory', npcId: 'protagonist', event: MEMORY_EVENTS.insistedTamExisted },
+        { type: 'tendency', axis: 'resolve', amount: 1 },
+      ],
+      next: 'after',
+    },
+    slip: {
+      id: 'slip',
+      speakerId: CHARACTER_IDS.mae,
+      text: 'Go on with what? …I was saying something. It’s gone. They go, sometimes. Whole people. Like a word you almost remember.',
+      effects: [
+        { type: 'addMemory', npcId: 'protagonist', event: MEMORY_EVENTS.letTamGoUnsaid },
+        { type: 'tendency', axis: 'empathy', amount: 1 },
+      ],
+      next: 'after',
+    },
+    after: {
+      id: 'after',
+      speakerId: CHARACTER_IDS.mae,
+      text: 'Listen to me. A Crown patrol rode in this morning, asking after an unstable Glyph — a person who flickers. You should be somewhere else, love. West, through the Glasswood, if you’re brave or tired enough.',
+      effects: [
+        { type: 'setFlag', flag: FLAGS.crownArrived, value: true },
+        { type: 'advanceQuest', questId: QUEST_IDS.theForgottenWayrest },
+        { type: 'openRoute', routeId: 'glasswoodRoad' },
+      ],
+      choices: [{ id: 'end', text: 'West, then. Thank you.' }],
+    },
+    afterForget: {
+      id: 'afterForget',
+      speakerId: CHARACTER_IDS.mae,
+      text: 'You should get west, love. The patrol asks questions I don’t have answers for. And whatever you think you remember — keep it behind your teeth.',
+      choices: [{ id: 'end', text: 'Leave.' }],
+    },
+  },
+};
+
+export const FEN_WAYREST: DialogueTree = {
+  id: 'fen_wayrest',
+  startNodeId: 'start',
+  startVariants: [{ nodeId: 'forgotStart', conditions: [{ type: 'flag', flag: FLAGS.wayrestForgot }] }],
+  nodes: {
+    start: {
+      id: 'start',
+      speakerId: CHARACTER_IDS.fen,
+      once: true,
+      text: 'Did you come from the road? Mae says the road eats people. You don’t look eaten.',
+      choices: [
+        { id: 'notEaten', text: 'Not eaten, no.', next: 'notEaten' },
+        { id: 'place', text: 'What is this place?', next: 'place' },
+      ],
+    },
+    notEaten: {
+      id: 'notEaten',
+      speakerId: CHARACTER_IDS.fen,
+      text: 'Good. Tam says the road only eats people who don’t wave. I wave at everything. Even the scarecrow. Even Mae’s cat, and it hates me.',
+      next: 'end',
+    },
+    place: {
+      id: 'place',
+      speakerId: CHARACTER_IDS.fen,
+      text: 'Wayrest. There’s Mae, and me, and Tam who bakes. Small, but everyone’s someone.',
+      next: 'end',
+    },
+    forgotStart: {
+      id: 'forgotStart',
+      speakerId: CHARACTER_IDS.fen,
+      once: true,
+      text: 'Mae says a stranger was asking about people who don’t exist. That’s you, isn’t it? …Who did you lose?',
+      choices: [
+        { id: 'baker', text: 'A baker.', next: 'baker' },
+        { id: 'noone', text: '', silent: true, next: 'noone' },
+      ],
+    },
+    baker: {
+      id: 'baker',
+      speakerId: CHARACTER_IDS.fen,
+      text: 'We never had a baker. …You’re making my head feel weird, mister. Like a word on the tip of my tongue that was never there.',
+      next: 'end',
+    },
+    noone: {
+      id: 'noone',
+      speakerId: CHARACTER_IDS.fen,
+      text: 'Then why do you look like that?',
+      next: 'end',
+    },
+    end: {
+      id: 'end',
+      speakerId: CHARACTER_IDS.fen,
+      text: 'I have to go wave at the road. So it knows.',
+      choices: [{ id: 'bye', text: 'Leave.' }],
+    },
+    repeat: {
+      id: 'repeat',
+      speakerId: CHARACTER_IDS.fen,
+      text: 'You again! The road didn’t eat you twice.',
+      choices: [{ id: 'bye', text: 'Leave.' }],
+    },
+  },
+};
@@ -0,0 +1,168 @@+import type { DialogueEffect } from './dialogues/types';
+import { GLYPH_IDS, GLYPHS } from './glyphs';
+
+const GLYPH_LOOKUP = new Map(GLYPHS.map((g) => [g.id, g.glyph]));
+
+function glyphOf(id: string): string {
+  const glyph = GLYPH_LOOKUP.get(id);
+  if (!glyph) throw new Error(`Unknown glyph id: ${id}`);
+  return glyph;
+}
+
+/**
+ * Name Echo objects: fragments of identity left in things. Short first-person
+ * vignettes — incomplete, sometimes contradictory (canon). One-time per save.
+ */
+export interface EchoDef {
+  id: string;
+  glyph: string;
+  title: string;
+  lines: string[];
+  effects?: DialogueEffect[];
+  /** Ability granted on first hearing (progression via the world, not menus). */
+  grantsAbility?: string;
+}
+
+export const ECHO_IDS = {
+  mirrorShard: 'echo_mirrorShard',
+  roadScroll: 'echo_roadScroll',
+  glassA: 'echo_glassA',
+  glassB: 'echo_glassB',
+  glassC: 'echo_glassC',
+  sanctuary: 'echo_sanctuary',
+  shiftedRoom: 'echo_shiftedRoom',
+  shipwreck: 'echo_shipwreck',
+  archivePast: 'echo_archivePast',
+} as const;
+
+export const ECHOES: Record<string, EchoDef> = {
+  [ECHO_IDS.mirrorShard]: {
+    id: ECHO_IDS.mirrorShard,
+    glyph: glyphOf(GLYPH_IDS.echoMirror),
+    title: 'A shard of mirror',
+    lines: [
+      'Your face, but older. No — younger. No — not yours.',
+      'Someone practiced a name in front of this mirror until it fit. It was not your name. You are almost sure.',
+      'The reflection blinks a moment after you do. It is learning you.',
+    ],
+    effects: [
+      { type: 'tendency', axis: 'insight', amount: 1 },
+      { type: 'discovery', loreId: 'lore_mirrorsLie' },
+    ],
+    grantsAbility: 'scrutinize',
+  },
+  [ECHO_IDS.roadScroll]: {
+    id: ECHO_IDS.roadScroll,
+    glyph: glyphOf(GLYPH_IDS.echoScroll),
+    title: 'A water-stained scroll',
+    lines: [
+      'A list of names, crossed out one by one. The handwriting changes halfway through, as if the writer forgot their own hand.',
+      'The last line is not crossed out. It says only: "I was here. I was —". The sentence was never finished. Or it was, and the ending was taken.',
+    ],
+    effects: [
+      { type: 'tendency', axis: 'insight', amount: 1 },
+      { type: 'discovery', loreId: 'lore_erasureList' },
+    ],
+  },
+  [ECHO_IDS.glassA]: {
+    id: ECHO_IDS.glassA,
+    glyph: glyphOf(GLYPH_IDS.echoMirror),
+    title: 'A mirror like a wound',
+    lines: [
+      'A harvest dance under paper lanterns. Your feet know the steps. Your feet have never done them.',
+      'The joy fits like someone else’s coat — warm, and not yours. Someone dropped this, or it was taken.',
+    ],
+    effects: [
+      { type: 'tendency', axis: 'insight', amount: 1 },
+      { type: 'discovery', loreId: 'lore_mirrorStolen' },
+    ],
+  },
+  [ECHO_IDS.glassB]: {
+    id: ECHO_IDS.glassB,
+    glyph: glyphOf(GLYPH_IDS.echoMirror),
+    title: 'A mirror like a held breath',
+    lines: [
+      'A small hand in yours, sticky with sap. A laugh you would know anywhere. You have never had a child.',
+      'You are almost certain you have never had a child. The mirror is patient. It can wait for almost to become maybe.',
+    ],
+    effects: [
+      { type: 'tendency', axis: 'insight', amount: 1 },
+      { type: 'discovery', loreId: 'lore_mirrorInvented' },
+    ],
+  },
+  [ECHO_IDS.glassC]: {
+    id: ECHO_IDS.glassC,
+    glyph: glyphOf(GLYPH_IDS.echoMirror),
+    title: 'A mirror like a scar',
+    lines: [
+      'Rain on grey bread. An oven door closing. A man singing to the dough so it would not be afraid of the fire.',
+      'This one is yours. You can tell because it hurts in the right place. You did not know you were carrying it.',
+    ],
+    effects: [
+      { type: 'tendency', axis: 'insight', amount: 1 },
+      { type: 'discovery', loreId: 'lore_mirrorReal' },
+    ],
+  },
+  [ECHO_IDS.sanctuary]: {
+    id: ECHO_IDS.sanctuary,
+    glyph: glyphOf(GLYPH_IDS.echoScroll),
+    title: 'A scorched archive wall',
+    lines: [
+      'Shelves, full. Names, safe. A knock at the door that everyone inside pretends is the wind.',
+      'An officer reads an order with a steady voice. His hand is not steady. He burns it all anyway, and calls it protection, and hears the screaming stop being people.',
+      'The order is signed. The signature is Rook.',
+    ],
+    effects: [
+      { type: 'tendency', axis: 'insight', amount: 1 },
+      { type: 'discovery', loreId: 'lore_sanctuary' },
+      { type: 'setFlag', flag: 'sanctuaryFound', value: true },
+    ],
+  },
+  [ECHO_IDS.shiftedRoom]: {
+    id: ECHO_IDS.shiftedRoom,
+    glyph: glyphOf(GLYPH_IDS.propEye),
+    title: 'A detail that moved',
+    lines: [
+      'This room has a door on the east wall. You are sure. You walked through it.',
+      'This room has a door on the west wall. You are sure. You walked through it.',
+      'Both memories are yours. One of them is being listened to.',
+    ],
+    effects: [
+      { type: 'tendency', axis: 'insight', amount: 1 },
+      { type: 'discovery', loreId: 'lore_listenerWatching' },
+    ],
+  },
+  [ECHO_IDS.shipwreck]: {
+    id: ECHO_IDS.shipwreck,
+    glyph: glyphOf(GLYPH_IDS.propShip),
+    title: 'A crewless ship',
+    lines: [
+      "The deck is swept. The log is kept. The last entry is tomorrow's date, in handwriting you almost recognize.",
+      'The crew did not drown. The crew was unwritten, one by one, and the ship kept sailing out of habit.',
+    ],
+    effects: [
+      { type: 'tendency', axis: 'insight', amount: 1 },
+      { type: 'giveItem', itemId: 'letterUnsent', quantity: 1 },
+    ],
+  },
+  [ECHO_IDS.archivePast]: {
+    id: ECHO_IDS.archivePast,
+    glyph: glyphOf(GLYPH_IDS.echoBook),
+    title: 'Vesper, before',
+    lines: [
+      'A city of names. Bakers who became poets on weekends. Children who chose new names like new coats.',
+      'The Empty Bell rang, and the King wept, and the First Scribe removed one last line before sealing the Book. The official history does not mention the weeping. It does not mention the line.',
+      'The line is walking around now, reading this. Hello.',
+    ],
+    effects: [
+      { type: 'tendency', axis: 'insight', amount: 1 },
+      { type: 'discovery', loreId: 'lore_archivePast' },
+    ],
+  },
+};
+
+export function echoDef(id: string): EchoDef {
+  const def = ECHOES[id];
+  if (!def) throw new Error(`Unknown echo id: ${id}`);
+  return def;
+}
@@ -0,0 +1,158 @@+import type { ActorAppearance, ActorStats, AiType, ConditionId, MoveArchetype } from '../core/types';
+import { GLYPH_IDS, GLYPHS } from './glyphs';
+
+const GLYPH_LOOKUP = new Map(GLYPHS.map((g) => [g.id, g.glyph]));
+
+function glyphOf(id: string): string {
+  const glyph = GLYPH_LOOKUP.get(id);
+  if (!glyph) throw new Error(`Unknown glyph id: ${id}`);
+  return glyph;
+}
+
+export interface EnemyDef {
+  id: string;
+  name: string;
+  appearance: ActorAppearance;
+  hp: number;
+  focus: number;
+  stats: ActorStats;
+  aiType: AiType;
+  moveArchetype: MoveArchetype;
+  aggroRange: number;
+  moveRange: number;
+  attackRange: number;
+  /** Flanking allies grant this enemy +2 attack. */
+  packTactics?: boolean;
+  /** Can be calmed/spared via the Empathy action — a nonviolent resolution. */
+  calmable?: boolean;
+  /** Condition this enemy's attacks inflict (e.g. the drowned apply Forgotten). */
+  appliesCondition?: { id: ConditionId; duration: number };
+  inspectText: string;
+}
+
+export const ENEMY_IDS = {
+  ashWolf: 'ashWolf',
+  hollowSoldier: 'hollowSoldier',
+  crownArcher: 'crownArcher',
+  crownSoldier: 'crownSoldier',
+  mireLurker: 'mireLurker',
+  rustMachine: 'rustMachine',
+} as const;
+
+export const ENEMIES: Record<string, EnemyDef> = {
+  [ENEMY_IDS.ashWolf]: {
+    id: ENEMY_IDS.ashWolf,
+    name: 'Ash Wolf',
+    appearance: { bodyGlyph: glyphOf(GLYPH_IDS.wolf) },
+    hp: 14,
+    focus: 4,
+    stats: { attack: 5, defense: 1, speed: 6, insight: 2 },
+    aiType: 'wolf',
+    moveArchetype: 'light',
+    aggroRange: 4,
+    moveRange: 3,
+    attackRange: 1,
+    packTactics: true,
+    calmable: true,
+    inspectText:
+      'Hungry, but not cruel. It hunts because the woods forgot how to feed it. It might remember kindness.',
+  },
+  [ENEMY_IDS.hollowSoldier]: {
+    id: ENEMY_IDS.hollowSoldier,
+    name: 'Hollow Soldier',
+    appearance: {
+      bodyGlyph: glyphOf(GLYPH_IDS.hollowSoldierBody),
+      statusGlyph: glyphOf(GLYPH_IDS.hollowSoldierStatus),
+    },
+    hp: 22,
+    focus: 0,
+    stats: { attack: 6, defense: 3, speed: 3, insight: 1 },
+    aiType: 'hollowSoldier',
+    moveArchetype: 'irregular',
+    aggroRange: 3,
+    moveRange: 2,
+    attackRange: 1,
+    inspectText:
+      'A uniform walking a patrol route for a war that ended. It still asks for a password nobody remembers.',
+  },
+  [ENEMY_IDS.crownArcher]: {
+    id: ENEMY_IDS.crownArcher,
+    name: 'Crown Archer',
+    appearance: {
+      bodyGlyph: glyphOf(GLYPH_IDS.crownArcherBody),
+      equipmentGlyph: glyphOf(GLYPH_IDS.crownArcherEquipment),
+      factionGlyph: glyphOf(GLYPH_IDS.crownFaction),
+    },
+    hp: 12,
+    focus: 6,
+    stats: { attack: 5, defense: 1, speed: 5, insight: 4 },
+    aiType: 'archer',
+    moveArchetype: 'default',
+    aggroRange: 6,
+    moveRange: 2,
+    attackRange: 4,
+    inspectText: 'Keeps distance, counts arrows, follows orders. The orders are old.',
+  },
+  [ENEMY_IDS.crownSoldier]: {
+    id: ENEMY_IDS.crownSoldier,
+    name: 'Crown Soldier',
+    appearance: {
+      bodyGlyph: glyphOf(GLYPH_IDS.crownSoldierBody),
+      roleGlyph: glyphOf(GLYPH_IDS.crownSoldierRole),
+      factionGlyph: glyphOf(GLYPH_IDS.crownFaction),
+    },
+    hp: 18,
+    focus: 4,
+    stats: { attack: 5, defense: 3, speed: 4, insight: 2 },
+    aiType: 'crownSoldier',
+    moveArchetype: 'heavy',
+    aggroRange: 3,
+    moveRange: 2,
+    attackRange: 1,
+    inspectText:
+      'Ordered to bring in the unstable Glyph alive. The order does not say what to do if it fights back, so he is improvising.',
+  },
+  [ENEMY_IDS.mireLurker]: {
+    id: ENEMY_IDS.mireLurker,
+    name: 'Mire Lurker',
+    appearance: {
+      bodyGlyph: glyphOf(GLYPH_IDS.mireLurkerBody),
+      statusGlyph: glyphOf(GLYPH_IDS.mireLurkerStatus),
+    },
+    hp: 16,
+    focus: 0,
+    stats: { attack: 5, defense: 1, speed: 2, insight: 1 },
+    aiType: 'mireLurker',
+    moveArchetype: 'irregular',
+    aggroRange: 2,
+    moveRange: 2,
+    attackRange: 1,
+    appliesCondition: { id: 'forgotten', duration: 2 },
+    inspectText:
+      'Half-drowned, half-forgotten. It rises only when something warm comes close. Its touch makes you less remembered. The bells used to keep it down.',
+  },
+  [ENEMY_IDS.rustMachine]: {
+    id: ENEMY_IDS.rustMachine,
+    name: 'Rust Machine',
+    appearance: {
+      bodyGlyph: glyphOf(GLYPH_IDS.rustMachineBody),
+      roleGlyph: glyphOf(GLYPH_IDS.rustMachineRole),
+    },
+    hp: 30,
+    focus: 0,
+    stats: { attack: 8, defense: 4, speed: 2, insight: 0 },
+    aiType: 'rustMachine',
+    moveArchetype: 'heavy',
+    aggroRange: 3,
+    moveRange: 1,
+    attackRange: 1,
+    inspectText:
+      'Advance. Strike. Wind down. Repeat. It builds weapons for an army of the dead, on schedule.',
+  },
+};
+
+export function enemyDef(id: string): EnemyDef {
+  const def = ENEMIES[id];
+  if (!def) throw new Error(`Unknown enemy id: ${id}`);
+  return def;
+}
@@ -0,0 +1,145 @@+// Centralized world-flag ids. NPC event-memories follow the same convention:
+// camelCase past-tense event names (sparedMirasBrother, liedToRook, ...).
+export const FLAGS = {
+  metCrow: 'metCrow',
+  heardFirstEcho: 'heardFirstEcho',
+  calmedWolf: 'calmedWolf',
+  killedWolf: 'killedWolf',
+  readGrave: 'readGrave',
+  tookRustedSword: 'tookRustedSword',
+  tookWeatheredCloak: 'tookWeatheredCloak',
+  // prologue — Wayrest
+  wayrestForgot: 'wayrestForgot',
+  insistedTamExisted: 'insistedTamExisted',
+  crownArrived: 'crownArrived',
+  // the Glasswood
+  miraPeaceful: 'miraPeaceful',
+  miraDefeated: 'miraDefeated',
+  glasswoodMemoryRestored: 'glasswoodMemoryRestored',
+  glasswoodMemoryLeftLie: 'glasswoodMemoryLeftLie',
+  // the Mire of Bells
+  mireBellRung: 'mireBellRung',
+  bellMotherAffirmed: 'bellMotherAffirmed',
+  bellMotherTruthed: 'bellMotherTruthed',
+  mireBothWays: 'mireBothWays',
+  metMarshKing: 'metMarshKing',
+  // the Rust March
+  sanctuaryFound: 'sanctuaryFound',
+  rookAlly: 'rookAlly',
+  rookExposed: 'rookExposed',
+  rookHostile: 'rookHostile',
+  worksSilenced: 'worksSilenced',
+  worksKeptRunning: 'worksKeptRunning',
+  // the Moonless Coast
+  letterDelivered: 'letterDelivered',
+  letterBurned: 'letterBurned',
+  letterKept: 'letterKept',
+  valeTruth: 'valeTruth',
+  valeWithheld: 'valeWithheld',
+  // Crown City
+  gatePassedFree: 'gatePassedFree',
+  defendedGlyphChanger: 'defendedGlyphChanger',
+  watchedArrestSilently: 'watchedArrestSilently',
+  corvanCracked: 'corvanCracked',
+  corvanFought: 'corvanFought',
+  // the Archive
+  crowConfessed: 'crowConfessed',
+  kingSurrendered: 'kingSurrendered',
+  kingAlly: 'kingAlly',
+  kingFought: 'kingFought',
+  endingsUnlocked: 'endingsUnlocked',
+  gameCompleted: 'gameCompleted',
+} as const;
+
+export const MEMORY_EVENTS = {
+  metCrow: 'metCrow',
+  choseSilence: 'choseSilence',
+  calmedWolf: 'calmedWolf',
+  killedWolf: 'killedWolf',
+  acceptedFeather: 'acceptedFeather',
+  // Wayrest
+  insistedTamExisted: 'insistedTamExisted',
+  letTamGoUnsaid: 'letTamGoUnsaid',
+  // Glasswood
+  miraStandoffPeaceful: 'miraStandoffPeaceful',
+  miraStandoffViolent: 'miraStandoffViolent',
+  foughtMira: 'foughtMira',
+  miraToldYouAboutBrother: 'miraToldYouAboutBrother',
+  lioraToldYou: 'lioraToldYou',
+  restoredFamilyMemory: 'restoredFamilyMemory',
+  letMemoryLie: 'letMemoryLie',
+  // Mire
+  rangSilentBell: 'rangSilentBell',
+  playedAlongWithBellMother: 'playedAlongWithBellMother',
+  affirmedAcceptedIdentity: 'affirmedAcceptedIdentity',
+  toldTruthInMire: 'toldTruthInMire',
+  choseBothInMire: 'choseBothInMire',
+  // Rust March
+  liedToRook: 'liedToRook',
+  rookWarnedYou: 'rookWarnedYou',
+  exposedRook: 'exposedRook',
+  foughtRook: 'foughtRook',
+  destroyedRustFactory: 'destroyedRustFactory',
+  sparedRustFactory: 'sparedRustFactory',
+  heardSanctuaryTruth: 'heardSanctuaryTruth',
+  // Moonless Coast
+  deliveredDrownedLetter: 'deliveredDrownedLetter',
+  burnedDrownedLetter: 'burnedDrownedLetter',
+  keptDrownedLetter: 'keptDrownedLetter',
+  valeToldTruth: 'valeToldTruth',
+  valeWasWithheld: 'valeWasWithheld',
+  // Crown City
+  defendedGlyphChanger: 'defendedGlyphChanger',
+  watchedArrestSilently: 'watchedArrestSilently',
+  sparedMirasBrother: 'sparedMirasBrother',
+  foughtCorvan: 'foughtCorvan',
+  toldMiraAboutCorvan: 'toldMiraAboutCorvan',
+  // Archive
+  heardCrowConfession: 'heardCrowConfession',
+  joinedKingAgainstListener: 'joinedKingAgainstListener',
+  foughtHollowKing: 'foughtHollowKing',
+} as const;
+
+/** Journal "important decisions" lines for memory events. Unknown events are
+    rendered as-is, but content phases should add a line here. */
+export const DECISION_TEXT: Record<string, string> = {
+  metCrow: 'Trusted the black crow enough to listen.',
+  choseSilence: 'Answered the crow with silence. It approved.',
+  calmedWolf: 'Calmed an ash wolf instead of killing it.',
+  killedWolf: 'Killed an ash wolf.',
+  acceptedFeather: 'Accepted the crow’s black feather.',
+  insistedTamExisted: 'Insisted Tam the baker existed, when no one else remembered.',
+  letTamGoUnsaid: 'Let Tam’s name go unsaid. It felt safer. It was not.',
+  miraStandoffPeaceful: 'Talked Mira down from an arrow to a conversation.',
+  miraStandoffViolent: 'Answered Mira’s arrow with steel.',
+  foughtMira: 'Fought Mira and won. She yielded.',
+  miraToldYouAboutBrother: 'Heard about Mira’s brother, who wears a Crown glyph now.',
+  lioraToldYou: 'Listened to Liora’s mirror-mother without judging which one was real.',
+  restoredFamilyMemory: 'Gave Liora the true memory of her mother — afraid, and brave anyway.',
+  letMemoryLie: 'Let Liora keep the kind, invented memory of her mother.',
+  rangSilentBell: 'Rang a silent bell. The water remembered hating it.',
+  playedAlongWithBellMother: 'Played along as the Bell-Mother’s imagined apprentice.',
+  affirmedAcceptedIdentity: 'Ruled that a role held by everyone becomes true.',
+  toldTruthInMire: 'Ruled that a worn memory is not a name, whatever it costs.',
+  choseBothInMire: 'Let the Mire keep being two things at once.',
+  liedToRook: 'Lied to Captain Rook about who you are.',
+  rookWarnedYou: 'Let Rook walk away with a warning instead of a fight.',
+  exposedRook: 'Made Rook say the sanctuary’s name out loud.',
+  foughtRook: 'Fought Captain Rook. He yielded, unbroken.',
+  destroyedRustFactory: 'Silenced the Rust Works and its dead armies’ orders.',
+  sparedRustFactory: 'Left the Rust Works running. The machines still have a purpose.',
+  heardSanctuaryTruth: 'Found what is left of the Scribe sanctuary Rook burned.',
+  deliveredDrownedLetter: 'Gave the drowned their letter. They remember, and it hurts, and they asked for it.',
+  burnedDrownedLetter: 'Burned the letter. Some grief should be let go.',
+  keptDrownedLetter: 'Kept the letter. Someone has to carry it.',
+  valeToldTruth: 'Proved intent to Sister Vale. She told you what you are.',
+  valeWasWithheld: 'Sister Vale looked at you and said: not yet.',
+  defendedGlyphChanger: 'Stood between Crown law and a person who changed their Glyph.',
+  watchedArrestSilently: 'Watched the Crown take the glyph-changer. Said nothing.',
+  sparedMirasBrother: 'Cracked Corvan’s assigned Glyph with his sister’s name.',
+  foughtCorvan: 'Fought Corvan. The Crown’s Glyph held.',
+  toldMiraAboutCorvan: 'Told Mira what became of her brother in the city.',
+  heardCrowConfession: 'Heard the crow confess: it is the First Scribe’s regret.',
+  joinedKingAgainstListener: 'The Hollow King chose to stand with you against the Pale Listener.',
+  foughtHollowKing: 'Fought the Hollow King. He yielded like a man setting down weight.',
+};
@@ -0,0 +1,265 @@+// Central registry of every glyph used by the game. The dev-only Glyph
+// Gallery (?dev=1) renders this list large to spot broken ZWJ sequences.
+export interface GlyphEntry {
+  id: string;
+  glyph: string;
+  label: string;
+}
+
+export const GLYPH_IDS = {
+  // player variants
+  playerNeutral: 'player.neutral',
+  playerLight: 'player.light',
+  playerMedium: 'player.medium',
+  playerDark: 'player.dark',
+  playerWoman: 'player.woman',
+  playerMan: 'player.man',
+  playerRedHair: 'player.redhair',
+  playerCurly: 'player.curly',
+  playerElder: 'player.elder',
+  // major characters
+  crow: 'char.crow',
+  miraBody: 'char.mira.body',
+  miraRole: 'char.mira.role',
+  rookBody: 'char.rook.body',
+  rookRole: 'char.rook.role',
+  rookEquipment: 'char.rook.equipment',
+  valeBody: 'char.vale.body',
+  valeRole: 'char.vale.role',
+  hollowKingBody: 'char.hollowKing.body',
+  hollowKingRole: 'char.hollowKing.role',
+  hollowKingStatus: 'char.hollowKing.status',
+  marshKingBody: 'char.marshKing.body',
+  marshKingRole: 'char.marshKing.role',
+  // settlement & region NPCs
+  tamBody: 'char.tam.body',
+  maeBody: 'char.mae.body',
+  fenBody: 'char.fen.body',
+  lioraBody: 'char.liora.body',
+  brannBody: 'char.brann.body',
+  wennaBody: 'char.wenna.body',
+  sedgeBody: 'char.sedge.body',
+  bellMotherBody: 'char.bellMother.body',
+  heartMirrorBody: 'char.heartMirror.body',
+  hollowSergeantBody: 'char.hollowSergeant.body',
+  hollowSergeantStatus: 'char.hollowSergeant.status',
+  corvanBody: 'char.corvan.body',
+  corvanRole: 'char.corvan.role',
+  gateOfficerBody: 'char.gateOfficer.body',
+  odellBody: 'char.odell.body',
+  odellRole: 'char.odell.role',
+  anselmBody: 'char.anselm.body',
+  anselmRole: 'char.anselm.role',
+  nyraBody: 'char.nyra.body',
+  nyraRole: 'char.nyra.role',
+  glyphChangerBody: 'char.glyphChanger.body',
+  glyphChangerRole: 'char.glyphChanger.role',
+  henkBody: 'char.henk.body',
+  henkRole: 'char.henk.role',
+  pellBody: 'char.pell.body',
+  pellRole: 'char.pell.role',
+  firstBookBody: 'char.firstBook.body',
+  // enemies
+  wolf: 'enemy.wolf',
+  hollowSoldierBody: 'enemy.hollowSoldier.body',
+  hollowSoldierStatus: 'enemy.hollowSoldier.status',
+  crownArcherBody: 'enemy.crownArcher.body',
+  crownArcherEquipment: 'enemy.crownArcher.equipment',
+  crownSoldierBody: 'enemy.crownSoldier.body',
+  crownSoldierRole: 'enemy.crownSoldier.role',
+  crownFaction: 'enemy.crown.faction',
+  mireLurkerBody: 'enemy.mireLurker.body',
+  mireLurkerStatus: 'enemy.mireLurker.status',
+  drownedBody: 'enemy.drowned.body',
+  drownedStatus: 'enemy.drowned.status',
+  rustMachineBody: 'enemy.rustMachine.body',
+  rustMachineRole: 'enemy.rustMachine.role',
+  // terrain & props
+  tree: 'terrain.tree',
+  deadTree: 'terrain.deadTree',
+  grave: 'terrain.grave',
+  rock: 'terrain.rock',
+  water: 'terrain.water',
+  unstable: 'terrain.unstable',
+  candle: 'prop.candle',
+  propHut: 'prop.hut',
+  propBell: 'prop.bell',
+  propBucket: 'prop.bucket',
+  propGear: 'prop.gear',
+  propShip: 'prop.ship',
+  propBooks: 'prop.books',
+  propEye: 'prop.eye',
+  uiBlank: 'ui.blank',
+  echoBook: 'echo.book',
+  itemLetterUnsent: 'item.letterUnsent',
+  itemLetterDrowned: 'item.letterDrowned',
+  // combat effects
+  fxSlash: 'fx.slash',
+  fxArrow: 'fx.arrow',
+  fxSparkle: 'fx.sparkle',
+  fxFire: 'fx.fire',
+  fxIce: 'fx.ice',
+  fxLightning: 'fx.lightning',
+  fxBlood: 'fx.blood',
+  fxImpact: 'fx.impact',
+  fxGust: 'fx.gust',
+  fxShield: 'fx.shield',
+  fxSwirl: 'fx.swirl',
+  // conditions
+  condBleeding: 'condition.bleeding',
+  condBurning: 'condition.burning',
+  condFrozen: 'condition.frozen',
+  condStaggered: 'condition.staggered',
+  condAfraid: 'condition.afraid',
+  condGuarded: 'condition.guarded',
+  condSilenced: 'condition.silenced',
+  condForgotten: 'condition.forgotten',
+  condRemembered: 'condition.remembered',
+  condUnstable: 'condition.unstable',
+  // items
+  itemRustedSword: 'item.rustedSword',
+  itemHuntingBow: 'item.huntingBow',
+  itemBandage: 'item.bandage',
+  itemEmberSalve: 'item.emberSalve',
+  itemCrowFeather: 'item.crowFeather',
+  itemFadedLetter: 'item.fadedLetter',
+  itemWeatheredCloak: 'item.weatheredCloak',
+  itemMossBread: 'item.mossBread',
+  // echoes & shards
+  echoMirror: 'echo.mirror',
+  echoScroll: 'echo.scroll',
+  echoCandle: 'echo.candle',
+  shardGlasswood: 'shard.glasswood',
+  shardMire: 'shard.mire',
+  shardRust: 'shard.rust',
+  shardCoast: 'shard.coast',
+  // ui
+  uiHeart: 'ui.heart',
+  uiFocus: 'ui.focus',
+  uiQuest: 'ui.quest',
+  uiEye: 'ui.eye',
+} as const;
+
+export const GLYPHS: GlyphEntry[] = [
+  { id: GLYPH_IDS.playerNeutral, glyph: '🧑', label: 'Protagonist (neutral)' },
+  { id: GLYPH_IDS.playerLight, glyph: '🧑🏻', label: 'Protagonist (light skin)' },
+  { id: GLYPH_IDS.playerMedium, glyph: '🧑🏽', label: 'Protagonist (medium skin)' },
+  { id: GLYPH_IDS.playerDark, glyph: '🧑🏿', label: 'Protagonist (dark skin)' },
+  { id: GLYPH_IDS.playerWoman, glyph: '👩🏼', label: 'Protagonist (woman)' },
+  { id: GLYPH_IDS.playerMan, glyph: '👨🏾', label: 'Protagonist (man)' },
+  { id: GLYPH_IDS.playerRedHair, glyph: '🧑🏻‍🦰', label: 'Protagonist (red hair)' },
+  { id: GLYPH_IDS.playerCurly, glyph: '👩🏿‍🦱', label: 'Protagonist (curly hair)' },
+  { id: GLYPH_IDS.playerElder, glyph: '🧓🏽', label: 'Protagonist (elder)' },
+  { id: GLYPH_IDS.crow, glyph: '🐦‍⬛', label: 'Black Crow' },
+  { id: GLYPH_IDS.miraBody, glyph: '👩🏽‍🦱', label: 'Mira (body)' },
+  { id: GLYPH_IDS.miraRole, glyph: '🏹', label: 'Mira (role: ranger)' },
+  { id: GLYPH_IDS.rookBody, glyph: '🧔🏻‍♂️', label: 'Captain Rook (body)' },
+  { id: GLYPH_IDS.rookRole, glyph: '🛡️', label: 'Rook (role: shield)' },
+  { id: GLYPH_IDS.rookEquipment, glyph: '⚔️', label: 'Rook (equipment: swords)' },
+  { id: GLYPH_IDS.valeBody, glyph: '👩🏿‍🦲', label: 'Sister Vale (body)' },
+  { id: GLYPH_IDS.valeRole, glyph: '🕯️', label: 'Vale (role: candle)' },
+  { id: GLYPH_IDS.hollowKingBody, glyph: '👴🏼', label: 'Hollow King (body)' },
+  { id: GLYPH_IDS.hollowKingRole, glyph: '👑', label: 'Hollow King (role: crown)' },
+  { id: GLYPH_IDS.hollowKingStatus, glyph: '🕳️', label: 'Hollow King (status: hollow)' },
+  { id: GLYPH_IDS.marshKingBody, glyph: '🐸', label: 'Marsh King (body)' },
+  { id: GLYPH_IDS.marshKingRole, glyph: '👑', label: 'Marsh King (role: crown)' },
+  { id: GLYPH_IDS.tamBody, glyph: '🧑🏽‍🍳', label: 'Tam, baker of Wayrest' },
+  { id: GLYPH_IDS.maeBody, glyph: '👵🏼', label: 'Mae, elder of Wayrest' },
+  { id: GLYPH_IDS.fenBody, glyph: '🧒🏻', label: 'Fen, child of Wayrest' },
+  { id: GLYPH_IDS.lioraBody, glyph: '🧒🏽', label: 'Liora, Glasswood child' },
+  { id: GLYPH_IDS.brannBody, glyph: '🧓🏾', label: 'Brann, Glasswood elder' },
+  { id: GLYPH_IDS.wennaBody, glyph: '👵🏿', label: 'Wenna, Mire elder' },
+  { id: GLYPH_IDS.sedgeBody, glyph: '🧒🏾', label: 'Sedge, Mire child' },
+  { id: GLYPH_IDS.bellMotherBody, glyph: '🧓🏽', label: 'The Bell-Mother / Old Sorrow' },
+  { id: GLYPH_IDS.heartMirrorBody, glyph: '🪞', label: 'The Heart Mirror' },
+  { id: GLYPH_IDS.hollowSergeantBody, glyph: '🧟‍♂️', label: 'Hollow sergeant (body)' },
+  { id: GLYPH_IDS.hollowSergeantStatus, glyph: '🕳️', label: 'Hollow sergeant (status)' },
+  { id: GLYPH_IDS.corvanBody, glyph: '🧑🏽‍🦱', label: 'Corvan, Crown agent (body)' },
+  { id: GLYPH_IDS.corvanRole, glyph: '🛡️', label: 'Corvan (role: shield)' },
+  { id: GLYPH_IDS.gateOfficerBody, glyph: '🧑🏻‍✈️', label: 'Gate officer' },
+  { id: GLYPH_IDS.odellBody, glyph: '🧑🏻‍🍳', label: 'Odell, Crown City baker' },
+  { id: GLYPH_IDS.odellRole, glyph: '🥖', label: 'Odell (role: bread)' },
+  { id: GLYPH_IDS.anselmBody, glyph: '🧔🏾', label: 'Anselm, Crown City priest' },
+  { id: GLYPH_IDS.anselmRole, glyph: '📿', label: 'Anselm (role: beads)' },
+  { id: GLYPH_IDS.nyraBody, glyph: '🧑🏽', label: 'Nyra, Crown City prisoner' },
+  { id: GLYPH_IDS.nyraRole, glyph: '⛓️', label: 'Nyra (role: chains)' },
+  { id: GLYPH_IDS.glyphChangerBody, glyph: '🧑🏿‍🦱', label: 'The glyph-changer' },
+  { id: GLYPH_IDS.glyphChangerRole, glyph: '🔔', label: 'Glyph-changer (role: bell)' },
+  { id: GLYPH_IDS.henkBody, glyph: '👴🏿', label: 'Old Henk, caretaker' },
+  { id: GLYPH_IDS.henkRole, glyph: '⚓', label: 'Henk (role: anchor)' },
+  { id: GLYPH_IDS.pellBody, glyph: '🧒🏼', label: 'Pell, coast child' },
+  { id: GLYPH_IDS.pellRole, glyph: '🐚', label: 'Pell (role: shell)' },
+  { id: GLYPH_IDS.firstBookBody, glyph: '📖', label: 'The First Book' },
+  { id: GLYPH_IDS.wolf, glyph: '🐺', label: 'Ash wolf' },
+  { id: GLYPH_IDS.hollowSoldierBody, glyph: '🧟', label: 'Hollow soldier (body)' },
+  { id: GLYPH_IDS.hollowSoldierStatus, glyph: '🕳️', label: 'Hollow soldier (status)' },
+  { id: GLYPH_IDS.crownArcherBody, glyph: '🧑🏼', label: 'Crown archer (body)' },
+  { id: GLYPH_IDS.crownArcherEquipment, glyph: '🏹', label: 'Crown archer (equipment)' },
+  { id: GLYPH_IDS.crownSoldierBody, glyph: '🧑🏻', label: 'Crown soldier (body)' },
+  { id: GLYPH_IDS.crownSoldierRole, glyph: '🛡️', label: 'Crown soldier (role: shield)' },
+  { id: GLYPH_IDS.crownFaction, glyph: '👑', label: 'Crown faction badge' },
+  { id: GLYPH_IDS.mireLurkerBody, glyph: '🧟‍♀️', label: 'Mire lurker (body)' },
+  { id: GLYPH_IDS.mireLurkerStatus, glyph: '🌊', label: 'Mire lurker (status: drowned)' },
+  { id: GLYPH_IDS.drownedBody, glyph: '🧟‍♂️', label: 'Drowned watcher (body)' },
+  { id: GLYPH_IDS.drownedStatus, glyph: '🌊', label: 'Drowned watcher (status)' },
+  { id: GLYPH_IDS.rustMachineBody, glyph: '🤖', label: 'Rust machine (body)' },
+  { id: GLYPH_IDS.rustMachineRole, glyph: '⚙️', label: 'Rust machine (role: gear)' },
+  { id: GLYPH_IDS.tree, glyph: '🌲', label: 'Glasswood tree' },
+  { id: GLYPH_IDS.deadTree, glyph: '🌳', label: 'Dead tree (greyed)' },
+  { id: GLYPH_IDS.grave, glyph: '🪦', label: 'Grave' },
+  { id: GLYPH_IDS.rock, glyph: '🪨', label: 'Rock' },
+  { id: GLYPH_IDS.water, glyph: '🌊', label: 'Black water' },
+  { id: GLYPH_IDS.unstable, glyph: '🕳️', label: 'Unstable space' },
+  { id: GLYPH_IDS.candle, glyph: '🕯️', label: 'Candle' },
+  { id: GLYPH_IDS.propHut, glyph: '🛖', label: 'Hut' },
+  { id: GLYPH_IDS.propBell, glyph: '🔔', label: 'Silent bell' },
+  { id: GLYPH_IDS.propBucket, glyph: '🪣', label: 'The ceremonial bucket' },
+  { id: GLYPH_IDS.propGear, glyph: '⚙️', label: 'Gear control' },
+  { id: GLYPH_IDS.propShip, glyph: '⛵', label: 'Crewless ship' },
+  { id: GLYPH_IDS.propBooks, glyph: '📚', label: 'Archive shelves' },
+  { id: GLYPH_IDS.propEye, glyph: '👁️', label: 'An eye that should not be there' },
+  { id: GLYPH_IDS.uiBlank, glyph: '□', label: 'Unwritten space' },
+  { id: GLYPH_IDS.echoBook, glyph: '📖', label: 'Echo: the First Book' },
+  { id: GLYPH_IDS.itemLetterUnsent, glyph: '✉️', label: 'Item: unsent letter' },
+  { id: GLYPH_IDS.itemLetterDrowned, glyph: '✉️', label: 'Item: letter to the drowned' },
+  { id: GLYPH_IDS.fxSlash, glyph: '⚔️', label: 'Effect: slash' },
+  { id: GLYPH_IDS.fxArrow, glyph: '🏹', label: 'Effect: arrow' },
+  { id: GLYPH_IDS.fxSparkle, glyph: '✨', label: 'Effect: sparkle (Name Echo)' },
+  { id: GLYPH_IDS.fxFire, glyph: '🔥', label: 'Effect: fire' },
+  { id: GLYPH_IDS.fxIce, glyph: '❄️', label: 'Effect: ice' },
+  { id: GLYPH_IDS.fxLightning, glyph: '⚡', label: 'Effect: lightning' },
+  { id: GLYPH_IDS.fxBlood, glyph: '🩸', label: 'Effect: blood' },
+  { id: GLYPH_IDS.fxImpact, glyph: '💥', label: 'Effect: impact' },
+  { id: GLYPH_IDS.fxGust, glyph: '💨', label: 'Effect: gust (push)' },
+  { id: GLYPH_IDS.fxShield, glyph: '🛡️', label: 'Effect: guard' },
+  { id: GLYPH_IDS.fxSwirl, glyph: '🌀', label: 'Effect: swirl' },
+  { id: GLYPH_IDS.condBleeding, glyph: '🩸', label: 'Condition: Bleeding' },
+  { id: GLYPH_IDS.condBurning, glyph: '🔥', label: 'Condition: Burning' },
+  { id: GLYPH_IDS.condFrozen, glyph: '❄️', label: 'Condition: Frozen' },
+  { id: GLYPH_IDS.condStaggered, glyph: '💫', label: 'Condition: Staggered' },
+  { id: GLYPH_IDS.condAfraid, glyph: '😨', label: 'Condition: Afraid' },
+  { id: GLYPH_IDS.condGuarded, glyph: '🛡️', label: 'Condition: Guarded' },
+  { id: GLYPH_IDS.condSilenced, glyph: '🔇', label: 'Condition: Silenced' },
+  { id: GLYPH_IDS.condForgotten, glyph: '🌫️', label: 'Condition: Forgotten' },
+  { id: GLYPH_IDS.condRemembered, glyph: '✨', label: 'Condition: Remembered' },
+  { id: GLYPH_IDS.condUnstable, glyph: '🕳️', label: 'Condition: Unstable' },
+  { id: GLYPH_IDS.itemRustedSword, glyph: '🗡️', label: 'Item: rusted sword' },
+  { id: GLYPH_IDS.itemHuntingBow, glyph: '🏹', label: 'Item: hunting bow' },
+  { id: GLYPH_IDS.itemBandage, glyph: '🩹', label: 'Item: bandage roll' },
+  { id: GLYPH_IDS.itemEmberSalve, glyph: '🧴', label: 'Item: ember salve' },
+  { id: GLYPH_IDS.itemCrowFeather, glyph: '🪶', label: 'Item: crow feather' },
+  { id: GLYPH_IDS.itemFadedLetter, glyph: '✉️', label: 'Item: faded letter' },
+  { id: GLYPH_IDS.itemWeatheredCloak, glyph: '🧥', label: 'Item: weathered cloak' },
+  { id: GLYPH_IDS.itemMossBread, glyph: '🍞', label: 'Item: moss bread' },
+  { id: GLYPH_IDS.echoMirror, glyph: '🪞', label: 'Echo: mirror shard' },
+  { id: GLYPH_IDS.echoScroll, glyph: '📜', label: 'Echo: scroll' },
+  { id: GLYPH_IDS.echoCandle, glyph: '🕯️', label: 'Echo: candle' },
+  { id: GLYPH_IDS.shardGlasswood, glyph: '🌲', label: 'Name Shard: Glasswood' },
+  { id: GLYPH_IDS.shardMire, glyph: '🐸', label: 'Name Shard: Mire' },
+  { id: GLYPH_IDS.shardRust, glyph: '⚙️', label: 'Name Shard: Rust March' },
+  { id: GLYPH_IDS.shardCoast, glyph: '🌊', label: 'Name Shard: Coast' },
+  { id: GLYPH_IDS.uiHeart, glyph: '🫀', label: 'UI: health' },
+  { id: GLYPH_IDS.uiFocus, glyph: '🔮', label: 'UI: focus' },
+  { id: GLYPH_IDS.uiQuest, glyph: '📜', label: 'UI: objective' },
+  { id: GLYPH_IDS.uiEye, glyph: '👁️', label: 'UI: the Listener motif' },
+];
@@ -0,0 +1,123 @@+import type { ConditionId } from '../core/types';
+import { GLYPH_IDS, GLYPHS } from './glyphs';
+
+const GLYPH_LOOKUP = new Map(GLYPHS.map((g) => [g.id, g.glyph]));
+
+function glyphOf(id: string): string {
+  const glyph = GLYPH_LOOKUP.get(id);
+  if (!glyph) throw new Error(`Unknown glyph id: ${id}`);
+  return glyph;
+}
+
+export type ItemType = 'weapon' | 'armor' | 'utility' | 'consumable' | 'relic' | 'key';
+
+export interface ItemDef {
+  id: string;
+  name: string;
+  glyph: string;
+  type: ItemType;
+  description: string;
+  attackBonus?: number;
+  defenseBonus?: number;
+  healAmount?: number;
+  curesCondition?: ConditionId;
+}
+
+export const ITEM_IDS = {
+  rustedSword: 'rustedSword',
+  huntingBow: 'huntingBow',
+  weatheredCloak: 'weatheredCloak',
+  bandageRoll: 'bandageRoll',
+  emberSalve: 'emberSalve',
+  mossBread: 'mossBread',
+  crowFeather: 'crowFeather',
+  fadedLetter: 'fadedLetter',
+  letterUnsent: 'letterUnsent',
+  letterToTheDrowned: 'letterToTheDrowned',
+} as const;
+
+export const ITEMS: Record<string, ItemDef> = {
+  [ITEM_IDS.rustedSword]: {
+    id: ITEM_IDS.rustedSword,
+    name: 'Rusted Sword',
+    glyph: glyphOf(GLYPH_IDS.itemRustedSword),
+    type: 'weapon',
+    description: 'Someone buried it with you. The rust remembers rain. +2 attack.',
+    attackBonus: 2,
+  },
+  [ITEM_IDS.huntingBow]: {
+    id: ITEM_IDS.huntingBow,
+    name: 'Hunting Bow',
+    glyph: glyphOf(GLYPH_IDS.itemHuntingBow),
+    type: 'weapon',
+    description: 'Glasswood yew. The string hums near mirrors. +1 attack.',
+    attackBonus: 1,
+  },
+  [ITEM_IDS.weatheredCloak]: {
+    id: ITEM_IDS.weatheredCloak,
+    name: 'Weathered Cloak',
+    glyph: glyphOf(GLYPH_IDS.itemWeatheredCloak),
+    type: 'armor',
+    description: 'It has kept someone alive before. It remembers how. +1 defense.',
+    defenseBonus: 1,
+  },
+  [ITEM_IDS.bandageRoll]: {
+    id: ITEM_IDS.bandageRoll,
+    name: 'Bandage Roll',
+    glyph: glyphOf(GLYPH_IDS.itemBandage),
+    type: 'consumable',
+    description: 'Clean linen. Restores 6 health.',
+    healAmount: 6,
+  },
+  [ITEM_IDS.emberSalve]: {
+    id: ITEM_IDS.emberSalve,
+    name: 'Ember Salve',
+    glyph: glyphOf(GLYPH_IDS.itemEmberSalve),
+    type: 'consumable',
+    description: 'Smells of cold ash. Cures Burning, restores 2 health.',
+    healAmount: 2,
+    curesCondition: 'burning',
+  },
+  [ITEM_IDS.mossBread]: {
+    id: ITEM_IDS.mossBread,
+    name: 'Moss Bread',
+    glyph: glyphOf(GLYPH_IDS.itemMossBread),
+    type: 'consumable',
+    description: 'Dense, grey, honest. Restores 4 health.',
+    healAmount: 4,
+  },
+  [ITEM_IDS.crowFeather]: {
+    id: ITEM_IDS.crowFeather,
+    name: 'Black Feather',
+    glyph: glyphOf(GLYPH_IDS.itemCrowFeather),
+    type: 'relic',
+    description: 'A gift from the crow. Warm, though the night is not.',
+  },
+  [ITEM_IDS.fadedLetter]: {
+    id: ITEM_IDS.fadedLetter,
+    name: 'Faded Letter',
+    glyph: glyphOf(GLYPH_IDS.itemFadedLetter),
+    type: 'relic',
+    description: 'Addressed to no one. The ink shifts when unread.',
+  },
+  [ITEM_IDS.letterUnsent]: {
+    id: ITEM_IDS.letterUnsent,
+    name: 'Unsent Letter',
+    glyph: glyphOf(GLYPH_IDS.itemLetterUnsent),
+    type: 'relic',
+    description: 'Found on a crewless ship. The address is a name that no longer exists. The ink is dry. The grief is not.',
+  },
+  [ITEM_IDS.letterToTheDrowned]: {
+    id: ITEM_IDS.letterToTheDrowned,
+    name: 'Letter to the Drowned',
+    glyph: glyphOf(GLYPH_IDS.itemLetterDrowned),
+    type: 'key',
+    description: 'Vale wrote it for a woman the water took. The drowned will stop for it. That is exactly the problem.',
+  },
+};
+
+export function itemDef(id: string): ItemDef {
+  const def = ITEMS[id];
+  if (!def) throw new Error(`Unknown item id: ${id}`);
+  return def;
+}
@@ -0,0 +1,88 @@+/** Journal lore entries. May describe uncertainty, per canon. */
+export interface LoreEntry {
+  id: string;
+  title: string;
+  text: string;
+}
+
+export const LORE: Record<string, LoreEntry> = {
+  lore_mirrorsLie: {
+    id: 'lore_mirrorsLie',
+    title: 'Mirrors lie',
+    text: 'Mirrors in Vesper do not always show what stands before them. Sometimes they show what used to. Sometimes what was feared. It is unclear which is worse.',
+  },
+  lore_erasureList: {
+    id: 'lore_erasureList',
+    title: 'The crossed-out list',
+    text: 'Someone kept a list of names and crossed them out as they were forgotten — or as they were taken. The last name was left unfinished. It may have been the writer\'s own.',
+  },
+  lore_graves: {
+    id: 'lore_graves',
+    title: 'Shallow graves',
+    text: 'The dead are returning, and graves have stopped meaning what they meant. You climbed out of one. You are choosing not to think about what that implies.',
+  },
+  lore_forgetting: {
+    id: 'lore_forgetting',
+    title: 'The forgotten baker',
+    text: 'Tam of Wayrest was forgotten mid-sentence, by everyone, at once. Only you remember his oven, his grey bread, his singing. Either the world is wrong, or you are. You are not sure which you hope for.',
+  },
+  lore_mirrorReal: {
+    id: 'lore_mirrorReal',
+    title: 'A real memory',
+    text: 'One mirror showed rain on grey bread and an oven door closing. You believe it is real — it hurts in the right place. You cannot prove it. That is what real means, here.',
+  },
+  lore_mirrorStolen: {
+    id: 'lore_mirrorStolen',
+    title: 'A stolen memory',
+    text: 'One mirror showed a harvest dance you never danced, joy that fits like someone else’s coat. The Glasswood keeps what people drop. Or it takes what people do not drop.',
+  },
+  lore_mirrorInvented: {
+    id: 'lore_mirrorInvented',
+    title: 'An invented memory',
+    text: 'One mirror showed a small sticky hand in yours. You have never had a child. Almost certain. The almost is the mirror’s favorite food.',
+  },
+  lore_crownGlyphs: {
+    id: 'lore_crownGlyphs',
+    title: 'Assigned Glyphs',
+    text: 'The Crown assigns Glyphs to the willing: clean, legal, safe. The price is every memory that contradicts the assignment. Mira’s brother paid it. He hunts the unstable now, and does not grieve, because grief would contradict.',
+  },
+  lore_bellsSilent: {
+    id: 'lore_bellsSilent',
+    title: 'The silent bells',
+    text: 'The bells of the Mire ring without sound, holding forgotten voices. Ringing one does not wake the voices. It reminds the things in the water why they stayed down.',
+  },
+  lore_sanctuary: {
+    id: 'lore_sanctuary',
+    title: 'The sanctuary order',
+    text: "The order to burn the Scribe sanctuary was signed with a steady hand. Rook's. He has told himself for years that unjust order beats collapse. The echo does not argue. It just repeats.",
+  },
+  lore_listenerWatching: {
+    id: 'lore_listenerWatching',
+    title: 'Something is watching',
+    text: 'Eye-glyphs in places no one drew them. Details wrong in ways too small to prove. An NPC greeting you like an old friend before you have met. The Pale Listener is still far. It is not as far as it was.',
+  },
+  lore_drownedLetters: {
+    id: 'lore_drownedLetters',
+    title: 'Letters to the drowned',
+    text: 'The caretakers write letters to people the water took, and read them aloud at the tide. The drowned come close to listen. Remembering is the only warmth left — and remembering is what keeps them from rest.',
+  },
+  lore_crownCityGlyphs: {
+    id: 'lore_crownCityGlyphs',
+    title: 'Assigned Glyphs',
+    text: 'In Crown City your Glyph is state property. The baker stays bread, the prisoner stays chains. Changing it is a crime called self-theft. The city is orderly. Order is not the same as kind.',
+  },
+  lore_origin: {
+    id: 'lore_origin',
+    title: 'What you are',
+    text: 'You are the final line the First Scribe removed from the First Book before sealing it — the line that lets a name mean what it chooses, made flesh. Not a hero. Not an accident. A decision the world has to make, walking.',
+  },
+  lore_archivePast: {
+    id: 'lore_archivePast',
+    title: 'The Archive contradicts itself',
+    text: 'Pre-Erasure records describe the Empty Bell as salvation. Other shelves describe it as surrender. The official history says the Scribes caused the catastrophe. The oldest paper says the King rang the Bell himself, and wept, and would do it again.',
+  },
+};
+
+export function loreEntry(id: string): LoreEntry | null {
+  return LORE[id] ?? null;
+}
@@ -0,0 +1,85 @@+import { defineMap, type MapDefinition } from '../../world/MapDefinition';
+import { CHARACTER_IDS } from '../characters';
+import { ENEMY_IDS } from '../enemies';
+import { GLYPH_IDS, GLYPHS } from '../glyphs';
+import { ECHO_IDS } from '../echoes';
+import { QUEST_IDS } from '../quests';
+
+function glyphOf(id: string): string | null {
+  return GLYPHS.find((g) => g.id === id)?.glyph ?? null;
+}
+
+// The Glasswood, edge: trees that reflect memories instead of light. Mira's
+// standoff, the warden camp, a wolf pack hunting between the trunks.
+export const GLASSWOOD: MapDefinition = defineMap({
+  id: 'glasswood',
+  name: 'The Glasswood',
+  region: 'The Glasswood',
+  width: 16,
+  height: 12,
+  rows: [
+    'tttttttttttttttt',
+    't,,,,t,,,,t,,,,t',
+    't,,,,,,,t,,,t,,t',
+    't,,t,,t,,,,,,,,t',
+    't,,,,,,,,tt,,,,t',
+    't,,t,,tt,,,,,t,t',
+    't,,,,,,,,,,s,,,t',
+    'tt,,t,,t,,sss,,t',
+    't,,,,,,,,,,s,,,t',
+    't,,t,,t,,,,,,t,t',
+    't,,,,,,,,,,,,,,t',
+    'tttttttttttttttt',
+  ],
+  playerSpawn: { x: 14, y: 6 },
+  npcs: [
+    { defId: CHARACTER_IDS.mira, pos: { x: 7, y: 6 } },
+    { defId: CHARACTER_IDS.liora, pos: { x: 11, y: 7 } },
+    { defId: CHARACTER_IDS.brann, pos: { x: 10, y: 8 } },
+  ],
+  enemies: [
+    { defId: ENEMY_IDS.ashWolf, pos: { x: 5, y: 4 } },
+    { defId: ENEMY_IDS.ashWolf, pos: { x: 4, y: 5 } },
+    { defId: ENEMY_IDS.ashWolf, pos: { x: 6, y: 4 } },
+    { defId: ENEMY_IDS.crownArcher, pos: { x: 13, y: 8 } },
+  ],
+  interactables: [
+    {
+      id: 'glassMirrorA',
+      pos: { x: 3, y: 2 },
+      glyph: glyphOf(GLYPH_IDS.echoMirror),
+      name: 'A mirror like a wound',
+      action: { type: 'echo', echoId: ECHO_IDS.glassA },
+    },
+    {
+      id: 'glassMirrorB',
+      pos: { x: 12, y: 3 },
+      glyph: glyphOf(GLYPH_IDS.echoMirror),
+      name: 'A mirror like a held breath',
+      action: { type: 'echo', echoId: ECHO_IDS.glassB },
+    },
+    {
+      id: 'glassMirrorC',
+      pos: { x: 5, y: 9 },
+      glyph: glyphOf(GLYPH_IDS.echoMirror),
+      name: 'A mirror like a scar',
+      action: { type: 'echo', echoId: ECHO_IDS.glassC },
+    },
+  ],
+  exits: [
+    {
+      pos: { x: 14, y: 6 },
+      toMapId: 'wayrest',
+      toPos: { x: 2, y: 5 },
+      label: 'East, back to Wayrest and its cold oven.',
+    },
+    {
+      pos: { x: 8, y: 1 },
+      toMapId: 'glasswoodHeart',
+      toPos: { x: 8, y: 9 },
+      label: 'North, where the mirrors grow thickest. The heart of the wood.',
+    },
+  ],
+  objective: 'The Glasswood reflects memories instead of light. Someone is watching you.',
+  onEnterQuest: QUEST_IDS.glasswoodShard,
+});
@@ -0,0 +1,57 @@+import { defineMap, type MapDefinition } from '../../world/MapDefinition';
+import { GLYPH_IDS, GLYPHS } from '../glyphs';
+
+function glyphOf(id: string): string | null {
+  return GLYPHS.find((g) => g.id === id)?.glyph ?? null;
+}
+
+// The heart of the Glasswood: a glade walled by mirror-trees, and the Heart
+// Mirror holding two versions of one mother.
+export const GLASSWOOD_HEART: MapDefinition = defineMap({
+  id: 'glasswoodHeart',
+  name: 'Heart of the Glasswood',
+  region: 'The Glasswood',
+  width: 16,
+  height: 12,
+  rows: [
+    'tttttttttttttttt',
+    't,,,,,,,,,,,,,,t',
+    't,,tttttttt,,,,t',
+    't,,t,,,,,,t,,,,t',
+    't,,t,,,,,,t,,,,t',
+    't,,t,,,,,,t,,,,t',
+    't,,,,,,,,,,,,,,t',
+    't,,,,,,,,,,,,,,t',
+    't,,t,,,,,,t,,,,t',
+    't,,ttttt,ttt,,,t',
+    't,,,,,,,d,,,,,,t',
+    'tttttttttttttttt',
+  ],
+  playerSpawn: { x: 8, y: 9 },
+  npcs: [],
+  enemies: [],
+  interactables: [
+    {
+      id: 'heartMirror',
+      pos: { x: 5, y: 4 },
+      glyph: glyphOf(GLYPH_IDS.heartMirrorBody),
+      name: 'The Heart Mirror',
+      action: { type: 'dialogue', dialogueId: 'heart_mirror' },
+    },
+  ],
+  exits: [
+    {
+      pos: { x: 8, y: 10 },
+      toMapId: 'glasswood',
+      toPos: { x: 8, y: 3 },
+      label: 'South, back under the mirrored canopy.',
+    },
+    {
+      pos: { x: 1, y: 6 },
+      toMapId: 'mire',
+      toPos: { x: 14, y: 6 },
+      label: 'West, where the land gives up and becomes water. The Mire of Bells.',
+    },
+  ],
+  objective: 'Something hangs in the mirrors here, waiting to be decided.',
+});
@@ -0,0 +1,91 @@+import { defineMap, type MapDefinition } from '../../world/MapDefinition';
+import { CHARACTER_IDS } from '../characters';
+import { ENEMY_IDS } from '../enemies';
+import { ITEM_IDS } from '../items';
+import { FLAGS } from '../flags';
+import { GLYPH_IDS, GLYPHS } from '../glyphs';
+import { ECHO_IDS } from '../echoes';
+
+// Prologue: the grave clearing. Small, hand-authored, proves exploration and
+// combat on the same map. Real region content comes in a later phase.
+export const GRAVE_CLEARING: MapDefinition = defineMap({
+  id: 'graveClearing',
+  name: 'The Grave Clearing',
+  region: 'The Nameless Edge',
+  width: 16,
+  height: 12,
+  rows: [
+    'tttttttttttttttt',
+    't,,,,,,,,,,,,,,t',
+    't,,.TT.,,~~~,,,t',
+    't,..TG.,,~~~,,,t',
+    't,,..dd,,,,,,,,t',
+    't,,,,d,,,,,,,,,t',
+    't,,,,d,,,,,.,,,t',
+    't,,,,dd,,,,,,,,t',
+    't,,,,,d,,,dd,,rt',
+    't,,,,,,,,,,,u,,t',
+    't,,,,,,,,,,,,,,t',
+    'tttttttttttttttt',
+  ],
+  playerSpawn: { x: 6, y: 3 },
+  npcs: [{ defId: CHARACTER_IDS.crow, pos: { x: 3, y: 3 } }],
+  enemies: [
+    { defId: ENEMY_IDS.ashWolf, pos: { x: 11, y: 6 } },
+    { defId: ENEMY_IDS.ashWolf, pos: { x: 12, y: 7 } },
+  ],
+  interactables: [
+    {
+      id: 'yourGrave',
+      pos: { x: 5, y: 3 },
+      glyph: null,
+      name: 'Your grave',
+      action: {
+        type: 'item',
+        itemId: ITEM_IDS.rustedSword,
+        onceFlag: FLAGS.tookRustedSword,
+        afterText: 'Fresh earth. The headstone is blank — no glyph, no name. Yours.',
+      },
+    },
+    {
+      id: 'deadTree',
+      pos: { x: 5, y: 2 },
+      glyph: null,
+      name: 'The dead tree',
+      action: {
+        type: 'inspect',
+        text: 'Its roots grip the grave like fingers. For a moment you hear a word that is almost a name — then it is gone.',
+        effects: [
+          { type: 'setFlag', flag: FLAGS.heardFirstEcho, value: true },
+          { type: 'tendency', axis: 'insight', amount: 1 },
+        ],
+      },
+    },
+    {
+      id: 'blackWater',
+      pos: { x: 9, y: 2 },
+      glyph: null,
+      name: 'Black water',
+      action: {
+        type: 'inspect',
+        text: 'The water does not reflect you. It has not decided what you are yet.',
+      },
+    },
+    {
+      id: 'mirrorShard',
+      pos: { x: 13, y: 4 },
+      glyph: GLYPHS.find((g) => g.id === GLYPH_IDS.echoMirror)?.glyph ?? null,
+      name: 'A shard of mirror',
+      action: { type: 'echo', echoId: ECHO_IDS.mirrorShard },
+    },
+  ],
+  exits: [
+    {
+      pos: { x: 8, y: 10 },
+      toMapId: 'oldRoad',
+      toPos: { x: 8, y: 1 },
+      label: 'A road that has forgotten its destination. It leads south.',
+    },
+  ],
+  objective: 'You woke in a shallow grave. Get out of the clearing.',
+});
@@ -0,0 +1,19 @@+import type { MapDefinition } from '../../world/MapDefinition';
+import { GRAVE_CLEARING } from './graveClearing';
+import { OLD_ROAD } from './oldRoad';
+import { WAYREST } from './wayrest';
+import { GLASSWOOD } from './glasswood';
+import { GLASSWOOD_HEART } from './glasswoodHeart';
+import { MIRE } from './mire';
+import { MIRE_THRONE } from './mireThrone';
+
+/** Every map in the game. Registered in Game.boot and content validation. */
+export const ALL_MAPS: MapDefinition[] = [
+  GRAVE_CLEARING,
+  OLD_ROAD,
+  WAYREST,
+  GLASSWOOD,
+  GLASSWOOD_HEART,
+  MIRE,
+  MIRE_THRONE,
+];
@@ -0,0 +1,91 @@+import { defineMap, type MapDefinition } from '../../world/MapDefinition';
+import { CHARACTER_IDS } from '../characters';
+import { ENEMY_IDS } from '../enemies';
+import { FLAGS, MEMORY_EVENTS } from '../flags';
+import { GLYPH_IDS, GLYPHS } from '../glyphs';
+import { QUEST_IDS } from '../quests';
+
+function glyphOf(id: string): string | null {
+  return GLYPHS.find((g) => g.id === id)?.glyph ?? null;
+}
+
+// The Mire of Bells: flooded causeways, silent bells, things under the water
+// that rise for warm footsteps. Ringing a bell repels them — nonviolent path.
+export const MIRE: MapDefinition = defineMap({
+  id: 'mire',
+  name: 'The Mire of Bells',
+  region: 'The Mire of Bells',
+  width: 16,
+  height: 12,
+  rows: [
+    'tttttttttttttttt',
+    't,,,,,,,d,,,,,,t',
+    't,,h,,,,,,,,,,,t',
+    't,,,,,~~~,,,,,,t',
+    't,,dd~~d~~dd,,,t',
+    't,,d~~~d~~~d,,,t',
+    't,,d,,,d,,,d,,,t',
+    't,,ddddd,,,d,,,t',
+    't,,,,,,,,,,d,,,t',
+    't,,,h,,,,,,,,,,t',
+    't,,,,,,,,,,,,,,t',
+    'tttttttttttttttt',
+  ],
+  playerSpawn: { x: 14, y: 6 },
+  npcs: [
+    { defId: CHARACTER_IDS.wenna, pos: { x: 4, y: 2 } },
+    { defId: CHARACTER_IDS.sedge, pos: { x: 5, y: 9 } },
+  ],
+  enemies: [
+    { defId: ENEMY_IDS.mireLurker, pos: { x: 7, y: 6 } },
+    { defId: ENEMY_IDS.mireLurker, pos: { x: 11, y: 7 } },
+  ],
+  interactables: [
+    {
+      id: 'westBell',
+      pos: { x: 1, y: 6 },
+      glyph: glyphOf(GLYPH_IDS.propBell),
+      name: 'A silent bell',
+      action: {
+        type: 'inspect',
+        text: 'You ring it. No sound comes out — but the water goes still, and something under it decides to stay down.',
+        effects: [
+          { type: 'setFlag', flag: FLAGS.mireBellRung, value: true },
+          { type: 'addMemory', npcId: 'protagonist', event: MEMORY_EVENTS.rangSilentBell },
+          { type: 'discovery', loreId: 'lore_bellsSilent' },
+        ],
+      },
+    },
+    {
+      id: 'eastBell',
+      pos: { x: 13, y: 8 },
+      glyph: glyphOf(GLYPH_IDS.propBell),
+      name: 'A silent bell',
+      action: {
+        type: 'inspect',
+        text: 'Already still. The water learned its lesson from the other bell — habit keeps the deep things honest.',
+        effects: [
+          { type: 'setFlag', flag: FLAGS.mireBellRung, value: true },
+          { type: 'addMemory', npcId: 'protagonist', event: MEMORY_EVENTS.rangSilentBell },
+          { type: 'discovery', loreId: 'lore_bellsSilent' },
+        ],
+      },
+    },
+  ],
+  exits: [
+    {
+      pos: { x: 14, y: 6 },
+      toMapId: 'glasswoodHeart',
+      toPos: { x: 2, y: 6 },
+      label: 'East, back to the mirrored trees.',
+    },
+    {
+      pos: { x: 8, y: 1 },
+      toMapId: 'mireThrone',
+      toPos: { x: 7, y: 9 },
+      label: 'North, over the planks, to the throne of the Marsh King.',
+    },
+  ],
+  objective: 'The bells are silent. The things in the water prefer it that way.',
+  onEnterQuest: QUEST_IDS.mireShard,
+});
@@ -0,0 +1,68 @@+import { defineMap, type MapDefinition } from '../../world/MapDefinition';
+import { CHARACTER_IDS } from '../characters';
+import { GLYPH_IDS, GLYPHS } from '../glyphs';
+
+function glyphOf(id: string): string | null {
+  return GLYPHS.find((g) => g.id === id)?.glyph ?? null;
+}
+
+// The throne of the Marsh King: a stone court in black water, one bell, one
+// bucket, one old woman being someone.
+export const MIRE_THRONE: MapDefinition = defineMap({
+  id: 'mireThrone',
+  name: 'Throne of the Mire',
+  region: 'The Mire of Bells',
+  width: 16,
+  height: 12,
+  rows: [
+    'tttttttttttttttt',
+    't,~~~~~~~~~~~,,t',
+    't,~,,,,,,,,,~,,t',
+    't,~,,sssss,,~,,t',
+    't,~,,sssss,,~,,t',
+    't,~~,sssss,~~,,t',
+    't,,,,,sss,,,,,,t',
+    't,,,,,,s,,,,,,,t',
+    't,,,,,,s,,,,,,,t',
+    't,,,,,,d,,,,,,,t',
+    't,,,,,,d,,,,,,,t',
+    'tttttttttttttttt',
+  ],
+  playerSpawn: { x: 7, y: 9 },
+  npcs: [
+    { defId: CHARACTER_IDS.marshKing, pos: { x: 6, y: 3 } },
+    { defId: CHARACTER_IDS.bellMother, pos: { x: 8, y: 4 } },
+  ],
+  enemies: [],
+  interactables: [
+    {
+      id: 'ceremonialBucket',
+      pos: { x: 5, y: 3 },
+      glyph: glyphOf(GLYPH_IDS.propBucket),
+      name: 'The ceremonial bucket',
+      action: {
+        type: 'inspect',
+        text: 'Empty. Dented. Older than the throne. Whatever the ceremony is, the bucket has outlasted everyone who knew it.',
+      },
+    },
+    {
+      id: 'throneBell',
+      pos: { x: 7, y: 4 },
+      glyph: glyphOf(GLYPH_IDS.propBell),
+      name: 'The great bell',
+      action: {
+        type: 'inspect',
+        text: 'The largest of the silent bells. If it ever sounds, the whole Mire will hear what it has been holding.',
+      },
+    },
+  ],
+  exits: [
+    {
+      pos: { x: 7, y: 10 },
+      toMapId: 'mire',
+      toPos: { x: 8, y: 2 },
+      label: 'South, back over the planks.',
+    },
+  ],
+  objective: 'The Marsh King will see you now. Mind the bucket.',
+});
@@ -0,0 +1,93 @@+import { defineMap, type MapDefinition } from '../../world/MapDefinition';
+import { ENEMY_IDS } from '../enemies';
+import { ITEM_IDS } from '../items';
+import { FLAGS } from '../flags';
+import { GLYPH_IDS, GLYPHS } from '../glyphs';
+import { ECHO_IDS } from '../echoes';
+
+function glyphOf(id: string): string | null {
+  return GLYPHS.find((g) => g.id === id)?.glyph ?? null;
+}
+
+// The Old Road: a short transit map south of the grave clearing. Proves exit
+// traversal, per-map persistence, a patrolling Hollow soldier, an echo object
+// and an armor pickup. Region content arrives in a later phase.
+export const OLD_ROAD: MapDefinition = defineMap({
+  id: 'oldRoad',
+  name: 'The Old Road',
+  region: 'The Nameless Edge',
+  width: 16,
+  height: 12,
+  rows: [
+    'tttttttttttttttt',
+    't,,,,,,,d,,,,,,t',
+    't,,rr,,,d,,,rr,t',
+    't,,,,,,dd,,,,,,t',
+    't,~~~,,d,,,,,,,t',
+    't,~~~,,d,,,s,,,t',
+    't,,,,,,dd,,,,,,t',
+    't,,,,,,,d,,,,,,t',
+    't,,rr,,,d,,,rr,t',
+    't,,,,,,,d,,,,,,t',
+    't,,,,,,,,,,,,,,t',
+    'tttttttttttttttt',
+  ],
+  playerSpawn: { x: 8, y: 1 },
+  npcs: [],
+  enemies: [
+    {
+      defId: ENEMY_IDS.hollowSoldier,
+      pos: { x: 8, y: 4 },
+      patrol: [
+        { x: 8, y: 4 },
+        { x: 8, y: 8 },
+      ],
+    },
+  ],
+  interactables: [
+    {
+      id: 'roadScroll',
+      pos: { x: 9, y: 5 },
+      glyph: glyphOf(GLYPH_IDS.echoScroll),
+      name: 'A water-stained scroll',
+      action: { type: 'echo', echoId: ECHO_IDS.roadScroll },
+    },
+    {
+      id: 'abandonedPack',
+      pos: { x: 3, y: 3 },
+      glyph: null,
+      name: 'An abandoned pack',
+      action: {
+        type: 'item',
+        itemId: ITEM_IDS.weatheredCloak,
+        onceFlag: FLAGS.tookWeatheredCloak,
+        afterText: 'The pack is empty now. Whoever left it walked north, toward the graves.',
+      },
+    },
+    {
+      id: 'milestone',
+      pos: { x: 11, y: 5 },
+      glyph: null,
+      name: 'A milestone',
+      action: {
+        type: 'inspect',
+        text: 'Distances to places that no longer have names. Someone scratched out every word except one: "south".',
+      },
+    },
+  ],
+  exits: [
+    {
+      pos: { x: 8, y: 1 },
+      toMapId: 'graveClearing',
+      toPos: { x: 8, y: 9 },
+      label: 'The road climbs north, back toward the graves.',
+    },
+    {
+      pos: { x: 8, y: 10 },
+      toMapId: 'wayrest',
+      toPos: { x: 8, y: 1 },
+      label: 'South, chimney smoke over a handful of huts. Wayrest.',
+    },
+  ],
+  objective: 'The road loops when unwatched. Keep moving.',
+});
@@ -0,0 +1,67 @@+import { defineMap, type MapDefinition } from '../../world/MapDefinition';
+import { CHARACTER_IDS } from '../characters';
+import { ENEMY_IDS } from '../enemies';
+import { FLAGS } from '../flags';
+import { QUEST_IDS } from '../quests';
+
+// Wayrest: a small settlement south of the Old Road. The prologue's central
+// beat — someone is forgotten mid-conversation, and only you remember.
+export const WAYREST: MapDefinition = defineMap({
+  id: 'wayrest',
+  name: 'Wayrest',
+  region: 'The Nameless Edge',
+  width: 16,
+  height: 12,
+  rows: [
+    'tttttttttttttttt',
+    't,,,,,,,d,,,,,,t',
+    't,,h,,,d,,,h,,,t',
+    't,,,,,,d,,,,,,,t',
+    't,,,sssss,,,,,,t',
+    't,,hsssssh,,,,,t',
+    't,,,sssss,,,,,,t',
+    't,,,,,,,,,,,,,,t',
+    't,,h,,,,,,h,,,,t',
+    't,,,,,,,,,,,,,,t',
+    't,,,,,,,,,,,,,,t',
+    'tttttttttttttttt',
+  ],
+  playerSpawn: { x: 8, y: 2 },
+  npcs: [
+    { defId: CHARACTER_IDS.tam, pos: { x: 4, y: 2 }, hiddenFlag: FLAGS.wayrestForgot },
+    { defId: CHARACTER_IDS.mae, pos: { x: 5, y: 5 } },
+    { defId: CHARACTER_IDS.fen, pos: { x: 9, y: 6 } },
+  ],
+  enemies: [
+    { defId: ENEMY_IDS.crownSoldier, pos: { x: 8, y: 2 }, requiresFlag: FLAGS.wayrestForgot },
+    { defId: ENEMY_IDS.crownSoldier, pos: { x: 8, y: 3 }, requiresFlag: FLAGS.wayrestForgot },
+  ],
+  interactables: [
+    {
+      id: 'coldOven',
+      pos: { x: 3, y: 2 },
+      glyph: null,
+      name: 'A cold oven',
+      action: {
+        type: 'inspect',
+        text: 'An oven, recently used. Flour still dusts the stones. Someone baked here this morning — the village just cannot say who.',
+      },
+    },
+  ],
+  exits: [
+    {
+      pos: { x: 8, y: 1 },
+      toMapId: 'oldRoad',
+      toPos: { x: 8, y: 9 },
+      label: 'The Old Road, north toward the graves.',
+    },
+    {
+      pos: { x: 1, y: 5 },
+      toMapId: 'glasswood',
+      toPos: { x: 14, y: 6 },
+      label: 'West, where the trees shine without light. The Glasswood.',
+    },
+  ],
+  objective: 'Wayrest: a handful of huts around a cold oven. See who lives here.',
+  onEnterQuest: QUEST_IDS.theForgottenWayrest,
+});
@@ -0,0 +1,111 @@+export interface QuestStage {
+  id: string;
+  text: string;
+}
+
+export interface QuestDef {
+  id: string;
+  name: string;
+  stages: QuestStage[];
+}
+
+export const QUEST_IDS = {
+  prologueAwakening: 'prologueAwakening',
+  theForgottenWayrest: 'theForgottenWayrest',
+  glasswoodShard: 'glasswoodShard',
+  mireShard: 'mireShard',
+  miraBrother: 'miraBrother',
+  rustShard: 'rustShard',
+  coastShard: 'coastShard',
+  crownCity: 'crownCity',
+  theFirstBook: 'theFirstBook',
+} as const;
+
+export const QUESTS: Record<string, QuestDef> = {
+  [QUEST_IDS.prologueAwakening]: {
+    id: QUEST_IDS.prologueAwakening,
+    name: 'The Grave',
+    stages: [
+      { id: 'wake', text: 'You woke in a shallow grave. Get out of the clearing.' },
+      { id: 'followCrow', text: 'The black crow is waiting by the dead tree. Speak to it.' },
+      { id: 'wolves', text: 'Ash wolves circle the clearing. Survive — one way or another.' },
+    ],
+  },
+  [QUEST_IDS.theForgottenWayrest]: {
+    id: QUEST_IDS.theForgottenWayrest,
+    name: 'The Hole in Wayrest',
+    stages: [
+      { id: 'notice', text: 'Wayrest: a handful of huts around a cold oven. See who lives here.' },
+      { id: 'forgotten', text: 'Someone is missing. Only you remember they existed.' },
+      { id: 'leave', text: 'A Crown patrol is hunting an unstable Glyph. Take the west road to the Glasswood.' },
+    ],
+  },
+  [QUEST_IDS.glasswoodShard]: {
+    id: QUEST_IDS.glasswoodShard,
+    name: 'The Shard of Belonging',
+    stages: [
+      { id: 'mira', text: 'A ranger of the Glasswood has an arrow drawn on you. Survive the introduction.' },
+      { id: 'memory', text: 'Something hangs in the mirrors at the heart of the wood. Go north.' },
+      { id: 'shard', text: 'A child’s memory of her mother has two versions. Decide what it deserves.' },
+    ],
+  },
+  [QUEST_IDS.mireShard]: {
+    id: QUEST_IDS.mireShard,
+    name: 'The Shard of Authority',
+    stages: [
+      { id: 'bells', text: 'The bells of the Mire are silent. The things in the water prefer it that way.' },
+      { id: 'king', text: 'The Marsh King expects someone. It might as well be you.' },
+      { id: 'shard', text: 'A name everyone agrees on, or a truth no one says. Decide what a name is worth.' },
+    ],
+  },
+  [QUEST_IDS.miraBrother]: {
+    id: QUEST_IDS.miraBrother,
+    name: 'The Brother in the Crown',
+    stages: [
+      { id: 'seed', text: 'Mira’s brother accepted a Crown Glyph and forgot his family. He hunts the unstable now. This thread is not finished.' },
+      { id: 'found', text: 'Corvan stands in Crown City wearing the shield. Decide what Mira’s brother is still owed.' },
+    ],
+  },
+  [QUEST_IDS.rustShard]: {
+    id: QUEST_IDS.rustShard,
+    name: 'The Shard of Duty',
+    stages: [
+      { id: 'march', text: 'The Rust March fights a forgotten war. Its soldiers demand passwords that no longer exist.' },
+      { id: 'rook', text: 'Captain Rook holds the Works. Something he did here holds him back.' },
+      { id: 'shard', text: 'The Works build weapons for the dead. Decide what duty is owed to orders nobody remembers giving.' },
+    ],
+  },
+  [QUEST_IDS.coastShard]: {
+    id: QUEST_IDS.coastShard,
+    name: 'The Shard of Grief',
+    stages: [
+      { id: 'vale', text: 'On the Moonless Coast, the caretakers write letters to the drowned. Sister Vale keeps the tide chapel.' },
+      { id: 'letter', text: 'A letter no one dares to deliver. Decide what it deserves: the water, the fire, or your keeping.' },
+      { id: 'shard', text: 'Vale watches what you do with other people’s grief. She is deciding what you are ready to know.' },
+    ],
+  },
+  [QUEST_IDS.crownCity]: {
+    id: QUEST_IDS.crownCity,
+    name: 'Crown City',
+    stages: [
+      { id: 'gate', text: 'Crown City: unnaturally orderly. Your way in depends on what you left behind you.' },
+      { id: 'brother', text: 'Mira’s brother is here, wearing the Crown’s Glyph. So is everything you decided on the road.' },
+      { id: 'approach', text: 'The Archive waits beneath the palace. Reality is thinner the closer you get.' },
+    ],
+  },
+  [QUEST_IDS.theFirstBook]: {
+    id: QUEST_IDS.theFirstBook,
+    name: 'The First Book',
+    stages: [
+      { id: 'archive', text: 'The Archive remembers what the Crown rewrote. The crow has something to confess.' },
+      { id: 'king', text: 'The Hollow King waits at the bottom of the world. How this ends is up to both of you.' },
+      { id: 'choice', text: 'The First Book is open. Only some of its pages are available to you — the ones your journey wrote.' },
+    ],
+  },
+};
+
+export function questDef(id: string): QuestDef {
+  const def = QUESTS[id];
+  if (!def) throw new Error(`Unknown quest id: ${id}`);
+  return def;
+}
@@ -0,0 +1,61 @@+import { GLYPH_IDS, GLYPHS } from './glyphs';
+
+const GLYPH_LOOKUP = new Map(GLYPHS.map((g) => [g.id, g.glyph]));
+
+function glyphOf(id: string): string {
+  return GLYPH_LOOKUP.get(id) ?? '❔';
+}
+
+/**
+ * The four Name Shards (Act II). Journal tracks 0–4; endings hook off
+ * shards + tendencies + memories. Content phases award them via the
+ * 'gainShard' dialogue effect.
+ */
+export interface ShardDef {
+  id: string;
+  name: string;
+  region: string;
+  glyph: string;
+  /** The question the shard asks, per canon. */
+  question: string;
+}
+
+export const SHARD_IDS = {
+  glasswood: 'shard_glasswood',
+  mire: 'shard_mire',
+  rust: 'shard_rust',
+  coast: 'shard_coast',
+} as const;
+
+export const SHARDS: Record<string, ShardDef> = {
+  [SHARD_IDS.glasswood]: {
+    id: SHARD_IDS.glasswood,
+    name: 'Shard of Belonging',
+    region: 'The Glasswood',
+    glyph: glyphOf(GLYPH_IDS.shardGlasswood),
+    question: 'Origin versus what we choose to protect.',
+  },
+  [SHARD_IDS.mire]: {
+    id: SHARD_IDS.mire,
+    name: 'Shard of Authority',
+    region: 'The Mire of Bells',
+    glyph: glyphOf(GLYPH_IDS.shardMire),
+    question: 'If everyone accepts a role, does truth matter?',
+  },
+  [SHARD_IDS.rust]: {
+    id: SHARD_IDS.rust,
+    name: 'Shard of Duty',
+    region: 'The Rust March',
+    glyph: glyphOf(GLYPH_IDS.shardRust),
+    question: 'When does loyalty become cowardice?',
+  },
+  [SHARD_IDS.coast]: {
+    id: SHARD_IDS.coast,
+    name: 'Shard of Grief',
+    region: 'The Moonless Coast',
+    glyph: glyphOf(GLYPH_IDS.shardCoast),
+    question: 'Is remembering always love?',
+  },
+};
+
+export const TOTAL_SHARDS = Object.keys(SHARDS).length;
@@ -0,0 +1,52 @@+import type { ActorState } from '../core/GameState';
+import type { ActorAppearance, ConditionId, GridPos } from '../core/types';
+import { attackOf, defenseOf, hasCondition, speedOf } from '../systems/ConditionSystem';
+
+/**
+ * Thin behavior wrapper around ActorState. The state object stays the single
+ * source of truth (and is what gets serialized); this class only adds
+ * derived accessors so systems read intent instead of raw fields.
+ */
+export class Actor {
+  constructor(public readonly state: ActorState) {}
+
+  get id(): string {
+    return this.state.id;
+  }
+
+  get pos(): GridPos {
+    return this.state.pos;
+  }
+
+  get appearance(): ActorAppearance {
+    return this.state.appearance;
+  }
+
+  get alive(): boolean {
+    return this.state.alive;
+  }
+
+  get isHostile(): boolean {
+    return this.state.faction === 'hostile' && this.state.alive;
+  }
+
+  has(id: ConditionId): boolean {
+    return hasCondition(this.state, id);
+  }
+
+  effectiveSpeed(): number {
+    return speedOf(this.state);
+  }
+
+  effectiveAttack(): number {
+    return attackOf(this.state);
+  }
+
+  effectiveDefense(): number {
+    return defenseOf(this.state);
+  }
+
+  isAdjacentTo(other: Actor): boolean {
+    return Math.abs(this.pos.x - other.pos.x) + Math.abs(this.pos.y - other.pos.y) === 1;
+  }
+}
@@ -0,0 +1,24 @@+import type { ActorState } from '../core/GameState';
+import { Actor } from './Actor';
+import { enemyDef, type EnemyDef } from '../data/enemies';
+
+export class Enemy extends Actor {
+  constructor(state: ActorState) {
+    super(state);
+  }
+
+  get def(): EnemyDef {
+    return enemyDef(this.state.defId);
+  }
+
+  get calmable(): boolean {
+    return this.def.calmable === true;
+  }
+
+  /** Nonviolent-resolution hook: what the player could try instead of killing. */
+  get nonviolentHint(): string {
+    return this.calmable
+      ? 'It might be calmed — Empathy, food, or patience.'
+      : 'It cannot be reasoned with, but the environment might stop it.';
+  }
+}
@@ -0,0 +1,142 @@+import type { ActorState, GameState, PlayerState } from '../core/GameState';
+import type { GridPos } from '../core/types';
+import { CHARACTERS, playerVariantById } from '../data/characters';
+import { enemyDef } from '../data/enemies';
+import { STARTING_ABILITIES } from '../data/abilities';
+import { ITEM_IDS } from '../data/items';
+import { QUEST_IDS } from '../data/quests';
+import { SAVE_VERSION } from '../core/constants';
+import { DEFAULT_SETTINGS } from '../core/GameState';
+import type { Settings } from '../core/GameState';
+
+let actorCounter = 0;
+
+function nextActorId(defId: string): string {
+  actorCounter += 1;
+  return `${defId}#${actorCounter}`;
+}
+
+export function createPlayerState(variantId: string, spawn: GridPos): PlayerState {
+  const variant = playerVariantById(variantId);
+  return {
+    id: 'player',
+    kind: 'player',
+    defId: 'protagonist',
+    name: 'The Nameless',
+    appearance: { bodyGlyph: variant.bodyGlyph },
+    pos: { ...spawn },
+    hp: 24,
+    maxHp: 24,
+    focus: 6,
+    maxFocus: 6,
+    stats: { attack: 6, defense: 2, speed: 5, insight: 4 },
+    conditions: [],
+    alive: true,
+    faction: 'player',
+    appearanceVariantId: variant.id,
+    abilityIds: [...STARTING_ABILITIES],
+    equippedWeaponId: null,
+    equippedArmorId: null,
+    facing: 'down',
+  };
+}
+
+export function createNpcState(defId: string, pos: GridPos, spawnKey?: string): ActorState {
+  const def = CHARACTERS[defId];
+  if (!def) throw new Error(`Unknown character id: ${defId}`);
+  const combat = def.combat;
+  return {
+    id: nextActorId(defId),
+    kind: 'npc',
+    defId,
+    name: def.name,
+    appearance: { ...def.appearance },
+    pos: { ...pos },
+    hp: combat?.hp ?? 10,
+    maxHp: combat?.hp ?? 10,
+    focus: combat?.focus ?? 0,
+    maxFocus: combat?.focus ?? 0,
+    stats: combat ? { ...combat.stats } : { attack: 0, defense: 0, speed: 4, insight: 5 },
+    conditions: [],
+    alive: true,
+    faction: 'neutral',
+    aiType: combat?.aiType,
+    spawnKey,
+  };
+}
+
+export function createEnemyState(
+  defId: string,
+  pos: GridPos,
+  patrol?: GridPos[],
+  spawnKey?: string,
+): ActorState {
+  const def = enemyDef(defId);
+  const state: ActorState = {
+    id: nextActorId(defId),
+    kind: 'enemy',
+    defId,
+    name: def.name,
+    appearance: { ...def.appearance },
+    pos: { ...pos },
+    hp: def.hp,
+    maxHp: def.hp,
+    focus: def.focus,
+    maxFocus: def.focus,
+    stats: { ...def.stats },
+    conditions: [],
+    alive: true,
+    faction: 'hostile',
+    aiType: def.aiType,
+    routineStep: 0,
+    aggroed: false,
+    spawnKey,
+  };
+  if (patrol) {
+    state.patrol = patrol.map((p) => ({ ...p }));
+    state.patrolIndex = 0;
+  }
+  return state;
+}
+
+export interface NewGameOptions {
+  variantId: string;
+  mapId: string;
+  region: string;
+  objective: string;
+  playerSpawn: GridPos;
+  settings?: Partial<Settings>;
+  seed?: number;
+}
+
+export function createNewGameState(options: NewGameOptions): GameState {
+  return {
+    version: SAVE_VERSION,
+    mode: 'exploration',
+    mapId: options.mapId,
+    region: options.region,
+    objective: options.objective,
+    player: createPlayerState(options.variantId, options.playerSpawn),
+    actors: [],
+    mapStates: {},
+    flags: {},
+    npcMemories: {},
+    npcAttitudes: {},
+    quests: {
+      [QUEST_IDS.prologueAwakening]: { id: QUEST_IDS.prologueAwakening, status: 'active', stageIndex: 0 },
+    },
+    inventory: { items: { [ITEM_IDS.bandageRoll]: 2, [ITEM_IDS.emberSalve]: 1 } },
+    tendencies: { resolve: 0, insight: 0, empathy: 0 },
+    discoveries: [],
+    metCharacters: [],
+    shards: [],
+    echoesHeard: [],
+    routes: [],
+    regionalOutcomes: {},
+    settings: { ...DEFAULT_SETTINGS, ...options.settings },
+    playtimeSeconds: 0,
+    rngState: options.seed ?? Date.now() >>> 0,
+    dialogueSeen: [],
+    consumedInteractables: [],
+  };
+}
@@ -0,0 +1,17 @@+import type { ActorState } from '../core/GameState';
+import { Actor } from './Actor';
+import { CHARACTERS } from '../data/characters';
+
+export class NPC extends Actor {
+  constructor(state: ActorState) {
+    super(state);
+  }
+
+  get dialogueId(): string | null {
+    return CHARACTERS[this.state.defId]?.dialogueId ?? null;
+  }
+
+  get title(): string {
+    return CHARACTERS[this.state.defId]?.title ?? '';
+  }
+}
@@ -0,0 +1,23 @@+import type { PlayerState } from '../core/GameState';
+import { Actor } from './Actor';
+import { itemDef } from '../data/items';
+
+/**
+ * The protagonist. Equipment bonuses are folded into attackOf/defenseOf in
+ * the ConditionSystem (equipment is a badge and a stat bonus — never the body).
+ */
+export class Player extends Actor {
+  constructor(public override readonly state: PlayerState) {
+    super(state);
+  }
+
+  weaponAttackBonus(): number {
+    const weaponId = this.state.equippedWeaponId;
+    return weaponId ? (itemDef(weaponId).attackBonus ?? 0) : 0;
+  }
+
+  armorDefenseBonus(): number {
+    const armorId = this.state.equippedArmorId;
+    return armorId ? (itemDef(armorId).defenseBonus ?? 0) : 0;
+  }
+}
@@ -0,0 +1,13 @@+import './styles/tokens.css';
+import './styles/base.css';
+import './styles/layout.css';
+import './styles/game.css';
+import './styles/dialogue.css';
+import './styles/animations.css';
+import { Game } from './core/Game';
+
+const root = document.getElementById('app');
+if (!root) throw new Error('Missing #app root element');
+
+const game = new Game(root);
+game.boot();
@@ -0,0 +1,40 @@+/**
+ * Sequential animation queue. While anything is queued/running the game
+ * locks input — animation is never the source of truth, but the player must
+ * not act again until the previous result is on screen.
+ */
+export class AnimationQueue {
+  private chain: Promise<void> = Promise.resolve();
+  private pending = 0;
+
+  constructor(private readonly onBusyChange: (busy: boolean) => void) {}
+
+  get busy(): boolean {
+    return this.pending > 0;
+  }
+
+  enqueue(job: () => Promise<void> | void): Promise<void> {
+    this.pending += 1;
+    if (this.pending === 1) this.onBusyChange(true);
+    const result = this.chain.then(() => job());
+    this.chain = result.then(
+      () => undefined,
+      () => undefined,
+    );
+    const done = (): void => {
+      this.pending -= 1;
+      if (this.pending === 0) this.onBusyChange(false);
+    };
+    return result.then(done, done);
+  }
+
+  /** Millisecond wait that collapses to nothing under reduced motion. */
+  wait(ms: number, reducedMotion: boolean): Promise<void> {
+    return this.enqueue(
+      () =>
+        new Promise<void>((resolve) => {
+          setTimeout(resolve, reducedMotion ? 0 : ms);
+        }),
+    );
+  }
+}
@@ -0,0 +1,68 @@+import type { GridPos } from '../core/types';
+import type { Renderer } from './Renderer';
+import type { AnimationQueue } from './AnimationQueue';
+import type { EffectRequest } from '../systems/EffectSystem';
+import { GLYPH_IDS, GLYPHS } from '../data/glyphs';
+
+const GLYPH_LOOKUP = new Map(GLYPHS.map((g) => [g.id, g.glyph]));
+
+function glyphOf(id: string): string {
+  return GLYPH_LOOKUP.get(id) ?? '✨';
+}
+
+/**
+ * Emoji-based combat effects: lunge → equipment glyph → impact → recoil →
+ * return, plus floating words over damage numbers.
+ */
+export class EffectRenderer {
+  constructor(
+    private readonly renderer: Renderer,
+    private readonly animations: AnimationQueue,
+    private readonly isReducedMotion: () => boolean,
+  ) {}
+
+  handle(request: EffectRequest): void {
+    switch (request.kind) {
+      case 'impact':
+        this.renderer.spawnEffectGlyph(request.pos, request.glyph, 'effect--impact');
+        break;
+      case 'floatingText':
+        this.renderer.spawnFloatingText(request.pos, request.text, request.tone);
+        break;
+      case 'shake':
+        this.renderer.shake(true);
+        break;
+    }
+  }
+
+  /** The full attack beat. Returns a promise that resolves when it lands. */
+  attackSequence(attackerId: string, targetPos: GridPos, effectGlyph: string): Promise<void> {
+    return this.animations.enqueue(async () => {
+      const node = this.renderer.actorElement(attackerId);
+      const reduced = this.isReducedMotion();
+      if (!node || reduced) {
+        this.renderer.spawnEffectGlyph(targetPos, effectGlyph, 'effect--impact');
+        return;
+      }
+      node.classList.add('actor--lunging');
+      await pause(90);
+      this.renderer.spawnEffectGlyph(targetPos, effectGlyph, 'effect--impact');
+      await pause(140);
+      node.classList.remove('actor--lunging');
+      node.classList.add('actor--recoiling');
+      await pause(120);
+      node.classList.remove('actor--recoiling');
+    });
+  }
+
+  rangedSequence(targetPos: GridPos): Promise<void> {
+    return this.animations.enqueue(async () => {
+      this.renderer.spawnEffectGlyph(targetPos, glyphOf(GLYPH_IDS.fxArrow), 'effect--impact');
+      await pause(this.isReducedMotion() ? 0 : 150);
+    });
+  }
+}
+
+function pause(ms: number): Promise<void> {
+  return new Promise((resolve) => setTimeout(resolve, ms));
+}
@@ -0,0 +1,280 @@+import type { ActorState } from '../core/GameState';
+import type { GridPos, MoveArchetype } from '../core/types';
+import { posKey } from '../core/types';
+import { TILE_SIZE, MOVE_ANIMATION_MS } from '../core/constants';
+import { tileAt, type MapDefinition } from '../world/MapDefinition';
+import type { ReachableTile } from '../world/Pathfinding';
+import { Camera } from '../world/Camera';
+import { el, clear } from '../utils/dom';
+import { conditionDef } from '../data/conditions';
+import { CHARACTERS } from '../data/characters';
+import { ENEMIES } from '../data/enemies';
+import { hasCondition } from '../systems/ConditionSystem';
+
+/**
+ * Layered DOM renderer: terrain CSS Grid, absolutely positioned actors moved
+ * by CSS transforms, highlights, effects. Logical coordinates stay in game
+ * state; this class only projects them to pixels.
+ */
+export class Renderer {
+  readonly root: HTMLElement;
+  private readonly viewport: HTMLElement;
+  private readonly world: HTMLElement;
+  private readonly terrainLayer: HTMLElement;
+  private readonly propsLayer: HTMLElement;
+  private readonly highlightLayer: HTMLElement;
+  private readonly actorLayer: HTMLElement;
+  private readonly effectLayer: HTMLElement;
+  private readonly camera: Camera;
+  private readonly actorElements = new Map<string, HTMLElement>();
+  private map: MapDefinition | null = null;
+  private reducedMotion = false;
+  private animationSpeed = 1;
+
+  constructor(parent: HTMLElement) {
+    this.root = el('div', 'game-view');
+    this.viewport = el('div', 'game-view__viewport');
+    this.world = el('div', 'game-view__world');
+    this.terrainLayer = el('div', 'layer layer--terrain');
+    this.propsLayer = el('div', 'layer layer--props');
+    this.highlightLayer = el('div', 'layer layer--highlights');
+    this.actorLayer = el('div', 'layer layer--actors');
+    this.effectLayer = el('div', 'layer layer--effects');
+    this.world.append(
+      this.terrainLayer,
+      this.propsLayer,
+      this.highlightLayer,
+      this.actorLayer,
+      this.effectLayer,
+    );
+    this.viewport.append(this.world);
+    this.root.append(this.viewport);
+    parent.append(this.root);
+    this.camera = new Camera(this.viewport, this.world);
+  }
+
+  get viewportEl(): HTMLElement {
+    return this.viewport;
+  }
+
+  setMotionOptions(reducedMotion: boolean, animationSpeed: number): void {
+    this.reducedMotion = reducedMotion;
+    this.animationSpeed = animationSpeed;
+  }
+
+  private get moveDurationMs(): number {
+    if (this.reducedMotion) return 0;
+    return MOVE_ANIMATION_MS * this.animationSpeed;
+  }
+
+  renderMap(map: MapDefinition): void {
+    this.map = map;
+    this.camera.setMapSize(map.width, map.height);
+    this.clearActors();
+    this.clearHighlights();
+    clear(this.propsLayer);
+    clear(this.terrainLayer);
+    this.terrainLayer.style.gridTemplateColumns = `repeat(${map.width}, ${TILE_SIZE}px)`;
+    for (let y = 0; y < map.height; y++) {
+      for (let x = 0; x < map.width; x++) {
+        const tile = tileAt(map, { x, y });
+        const cell = el('div', `tile tile--${tile?.terrain ?? 'grass'}`);
+        cell.style.width = `${TILE_SIZE}px`;
+        cell.style.height = `${TILE_SIZE}px`;
+        if (tile?.glyph) {
+          const glyph = el('span', 'tile__glyph glyph', tile.glyph);
+          cell.append(glyph);
+        }
+        if (tile) cell.title = tile.label;
+        this.terrainLayer.append(cell);
+      }
+    }
+    for (const prop of map.interactables) {
+      if (!prop.glyph) continue;
+      const node = el('span', 'prop glyph', prop.glyph);
+      node.style.transform = `translate3d(${prop.pos.x * TILE_SIZE}px, ${prop.pos.y * TILE_SIZE}px, 0)`;
+      node.title = prop.name;
+      this.propsLayer.append(node);
+    }
+  }
+
+  /** Drop all actor elements (map transitions, reloads). */
+  clearActors(): void {
+    clear(this.actorLayer);
+    this.actorElements.clear();
+  }
+
+  spawnActor(actor: ActorState): void {
+    this.removeActor(actor.id);
+    const node = el('div', `actor actor--${archetypeOf(actor)}`);
+    node.dataset.actorId = actor.id;
+    node.style.width = `${TILE_SIZE}px`;
+    node.style.height = `${TILE_SIZE}px`;
+    const body = el('span', 'actor__body glyph', actor.appearance.bodyGlyph);
+    node.append(body);
+    if (actor.appearance.roleGlyph) {
+      node.append(el('span', 'actor__badge actor__badge--role glyph', actor.appearance.roleGlyph));
+    }
+    if (actor.appearance.equipmentGlyph) {
+      node.append(
+        el('span', 'actor__badge actor__badge--equipment glyph', actor.appearance.equipmentGlyph),
+      );
+    }
+    if (actor.appearance.statusGlyph) {
+      node.append(el('span', 'actor__badge actor__badge--status glyph', actor.appearance.statusGlyph));
+    }
+    if (actor.appearance.factionGlyph) {
+      node.append(
+        el('span', 'actor__badge actor__badge--faction glyph', actor.appearance.factionGlyph),
+      );
+    }
+    const hp = el('div', 'actor__hp');
+    hp.append(el('i'));
+    node.append(hp);
+    this.actorLayer.append(node);
+    this.actorElements.set(actor.id, node);
+    this.placeActor(actor, false);
+    this.refreshActor(actor);
+  }
+
+  refreshActor(actor: ActorState): void {
+    const node = this.actorElements.get(actor.id);
+    if (!node) return;
+    node.classList.toggle('actor--dead', !actor.alive);
+    node.classList.toggle('actor--unstable', hasCondition(actor, 'unstable'));
+    const hpFill = node.querySelector('.actor__hp i');
+    if (hpFill instanceof HTMLElement) {
+      hpFill.style.width = `${Math.round((actor.hp / actor.maxHp) * 100)}%`;
+    }
+    let chip = node.querySelector('.actor__condition');
+    const first = actor.conditions[0];
+    if (first) {
+      if (!(chip instanceof HTMLElement)) {
+        chip = el('span', 'actor__condition glyph');
+        node.append(chip);
+      }
+      const def = conditionDef(first.id);
+      chip.textContent = def.glyph;
+      (chip as HTMLElement).title = `${def.name} — ${def.tooltip}`;
+    } else if (chip instanceof HTMLElement) {
+      chip.remove();
+    }
+  }
+
+  placeActor(actor: ActorState, animate: boolean): Promise<void> {
+    const node = this.actorElements.get(actor.id);
+    if (!node) return Promise.resolve();
+    const duration = animate ? this.moveDurationMs : 0;
+    node.style.transitionDuration = `${duration}ms`;
+    node.style.transform = `translate3d(${actor.pos.x * TILE_SIZE}px, ${actor.pos.y * TILE_SIZE}px, 0)`;
+    if (duration === 0) return Promise.resolve();
+    node.classList.add('actor--moving');
+    return new Promise((resolve) => {
+      const finish = (): void => {
+        node.classList.remove('actor--moving');
+        resolve();
+      };
+      const timer = setTimeout(finish, duration + 40);
+      node.addEventListener(
+        'transitionend',
+        () => {
+          clearTimeout(timer);
+          finish();
+        },
+        { once: true },
+      );
+    });
+  }
+
+  removeActor(actorId: string): void {
+    const node = this.actorElements.get(actorId);
+    if (node) {
+      node.remove();
+      this.actorElements.delete(actorId);
+    }
+  }
+
+  actorIds(): string[] {
+    return [...this.actorElements.keys()];
+  }
+
+  actorElement(actorId: string): HTMLElement | null {
+    return this.actorElements.get(actorId) ?? null;
+  }
+
+  showReachable(tiles: Map<string, ReachableTile>): void {
+    clear(this.highlightLayer);
+    for (const tile of tiles.values()) {
+      this.highlightLayer.append(this.highlightCell(tile.pos, 'highlight'));
+    }
+  }
+
+  /** Targeting mode: valid targets in red, the selected one outlined amber. */
+  showTargets(positions: GridPos[], selected: GridPos | null): void {
+    clear(this.highlightLayer);
+    for (const pos of positions) {
+      const isSelected = selected !== null && pos.x === selected.x && pos.y === selected.y;
+      this.highlightLayer.append(
+        this.highlightCell(pos, `highlight highlight--target${isSelected ? ' highlight--selected' : ''}`),
+      );
+    }
+  }
+
+  private highlightCell(pos: GridPos, className: string): HTMLElement {
+    const cell = el('div', className);
+    cell.style.transform = `translate3d(${pos.x * TILE_SIZE}px, ${pos.y * TILE_SIZE}px, 0)`;
+    cell.style.width = `${TILE_SIZE}px`;
+    cell.style.height = `${TILE_SIZE}px`;
+    return cell;
+  }
+
+  clearHighlights(): void {
+    clear(this.highlightLayer);
+  }
+
+  centerCameraOn(pos: GridPos, animate: boolean): void {
+    this.camera.centerOn(pos, animate);
+  }
+
+  /** Pointer coordinates -> logical tile, or null outside the map. */
+  pickTile(clientX: number, clientY: number): GridPos | null {
+    if (!this.map) return null;
+    const rect = this.world.getBoundingClientRect();
+    const x = Math.floor((clientX - rect.left) / TILE_SIZE);
+    const y = Math.floor((clientY - rect.top) / TILE_SIZE);
+    if (x < 0 || y < 0 || x >= this.map.width || y >= this.map.height) return null;
+    return { x, y };
+  }
+
+  spawnEffectGlyph(pos: GridPos, glyph: string, extraClass = ''): void {
+    const node = el('span', `effect glyph ${extraClass}`, glyph);
+    node.style.transform = `translate3d(${pos.x * TILE_SIZE}px, ${pos.y * TILE_SIZE}px, 0)`;
+    this.effectLayer.append(node);
+    setTimeout(() => node.remove(), this.reducedMotion ? 60 : 700);
+  }
+
+  spawnFloatingText(pos: GridPos, text: string, tone: 'damage' | 'heal' | 'word'): void {
+    const node = el('span', `float-text float-text--${tone}`, text);
+    node.style.transform = `translate3d(${pos.x * TILE_SIZE}px, ${pos.y * TILE_SIZE}px, 0)`;
+    this.effectLayer.append(node);
+    setTimeout(() => node.remove(), this.reducedMotion ? 60 : 1100);
+  }
+
+  shake(enabled: boolean): void {
+    if (!enabled || this.reducedMotion) return;
+    this.viewport.classList.remove('is-shaking');
+    // force reflow so the animation restarts
+    void this.viewport.offsetWidth;
+    this.viewport.classList.add('is-shaking');
+  }
+
+  tileKeyOf(pos: GridPos): string {
+    return posKey(pos);
+  }
+}
+
+function archetypeOf(actor: ActorState): MoveArchetype {
+  if (actor.kind === 'player') return 'default';
+  if (actor.kind === 'npc') return CHARACTERS[actor.defId]?.moveArchetype ?? 'default';
+  return ENEMIES[actor.defId]?.moveArchetype ?? 'default';
+}
@@ -0,0 +1,179 @@+/* Movement personalities (per archetype), combat beats, camera shake.
+   All motion collapses under prefers-reduced-motion or the settings toggle. */
+
+@keyframes effect-impact {
+  0% {
+    transform: scale(0.5);
+    opacity: 0;
+  }
+  25% {
+    transform: scale(1.25);
+    opacity: 1;
+  }
+  100% {
+    transform: scale(1);
+    opacity: 0;
+  }
+}
+
+@keyframes float-up {
+  0% {
+    margin-top: 0;
+    opacity: 0;
+  }
+  15% {
+    opacity: 1;
+  }
+  100% {
+    margin-top: -34px;
+    opacity: 0;
+  }
+}
+
+/* personality bobs — applied to the body glyph while the actor translates */
+@keyframes bob-default {
+  50% {
+    transform: translateY(-3px);
+  }
+}
+
+@keyframes bob-heavy {
+  0%,
+  100% {
+    transform: translateX(0);
+  }
+  25% {
+    transform: translateX(-1px) translateY(-1px);
+  }
+  75% {
+    transform: translateX(1px) translateY(-1px);
+  }
+}
+
+@keyframes bob-light {
+  50% {
+    transform: translateY(-7px) scale(1.04);
+  }
+}
+
+@keyframes bob-irregular {
+  20% {
+    transform: translateX(-2px) rotate(-4deg);
+  }
+  55% {
+    transform: translateX(2px) translateY(-2px);
+  }
+  80% {
+    transform: rotate(3deg);
+  }
+}
+
+@keyframes bob-hop {
+  40% {
+    transform: translateY(-11px) scaleY(0.96);
+  }
+  70% {
+    transform: translateY(0) scaleY(0.9);
+  }
+}
+
+.actor--moving.actor--default .actor__body {
+  animation: bob-default 180ms ease-in-out infinite;
+}
+
+.actor--moving.actor--heavy .actor__body {
+  animation: bob-heavy 220ms ease-in-out infinite;
+}
+
+.actor--moving.actor--light .actor__body {
+  animation: bob-light 160ms ease-in-out infinite;
+}
+
+.actor--moving.actor--irregular .actor__body {
+  animation: bob-irregular 240ms steps(2, end) infinite;
+}
+
+.actor--moving.actor--hop .actor__body {
+  animation: bob-hop 200ms ease-in-out infinite;
+}
+
+/* combat beats: lunge → impact → recoil → return */
+.actor--lunging .actor__body {
+  transform: translateY(-4px) scale(1.12);
+  transition: transform 90ms ease-in;
+}
+
+.actor--recoiling .actor__body {
+  transform: translateY(1px) scale(0.94);
+  transition: transform 120ms ease-out;
+}
+
+/* Unstable glyph flicker */
+@keyframes flicker {
+  0%,
+  100% {
+    opacity: 1;
+  }
+  45% {
+    opacity: 0.35;
+  }
+  55% {
+    opacity: 0.8;
+  }
+  70% {
+    opacity: 0.5;
+  }
+}
+
+.actor--unstable .actor__body {
+  animation: flicker 900ms linear infinite;
+}
+
+/* screen shake (settings-toggleable) */
+@keyframes shake {
+  0%,
+  100% {
+    transform: translate(0, 0);
+  }
+  20% {
+    transform: translate(-4px, 2px);
+  }
+  40% {
+    transform: translate(3px, -3px);
+  }
+  60% {
+    transform: translate(-2px, -2px);
+  }
+  80% {
+    transform: translate(2px, 3px);
+  }
+}
+
+.is-shaking {
+  animation: shake 280ms linear;
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .actor--moving .actor__body,
+  .actor--unstable .actor__body,
+  .is-shaking {
+    animation: none;
+  }
+
+  .effect,
+  .float-text {
+    animation-duration: 60ms;
+  }
+}
+
+/* settings toggle mirrors the media query */
+body.reduced-motion .actor--moving .actor__body,
+body.reduced-motion .actor--unstable .actor__body,
+body.reduced-motion .is-shaking {
+  animation: none;
+}
+
+body.reduced-motion .effect,
+body.reduced-motion .float-text {
+  animation-duration: 60ms;
+}
@@ -0,0 +1,69 @@+*,
+*::before,
+*::after {
+  box-sizing: border-box;
+}
+
+html,
+body {
+  margin: 0;
+  padding: 0;
+  height: 100%;
+}
+
+body {
+  background: var(--ink);
+  color: var(--paper);
+  font-family: var(--font-editorial);
+  font-size: calc(16px * var(--text-scale));
+  line-height: 1.5;
+  overflow: hidden;
+  /* subtle paper-grain texture */
+  background-image: radial-gradient(rgb(255 255 255 / 0.015) 1px, transparent 1px);
+  background-size: 4px 4px;
+}
+
+#app {
+  height: 100%;
+}
+
+/* Emoji font stack on glyph elements only. */
+.glyph {
+  font-family: var(--font-glyph);
+  font-style: normal;
+}
+
+button {
+  font-family: var(--font-editorial);
+  font-size: inherit;
+  color: var(--paper);
+  background: var(--charcoal);
+  border: var(--border-subtle);
+  border-radius: 4px;
+  padding: 0.55em 1.1em;
+  cursor: pointer;
+  transition: background 120ms ease, border-color 120ms ease;
+}
+
+button:hover:not(:disabled) {
+  background: var(--charcoal-2);
+  border-color: color-mix(in srgb, var(--amber) 45%, transparent);
+}
+
+button:disabled {
+  opacity: 0.45;
+  cursor: not-allowed;
+}
+
+/* Visible focus states everywhere. */
+:focus-visible {
+  outline: 2px solid var(--amber);
+  outline-offset: 2px;
+}
+
+h1,
+h2,
+h3 {
+  font-weight: 600;
+  letter-spacing: 0.02em;
+}
@@ -0,0 +1,157 @@+/* Dialogue presentation: large speaker body glyph + nameplate with role
+   badge. Human characters are shown as human bodies, never as role glyphs. */
+.dialogue {  position: absolute;
+  left: 50%;
+  bottom: 2.5%;
+  transform: translateX(-50%) translateY(12px);
+  width: min(760px, 94vw);
+  display: none;
+  gap: 1rem;
+  background: color-mix(in srgb, var(--ink-2) 92%, transparent);
+  border: var(--border-subtle);
+  border-radius: 6px;
+  box-shadow: var(--shadow-deep);
+  padding: 1rem 1.25rem;
+  z-index: 20;
+  cursor: pointer;
+  opacity: 0;
+  transition: opacity 160ms ease, transform 160ms ease;
+}
+
+.dialogue--visible {
+  display: flex;
+  opacity: 1;
+  transform: translateX(-50%) translateY(0);
+}
+
+.dialogue__portrait {
+  font-size: 3.2rem;
+  line-height: 1;
+  align-self: flex-start;
+  filter: drop-shadow(0 2px 6px rgb(0 0 0 / 0.6));
+}
+
+.dialogue__body {
+  flex: 1;
+  min-width: 0;
+}
+
+.dialogue__plate {
+  display: flex;
+  align-items: baseline;
+  gap: 0.6rem;
+  margin-bottom: 0.35rem;
+}
+
+.dialogue__name {
+  color: var(--amber);
+  font-weight: 600;
+  letter-spacing: 0.03em;
+}
+
+.dialogue__title {
+  color: var(--paper-faint);
+  font-size: calc(0.8rem * var(--text-scale));
+  font-style: italic;
+}
+
+.dialogue__text {
+  margin: 0 0 0.6rem;
+  min-height: 3.2em;
+  color: var(--paper);
+  font-size: calc(1.02rem * var(--text-scale));
+}
+
+.dialogue__choices {
+  display: flex;
+  flex-direction: column;
+  gap: 0.35rem;
+  align-items: stretch;
+}
+
+.dialogue__choice {
+  text-align: left;
+  background: transparent;
+  border: var(--border-subtle);
+  padding: 0.4em 0.8em;
+}
+
+.dialogue__choice:hover:not(:disabled) {
+  background: var(--charcoal);
+}
+
+.dialogue__choice--silent {
+  font-style: italic;
+  color: var(--paper-dim);
+}
+
+.dialogue__hint {
+  color: var(--paper-faint);
+  font-size: calc(0.75rem * var(--text-scale));
+  font-style: italic;
+  text-align: right;
+}
+
+/* Name Echo vignette: moonlight, not amber — memory, not speech. */
+.echo-overlay {
+  position: absolute;
+  inset: 0;
+  display: none;
+  align-items: center;
+  justify-content: center;
+  background: rgb(10 12 16 / 0.82);
+  z-index: 26;
+  cursor: pointer;
+}
+
+.echo-overlay--visible {
+  display: flex;
+}
+
+.echo-overlay__panel {
+  width: min(560px, 90vw);
+  padding: 2rem;
+  border: 1px solid color-mix(in srgb, var(--moonlight) 30%, transparent);
+  border-radius: 6px;
+  background: color-mix(in srgb, var(--ink-2) 88%, transparent);
+  box-shadow: 0 0 40px color-mix(in srgb, var(--moonlight) 12%, transparent);
+  text-align: center;
+}
+
+.echo-overlay__glyph {
+  font-size: 3rem;
+  margin-bottom: 0.5rem;
+}
+
+.echo-overlay__title {
+  margin: 0 0 1rem;
+  color: var(--moonlight);
+  font-style: italic;
+  font-weight: 500;
+}
+
+.echo-overlay__line {
+  color: var(--paper);
+  font-style: italic;
+  line-height: 1.7;
+  margin: 0.8rem 0;
+  animation: echo-line-in 500ms ease-out;
+}
+
+.echo-overlay__hint {
+  margin-top: 1.4rem;
+  color: var(--paper-faint);
+  font-size: calc(0.75rem * var(--text-scale));
+  font-style: italic;
+}
+
+@keyframes echo-line-in {
+  from {
+    opacity: 0;
+    transform: translateY(6px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
@@ -0,0 +1,421 @@+/* Game screen: viewport + layered world + HUD. */
+.game-screen {
+  position: relative;
+  width: 100%;
+  height: 100%;
+  background: var(--ink);
+}
+
+.game-view {
+  position: absolute;
+  inset: 0;
+}
+
+.game-view__viewport {
+  position: absolute;
+  inset: 0;
+  overflow: hidden;
+}
+
+.game-view__world {
+  position: absolute;
+  top: 0;
+  left: 0;
+  transition-property: transform;
+  transition-timing-function: ease-out;
+}
+
+.layer {
+  position: absolute;
+  top: 0;
+  left: 0;
+}
+
+.layer--terrain {
+  display: grid;
+}
+
+.layer--props {
+  pointer-events: none;
+  z-index: 1;
+}
+
+.prop {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: var(--tile-size);
+  height: var(--tile-size);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: calc(var(--tile-size) * 0.5);
+}
+
+.layer--highlights,
+.layer--effects {
+  pointer-events: none;
+  z-index: 2;
+}
+
+.layer--actors {
+  z-index: 3;
+}
+
+.layer--effects {
+  z-index: 4;
+}
+
+/* Terrain tiles */
+.tile {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.tile__glyph {
+  font-size: calc(var(--tile-size) * 0.66);
+  line-height: 1;
+}
+
+.tile--grass {
+  background: #1d241c;
+}
+
+.tile--deadGrass {
+  background: #23221a;
+}
+
+.tile--dirt {
+  background: #2b241a;
+}
+
+.tile--stone {
+  background: #26262a;
+}
+
+.tile--deadTree,
+.tile--tree {
+  background: #1a211a;
+}
+
+.tile--deadTree .tile__glyph {
+  filter: grayscale(0.9) brightness(0.75);
+}
+
+.tile--grave {
+  background: #221f1b;
+}
+
+.tile--rock {
+  background: #232019;
+}
+
+.tile--hut {
+  background: #241f18;
+}
+
+.tile--water {
+  background: var(--water);
+}
+
+.tile--unstable {
+  background: #1d1620;
+}
+
+/* Reachable-tile tactical highlight */
+.highlight {
+  position: absolute;
+  top: 0;
+  left: 0;
+  border: 1px solid color-mix(in srgb, var(--amber) 55%, transparent);
+  background: color-mix(in srgb, var(--amber) 12%, transparent);
+}
+
+/* Targeting mode highlights */
+.highlight--target {
+  border-color: color-mix(in srgb, var(--danger) 70%, transparent);
+  background: color-mix(in srgb, var(--danger) 14%, transparent);
+}
+
+.highlight--selected {
+  border-color: var(--amber);
+  background: color-mix(in srgb, var(--amber) 22%, transparent);
+  box-shadow: inset 0 0 0 2px var(--amber);
+}
+
+/* Actors: positioned by CSS transform, never by layout. */
+.actor {
+  position: absolute;
+  top: 0;
+  left: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  transition-property: transform;
+  transition-timing-function: ease-in-out;
+  will-change: transform;
+}
+
+.actor__body {
+  font-size: calc(var(--tile-size) * 0.7);
+  line-height: 1;
+}
+
+.actor__badge {
+  position: absolute;
+  font-size: calc(var(--tile-size) * 0.3);
+  line-height: 1;
+  filter: drop-shadow(0 1px 1px rgb(0 0 0 / 0.7));
+}
+
+.actor__badge--role {
+  top: 0;
+  right: 0;
+}
+
+.actor__badge--equipment {
+  bottom: 4px;
+  right: 0;
+}
+
+.actor__badge--status {
+  top: 0;
+  left: 0;
+}
+
+.actor__badge--faction {
+  bottom: 4px;
+  left: 0;
+}
+
+.actor__hp {
+  position: absolute;
+  bottom: 1px;
+  left: 8%;
+  width: 84%;
+  height: 3px;
+  background: rgb(0 0 0 / 0.6);
+  border-radius: 2px;
+}
+
+.actor__hp i {
+  display: block;
+  height: 100%;
+  background: var(--danger);
+  border-radius: 2px;
+  transition: width 160ms ease;
+}
+
+.actor__condition {
+  position: absolute;
+  top: -6px;
+  left: 50%;
+  transform: translateX(-50%);
+  font-size: calc(var(--tile-size) * 0.3);
+}
+
+.actor--dead {
+  opacity: 0.35;
+  filter: grayscale(1);
+}
+
+/* Effects & floating text */
+.effect {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: var(--tile-size);
+  height: var(--tile-size);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: calc(var(--tile-size) * 0.6);
+  animation: effect-impact 650ms ease-out forwards;
+}
+
+.float-text {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: var(--tile-size);
+  text-align: center;
+  font-size: calc(0.95rem * var(--text-scale));
+  font-weight: 600;
+  animation: float-up 1050ms ease-out forwards;
+  text-shadow: 0 1px 3px rgb(0 0 0 / 0.9);
+}
+
+.float-text--damage {
+  color: #e0705c;
+}
+
+.float-text--heal {
+  color: #7fb069;
+}
+
+.float-text--word {
+  color: var(--amber);
+  font-style: italic;
+}
+
+/* HUD */
+.hud {
+  position: absolute;
+  inset: 0;
+  pointer-events: none;
+  z-index: 10;
+  display: grid;
+  grid-template-rows: auto auto 1fr auto auto;
+  padding: 0.75rem;
+}
+
+.hud > * {
+  pointer-events: auto;
+}
+
+.hud__status {
+  display: flex;
+  gap: 0.75rem;
+  max-width: 420px;
+}
+
+.bar {
+  position: relative;
+  flex: 1;
+  min-width: 140px;
+}
+
+.bar__track {
+  height: 12px;
+  background: var(--ink-3);
+  border: var(--border-subtle);
+  border-radius: 3px;
+  overflow: hidden;
+}
+
+.bar__track i {
+  display: block;
+  height: 100%;
+  transition: width 180ms ease;
+}
+
+.bar--health .bar__track i {
+  background: var(--danger);
+}
+
+.bar--focus .bar__track i {
+  background: var(--moonlight);
+}
+
+.bar__text {
+  font-size: calc(0.72rem * var(--text-scale));
+  color: var(--paper-dim);
+}
+
+.hud__conditions {
+  display: flex;
+  gap: 0.35rem;
+  margin-top: 0.4rem;
+}
+
+.condition-chip {
+  font-size: 1.15rem;
+  background: var(--ink-2);
+  border: var(--border-subtle);
+  border-radius: 4px;
+  padding: 0.1rem 0.3rem;
+}
+
+.hud__context {
+  justify-self: end;
+  text-align: right;
+  grid-row: 1;
+  grid-column: 1;
+}
+
+.hud__region {
+  color: var(--amber-dim);
+  font-size: calc(0.85rem * var(--text-scale));
+  letter-spacing: 0.08em;
+  text-transform: uppercase;
+}
+
+.hud__objective {
+  color: var(--paper-dim);
+  font-style: italic;
+  max-width: 40ch;
+}
+
+.hud__turn-order {
+  display: none;
+  gap: 0.4rem;
+  align-self: end;
+  justify-self: center;
+  grid-row: 4;
+  margin-bottom: 0.4rem;
+}
+
+.hud__turn-order--visible {
+  display: flex;
+}
+
+.turn-chip {
+  display: flex;
+  align-items: center;
+  gap: 0.3rem;
+  background: var(--ink-2);
+  border: var(--border-subtle);
+  border-radius: 4px;
+  padding: 0.15rem 0.5rem;
+  font-size: calc(0.8rem * var(--text-scale));
+  color: var(--paper-dim);
+}
+
+.turn-chip .glyph {
+  font-size: 1.1rem;
+}
+
+.turn-chip--active {
+  border-color: var(--amber);
+  color: var(--paper);
+}
+
+.hud__actions {
+  display: none;
+  gap: 0.4rem;
+  justify-self: center;
+  grid-row: 5;
+  flex-wrap: wrap;
+  justify-content: center;
+}
+
+.hud__actions--visible {
+  display: flex;
+}
+
+.action-button {
+  font-size: calc(0.85rem * var(--text-scale));
+  padding: 0.4em 0.8em;
+}
+
+.hud__toast {
+  position: absolute;
+  bottom: 14%;
+  left: 50%;
+  transform: translateX(-50%);
+  background: var(--ink-2);
+  border: var(--border-subtle);
+  border-radius: 4px;
+  padding: 0.5rem 1rem;
+  max-width: min(520px, 86vw);
+  color: var(--paper);
+  opacity: 0;
+  transition: opacity 200ms ease;
+  pointer-events: none;
+  text-align: center;
+}
+
+.hud__toast--visible {
+  opacity: 1;
+}
@@ -0,0 +1,427 @@+.screen {
+  display: none;
+  height: 100%;
+}
+
+.screen--active {
+  display: flex;
+}
+
+/* Menu screens */
+.menu {
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 1rem;
+  width: 100%;
+  height: 100%;
+  padding: 2rem;
+  text-align: center;
+  overflow-y: auto;
+}
+
+.menu__game-title {
+  font-size: calc(3rem * var(--text-scale));
+  margin: 0;
+  color: var(--paper);
+  text-shadow: 0 0 24px rgb(201 150 63 / 0.18);
+}
+
+.menu__tagline {
+  color: var(--paper-dim);
+  font-style: italic;
+  margin: 0 0 1.5rem;
+}
+
+.menu__heading {
+  font-size: calc(1.6rem * var(--text-scale));
+  margin: 0 0 0.5rem;
+}
+
+.menu__note {
+  color: var(--paper-dim);
+  max-width: 42ch;
+  margin: 0;
+}
+
+.menu__nav {
+  display: flex;
+  flex-direction: column;
+  gap: 0.6rem;
+  min-width: 240px;
+  margin-top: 1rem;
+}
+
+.menu__nav--row {
+  flex-direction: row;
+  justify-content: center;
+}
+
+.menu__button {
+  font-size: calc(1.05rem * var(--text-scale));
+  padding: 0.6em 1.4em;
+}
+
+.menu__author {
+  margin-top: 2.5rem;
+  color: var(--paper-faint);
+  font-size: calc(0.85rem * var(--text-scale));
+}
+
+/* New game: appearance selection */
+.appearance-grid {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(90px, 120px));
+  gap: 0.75rem;
+  margin: 1rem 0;
+}
+
+.appearance-option {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 0.3rem;
+  padding: 0.8rem 0.4rem;
+}
+
+.appearance-option__glyph {
+  font-size: 2.4rem;
+}
+
+.appearance-option__label {
+  font-size: calc(0.8rem * var(--text-scale));
+  color: var(--paper-dim);
+}
+
+.appearance-option--selected {
+  border-color: var(--amber);
+  background: var(--ink-3);
+}
+
+/* Settings */
+.settings-form {
+  display: flex;
+  flex-direction: column;
+  gap: 0.9rem;
+  min-width: min(420px, 90vw);
+  text-align: left;
+}
+
+.settings-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 1rem;
+  padding: 0.5rem 0.2rem;
+  border-bottom: var(--border-subtle);
+}
+
+.settings-row input[type='checkbox'] {
+  width: 1.2rem;
+  height: 1.2rem;
+  accent-color: var(--amber);
+}
+
+.settings-row input[type='range'] {
+  accent-color: var(--amber);
+  flex: 1;
+}
+
+.settings-row__value {
+  min-width: 5ch;
+  text-align: right;
+  color: var(--paper-dim);
+}
+
+/* Credits */
+.credits-block {
+  display: flex;
+  flex-direction: column;
+  gap: 0.4rem;
+  color: var(--paper-dim);
+}
+
+.credits-block p:first-child {
+  font-size: calc(1.3rem * var(--text-scale));
+  color: var(--paper);
+}
+
+.credits-block__legal {
+  margin-top: 1.5rem;
+  font-size: calc(0.85rem * var(--text-scale));
+  color: var(--paper-faint);
+}
+
+/* Dev glyph gallery */
+.glyph-gallery {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
+  gap: 0.6rem;
+  width: min(900px, 92vw);
+}
+
+.glyph-gallery__cell {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 0.2rem;
+  padding: 0.7rem 0.3rem;
+  background: var(--ink-2);
+  border: var(--border-subtle);
+  border-radius: 4px;
+}
+
+.glyph-gallery__glyph {
+  font-size: 2.2rem;
+}
+
+.glyph-gallery__id {
+  font-size: 0.7rem;
+  color: var(--amber-dim);
+  word-break: break-all;
+}
+
+.glyph-gallery__label {
+  font-size: 0.7rem;
+  color: var(--paper-faint);
+}
+
+/* Pause overlay sits above the game screen */
+.pause-overlay {
+  position: absolute;
+  inset: 0;
+  display: none;
+  align-items: center;
+  justify-content: center;
+  background: rgb(16 14 12 / 0.78);
+  z-index: 30;
+}
+
+.pause-overlay--visible {
+  display: flex;
+}
+
+.pause-overlay__panel {
+  background: var(--ink-2);
+  border: var(--border-subtle);
+  border-radius: 6px;
+  box-shadow: var(--shadow-deep);
+  padding: 1.5rem 2rem;
+  min-width: 280px;
+}
+
+/* Full panels: inventory, character, journal, saves */
+.panel-overlay {
+  position: absolute;
+  inset: 0;
+  display: none;
+  align-items: center;
+  justify-content: center;
+  background: rgb(16 14 12 / 0.72);
+  z-index: 25;
+}
+
+.panel-overlay--visible {
+  display: flex;
+}
+
+.panel-overlay__panel {
+  background: var(--ink-2);
+  border: var(--border-subtle);
+  border-radius: 6px;
+  box-shadow: var(--shadow-deep);
+  padding: 1.25rem 1.5rem;
+  width: min(480px, 92vw);
+  max-height: 86vh;
+  overflow-y: auto;
+}
+
+.panel-overlay__panel--wide {
+  width: min(640px, 94vw);
+}
+
+.panel-overlay__empty {
+  color: var(--paper-faint);
+  font-style: italic;
+}
+
+/* Inventory */
+.inventory__section {
+  margin: 0.9rem 0 0.3rem;
+  color: var(--amber-dim);
+  font-size: calc(0.85rem * var(--text-scale));
+  letter-spacing: 0.08em;
+  text-transform: uppercase;
+}
+
+.inventory__row {
+  display: flex;
+  align-items: center;
+  gap: 0.6rem;
+  padding: 0.35rem 0.2rem;
+  border-bottom: var(--border-subtle);
+  width: 100%;
+  text-align: left;
+}
+
+.inventory__row--button {
+  background: transparent;
+  border: var(--border-subtle);
+  margin-bottom: 0.3rem;
+  cursor: pointer;
+}
+
+.inventory__row--button:hover {
+  background: var(--charcoal);
+}
+
+.inventory__glyph {
+  font-size: 1.4rem;
+}
+
+.inventory__name {
+  flex: 1;
+}
+
+.inventory__count {
+  color: var(--paper-dim);
+}
+
+.inventory__equipped {
+  color: var(--amber);
+  font-size: calc(0.75rem * var(--text-scale));
+  font-style: italic;
+}
+
+.inventory__action {
+  font-size: calc(0.8rem * var(--text-scale));
+  padding: 0.25em 0.7em;
+}
+
+/* Character sheet */
+.character__appearance {
+  display: flex;
+  align-items: center;
+  gap: 0.9rem;
+  margin-bottom: 0.8rem;
+}
+
+.character__body {
+  font-size: 3rem;
+}
+
+.character__badges {
+  display: flex;
+  gap: 0.4rem;
+}
+
+.character__badge {
+  font-size: 1.5rem;
+}
+
+.character__stats {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 0.3rem 1rem;
+  margin: 0 0 0.8rem;
+}
+
+.character__stats dt {
+  color: var(--paper-faint);
+  font-size: calc(0.78rem * var(--text-scale));
+}
+
+.character__stats dd {
+  margin: 0;
+  color: var(--paper);
+}
+
+.character__section {
+  margin: 0.9rem 0 0.3rem;
+  color: var(--amber-dim);
+  font-size: calc(0.85rem * var(--text-scale));
+  letter-spacing: 0.08em;
+  text-transform: uppercase;
+}
+
+.character__tendency,
+.character__ability {
+  display: flex;
+  gap: 0.6rem;
+  padding: 0.2rem 0;
+  color: var(--paper-dim);
+}
+
+.character__tendency-name {
+  min-width: 5.5em;
+  color: var(--paper);
+}
+
+/* Journal */
+.journal__section {
+  margin: 1rem 0 0.3rem;
+  color: var(--amber-dim);
+  font-size: calc(0.85rem * var(--text-scale));
+  letter-spacing: 0.08em;
+  text-transform: uppercase;
+}
+
+.journal__line {
+  margin: 0.25rem 0;
+  color: var(--paper-dim);
+}
+
+.journal__line--glyph {
+  display: flex;
+  gap: 0.6rem;
+  align-items: center;
+}
+
+.journal__line--glyph .glyph {
+  font-size: 1.3rem;
+}
+
+.journal__lore-title {
+  margin: 0.6rem 0 0.1rem;
+  color: var(--paper);
+}
+
+/* Saves */
+.saves__row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 1rem;
+  padding: 0.5rem 0.2rem;
+  border-bottom: var(--border-subtle);
+}
+
+.saves__label {
+  display: flex;
+  flex-direction: column;
+  gap: 0.1rem;
+}
+
+.saves__meta {
+  color: var(--paper-faint);
+  font-size: calc(0.8rem * var(--text-scale));
+}
+
+.saves__actions {
+  display: flex;
+  gap: 0.35rem;
+  flex-wrap: wrap;
+  justify-content: flex-end;
+}
+
+.saves__action {
+  font-size: calc(0.8rem * var(--text-scale));
+  padding: 0.3em 0.7em;
+}
+
+.saves__footer {
+  display: flex;
+  gap: 0.6rem;
+  justify-content: flex-end;
+  margin-top: 1rem;
+}
@@ -0,0 +1,27 @@+/* Visual tone: dark ink, old paper, charcoal, oxidized metal, muted
+   moonlight, restrained amber, deep forest, black water, subtle danger red. */
+:root {
+  --ink: #100e0c;
+  --ink-2: #181511;
+  --ink-3: #201c17;
+  --charcoal: #2a251f;
+  --charcoal-2: #38322a;
+  --paper: #d6cbb0;
+  --paper-dim: #9d937c;
+  --paper-faint: #6f6656;
+  --amber: #c9963f;
+  --amber-dim: #8a6527;
+  --forest: #2c3d31;
+  --water: #141e29;
+  --danger: #a8402f;
+  --moonlight: #8fa1b5;
+  --bone: #cfc6b3;
+
+  --font-editorial: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, "Times New Roman", serif;
+  --font-glyph: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
+
+  --tile-size: 48px;
+  --text-scale: 1;
+  --border-subtle: 1px solid color-mix(in srgb, var(--paper) 14%, transparent);
+  --shadow-deep: 0 12px 40px rgb(0 0 0 / 0.55);
+}
@@ -0,0 +1,46 @@+import type { ActorState, GameState } from '../core/GameState';
+import { abilityDef, tendencyUnlocks, type AbilityDef } from '../data/abilities';
+import { abilitiesBlocked } from './ConditionSystem';
+
+export interface AbilityCheck {
+  ok: boolean;
+  reason?: string;
+}
+
+export function canUseAbility(actor: ActorState, ability: AbilityDef): AbilityCheck {
+  if (!actor.alive) return { ok: false, reason: 'Down.' };
+  if (abilitiesBlocked(actor) && ability.id !== 'strike' && ability.id !== 'guardStance') {
+    return { ok: false, reason: 'Silenced — abilities are out of reach.' };
+  }
+  if (actor.focus < ability.focusCost) {
+    return { ok: false, reason: 'Not enough focus.' };
+  }
+  return { ok: true };
+}
+
+export function spendAbilityFocus(actor: ActorState, abilityId: string): void {
+  const ability = abilityDef(abilityId);
+  actor.focus = Math.max(0, actor.focus - ability.focusCost);
+}
+
+/**
+ * Tendency unlocks: every ability whose threshold the tendencies have reached
+ * but which the player does not have yet. Mixed approaches never lock
+ * anything — thresholds only grant.
+ */
+export function checkNewUnlocks(state: GameState): string[] {
+  return tendencyUnlocks()
+    .filter(
+      ({ abilityId, unlock }) =>
+        state.tendencies[unlock.axis] >= unlock.threshold &&
+        !state.player.abilityIds.includes(abilityId),
+    )
+    .map(({ abilityId }) => abilityId);
+}
+
+/** Grants all newly reached abilities; returns the ids granted. */
+export function grantUnlocks(state: GameState): string[] {
+  const fresh = checkNewUnlocks(state);
+  for (const id of fresh) state.player.abilityIds.push(id);
+  return fresh;
+}
@@ -0,0 +1,145 @@+import type { ActorState } from '../core/GameState';
+import type { ConditionId, GridPos } from '../core/types';
+import { manhattan } from '../core/types';
+import { Rng } from '../core/Rng';
+import {
+  applyCondition,
+  attackOf,
+  defenseOf,
+  incomingDamageMult,
+  onActorHit,
+  removeCondition,
+} from './ConditionSystem';
+import { enemyDef } from '../data/enemies';
+
+/**
+ * Damage model: (attack × mult − defense) × variance, minimum 1.
+ * Deterministic for a given Rng state. Guarded/Staggered/etc. enter through
+ * attackOf/defenseOf, which fold in condition multipliers.
+ */
+export function computeDamage(
+  attack: number,
+  defense: number,
+  mult: number,
+  rng: Rng,
+): number {
+  const variance = 0.85 + rng.next() * 0.3;
+  const raw = (attack * mult - defense) * variance;
+  return Math.max(1, Math.round(raw));
+}
+
+export interface AttackOptions {
+  mult?: number;
+  appliesCondition?: { id: ConditionId; duration: number };
+  selfCondition?: { id: ConditionId; duration: number };
+}
+
+export interface AttackOutcome {
+  attackerId: string;
+  defenderId: string;
+  damage: number;
+  killed: boolean;
+  conditionsApplied: ConditionId[];
+  brokenConditions: ConditionId[];
+}
+
+/** +2 attack when an ally of the attacker stands adjacent to the target (wolf packs). */
+export function flankingBonus(attacker: ActorState, defender: ActorState, all: ActorState[]): number {
+  if (attacker.kind !== 'enemy' || !enemyDef(attacker.defId).packTactics) return 0;
+  const allyAdjacent = all.some(
+    (a) =>
+      a.id !== attacker.id &&
+      a.alive &&
+      a.faction === attacker.faction &&
+      manhattan(a.pos, defender.pos) === 1,
+  );
+  return allyAdjacent ? 2 : 0;
+}
+
+export function performAttack(
+  attacker: ActorState,
+  defender: ActorState,
+  rng: Rng,
+  all: ActorState[],
+  options: AttackOptions = {},
+): AttackOutcome {
+  const attack = attackOf(attacker) + flankingBonus(attacker, defender, all);
+  const defense = defenseOf(defender);
+  const damage = Math.round(
+    computeDamage(attack, defense, options.mult ?? 1, rng) * incomingDamageMult(defender),
+  );
+  defender.hp = Math.max(0, defender.hp - damage);
+  const brokenConditions = onActorHit(defender);
+  if (defender.hp === 0) {
+    defender.alive = false;
+    defender.conditions = [];
+  }
+  const conditionsApplied: ConditionId[] = [];
+  if (defender.alive && options.appliesCondition) {
+    if (applyCondition(defender, options.appliesCondition.id, options.appliesCondition.duration)) {
+      conditionsApplied.push(options.appliesCondition.id);
+    }
+  }
+  if (options.selfCondition) {
+    applyCondition(attacker, options.selfCondition.id, options.selfCondition.duration);
+  }
+  return {
+    attackerId: attacker.id,
+    defenderId: defender.id,
+    damage,
+    killed: !defender.alive,
+    conditionsApplied,
+    brokenConditions,
+  };
+}
+
+export function performDefend(actor: ActorState): void {
+  applyCondition(actor, 'guarded');
+}
+
+export interface PushResult {
+  moved: boolean;
+  to: GridPos | null;
+}
+
+/**
+ * Push the defender one tile directly away from the attacker. If the tile
+ * cannot be entered, the defender is Staggered instead. Mutates defender.pos.
+ */
+export function performPush(
+  attacker: ActorState,
+  defender: ActorState,
+  canEnter: (pos: GridPos) => boolean,
+): PushResult {
+  const dx = Math.sign(defender.pos.x - attacker.pos.x);
+  const dy = Math.sign(defender.pos.y - attacker.pos.y);
+  const to = { x: defender.pos.x + dx, y: defender.pos.y + dy };
+  if ((dx !== 0 || dy !== 0) && canEnter(to)) {
+    defender.pos = to;
+    return { moved: true, to };
+  }
+  applyCondition(defender, 'staggered', 1);
+  return { moved: false, to: null };
+}
+
+/** Empathy path: calm a calmable enemy out of the fight — nonviolent resolution. */
+export function performCalm(
+  actor: ActorState,
+  target: ActorState,
+  empathy: number,
+  rng: Rng,
+): boolean {
+  if (target.kind !== 'enemy' || !enemyDef(target.defId).calmable) return false;
+  if (manhattan(actor.pos, target.pos) > 2) return false;
+  const chance = Math.min(0.85, 0.35 + empathy * 0.15);
+  if (!rng.chance(chance)) return false;
+  target.faction = 'neutral';
+  target.aggroed = false;
+  removeCondition(target, 'afraid');
+  return true;
+}
+
+export function inspectEnemy(target: ActorState): string {
+  const def = enemyDef(target.defId);
+  return `${def.name} — ${def.hp} health, attack ${def.stats.attack}, defense ${def.stats.defense}. ${def.inspectText}`;
+}
@@ -0,0 +1,131 @@+import type { ActorState, PlayerState } from '../core/GameState';
+import type { ConditionId } from '../core/types';
+import { conditionDef } from '../data/conditions';
+import { itemDef } from '../data/items';
+
+function equipmentBonus(actor: ActorState, kind: 'attack' | 'defense'): number {
+  if (actor.kind !== 'player') return 0;
+  const player = actor as PlayerState;
+  let bonus = 0;
+  if (kind === 'attack' && player.equippedWeaponId) {
+    bonus += itemDef(player.equippedWeaponId).attackBonus ?? 0;
+  }
+  if (kind === 'defense' && player.equippedArmorId) {
+    bonus += itemDef(player.equippedArmorId).defenseBonus ?? 0;
+  }
+  return bonus;
+}
+
+export interface TickResult {
+  actorId: string;
+  damage: number;
+  focusDelta: number;
+  expired: ConditionId[];
+  preventsAction: boolean;
+}
+
+export function hasCondition(actor: ActorState, id: ConditionId): boolean {
+  return actor.conditions.some((c) => c.id === id);
+}
+
+/** Applies (or refreshes) a condition. Returns false if it was already there. */
+export function applyCondition(
+  actor: ActorState,
+  id: ConditionId,
+  duration?: number,
+): boolean {
+  const def = conditionDef(id);
+  const turns = duration ?? def.defaultDuration;
+  const existing = actor.conditions.find((c) => c.id === id);
+  if (existing) {
+    existing.remaining = Math.max(existing.remaining, turns);
+    return false;
+  }
+  actor.conditions.push({ id, remaining: turns });
+  return true;
+}
+
+export function removeCondition(actor: ActorState, id: ConditionId): boolean {
+  const before = actor.conditions.length;
+  actor.conditions = actor.conditions.filter((c) => c.id !== id);
+  return actor.conditions.length !== before;
+}
+
+function product(
+  actor: ActorState,
+  pick: (def: ReturnType<typeof conditionDef>) => number | undefined,
+): number {
+  let result = 1;
+  for (const active of actor.conditions) {
+    const value = pick(conditionDef(active.id));
+    if (value !== undefined) result *= value;
+  }
+  return result;
+}
+
+export function speedOf(actor: ActorState): number {
+  return Math.round(actor.stats.speed * product(actor, (d) => d.speedMult));
+}
+
+export function attackOf(actor: ActorState): number {
+  return (actor.stats.attack + equipmentBonus(actor, 'attack')) * product(actor, (d) => d.attackMult);
+}
+
+export function defenseOf(actor: ActorState): number {
+  return (actor.stats.defense + equipmentBonus(actor, 'defense')) * product(actor, (d) => d.defenseMult);
+}
+
+export function incomingDamageMult(actor: ActorState): number {
+  return product(actor, (d) => d.incomingMult);
+}
+
+export function abilitiesBlocked(actor: ActorState): boolean {
+  return actor.conditions.some((c) => conditionDef(c.id).preventsAbilities === true);
+}
+
+export function actionPrevented(actor: ActorState): boolean {
+  return actor.conditions.some((c) => conditionDef(c.id).preventsAction === true);
+}
+
+/** Conditions that break when the actor is struck (Frozen). */
+export function onActorHit(actor: ActorState): ConditionId[] {
+  const broken = actor.conditions
+    .filter((c) => conditionDef(c.id).breaksOnHit === true)
+    .map((c) => c.id);
+  if (broken.length > 0) {
+    actor.conditions = actor.conditions.filter((c) => !broken.includes(c.id));
+  }
+  return broken;
+}
+
+/**
+ * Turn-start tick: damage-over-time, focus drift, expiry countdown.
+ * A condition applied with duration N ticks exactly N times.
+ */
+export function tickConditions(actor: ActorState): TickResult {
+  const result: TickResult = {
+    actorId: actor.id,
+    damage: 0,
+    focusDelta: 0,
+    expired: [],
+    preventsAction: actionPrevented(actor),
+  };
+  const stillActive = [];
+  for (const active of actor.conditions) {
+    const def = conditionDef(active.id);
+    if (def.tickDamage) result.damage += def.tickDamage;
+    if (def.tickFocus) result.focusDelta += def.tickFocus;
+    active.remaining -= 1;
+    if (active.remaining <= 0) result.expired.push(active.id);
+    else stillActive.push(active);
+  }
+  actor.conditions = stillActive;
+  if (result.damage > 0) {
+    actor.hp = Math.max(0, actor.hp - result.damage);
+    if (actor.hp === 0) actor.alive = false;
+  }
+  if (result.focusDelta !== 0) {
+    actor.focus = Math.min(actor.maxFocus, Math.max(0, actor.focus + result.focusDelta));
+  }
+  return result;
+}
@@ -0,0 +1,171 @@+import type { GameState } from '../core/GameState';
+import type {
+  DialogueChoice,
+  DialogueCondition,
+  DialogueEffect,
+  DialogueNode,
+  DialogueTree,
+} from '../data/dialogues/types';
+import { addItem, hasItem, removeItem } from './InventorySystem';
+import { addMemory, remembers } from './RelationshipSystem';
+import { advanceQuest, completeQuest, questStatus, startQuest } from './QuestSystem';
+
+/**
+ * Data-driven dialogue. Conditions gate choices; effects mutate GameState
+ * through the owning systems. Content lives in src/data/dialogues/.
+ */
+export class DialogueSystem {
+  private tree: DialogueTree | null = null;
+  private currentNode: DialogueNode | null = null;
+
+  /** Returns the opening node, or null if the tree's once-start was seen. */
+  start(tree: DialogueTree, state: GameState): DialogueNode | null {
+    this.tree = tree;
+    let nodeId = tree.startNodeId;
+    for (const variant of tree.startVariants ?? []) {
+      if (variant.conditions.every((c) => checkCondition(c, state))) {
+        nodeId = variant.nodeId;
+        break;
+      }
+    }
+    let node = tree.nodes[nodeId];
+    if (!node) throw new Error(`Dialogue ${tree.id}: missing start node "${nodeId}"`);
+    if (node.once && state.dialogueSeen.includes(node.id)) {
+      // Fall back to a node literally named "repeat" when present.
+      node = tree.nodes['repeat'] ?? node;
+    }
+    return this.enter(node, state);
+  }
+
+  get node(): DialogueNode | null {
+    return this.currentNode;
+  }
+
+  private enter(node: DialogueNode, state: GameState): DialogueNode {
+    this.currentNode = node;
+    if (!state.dialogueSeen.includes(node.id)) state.dialogueSeen.push(node.id);
+    if (node.effects) this.applyEffects(node.effects, state);
+    return node;
+  }
+
+  availableChoices(node: DialogueNode, state: GameState): DialogueChoice[] {
+    return (node.choices ?? []).filter((choice) =>
+      (choice.conditions ?? []).every((c) => checkCondition(c, state)),
+    );
+  }
+
+  /** Advance past a node with `next` and no choices. Null ends the conversation. */
+  advance(state: GameState): DialogueNode | null {
+    if (!this.tree || !this.currentNode) return null;
+    const nextId = this.currentNode.next;
+    if (!nextId) return null;
+    const next = this.tree.nodes[nextId];
+    if (!next) throw new Error(`Dialogue ${this.tree.id}: missing node "${nextId}"`);
+    return this.enter(next, state);
+  }
+
+  choose(choiceId: string, state: GameState): DialogueNode | null {
+    if (!this.tree || !this.currentNode) return null;
+    const choice = this.availableChoices(this.currentNode, state).find((c) => c.id === choiceId);
+    if (!choice) return null;
+    if (choice.effects) this.applyEffects(choice.effects, state);
+    if (!choice.next) return null;
+    const next = this.tree.nodes[choice.next];
+    if (!next) throw new Error(`Dialogue ${this.tree.id}: missing node "${choice.next}"`);
+    return this.enter(next, state);
+  }
+
+  end(): void {
+    this.tree = null;
+    this.currentNode = null;
+  }
+
+  private applyEffects(effects: DialogueEffect[], state: GameState): void {
+    for (const effect of effects) applyEffect(effect, state);
+  }
+}
+
+export function checkCondition(condition: DialogueCondition, state: GameState): boolean {
+  switch (condition.type) {
+    case 'flag':
+      return (state.flags[condition.flag] ?? false) === (condition.value ?? true);
+    case 'item':
+      return hasItem(state, condition.itemId);
+    case 'memory':
+      return remembers(state, condition.npcId, condition.event);
+    case 'tendency':
+      return state.tendencies[condition.axis] >= condition.min;
+    case 'stat':
+      return state.player.stats[condition.stat] >= condition.min;
+    case 'quest':
+      return questStatus(state, condition.questId) === condition.stage;
+    case 'shard':
+      return state.shards.includes(condition.shardId);
+  }
+}
+
+export function applyEffect(effect: DialogueEffect, state: GameState): void {
+  switch (effect.type) {
+    case 'setFlag':
+      state.flags[effect.flag] = effect.value;
+      break;
+    case 'giveItem':
+      addItem(state, effect.itemId, effect.quantity ?? 1);
+      break;
+    case 'takeItem':
+      removeItem(state, effect.itemId, effect.quantity ?? 1);
+      break;
+    case 'startQuest':
+      startQuest(state, effect.questId);
+      break;
+    case 'advanceQuest':
+      advanceQuest(state, effect.questId);
+      break;
+    case 'completeQuest':
+      completeQuest(state, effect.questId);
+      break;
+    case 'addMemory':
+      addMemory(state, effect.npcId, effect.event);
+      break;
+    case 'changeNpcAttitude':
+      state.npcAttitudes[effect.npcId] = effect.attitude;
+      break;
+    case 'tendency':
+      state.tendencies[effect.axis] += effect.amount;
+      break;
+    case 'heal':
+      state.player.hp = Math.min(state.player.maxHp, state.player.hp + effect.amount);
+      break;
+    case 'objective':
+      state.objective = effect.text;
+      break;
+    case 'discovery':
+      if (!state.discoveries.includes(effect.loreId)) state.discoveries.push(effect.loreId);
+      break;
+    case 'unlockAbility':
+      if (!state.player.abilityIds.includes(effect.abilityId)) {
+        state.player.abilityIds.push(effect.abilityId);
+      }
+      break;
+    case 'openRoute':
+      if (!state.routes.includes(effect.routeId)) state.routes.push(effect.routeId);
+      break;
+    case 'regionalOutcome':
+      state.regionalOutcomes[effect.region] = effect.outcome;
+      break;
+    case 'gainShard':
+      if (!state.shards.includes(effect.shardId)) state.shards.push(effect.shardId);
+      break;
+    case 'removeActors':
+      state.actors = state.actors.filter((a) => a.defId !== effect.defId);
+      break;
+    case 'turnHostile': {
+      const actor = state.actors.find((a) => a.defId === effect.defId && a.alive);
+      if (actor) {
+        actor.faction = 'hostile';
+        actor.aggroed = true;
+      }
+      break;
+    }
+  }
+}
@@ -0,0 +1,29 @@+import type { GameState } from '../core/GameState';
+import { echoDef, type EchoDef } from '../data/echoes';
+import { applyEffect } from './DialogueSystem';
+
+export interface EchoResult {
+  def: EchoDef;
+  /** True when the echo was heard before: lines replay, effects do not. */
+  alreadyHeard: boolean;
+  grantedAbility: string | null;
+}
+
+/**
+ * Hearing a Name Echo: records it (one-time), applies its effects and grants
+ * its ability on first hearing only.
+ */
+export function hearEcho(state: GameState, echoId: string): EchoResult {
+  const def = echoDef(echoId);
+  if (state.echoesHeard.includes(echoId)) {
+    return { def, alreadyHeard: true, grantedAbility: null };
+  }
+  state.echoesHeard.push(echoId);
+  for (const effect of def.effects ?? []) applyEffect(effect, state);
+  let grantedAbility: string | null = null;
+  if (def.grantsAbility && !state.player.abilityIds.includes(def.grantsAbility)) {
+    state.player.abilityIds.push(def.grantsAbility);
+    grantedAbility = def.grantsAbility;
+  }
+  return { def, alreadyHeard: false, grantedAbility };
+}
@@ -0,0 +1,38 @@+import type { GridPos } from '../core/types';
+
+export type EffectRequest =
+  | { kind: 'impact'; pos: GridPos; glyph: string }
+  | { kind: 'floatingText'; pos: GridPos; text: string; tone: 'damage' | 'heal' | 'word' }
+  | { kind: 'shake' };
+
+export type EffectHandler = (request: EffectRequest) => void;
+
+/** Floating text / impact requests. Rendering subscribes; systems stay DOM-free. */
+export class EffectSystem {
+  private handler: EffectHandler | null = null;
+
+  connect(handler: EffectHandler): void {
+    this.handler = handler;
+  }
+
+  impact(pos: GridPos, glyph: string): void {
+    this.handler?.({ kind: 'impact', pos, glyph });
+  }
+
+  damageNumber(pos: GridPos, amount: number): void {
+    this.handler?.({ kind: 'floatingText', pos, text: `-${amount}`, tone: 'damage' });
+  }
+
+  healNumber(pos: GridPos, amount: number): void {
+    this.handler?.({ kind: 'floatingText', pos, text: `+${amount}`, tone: 'heal' });
+  }
+
+  /** Floating words like "Blocked", "Staggered", "Afraid". */
+  word(pos: GridPos, text: string): void {
+    this.handler?.({ kind: 'floatingText', pos, text, tone: 'word' });
+  }
+
+  shake(): void {
+    this.handler?.({ kind: 'shake' });
+  }
+}
@@ -0,0 +1,293 @@+import type { ActorState } from '../core/GameState';
+import type { AiType, GridPos } from '../core/types';
+import { manhattan, posKey } from '../core/types';
+import type { Rng } from '../core/Rng';
+import { buildOccupancy, type Occupancy } from '../world/Collision';
+import { findPath, hasLineOfSight, reachableSet } from '../world/Pathfinding';
+import type { MapDefinition } from '../world/MapDefinition';
+import { enemyDef } from '../data/enemies';
+import { CHARACTERS } from '../data/characters';
+import { FLAGS } from '../data/flags';
+import { hasCondition } from './ConditionSystem';
+
+export type AiIntent =
+  | { type: 'move'; path: GridPos[] }
+  | { type: 'attack'; targetId: string }
+  | { type: 'rangedAttack'; targetId: string }
+  | { type: 'guard' }
+  | { type: 'wait' };
+
+export interface AiContext {
+  map: MapDefinition;
+  actors: ActorState[];
+  player: ActorState;
+  flags: Record<string, boolean>;
+  rng: Rng;
+}
+
+export interface AiProfile {
+  aiType: AiType;
+  aggroRange: number;
+  moveRange: number;
+  attackRange: number;
+}
+
+/** Enemies carry their def; fightable named characters carry a combat profile. */
+export function aiProfileOf(actor: ActorState): AiProfile {
+  if (actor.kind === 'enemy') {
+    const def = enemyDef(actor.defId);
+    return {
+      aiType: def.aiType,
+      aggroRange: def.aggroRange,
+      moveRange: def.moveRange,
+      attackRange: def.attackRange,
+    };
+  }
+  const combat = CHARACTERS[actor.defId]?.combat;
+  if (!combat) throw new Error(`Actor ${actor.defId} has no combat profile`);
+  return {
+    aiType: combat.aiType,
+    aggroRange: combat.aggroRange,
+    moveRange: combat.moveRange,
+    attackRange: combat.attackRange,
+  };
+}
+
+export function aggroRangeOf(actor: ActorState): number {
+  if (actor.kind === 'enemy') return enemyDef(actor.defId).aggroRange;
+  return CHARACTERS[actor.defId]?.combat?.aggroRange ?? 3;
+}
+
+/**
+ * Distinct brains per enemy type — never one shared "walk and attack".
+ * Deterministic given the same Rng state.
+ */
+export function decideEnemyAction(enemy: ActorState, ctx: AiContext): AiIntent {
+  const profile = aiProfileOf(enemy);
+  const occupancy = buildOccupancy(ctx.actors);
+
+  // Afraid enemies retreat from the player instead of following their script.
+  if (hasCondition(enemy, 'afraid')) {
+    const away = stepAwayFrom(enemy, ctx.player.pos, ctx.map, occupancy, profile.moveRange);
+    return away ? { type: 'move', path: away } : { type: 'wait' };
+  }
+
+  switch (profile.aiType) {
+    case 'wolf':
+      return wolfAction(enemy, profile.moveRange, profile.attackRange, ctx, occupancy);
+    case 'hollowSoldier':
+      return hollowSoldierAction(enemy, profile.moveRange, profile.attackRange, profile.aggroRange, ctx, occupancy);
+    case 'archer':
+      return archerAction(enemy, profile.moveRange, profile.attackRange, ctx, occupancy);
+    case 'rustMachine':
+      return rustMachineAction(enemy, profile.moveRange, ctx, occupancy);
+    case 'crownSoldier':
+      return crownSoldierAction(enemy, profile.moveRange, profile.attackRange, ctx, occupancy);
+    case 'mireLurker':
+      return mireLurkerAction(enemy, profile.moveRange, profile.attackRange, profile.aggroRange, ctx, occupancy);
+  }
+}
+
+function attackIfAdjacent(
+  enemy: ActorState,
+  target: ActorState,
+  attackRange: number,
+): AiIntent | null {
+  return manhattan(enemy.pos, target.pos) <= attackRange
+    ? { type: 'attack', targetId: target.id }
+    : null;
+}
+
+function pathToward(
+  enemy: ActorState,
+  goal: GridPos,
+  map: MapDefinition,
+  occupancy: Occupancy,
+  maxSteps: number,
+): GridPos[] | null {
+  const path = findPath(map, occupancy, enemy.pos, goal, {
+    ignoreOccupantId: enemy.id,
+    allowOccupiedGoal: true,
+  });
+  if (!path || path.length === 0) return null;
+  return path.slice(0, maxSteps);
+}
+
+function stepAwayFrom(
+  enemy: ActorState,
+  threat: GridPos,
+  map: MapDefinition,
+  occupancy: Occupancy,
+  maxSteps: number,
+): GridPos[] | null {
+  const reachable = [...reachableSet(map, occupancy, enemy.pos, maxSteps).values()];
+  let best: { pos: GridPos; cost: number } | null = null;
+  for (const tile of reachable) {
+    if (best === null || manhattan(tile.pos, threat) > manhattan(best.pos, threat)) {
+      best = tile;
+    }
+  }
+  if (!best || manhattan(best.pos, threat) <= manhattan(enemy.pos, threat)) return null;
+  const path = findPath(map, occupancy, enemy.pos, best.pos, { ignoreOccupantId: enemy.id });
+  return path && path.length > 0 ? path : null;
+}
+
+// Wolf: surround the prey; retreat when badly hurt.
+function wolfAction(
+  enemy: ActorState,
+  moveRange: number,
+  attackRange: number,
+  ctx: AiContext,
+  occupancy: Occupancy,
+): AiIntent {
+  const hurt = enemy.hp <= enemy.maxHp * 0.3;
+  if (hurt) {
+    const away = stepAwayFrom(enemy, ctx.player.pos, ctx.map, occupancy, moveRange);
+    return away ? { type: 'move', path: away } : { type: 'wait' };
+  }
+  const adjacent = attackIfAdjacent(enemy, ctx.player, attackRange);
+  if (adjacent) return adjacent;
+  // Prefer a tile adjacent to the player (surround), not just any step closer.
+  const ring = neighborsOf(ctx.player.pos).filter(
+    (p) => !occupancy.has(posKey(p)) || posKey(p) === posKey(enemy.pos),
+  );
+  let bestPath: GridPos[] | null = null;
+  for (const spot of ring) {
+    const path = findPath(ctx.map, occupancy, enemy.pos, spot, { ignoreOccupantId: enemy.id });
+    if (path && (bestPath === null || path.length < bestPath.length)) bestPath = path;
+  }
+  if (bestPath) return { type: 'move', path: bestPath.slice(0, moveRange) };
+  const fallback = pathToward(enemy, ctx.player.pos, ctx.map, occupancy, moveRange);
+  return fallback ? { type: 'move', path: fallback } : { type: 'wait' };
+}
+
+// Hollow soldier: patrol forgotten orders until the player comes close.
+function hollowSoldierAction(
+  enemy: ActorState,
+  moveRange: number,
+  attackRange: number,
+  aggroRange: number,
+  ctx: AiContext,
+  occupancy: Occupancy,
+): AiIntent {
+  if (enemy.aggroed || manhattan(enemy.pos, ctx.player.pos) <= aggroRange) {
+    enemy.aggroed = true;
+    const adjacent = attackIfAdjacent(enemy, ctx.player, attackRange);
+    if (adjacent) return adjacent;
+    const path = pathToward(enemy, ctx.player.pos, ctx.map, occupancy, moveRange);
+    return path ? { type: 'move', path } : { type: 'wait' };
+  }
+  const patrol = enemy.patrol;
+  if (!patrol || patrol.length === 0) return { type: 'wait' };
+  const index = (enemy.patrolIndex ?? 0) % patrol.length;
+  const waypoint = patrol[index];
+  if (waypoint.x === enemy.pos.x && waypoint.y === enemy.pos.y) {
+    enemy.patrolIndex = (index + 1) % patrol.length;
+    return { type: 'wait' };
+  }
+  const path = pathToward(enemy, waypoint, ctx.map, occupancy, moveRange);
+  return path ? { type: 'move', path } : { type: 'wait' };
+}
+
+// Archer: keep distance, fire on line of sight.
+function archerAction(
+  enemy: ActorState,
+  moveRange: number,
+  attackRange: number,
+  ctx: AiContext,
+  occupancy: Occupancy,
+): AiIntent {
+  const distance = manhattan(enemy.pos, ctx.player.pos);
+  const sees = hasLineOfSight(ctx.map, enemy.pos, ctx.player.pos);
+  if (distance <= attackRange && sees && distance >= 2) {
+    return { type: 'rangedAttack', targetId: ctx.player.id };
+  }
+  if (distance < 2) {
+    const away = stepAwayFrom(enemy, ctx.player.pos, ctx.map, occupancy, moveRange);
+    if (away) return { type: 'move', path: away };
+  }
+  // Reposition toward a firing tile: in range, with sight.
+  const reachable = [...reachableSet(ctx.map, occupancy, enemy.pos, moveRange).values()];
+  for (const tile of reachable) {
+    const d = manhattan(tile.pos, ctx.player.pos);
+    if (d >= 2 && d <= attackRange && hasLineOfSight(ctx.map, tile.pos, ctx.player.pos)) {
+      const path = findPath(ctx.map, occupancy, enemy.pos, tile.pos, { ignoreOccupantId: enemy.id });
+      if (path) return { type: 'move', path };
+    }
+  }
+  const closer = pathToward(enemy, ctx.player.pos, ctx.map, occupancy, moveRange);
+  return closer ? { type: 'move', path: closer } : { type: 'wait' };
+}
+
+// Rust machine: a predictable three-step routine — advance, strike, wind down.
+function rustMachineAction(
+  enemy: ActorState,
+  moveRange: number,
+  ctx: AiContext,
+  occupancy: Occupancy,
+): AiIntent {
+  const step = (enemy.routineStep ?? 0) % 3;
+  enemy.routineStep = (step + 1) % 3;
+  if (step === 0) {
+    const path = pathToward(enemy, ctx.player.pos, ctx.map, occupancy, moveRange);
+    return path ? { type: 'move', path } : { type: 'wait' };
+  }
+  if (step === 1) {
+    const adjacent = attackIfAdjacent(enemy, ctx.player, 1);
+    if (adjacent) return adjacent;
+    return { type: 'wait' };
+  }
+  return { type: 'wait' }; // winding down — its tell
+}
+
+// Crown soldier: disciplined advance; when hurt he alternates guard/advance
+// instead of flailing — order, even in retreat.
+function crownSoldierAction(
+  enemy: ActorState,
+  moveRange: number,
+  attackRange: number,
+  ctx: AiContext,
+  occupancy: Occupancy,
+): AiIntent {
+  const adjacent = attackIfAdjacent(enemy, ctx.player, attackRange);
+  if (adjacent) return adjacent;
+  const hurt = enemy.hp < enemy.maxHp / 2;
+  if (hurt) {
+    enemy.routineStep = (enemy.routineStep ?? 0) + 1;
+    if (enemy.routineStep % 2 === 0) return { type: 'guard' };
+  }
+  const path = pathToward(enemy, ctx.player.pos, ctx.map, occupancy, moveRange);
+  return path ? { type: 'move', path } : { type: 'guard' };
+}
+
+// Mire lurker: submerged until something warm comes close. A rung bell
+// repels it — old habit keeps it honest. Never a chaser.
+function mireLurkerAction(
+  enemy: ActorState,
+  moveRange: number,
+  attackRange: number,
+  aggroRange: number,
+  ctx: AiContext,
+  occupancy: Occupancy,
+): AiIntent {
+  if (ctx.flags[FLAGS.mireBellRung]) {
+    const away = stepAwayFrom(enemy, ctx.player.pos, ctx.map, occupancy, moveRange);
+    return away ? { type: 'move', path: away } : { type: 'wait' };
+  }
+  if (manhattan(enemy.pos, ctx.player.pos) > aggroRange) {
+    return { type: 'wait' }; // still, watching, underwater-eyed
+  }
+  const adjacent = attackIfAdjacent(enemy, ctx.player, attackRange);
+  if (adjacent) return adjacent;
+  const path = pathToward(enemy, ctx.player.pos, ctx.map, occupancy, moveRange);
+  return path ? { type: 'move', path } : { type: 'wait' };
+}
+
+function neighborsOf(pos: GridPos): GridPos[] {
+  return [
+    { x: pos.x, y: pos.y - 1 },
+    { x: pos.x, y: pos.y + 1 },
+    { x: pos.x - 1, y: pos.y },
+    { x: pos.x + 1, y: pos.y },
+  ];
+}
@@ -0,0 +1,79 @@+import type { GameState } from '../core/GameState';
+import { itemDef } from '../data/items';
+import { removeCondition } from './ConditionSystem';
+
+export function itemCount(state: GameState, itemId: string): number {
+  return state.inventory.items[itemId] ?? 0;
+}
+
+export function hasItem(state: GameState, itemId: string): boolean {
+  return itemCount(state, itemId) > 0;
+}
+
+export function addItem(state: GameState, itemId: string, quantity = 1): void {
+  itemDef(itemId); // validate id
+  state.inventory.items[itemId] = itemCount(state, itemId) + quantity;
+}
+
+export function removeItem(state: GameState, itemId: string, quantity = 1): boolean {
+  const current = itemCount(state, itemId);
+  if (current < quantity) return false;
+  const remaining = current - quantity;
+  if (remaining === 0) delete state.inventory.items[itemId];
+  else state.inventory.items[itemId] = remaining;
+  return true;
+}
+
+export interface UseResult {
+  ok: boolean;
+  message: string;
+}
+
+/** Consumables only; weapons are equipped via equipWeapon. */
+export function useConsumable(state: GameState, itemId: string): UseResult {
+  const def = itemDef(itemId);
+  if (def.type !== 'consumable') return { ok: false, message: `${def.name} is not consumable.` };
+  if (!hasItem(state, itemId)) return { ok: false, message: `No ${def.name} left.` };
+  const player = state.player;
+  if (player.hp >= player.maxHp && !def.curesCondition) {
+    return { ok: false, message: 'Already at full health.' };
+  }
+  removeItem(state, itemId, 1);
+  if (def.healAmount) {
+    player.hp = Math.min(player.maxHp, player.hp + def.healAmount);
+  }
+  if (def.curesCondition) {
+    removeCondition(player, def.curesCondition);
+  }
+  return { ok: true, message: `Used ${def.name}.` };
+}
+
+export function equipWeapon(state: GameState, itemId: string): UseResult {
+  const def = itemDef(itemId);
+  if (def.type !== 'weapon') return { ok: false, message: `${def.name} is not a weapon.` };
+  if (!hasItem(state, itemId)) return { ok: false, message: `No ${def.name} to equip.` };
+  state.player.equippedWeaponId = itemId;
+  state.player.appearance.equipmentGlyph = def.glyph;
+  return { ok: true, message: `Equipped ${def.name}.` };
+}
+
+export function unequipWeapon(state: GameState): UseResult {
+  if (!state.player.equippedWeaponId) return { ok: false, message: 'No weapon equipped.' };
+  state.player.equippedWeaponId = null;
+  delete state.player.appearance.equipmentGlyph;
+  return { ok: true, message: 'Weapon unequipped.' };
+}
+
+export function equipArmor(state: GameState, itemId: string): UseResult {
+  const def = itemDef(itemId);
+  if (def.type !== 'armor') return { ok: false, message: `${def.name} is not armor.` };
+  if (!hasItem(state, itemId)) return { ok: false, message: `No ${def.name} to equip.` };
+  state.player.equippedArmorId = itemId;
+  return { ok: true, message: `Equipped ${def.name}.` };
+}
+
+export function unequipArmor(state: GameState): UseResult {
+  if (!state.player.equippedArmorId) return { ok: false, message: 'No armor equipped.' };
+  state.player.equippedArmorId = null;
+  return { ok: true, message: 'Armor unequipped.' };
+}
@@ -0,0 +1,22 @@+import type { GameState, PersistedMapState } from '../core/GameState';
+
+/**
+ * Per-map persistence. The current map lives directly on GameState
+ * (actors / consumedInteractables); leaving a map stores it under mapStates,
+ * entering takes it back. Dead enemies stay dead, moved NPCs stay moved,
+ * taken items stay taken.
+ */
+export function storeCurrentMap(state: GameState): void {
+  state.mapStates[state.mapId] = {
+    actors: state.actors,
+    consumedInteractables: state.consumedInteractables,
+  };
+}
+
+/** Removes and returns the stored state for a map, or null if never visited. */
+export function takeMapState(state: GameState, mapId: string): PersistedMapState | null {
+  const stored = state.mapStates[mapId];
+  if (!stored) return null;
+  delete state.mapStates[mapId];
+  return stored;
+}
@@ -0,0 +1,83 @@+import type { ActorState, GameState } from '../core/GameState';
+import { allActors } from '../core/GameState';
+import type { GridPos } from '../core/types';
+import type { HazardKind, MapDefinition } from '../world/MapDefinition';
+import { buildOccupancy, isBlocked, occupantAt, type Occupancy } from '../world/Collision';
+import { tileAt } from '../world/MapDefinition';
+import type { Renderer } from '../rendering/Renderer';
+import type { AnimationQueue } from '../rendering/AnimationQueue';
+import type { EffectSystem } from './EffectSystem';
+import { applyCondition, removeCondition } from './ConditionSystem';
+
+export interface MovementDeps {
+  getState: () => GameState;
+  getMap: () => MapDefinition;
+  renderer: Renderer;
+  animations: AnimationQueue;
+  effects: EffectSystem;
+}
+
+export interface StepCheck {
+  ok: boolean;
+  occupantId?: string;
+}
+
+/**
+ * Movement pipeline: validate → reserve (state update) → animate → tile
+ * events. State changes first; the renderer animates the result.
+ */
+export class MovementSystem {
+  constructor(private readonly deps: MovementDeps) {}
+
+  occupancy(exceptId?: string): Occupancy {
+    const actors = allActors(this.deps.getState()).filter((a) => a.id !== exceptId);
+    return buildOccupancy(actors);
+  }
+
+  checkStep(actor: ActorState, to: GridPos): StepCheck {
+    const map = this.deps.getMap();
+    const occupancy = this.occupancy(actor.id);
+    const occupant = occupantAt(occupancy, to);
+    if (occupant) return { ok: false, occupantId: occupant };
+    if (isBlocked(map, occupancy, to)) return { ok: false };
+    return { ok: true };
+  }
+
+  /** One tile. Returns false when the step was invalid. */
+  async stepActor(actor: ActorState, to: GridPos): Promise<boolean> {
+    const check = this.checkStep(actor, to);
+    if (!check.ok) return false;
+    actor.pos = { ...to }; // reserve + update state (source of truth)
+    await this.deps.animations.enqueue(() => this.deps.renderer.placeActor(actor, true));
+    this.applyTileEvents(actor);
+    return true;
+  }
+
+  /** A precomputed path (tap-to-move, AI moves). Stops early if blocked. */
+  async walkPath(actor: ActorState, path: GridPos[]): Promise<number> {
+    let steps = 0;
+    for (const pos of path) {
+      const moved = await this.stepActor(actor, pos);
+      if (!moved) break;
+      steps += 1;
+    }
+    return steps;
+  }
+
+  /** Hazards and tile effects fire after the actor stands on the tile. */
+  applyTileEvents(actor: ActorState): void {
+    const tile = tileAt(this.deps.getMap(), actor.pos);
+    if (!tile?.hazard) return;
+    const hazard: HazardKind = tile.hazard;
+    if (hazard === 'unstable') {
+      if (applyCondition(actor, 'unstable')) {
+        this.deps.effects.word(actor.pos, 'Unstable');
+        this.deps.renderer.refreshActor(actor);
+      }
+    }
+    if (hazard === 'water' && removeCondition(actor, 'burning')) {
+      this.deps.effects.word(actor.pos, 'Extinguished');
+      this.deps.renderer.refreshActor(actor);
+    }
+  }
+}
@@ -0,0 +1,34 @@+import type { GameState } from '../core/GameState';
+import { questDef } from '../data/quests';
+
+export function startQuest(state: GameState, questId: string): void {
+  if (state.quests[questId]) return;
+  state.quests[questId] = { id: questId, status: 'active', stageIndex: 0 };
+}
+
+export function advanceQuest(state: GameState, questId: string): void {
+  const quest = state.quests[questId];
+  if (!quest || quest.status !== 'active') return;
+  const def = questDef(questId);
+  if (quest.stageIndex < def.stages.length - 1) {
+    quest.stageIndex += 1;
+  } else {
+    quest.status = 'completed';
+  }
+}
+
+export function completeQuest(state: GameState, questId: string): void {
+  const quest = state.quests[questId];
+  if (quest) quest.status = 'completed';
+}
+
+export function questStatus(state: GameState, questId: string): 'inactive' | 'active' | 'completed' {
+  const quest = state.quests[questId];
+  return quest ? quest.status : 'inactive';
+}
+
+export function currentObjective(state: GameState, questId: string): string | null {
+  const quest = state.quests[questId];
+  if (!quest || quest.status !== 'active') return null;
+  return questDef(questId).stages[quest.stageIndex]?.text ?? null;
+}
@@ -0,0 +1,16 @@+import type { GameState } from '../core/GameState';
+
+// Event-based memory over friendship points (DESIGN.md). NPCs remember
+// concrete events: sparedMirasBrother, liedToRook, calmedWolf, ...
+export function addMemory(state: GameState, npcId: string, event: string): void {
+  const memories = (state.npcMemories[npcId] ??= []);
+  if (!memories.includes(event)) memories.push(event);
+}
+
+export function remembers(state: GameState, npcId: string, event: string): boolean {
+  return state.npcMemories[npcId]?.includes(event) ?? false;
+}
+
+export function memoriesFor(state: GameState, npcId: string): string[] {
+  return [...(state.npcMemories[npcId] ?? [])];
+}
@@ -0,0 +1,28 @@+import type { ActorState } from '../core/GameState';
+import { manhattan } from '../core/types';
+import { hasLineOfSight } from '../world/Pathfinding';
+import type { MapDefinition } from '../world/MapDefinition';
+import type { AbilityDef } from '../data/abilities';
+
+/**
+ * Valid targets for a targeted attack/ability: living hostiles in range.
+ * Ranged abilities (range > 1) additionally require line of sight.
+ */
+export function validTargets(
+  map: MapDefinition,
+  actors: ActorState[],
+  attacker: ActorState,
+  ability: AbilityDef,
+): ActorState[] {
+  if (ability.range <= 0) return [];
+  return actors
+    .filter(
+      (actor) =>
+        actor.alive &&
+        actor.faction === 'hostile' &&
+        actor.id !== attacker.id &&
+        manhattan(actor.pos, attacker.pos) <= ability.range &&
+        (ability.range <= 1 || hasLineOfSight(map, attacker.pos, actor.pos)),
+    )
+    .sort((a, b) => manhattan(a.pos, attacker.pos) - manhattan(b.pos, attacker.pos));
+}
@@ -0,0 +1,114 @@+import type { GameState } from '../core/GameState';
+import { abilityDef, tendencyUnlocks } from '../data/abilities';
+import { conditionDef } from '../data/conditions';
+import { itemDef } from '../data/items';
+import { attackOf, defenseOf, speedOf } from '../systems/ConditionSystem';
+import { el, clear } from '../utils/dom';
+
+const TENDENCY_LABELS = { resolve: 'Resolve', insight: 'Insight', empathy: 'Empathy' } as const;
+
+/** Character sheet: stats, tendencies with unlock progress, abilities, appearance, conditions. */
+export class CharacterView {
+  readonly root: HTMLElement;
+  private readonly body: HTMLElement;
+
+  constructor(parent: HTMLElement) {
+    this.root = el('section', 'panel-overlay');
+    this.root.setAttribute('aria-label', 'Character');
+    const panel = el('div', 'panel-overlay__panel');
+    panel.append(el('h2', 'menu__heading', 'The Nameless'));
+    this.body = el('div', 'panel-overlay__body');
+    panel.append(this.body);
+    this.root.append(panel);
+    parent.append(this.root);
+  }
+
+  get visible(): boolean {
+    return this.root.classList.contains('panel-overlay--visible');
+  }
+
+  show(state: GameState, onClose: () => void): void {
+    clear(this.body);
+    const player = state.player;
+
+    const appearance = el('div', 'character__appearance');
+    const body = el('span', 'character__body glyph', player.appearance.bodyGlyph);
+    appearance.append(body);
+    const badges = el('div', 'character__badges');
+    for (const [label, glyph] of [
+      ['Weapon', player.equippedWeaponId ? itemDef(player.equippedWeaponId).glyph : null],
+      ['Armor', player.equippedArmorId ? itemDef(player.equippedArmorId).glyph : null],
+      ['Role', player.appearance.roleGlyph ?? null],
+      ['Status', player.appearance.statusGlyph ?? null],
+    ] as const) {
+      if (glyph) {
+        const badge = el('span', 'character__badge glyph', glyph);
+        badge.title = label;
+        badges.append(badge);
+      }
+    }
+    appearance.append(badges);
+    this.body.append(appearance);
+
+    const stats = el('dl', 'character__stats');
+    const statRows: [string, string][] = [
+      ['Health', `${player.hp} / ${player.maxHp}`],
+      ['Focus', `${player.focus} / ${player.maxFocus}`],
+      ['Attack', `${Math.round(attackOf(player))}`],
+      ['Defense', `${Math.round(defenseOf(player))}`],
+      ['Speed', `${speedOf(player)}`],
+      ['Insight', `${player.stats.insight}`],
+    ];
+    for (const [label, value] of statRows) {
+      stats.append(el('dt', '', label), el('dd', '', value));
+    }
+    this.body.append(stats);
+
+    this.body.append(el('h3', 'character__section', 'Tendencies'));
+    for (const axis of ['resolve', 'insight', 'empathy'] as const) {
+      const value = state.tendencies[axis];
+      const next = tendencyUnlocks()
+        .filter((u) => u.unlock.axis === axis && u.unlock.threshold > value)
+        .sort((a, b) => a.unlock.threshold - b.unlock.threshold)[0];
+      const progress = next
+        ? `${value} — next unlock at ${next.unlock.threshold} (${abilityDef(next.abilityId).name})`
+        : `${value} — path complete`;
+      const row = el('div', 'character__tendency');
+      row.append(el('span', 'character__tendency-name', TENDENCY_LABELS[axis]), el('span', '', progress));
+      this.body.append(row);
+    }
+
+    this.body.append(el('h3', 'character__section', 'Abilities'));
+    for (const abilityId of player.abilityIds) {
+      const def = abilityDef(abilityId);
+      const row = el('div', 'character__ability');
+      row.title = def.description;
+      row.append(el('span', 'glyph', def.glyph), el('span', '', def.name));
+      this.body.append(row);
+    }
+
+    if (player.conditions.length > 0) {
+      this.body.append(el('h3', 'character__section', 'Conditions'));
+      for (const active of player.conditions) {
+        const def = conditionDef(active.id);
+        const row = el('div', 'character__ability');
+        row.title = def.tooltip;
+        row.append(
+          el('span', 'glyph', def.glyph),
+          el('span', '', `${def.name} (${active.remaining})`),
+        );
+        this.body.append(row);
+      }
+    }
+
+    const close = el('button', 'menu__button', 'Close (Esc)');
+    close.type = 'button';
+    close.addEventListener('click', onClose);
+    this.body.append(close);
+    this.root.classList.add('panel-overlay--visible');
+  }
+
+  hide(): void {
+    this.root.classList.remove('panel-overlay--visible');
+  }
+}
@@ -0,0 +1,136 @@+import type { DialogueChoice, DialogueNode } from '../data/dialogues/types';
+import { CHARACTERS } from '../data/characters';
+import { el, clear } from '../utils/dom';
+
+export interface DialogueViewHandlers {
+  onChoice: (choiceId: string) => void;
+  onAdvance: () => void;
+  onEnd: () => void;
+}
+
+/**
+ * Dialogue presentation: large speaker body glyph, nameplate with role
+ * badge, fast skippable typewriter, number-key choices. Data lives in
+ * src/data/dialogues/ — this view only renders it.
+ */
+export class DialogueView {
+  readonly root: HTMLElement;
+  private readonly portrait: HTMLElement;
+  private readonly speakerName: HTMLElement;
+  private readonly speakerTitle: HTMLElement;
+  private readonly textBox: HTMLElement;
+  private readonly choicesBox: HTMLElement;
+  private handlers: DialogueViewHandlers | null = null;
+  private typeTimer: number | null = null;
+  private fullText = '';
+  private typing = false;
+  private choices: DialogueChoice[] = [];
+
+  constructor(parent: HTMLElement) {
+    this.root = el('section', 'dialogue');
+    this.root.setAttribute('aria-label', 'Dialogue');
+    this.portrait = el('div', 'dialogue__portrait glyph');
+    const plate = el('div', 'dialogue__plate');
+    this.speakerName = el('div', 'dialogue__name');
+    this.speakerTitle = el('div', 'dialogue__title');
+    plate.append(this.speakerName, this.speakerTitle);
+    this.textBox = el('p', 'dialogue__text');
+    this.choicesBox = el('div', 'dialogue__choices');
+    const body = el('div', 'dialogue__body');
+    body.append(plate, this.textBox, this.choicesBox);
+    this.root.append(this.portrait, body);
+    this.root.addEventListener('click', () => this.skipOrAdvance());
+    parent.append(this.root);
+  }
+
+  get visible(): boolean {
+    return this.root.classList.contains('dialogue--visible');
+  }
+
+  show(node: DialogueNode, choices: DialogueChoice[], handlers: DialogueViewHandlers): void {
+    this.handlers = handlers;
+    this.choices = choices;
+    const speaker = CHARACTERS[node.speakerId];
+    this.portrait.textContent = speaker?.appearance.bodyGlyph ?? '❔';
+    this.speakerName.textContent = speaker?.name ?? node.speakerId;
+    const roleBadge = speaker?.appearance.roleGlyph ? `${speaker.appearance.roleGlyph} ` : '';
+    this.speakerTitle.textContent = speaker ? `${roleBadge}${speaker.title}` : '';
+    this.root.classList.add('dialogue--visible');
+    this.typeText(node.text);
+    this.renderChoices([]);
+  }
+
+  hide(): void {
+    this.stopTyping();
+    this.root.classList.remove('dialogue--visible');
+    this.handlers = null;
+  }
+
+  choiceCount(): number {
+    return this.choices.length;
+  }
+
+  pickByIndex(index: number): void {
+    const choice = this.choices[index];
+    if (choice && !this.typing) this.handlers?.onChoice(choice.id);
+  }
+
+  /** Click/Enter during typing reveals the full line; otherwise advances. */
+  skipOrAdvance(): void {
+    if (!this.visible) return;
+    if (this.typing) {
+      this.finishTyping();
+      this.renderChoices(this.choices);
+      return;
+    }
+    if (this.choices.length === 0) this.handlers?.onAdvance();
+  }
+
+  private typeText(text: string): void {
+    this.stopTyping();
+    this.fullText = text;
+    this.typing = true;
+    this.textBox.textContent = '';
+    let index = 0;
+    this.typeTimer = window.setInterval(() => {
+      index += 2;
+      this.textBox.textContent = this.fullText.slice(0, index);
+      if (index >= this.fullText.length) {
+        this.finishTyping();
+        this.renderChoices(this.choices);
+      }
+    }, 14);
+  }
+
+  private finishTyping(): void {
+    this.stopTyping();
+    this.typing = false;
+    this.textBox.textContent = this.fullText;
+  }
+
+  private stopTyping(): void {
+    if (this.typeTimer !== null) {
+      window.clearInterval(this.typeTimer);
+      this.typeTimer = null;
+    }
+    this.typing = false;
+  }
+
+  private renderChoices(choices: DialogueChoice[]): void {
+    clear(this.choicesBox);
+    choices.forEach((choice, index) => {
+      const label = choice.silent ? 'Say nothing.' : choice.text;
+      const button = el('button', 'dialogue__choice', `${index + 1}. ${label}`);
+      button.type = 'button';
+      if (choice.silent) button.classList.add('dialogue__choice--silent');
+      button.addEventListener('click', (event) => {
+        event.stopPropagation();
+        this.handlers?.onChoice(choice.id);
+      });
+      this.choicesBox.append(button);
+    });
+    if (choices.length === 0 && this.fullText.length > 0) {
+      this.choicesBox.append(el('span', 'dialogue__hint', 'Click or press Enter to continue'));
+    }
+  }
+}
@@ -0,0 +1,66 @@+import type { EchoDef } from '../data/echoes';
+import { el, clear } from '../utils/dom';
+
+/**
+ * Name Echo vignette: modal, first-person, lines revealed one at a time.
+ * Visually distinct from dialogue — moonlight, not amber.
+ */
+export class EchoView {
+  readonly root: HTMLElement;
+  private readonly glyphBox: HTMLElement;
+  private readonly titleBox: HTMLElement;
+  private readonly linesBox: HTMLElement;
+  private readonly hint: HTMLElement;
+  private lines: string[] = [];
+  private shown = 0;
+  private onDone: (() => void) | null = null;
+
+  constructor(parent: HTMLElement) {
+    this.root = el('section', 'echo-overlay');
+    this.root.setAttribute('aria-label', 'Name Echo');
+    const panel = el('div', 'echo-overlay__panel');
+    this.glyphBox = el('div', 'echo-overlay__glyph glyph');
+    this.titleBox = el('h2', 'echo-overlay__title');
+    this.linesBox = el('div', 'echo-overlay__lines');
+    this.hint = el('div', 'echo-overlay__hint', 'Click or press Enter');
+    panel.append(this.glyphBox, this.titleBox, this.linesBox, this.hint);
+    this.root.append(panel);
+    this.root.addEventListener('click', () => this.advance());
+    parent.append(this.root);
+  }
+
+  get visible(): boolean {
+    return this.root.classList.contains('echo-overlay--visible');
+  }
+
+  show(def: EchoDef, alreadyHeard: boolean, onDone: () => void): void {
+    this.onDone = onDone;
+    this.lines = alreadyHeard ? [...def.lines, '(You have heard this echo before.)'] : def.lines;
+    this.shown = 0;
+    this.glyphBox.textContent = def.glyph;
+    this.titleBox.textContent = def.title;
+    clear(this.linesBox);
+    this.root.classList.add('echo-overlay--visible');
+    this.advance();
+  }
+
+  /** Reveal the next line, or close when all are shown. */
+  advance(): void {
+    if (!this.visible) return;
+    if (this.shown >= this.lines.length) {
+      this.hide();
+      const done = this.onDone;
+      this.onDone = null;
+      done?.();
+      return;
+    }
+    const line = el('p', 'echo-overlay__line', this.lines[this.shown]);
+    this.linesBox.append(line);
+    this.shown += 1;
+    this.hint.textContent = this.shown >= this.lines.length ? 'Click or press Enter to let go' : 'Click or press Enter';
+  }
+
+  hide(): void {
+    this.root.classList.remove('echo-overlay--visible');
+  }
+}
@@ -0,0 +1,134 @@+import type { GameState } from '../core/GameState';
+import { el, clear } from '../utils/dom';
+import { conditionDef } from '../data/conditions';
+
+export interface ActionBarEntry {
+  id: string;
+  label: string;
+}
+
+export interface TurnOrderEntry {
+  actorId: string;
+  name: string;
+  glyph: string;
+  active: boolean;
+}
+
+/** HUD shows only: health, focus, conditions, region, objective, turn order in combat. */
+export class HUD {
+  readonly root: HTMLElement;
+  private readonly healthFill: HTMLElement;
+  private readonly healthText: HTMLElement;
+  private readonly focusFill: HTMLElement;
+  private readonly focusText: HTMLElement;
+  private readonly conditionsRow: HTMLElement;
+  private readonly regionLabel: HTMLElement;
+  private readonly objectiveLabel: HTMLElement;
+  private readonly turnOrderRow: HTMLElement;
+  private readonly actionBar: HTMLElement;
+  private readonly toastBox: HTMLElement;
+  private toastTimer: number | null = null;
+  private actionHandler: ((actionId: string) => void) | null = null;
+
+  constructor(parent: HTMLElement) {
+    this.root = el('div', 'hud');
+
+    const status = el('div', 'hud__status');
+    this.healthFill = el('i');
+    this.healthText = el('span', 'bar__text');
+    this.focusFill = el('i');
+    this.focusText = el('span', 'bar__text');
+    status.append(
+      bar('Health', 'bar--health', this.healthFill, this.healthText),
+      bar('Focus', 'bar--focus', this.focusFill, this.focusText),
+    );
+    this.conditionsRow = el('div', 'hud__conditions');
+
+    const context = el('div', 'hud__context');
+    this.regionLabel = el('div', 'hud__region');
+    this.objectiveLabel = el('div', 'hud__objective');
+    context.append(this.regionLabel, this.objectiveLabel);
+
+    this.turnOrderRow = el('div', 'hud__turn-order');
+    this.actionBar = el('div', 'hud__actions');
+    this.toastBox = el('div', 'hud__toast');
+    this.toastBox.setAttribute('role', 'status');
+
+    this.root.append(status, this.conditionsRow, context, this.turnOrderRow, this.actionBar, this.toastBox);
+    parent.append(this.root);
+  }
+
+  onAction(handler: (actionId: string) => void): void {
+    this.actionHandler = handler;
+  }
+
+  update(state: GameState): void {
+    const player = state.player;
+    this.healthFill.style.width = `${Math.round((player.hp / player.maxHp) * 100)}%`;
+    this.healthText.textContent = `${player.hp} / ${player.maxHp}`;
+    this.focusFill.style.width = `${Math.round((player.focus / Math.max(1, player.maxFocus)) * 100)}%`;
+    this.focusText.textContent = `${player.focus} / ${player.maxFocus}`;
+    this.regionLabel.textContent = state.region;
+    this.objectiveLabel.textContent = state.objective;
+
+    clear(this.conditionsRow);
+    for (const active of player.conditions) {
+      const def = conditionDef(active.id);
+      const chip = el('span', 'condition-chip glyph', def.glyph);
+      chip.title = `${def.name} (${active.remaining}) — ${def.tooltip}`;
+      this.conditionsRow.append(chip);
+    }
+  }
+
+  updateTurnOrder(entries: TurnOrderEntry[]): void {
+    clear(this.turnOrderRow);
+    if (entries.length === 0) {
+      this.turnOrderRow.classList.remove('hud__turn-order--visible');
+      return;
+    }
+    this.turnOrderRow.classList.add('hud__turn-order--visible');
+    for (const entry of entries) {
+      const chip = el(
+        'span',
+        `turn-chip${entry.active ? ' turn-chip--active' : ''}`,
+        '',
+      );
+      const glyph = el('span', 'glyph', entry.glyph);
+      chip.append(glyph, el('span', 'turn-chip__name', entry.name));
+      this.turnOrderRow.append(chip);
+    }
+  }
+
+  /** Null hides the bar; otherwise one button per entry, numbered 1..9. */
+  setActionBar(actions: ActionBarEntry[] | null, disabled: boolean): void {
+    clear(this.actionBar);
+    this.actionBar.classList.toggle('hud__actions--visible', actions !== null);
+    if (!actions) return;
+    actions.slice(0, 9).forEach((entry, index) => {
+      const button = el('button', 'action-button', `${index + 1} ${entry.label}`);
+      button.type = 'button';
+      button.disabled = disabled;
+      button.addEventListener('click', () => this.actionHandler?.(entry.id));
+      this.actionBar.append(button);
+    });
+  }
+
+  toast(text: string): void {
+    this.toastBox.textContent = text;
+    this.toastBox.classList.add('hud__toast--visible');
+    if (this.toastTimer !== null) window.clearTimeout(this.toastTimer);
+    this.toastTimer = window.setTimeout(() => {
+      this.toastBox.classList.remove('hud__toast--visible');
+    }, 3200);
+  }
+}
+
+function bar(label: string, className: string, fill: HTMLElement, text: HTMLElement): HTMLElement {
+  const wrapper = el('div', `bar ${className}`);
+  wrapper.setAttribute('role', 'meter');
+  wrapper.setAttribute('aria-label', label);
+  const track = el('div', 'bar__track');
+  track.append(fill);
+  wrapper.append(track, text);
+  return wrapper;
+}
@@ -0,0 +1,135 @@+import type { GameState } from '../core/GameState';
+import { itemDef, type ItemType } from '../data/items';
+import { el, clear } from '../utils/dom';
+
+export interface InventoryCallbacks {
+  onEquip: (itemId: string) => void;
+  onUnequip: (slot: 'weapon' | 'armor') => void;
+  onUse: (itemId: string) => void;
+  onClose: () => void;
+}
+
+const SECTION_ORDER: { type: ItemType; label: string }[] = [
+  { type: 'weapon', label: 'Weapons' },
+  { type: 'armor', label: 'Defense' },
+  { type: 'consumable', label: 'Consumables' },
+  { type: 'relic', label: 'Relics' },
+  { type: 'key', label: 'Key items' },
+  { type: 'utility', label: 'Utility' },
+];
+
+/** Full inventory panel: equip/unequip, use consumables, counts, tooltips. */
+export class InventoryView {
+  readonly root: HTMLElement;
+  private readonly body: HTMLElement;
+  private readonly heading: HTMLElement;
+
+  constructor(parent: HTMLElement) {
+    this.root = el('section', 'panel-overlay');
+    this.root.setAttribute('aria-label', 'Inventory');
+    const panel = el('div', 'panel-overlay__panel');
+    this.heading = el('h2', 'menu__heading', 'Inventory');
+    this.body = el('div', 'panel-overlay__body');
+    panel.append(this.heading, this.body);
+    this.root.append(panel);
+    parent.append(this.root);
+  }
+
+  get visible(): boolean {
+    return this.root.classList.contains('panel-overlay--visible');
+  }
+
+  show(state: GameState, callbacks: InventoryCallbacks): void {
+    this.heading.textContent = 'Inventory';
+    clear(this.body);
+    const entries = Object.entries(state.inventory.items);
+    if (entries.length === 0) {
+      this.body.append(el('p', 'panel-overlay__empty', 'You carry nothing but the grave-dirt on your boots.'));
+    }
+    for (const section of SECTION_ORDER) {
+      const items = entries.filter(([itemId]) => itemDef(itemId).type === section.type);
+      if (items.length === 0) continue;
+      this.body.append(el('h3', 'inventory__section', section.label));
+      for (const [itemId, count] of items) {
+        this.body.append(this.itemRow(state, itemId, count, callbacks));
+      }
+    }
+    const close = el('button', 'menu__button', 'Close (Esc)');
+    close.type = 'button';
+    close.addEventListener('click', callbacks.onClose);
+    this.body.append(close);
+    this.root.classList.add('panel-overlay--visible');
+  }
+
+  private itemRow(
+    state: GameState,
+    itemId: string,
+    count: number,
+    callbacks: InventoryCallbacks,
+  ): HTMLElement {
+    const def = itemDef(itemId);
+    const row = el('div', 'inventory__row');
+    row.title = def.description;
+    row.append(el('span', 'inventory__glyph glyph', def.glyph));
+    const name = el('span', 'inventory__name', def.name);
+    row.append(name);
+    if (count > 1) row.append(el('span', 'inventory__count', `×${count}`));
+
+    const equippedWeapon = state.player.equippedWeaponId === itemId;
+    const equippedArmor = state.player.equippedArmorId === itemId;
+    if (def.type === 'weapon' || def.type === 'armor') {
+      const slot = def.type === 'weapon' ? ('weapon' as const) : ('armor' as const);
+      const equipped = def.type === 'weapon' ? equippedWeapon : equippedArmor;
+      if (equipped) row.append(el('span', 'inventory__equipped', 'equipped'));
+      const button = el('button', 'inventory__action', equipped ? 'Unequip' : 'Equip');
+      button.type = 'button';
+      button.addEventListener('click', () =>
+        equipped ? callbacks.onUnequip(slot) : callbacks.onEquip(itemId),
+      );
+      row.append(button);
+    }
+    if (def.type === 'consumable') {
+      const button = el('button', 'inventory__action', 'Use');
+      button.type = 'button';
+      button.addEventListener('click', () => callbacks.onUse(itemId));
+      row.append(button);
+    }
+    return row;
+  }
+
+  /** Combat chooser: consumables only, one click = use + end turn. */
+  showChooser(
+    state: GameState,
+    onPick: (itemId: string) => void,
+    onCancel: () => void,
+  ): void {
+    this.heading.textContent = 'Use which item?';
+    clear(this.body);
+    const consumables = Object.keys(state.inventory.items).filter(
+      (itemId) => itemDef(itemId).type === 'consumable',
+    );
+    if (consumables.length === 0) {
+      this.body.append(el('p', 'panel-overlay__empty', 'No consumables left.'));
+    }
+    for (const itemId of consumables) {
+      const def = itemDef(itemId);
+      const button = el('button', 'inventory__row inventory__row--button');
+      button.type = 'button';
+      button.title = def.description;
+      button.append(el('span', 'inventory__glyph glyph', def.glyph));
+      button.append(el('span', 'inventory__name', def.name));
+      button.append(el('span', 'inventory__count', `×${state.inventory.items[itemId]}`));
+      button.addEventListener('click', () => onPick(itemId));
+      this.body.append(button);
+    }
+    const cancel = el('button', 'menu__button', 'Cancel (Esc)');
+    cancel.type = 'button';
+    cancel.addEventListener('click', onCancel);
+    this.body.append(cancel);
+    this.root.classList.add('panel-overlay--visible');
+  }
+
+  hide(): void {
+    this.root.classList.remove('panel-overlay--visible');
+  }
+}
@@ -0,0 +1,115 @@+import type { GameState } from '../core/GameState';
+import { QUESTS } from '../data/quests';
+import { LORE } from '../data/lore';
+import { CHARACTERS } from '../data/characters';
+import { DECISION_TEXT } from '../data/flags';
+import { SHARDS, TOTAL_SHARDS } from '../data/shards';
+import { el, clear } from '../utils/dom';
+
+/**
+ * Journal: current/completed objectives, discoveries, important decisions,
+ * known characters, Name Shard progress. May describe uncertainty (canon).
+ */
+export class JournalView {
+  readonly root: HTMLElement;
+  private readonly body: HTMLElement;
+
+  constructor(parent: HTMLElement) {
+    this.root = el('section', 'panel-overlay');
+    this.root.setAttribute('aria-label', 'Journal');
+    const panel = el('div', 'panel-overlay__panel panel-overlay__panel--wide');
+    panel.append(el('h2', 'menu__heading', 'Journal'));
+    this.body = el('div', 'panel-overlay__body journal');
+    panel.append(this.body);
+    this.root.append(panel);
+    parent.append(this.root);
+  }
+
+  get visible(): boolean {
+    return this.root.classList.contains('panel-overlay--visible');
+  }
+
+  show(state: GameState, onClose: () => void): void {
+    clear(this.body);
+
+    this.section('Current objectives');
+    const active = Object.values(state.quests).filter((q) => q.status === 'active');
+    if (active.length === 0) this.line('Nothing is asked of you right now. It will not last.');
+    for (const quest of active) {
+      const def = QUESTS[quest.id];
+      const stage = def?.stages[quest.stageIndex];
+      this.line(`${def?.name ?? quest.id} — ${stage?.text ?? '…'}`);
+    }
+
+    const completed = Object.values(state.quests).filter((q) => q.status === 'completed');
+    if (completed.length > 0) {
+      this.section('Completed');
+      for (const quest of completed) this.line(QUESTS[quest.id]?.name ?? quest.id);
+    }
+
+    this.section(`Name Shards — ${state.shards.length} of ${TOTAL_SHARDS}`);
+    if (state.shards.length === 0) {
+      this.line('You have found none. The Book is still silent about what they are.');
+    }
+    for (const shardId of state.shards) {
+      const shard = SHARDS[shardId];
+      if (shard) this.lineGlyph(shard.glyph, `${shard.name} (${shard.region}) — ${shard.question}`);
+    }
+
+    const decisions = Object.values(state.npcMemories)
+      .flat()
+      .filter((event, index, all) => all.indexOf(event) === index);
+    if (decisions.length > 0) {
+      this.section('Decisions');
+      for (const event of decisions) this.line(DECISION_TEXT[event] ?? event);
+    }
+
+    if (state.discoveries.length > 0) {
+      this.section('Discoveries');
+      for (const loreId of state.discoveries) {
+        const entry = LORE[loreId];
+        if (entry) {
+          this.body.append(el('h4', 'journal__lore-title', entry.title));
+          this.line(entry.text);
+        }
+      }
+    }
+
+    if (state.metCharacters.length > 0) {
+      this.section('Known characters');
+      for (const characterId of state.metCharacters) {
+        const character = CHARACTERS[characterId];
+        if (character) {
+          this.lineGlyph(
+            character.appearance.bodyGlyph,
+            `${character.name} — ${character.impression}`,
+          );
+        }
+      }
+    }
+
+    const close = el('button', 'menu__button', 'Close (Esc)');
+    close.type = 'button';
+    close.addEventListener('click', onClose);
+    this.body.append(close);
+    this.root.classList.add('panel-overlay--visible');
+  }
+
+  private section(title: string): void {
+    this.body.append(el('h3', 'journal__section', title));
+  }
+
+  private line(text: string): void {
+    this.body.append(el('p', 'journal__line', text));
+  }
+
+  private lineGlyph(glyph: string, text: string): void {
+    const row = el('p', 'journal__line journal__line--glyph');
+    row.append(el('span', 'glyph', glyph), el('span', '', text));
+    this.body.append(row);
+  }
+
+  hide(): void {
+    this.root.classList.remove('panel-overlay--visible');
+  }
+}
@@ -0,0 +1,143 @@+import type { SaveFile } from '../core/SaveManager';
+import { el, clear } from '../utils/dom';
+
+export interface SlotEntry {
+  slot: string;
+  label: string;
+  info: SaveFile | null;
+  /** Autosave slot: loadable/deletable, not manually writable. */
+  readOnly: boolean;
+}
+
+export interface SavesCallbacks {
+  entries: () => SlotEntry[];
+  canSaveNow: () => boolean;
+  onLoad: (slot: string) => void;
+  onSave: (slot: string) => void;
+  onDelete: (slot: string) => void;
+  onExport: (slot: string) => void;
+  /** Returns an error message, or null on success. */
+  onImportJson: (json: string) => string | null;
+  onClose: () => void;
+}
+
+/** Save management: slot info, save/load, delete with confirmation, export/import. */
+export class SavesView {
+  readonly root: HTMLElement;
+  private readonly body: HTMLElement;
+  private readonly fileInput: HTMLInputElement;
+  private callbacks: SavesCallbacks | null = null;
+
+  constructor(parent: HTMLElement) {
+    this.root = el('section', 'panel-overlay');
+    this.root.setAttribute('aria-label', 'Saves');
+    const panel = el('div', 'panel-overlay__panel panel-overlay__panel--wide');
+    panel.append(el('h2', 'menu__heading', 'Saves'));
+    this.body = el('div', 'panel-overlay__body');
+    panel.append(this.body);
+    this.root.append(panel);
+    this.fileInput = el('input') as HTMLInputElement;
+    this.fileInput.type = 'file';
+    this.fileInput.accept = '.json,application/json';
+    this.fileInput.style.display = 'none';
+    this.fileInput.addEventListener('change', () => void this.readPickedFile());
+    parent.append(this.root);
+    parent.append(this.fileInput);
+  }
+
+  get visible(): boolean {
+    return this.root.classList.contains('panel-overlay--visible');
+  }
+
+  show(callbacks: SavesCallbacks): void {
+    this.callbacks = callbacks;
+    this.render();
+    this.root.classList.add('panel-overlay--visible');
+  }
+
+  hide(): void {
+    this.root.classList.remove('panel-overlay--visible');
+  }
+
+  private render(): void {
+    const callbacks = this.callbacks;
+    if (!callbacks) return;
+    clear(this.body);
+    for (const entry of callbacks.entries()) {
+      this.body.append(this.slotRow(entry, callbacks));
+    }
+    const footer = el('div', 'saves__footer');
+    const importButton = el('button', 'menu__button', 'Import save file…');
+    importButton.type = 'button';
+    importButton.addEventListener('click', () => this.fileInput.click());
+    const close = el('button', 'menu__button', 'Close (Esc)');
+    close.type = 'button';
+    close.addEventListener('click', () => callbacks.onClose());
+    footer.append(importButton, close);
+    this.body.append(footer);
+  }
+
+  private slotRow(entry: SlotEntry, callbacks: SavesCallbacks): HTMLElement {
+    const row = el('div', 'saves__row');
+    const label = el('div', 'saves__label');
+    label.append(el('strong', '', entry.label));
+    const info = entry.info;
+    label.append(
+      el(
+        'span',
+        'saves__meta',
+        info
+          ? `${info.data.region} — ${formatPlaytime(info.playtimeSeconds)} — ${new Date(info.savedAt).toLocaleString()}`
+          : 'empty',
+      ),
+    );
+    row.append(label);
+    const actions = el('div', 'saves__actions');
+    if (info) {
+      actions.append(slotButton('Load', () => callbacks.onLoad(entry.slot)));
+      actions.append(slotButton('Export', () => callbacks.onExport(entry.slot)));
+      actions.append(
+        slotButton('Delete', () => {
+          if (window.confirm(`Delete the save in ${entry.label}?`)) {
+            callbacks.onDelete(entry.slot);
+            this.render();
+          }
+        }),
+      );
+    }
+    if (!entry.readOnly) {
+      const saveButton = slotButton('Save here', () => {
+        callbacks.onSave(entry.slot);
+        this.render();
+      });
+      saveButton.disabled = !callbacks.canSaveNow();
+      if (!callbacks.canSaveNow()) saveButton.title = 'Cannot save right now.';
+      actions.append(saveButton);
+    }
+    row.append(actions);
+    return row;
+  }
+
+  private async readPickedFile(): Promise<void> {
+    const file = this.fileInput.files?.[0];
+    this.fileInput.value = '';
+    if (!file || !this.callbacks) return;
+    const text = await file.text();
+    const error = this.callbacks.onImportJson(text);
+    if (error) window.alert(error);
+    this.render();
+  }
+}
+
+function slotButton(label: string, onClick: () => void): HTMLButtonElement {
+  const button = el('button', 'saves__action', label) as HTMLButtonElement;
+  button.type = 'button';
+  button.addEventListener('click', onClick);
+  return button;
+}
+
+function formatPlaytime(seconds: number): string {
+  const minutes = Math.floor(seconds / 60);
+  if (minutes < 60) return `${minutes}m`;
+  return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
+}
@@ -0,0 +1,33 @@+export type ScreenId = 'title' | 'newGame' | 'settings' | 'credits' | 'gallery' | 'game';
+
+/**
+ * Full-screen views; exactly one visible at a time. The game screen stays in
+ * the DOM (hidden) so renderer state survives a trip to settings/credits.
+ */
+export class ScreenManager {
+  private readonly screens = new Map<ScreenId, HTMLElement>();
+  private current: ScreenId | null = null;
+
+  register(id: ScreenId, element: HTMLElement): void {
+    element.classList.add('screen');
+    element.dataset.screen = id;
+    this.screens.set(id, element);
+  }
+
+  show(id: ScreenId): void {
+    for (const [screenId, element] of this.screens) {
+      element.classList.toggle('screen--active', screenId === id);
+    }
+    this.current = id;
+  }
+
+  get currentId(): ScreenId | null {
+    return this.current;
+  }
+
+  element(id: ScreenId): HTMLElement {
+    const element = this.screens.get(id);
+    if (!element) throw new Error(`Unknown screen: ${id}`);
+    return element;
+  }
+}
@@ -0,0 +1,225 @@+import type { Settings } from '../core/GameState';
+import { PLAYER_VARIANTS } from '../data/characters';
+import { GLYPHS } from '../data/glyphs';
+import { el, clear } from '../utils/dom';
+
+export interface TitleScreen {
+  element: HTMLElement;
+  setHasSave: (hasSave: boolean) => void;
+}
+
+export function buildTitleScreen(callbacks: {
+  onNewGame: () => void;
+  onContinue: () => void;
+  onSaves: () => void;
+  onSettings: () => void;
+  onCredits: () => void;
+  onGallery: () => void;
+  devMode: boolean;
+}): TitleScreen {
+  const element = el('div', 'menu menu--title');
+  element.append(el('h1', 'menu__game-title', 'The Last Glyph'));
+  element.append(el('p', 'menu__tagline', 'A world that lost its names.'));
+  const nav = el('nav', 'menu__nav');
+  const continueButton = menuButton('Continue', callbacks.onContinue);
+  nav.append(
+    continueButton,
+    menuButton('New Game', callbacks.onNewGame),
+    menuButton('Saves', callbacks.onSaves),
+    menuButton('Settings', callbacks.onSettings),
+    menuButton('Credits', callbacks.onCredits),
+  );
+  if (callbacks.devMode) {
+    nav.append(menuButton('Glyph Gallery (dev)', callbacks.onGallery));
+  }
+  element.append(nav);
+  element.append(el('p', 'menu__author', 'Created by Pablo Murad'));
+  return {
+    element,
+    setHasSave: (hasSave: boolean) => {
+      continueButton.disabled = !hasSave;
+    },
+  };
+}
+
+export function buildNewGameScreen(callbacks: {
+  onStart: (variantId: string) => void;
+  onBack: () => void;
+}): HTMLElement {
+  const element = el('div', 'menu menu--new-game');
+  element.append(el('h2', 'menu__heading', 'Who climbs out of the grave?'));
+  element.append(
+    el('p', 'menu__note', 'A body, nothing more. It changes nothing the world knows about you.'),
+  );
+  let selected = PLAYER_VARIANTS[0].id;
+  const grid = el('div', 'appearance-grid');
+  const buttons = new Map<string, HTMLButtonElement>();
+  for (const variant of PLAYER_VARIANTS) {
+    const button = el('button', 'appearance-option') as HTMLButtonElement;
+    button.type = 'button';
+    button.append(el('span', 'appearance-option__glyph glyph', variant.bodyGlyph));
+    button.append(el('span', 'appearance-option__label', variant.label));
+    button.addEventListener('click', () => {
+      selected = variant.id;
+      for (const [id, other] of buttons) {
+        other.classList.toggle('appearance-option--selected', id === selected);
+        other.setAttribute('aria-pressed', String(id === selected));
+      }
+    });
+    buttons.set(variant.id, button);
+    grid.append(button);
+  }
+  buttons.get(selected)?.classList.add('appearance-option--selected');
+  element.append(grid);
+  const nav = el('nav', 'menu__nav menu__nav--row');
+  nav.append(
+    menuButton('Begin', () => callbacks.onStart(selected)),
+    menuButton('Back', callbacks.onBack),
+  );
+  element.append(nav);
+  return element;
+}
+
+export function buildSettingsScreen(callbacks: {
+  settings: Settings;
+  onApply: (settings: Settings) => void;
+  onBack: () => void;
+}): HTMLElement {
+  const element = el('div', 'menu menu--settings');
+  element.append(el('h2', 'menu__heading', 'Settings'));
+  const draft: Settings = { ...callbacks.settings };
+  const form = el('div', 'settings-form');
+  form.append(
+    toggleRow('Reduced motion', draft.reducedMotion, (v) => (draft.reducedMotion = v)),
+    toggleRow('Screen shake', draft.screenShake, (v) => (draft.screenShake = v)),
+    sliderRow('Animation speed', draft.animationSpeed, [0.5, 1, 2], ['Fast', 'Normal', 'Slow'], (v) => {
+      draft.animationSpeed = v;
+    }),
+    sliderRow('Text scale', draft.textScale, [0.9, 1, 1.15, 1.3], ['Small', 'Normal', 'Large', 'Largest'], (v) => {
+      draft.textScale = v;
+    }),
+  );
+  element.append(form);
+  const nav = el('nav', 'menu__nav menu__nav--row');
+  nav.append(
+    menuButton('Apply', () => {
+      callbacks.onApply(draft);
+      callbacks.onBack();
+    }),
+    menuButton('Back', callbacks.onBack),
+  );
+  element.append(nav);
+  return element;
+}
+
+export function buildCreditsScreen(onBack: () => void): HTMLElement {
+  const element = el('div', 'menu menu--credits');
+  element.append(el('h2', 'menu__heading', 'Credits'));
+  const block = el('div', 'credits-block');
+  block.append(
+    el('p', '', 'The Last Glyph'),
+    el('p', '', 'Created by Pablo Murad'),
+    el('p', '', 'pablomurad@pm.me'),
+    el('p', 'credits-block__legal', 'Copyright © 2026 Pablo Murad. All rights reserved.'),
+  );
+  element.append(block);
+  const nav = el('nav', 'menu__nav');
+  nav.append(menuButton('Back', onBack));
+  element.append(nav);
+  return element;
+}
+
+/** Dev-only (?dev=1): every glyph in data/glyphs.ts, rendered large, with id. */
+export function buildGlyphGalleryScreen(onBack: () => void): HTMLElement {
+  const element = el('div', 'menu menu--gallery');
+  element.append(el('h2', 'menu__heading', 'Glyph Gallery'));
+  element.append(
+    el('p', 'menu__note', 'Dev tool: spot broken ZWJ sequences, duplicates, clipping.'),
+  );
+  const grid = el('div', 'glyph-gallery');
+  for (const entry of GLYPHS) {
+    const cell = el('div', 'glyph-gallery__cell');
+    cell.append(el('span', 'glyph-gallery__glyph glyph', entry.glyph));
+    cell.append(el('span', 'glyph-gallery__id', entry.id));
+    cell.append(el('span', 'glyph-gallery__label', entry.label));
+    grid.append(cell);
+  }
+  element.append(grid);
+  const nav = el('nav', 'menu__nav');
+  nav.append(menuButton('Back', onBack));
+  element.append(nav);
+  return element;
+}
+
+export function buildPauseOverlay(callbacks: {
+  onResume: () => void;
+  onSaves: () => void;
+  onQuit: () => void;
+}): { element: HTMLElement; show: () => void; hide: () => void; visible: () => boolean } {
+  const element = el('div', 'pause-overlay');
+  const panel = el('div', 'pause-overlay__panel');
+  panel.append(el('h2', 'menu__heading', 'Paused'));
+  const nav = el('nav', 'menu__nav');
+  nav.append(
+    menuButton('Resume', callbacks.onResume),
+    menuButton('Saves', callbacks.onSaves),
+    menuButton('Quit to title', callbacks.onQuit),
+  );
+  panel.append(nav);
+  element.append(panel);
+  return {
+    element,
+    show: () => element.classList.add('pause-overlay--visible'),
+    hide: () => element.classList.remove('pause-overlay--visible'),
+    visible: () => element.classList.contains('pause-overlay--visible'),
+  };
+}
+
+export function rebuildSettings(
+  container: HTMLElement,
+  callbacks: { settings: Settings; onApply: (settings: Settings) => void; onBack: () => void },
+): void {
+  clear(container);
+  container.append(buildSettingsScreen(callbacks));
+}
+
+function menuButton(label: string, onClick: () => void): HTMLButtonElement {
+  const button = el('button', 'menu__button', label) as HTMLButtonElement;
+  button.type = 'button';
+  button.addEventListener('click', onClick);
+  return button;
+}
+
+function toggleRow(label: string, initial: boolean, onChange: (value: boolean) => void): HTMLElement {
+  const row = el('label', 'settings-row');
+  const input = el('input') as HTMLInputElement;
+  input.type = 'checkbox';
+  input.checked = initial;
+  input.addEventListener('change', () => onChange(input.checked));
+  row.append(el('span', '', label), input);
+  return row;
+}
+
+function sliderRow(
+  label: string,
+  initial: number,
+  values: number[],
+  labels: string[],
+  onChange: (value: number) => void,
+): HTMLElement {
+  const row = el('label', 'settings-row settings-row--slider');
+  const input = el('input') as HTMLInputElement;
+  input.type = 'range';
+  input.min = '0';
+  input.max = String(values.length - 1);
+  input.step = '1';
+  input.value = String(Math.max(0, values.indexOf(initial)));
+  const valueLabel = el('span', 'settings-row__value', labels[Number(input.value)]);
+  input.addEventListener('input', () => {
+    const index = Number(input.value);
+    valueLabel.textContent = labels[index];
+    onChange(values[index]);
+  });
+  row.append(el('span', '', label), input, valueLabel);
+  return row;
+}
@@ -0,0 +1,14 @@+export function el<K extends keyof HTMLElementTagNameMap>(
+  tag: K,
+  className?: string,
+  text?: string,
+): HTMLElementTagNameMap[K] {
+  const node = document.createElement(tag);
+  if (className) node.className = className;
+  if (text !== undefined) node.textContent = text;
+  return node;
+}
+
+export function clear(node: HTMLElement): void {
+  node.replaceChildren();
+}
@@ -0,0 +1,11 @@+// Emoji are multi-codepoint strings (ZWJ sequences, skin tones, variation
+// selectors). Never index or split them — always segment by grapheme.
+const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
+
+export function graphemes(text: string): string[] {
+  return Array.from(segmenter.segment(text), (part) => part.segment);
+}
+
+export function graphemeLength(text: string): number {
+  return graphemes(text).length;
+}
@@ -0,0 +1,192 @@+import { GLYPHS } from '../data/glyphs';
+import { CHARACTERS } from '../data/characters';
+import { DIALOGUES } from '../data/dialogues';
+import type { DialogueEffect, DialogueTree } from '../data/dialogues/types';
+import { QUESTS } from '../data/quests';
+import { ITEMS } from '../data/items';
+import { ENEMIES } from '../data/enemies';
+import { ABILITIES } from '../data/abilities';
+import { LORE } from '../data/lore';
+import { ECHOES } from '../data/echoes';
+import { SHARDS } from '../data/shards';
+import { tileAt, type MapDefinition } from '../world/MapDefinition';
+
+/**
+ * Dev content validation with loud failures. Runs only under ?dev=1 and in
+ * tests — never in the player's path.
+ */
+export function validateContent(maps: MapDefinition[]): string[] {
+  const problems: string[] = [];
+  validateGlyphs(problems);
+  validateCharacters(problems);
+  for (const map of maps) validateMap(map, maps, problems);
+  for (const tree of Object.values(DIALOGUES)) validateDialogue(tree, problems);
+  validateKeyIdConsistency(problems);
+  return problems;
+}
+
+function validateGlyphs(problems: string[]): void {
+  const glyphIds = new Set<string>();
+  for (const entry of GLYPHS) {
+    if (entry.glyph.length === 0) problems.push(`Glyph ${entry.id} is empty`);
+    if (glyphIds.has(entry.id)) problems.push(`Duplicate glyph id: ${entry.id}`);
+    glyphIds.add(entry.id);
+  }
+}
+
+function validateCharacters(problems: string[]): void {
+  const bodies = new Map<string, string>();
+  for (const character of Object.values(CHARACTERS)) {
+    if (character.appearance.bodyGlyph.length === 0) {
+      problems.push(`Character ${character.id} has an empty body glyph`);
+    }
+    const seen = bodies.get(character.appearance.bodyGlyph);
+    if (seen) {
+      problems.push(
+        `Characters ${seen} and ${character.id} share a body glyph (${character.appearance.bodyGlyph})`,
+      );
+    }
+    bodies.set(character.appearance.bodyGlyph, character.id);
+    if (character.dialogueId && !DIALOGUES[character.dialogueId]) {
+      problems.push(`Character ${character.id} references missing dialogue "${character.dialogueId}"`);
+    }
+  }
+}
+
+function validateMap(map: MapDefinition, maps: MapDefinition[], problems: string[]): void {
+  const occupied = new Set<string>();
+  for (const spawn of [...map.npcs, ...map.enemies]) {
+    const tile = tileAt(map, spawn.pos);
+    if (!tile || !tile.walkable) {
+      problems.push(
+        `Map ${map.id}: actor ${spawn.defId} spawns on a blocked tile (${spawn.pos.x},${spawn.pos.y})`,
+      );
+    }
+    const key = `${spawn.pos.x},${spawn.pos.y}`;
+    if (occupied.has(key)) problems.push(`Map ${map.id}: two actors share tile ${key}`);
+    occupied.add(key);
+    if (!CHARACTERS[spawn.defId] && !ENEMIES[spawn.defId]) {
+      problems.push(`Map ${map.id}: unknown actor def "${spawn.defId}"`);
+    }
+  }
+  for (const exit of map.exits) {
+    const target = maps.find((m) => m.id === exit.toMapId);
+    if (!target) {
+      problems.push(`Map ${map.id}: exit to unknown map "${exit.toMapId}"`);
+    } else if (!tileAt(target, exit.toPos)?.walkable) {
+      problems.push(`Map ${map.id}: exit arrives on a blocked tile in ${target.id} (${exit.toPos.x},${exit.toPos.y})`);
+    }
+    if (!tileAt(map, exit.pos)?.walkable) {
+      problems.push(`Map ${map.id}: exit tile itself is blocked (${exit.pos.x},${exit.pos.y})`);
+    }
+  }
+  for (const interactable of map.interactables) {
+    if (interactable.action.type === 'echo' && !ECHOES[interactable.action.echoId]) {
+      problems.push(`Map ${map.id}: interactable ${interactable.id} references unknown echo "${interactable.action.echoId}"`);
+    }
+    if (interactable.action.type === 'dialogue' && !DIALOGUES[interactable.action.dialogueId]) {
+      problems.push(`Map ${map.id}: interactable ${interactable.id} references unknown dialogue "${interactable.action.dialogueId}"`);
+    }
+    if (interactable.action.type === 'item' && !ITEMS[interactable.action.itemId]) {
+      problems.push(`Map ${map.id}: interactable ${interactable.id} references unknown item "${interactable.action.itemId}"`);
+    }
+  }
+  if (map.onEnterQuest && !QUESTS[map.onEnterQuest]) {
+    problems.push(`Map ${map.id}: onEnterQuest references unknown quest "${map.onEnterQuest}"`);
+  }
+}
+
+function validateDialogue(tree: DialogueTree, problems: string[]): void {
+  // Reachability: every node must be reachable from the start (or a variant).
+  const roots = [tree.startNodeId, ...(tree.startVariants ?? []).map((v) => v.nodeId)];
+  const reachable = new Set<string>();
+  const queue = [...roots];
+  while (queue.length > 0) {
+    const id = queue.shift() as string;
+    if (reachable.has(id)) continue;
+    const node = tree.nodes[id];
+    if (!node) {
+      problems.push(`Dialogue ${tree.id}: start points to missing node "${id}"`);
+      continue;
+    }
+    reachable.add(id);
+    if (node.next) queue.push(node.next);
+    for (const choice of node.choices ?? []) {
+      if (choice.next) queue.push(choice.next);
+    }
+  }
+  if (tree.nodes['repeat']) reachable.add('repeat');
+
+  for (const [nodeId, node] of Object.entries(tree.nodes)) {
+    if (!reachable.has(nodeId)) {
+      problems.push(`Dialogue ${tree.id}: node "${nodeId}" is unreachable`);
+    }
+    if (!CHARACTERS[node.speakerId]) {
+      problems.push(`Dialogue ${tree.id}: node ${nodeId} has unknown speaker "${node.speakerId}"`);
+    }
+    const targets = [node.next, ...(node.choices ?? []).map((c) => c.next)];
+    for (const target of targets) {
+      if (target && !tree.nodes[target]) {
+        problems.push(`Dialogue ${tree.id}: node ${nodeId} points to missing node "${target}"`);
+      }
+    }
+    for (const effect of [...(node.effects ?? []), ...(node.choices ?? []).flatMap((c) => c.effects ?? [])]) {
+      validateEffectRefs(tree.id, nodeId, effect, problems);
+    }
+  }
+}
+
+function validateEffectRefs(
+  treeId: string,
+  nodeId: string,
+  effect: DialogueEffect,
+  problems: string[],
+): void {
+  const missing = (what: string): void => {
+    problems.push(`Dialogue ${treeId}.${nodeId}: effect references unknown ${what}`);
+  };
+  switch (effect.type) {
+    case 'giveItem':
+    case 'takeItem':
+      if (!ITEMS[effect.itemId]) missing(`item "${effect.itemId}"`);
+      break;
+    case 'startQuest':
+    case 'advanceQuest':
+    case 'completeQuest':
+      if (!QUESTS[effect.questId]) missing(`quest "${effect.questId}"`);
+      break;
+    case 'discovery':
+      if (!LORE[effect.loreId]) missing(`lore "${effect.loreId}"`);
+      break;
+    case 'unlockAbility':
+      if (!ABILITIES[effect.abilityId]) missing(`ability "${effect.abilityId}"`);
+      break;
+    case 'gainShard':
+      if (!SHARDS[effect.shardId]) missing(`shard "${effect.shardId}"`);
+      break;
+    case 'addMemory':
+    case 'changeNpcAttitude':
+      if (effect.npcId !== 'protagonist' && !CHARACTERS[effect.npcId]) missing(`npc "${effect.npcId}"`);
+      break;
+    case 'removeActors':
+    case 'turnHostile':
+      if (!CHARACTERS[effect.defId] && !ENEMIES[effect.defId]) missing(`actor def "${effect.defId}"`);
+      break;
+    default:
+      break;
+  }
+}
+
+function validateKeyIdConsistency(problems: string[]): void {
+  const registries: [string, Record<string, { id: string }>][] = [
+    ['Item', ITEMS],
+    ['Enemy', ENEMIES],
+    ['Quest', QUESTS],
+    ['Ability', ABILITIES],
+  ];
+  for (const [label, registry] of registries) {
+    for (const [key, entry] of Object.entries(registry)) {
+      if (entry.id !== key) problems.push(`${label} key/id mismatch: ${key}`);
+    }
+  }
+}
@@ -0,0 +1,43 @@+import type { GridPos } from '../core/types';
+import { TILE_SIZE } from '../core/constants';
+
+// Camera follows the player in pixel space; the world layer is translated
+// with a CSS transform, clamped so we never show outside the map.
+export class Camera {
+  private offset: GridPos = { x: 0, y: 0 };
+  private mapPixelWidth = 0;
+  private mapPixelHeight = 0;
+
+  constructor(
+    private readonly viewport: HTMLElement,
+    private readonly world: HTMLElement,
+  ) {}
+
+  setMapSize(widthTiles: number, heightTiles: number): void {
+    this.mapPixelWidth = widthTiles * TILE_SIZE;
+    this.mapPixelHeight = heightTiles * TILE_SIZE;
+    this.world.style.width = `${this.mapPixelWidth}px`;
+    this.world.style.height = `${this.mapPixelHeight}px`;
+  }
+
+  centerOn(logical: GridPos, animate: boolean): void {
+    const viewW = this.viewport.clientWidth;
+    const viewH = this.viewport.clientHeight;
+    const targetX = logical.x * TILE_SIZE + TILE_SIZE / 2 - viewW / 2;
+    const targetY = logical.y * TILE_SIZE + TILE_SIZE / 2 - viewH / 2;
+    this.offset = {
+      x: clamp(targetX, 0, Math.max(0, this.mapPixelWidth - viewW)),
+      y: clamp(targetY, 0, Math.max(0, this.mapPixelHeight - viewH)),
+    };
+    this.world.style.transitionDuration = animate ? '200ms' : '0ms';
+    this.world.style.transform = `translate3d(${-this.offset.x}px, ${-this.offset.y}px, 0)`;
+  }
+
+  getOffset(): GridPos {
+    return this.offset;
+  }
+}
+
+function clamp(value: number, min: number, max: number): number {
+  return Math.min(max, Math.max(min, value));
+}
@@ -0,0 +1,41 @@+import type { GridPos } from '../core/types';
+import { posKey } from '../core/types';
+import { tileAt, type MapDefinition } from './MapDefinition';
+
+export function inBounds(map: MapDefinition, pos: GridPos): boolean {
+  return pos.x >= 0 && pos.y >= 0 && pos.x < map.width && pos.y < map.height;
+}
+
+export function isTerrainWalkable(map: MapDefinition, pos: GridPos): boolean {
+  const tile = tileAt(map, pos);
+  return tile !== null && tile.walkable;
+}
+
+/** Occupancy: tile key -> actor id. Only living actors block tiles. */
+export type Occupancy = Map<string, string>;
+
+export interface OccupantLike {
+  id: string;
+  pos: GridPos;
+  alive: boolean;
+}
+
+export function buildOccupancy(actors: readonly OccupantLike[]): Occupancy {
+  const occupancy: Occupancy = new Map();
+  for (const actor of actors) {
+    if (actor.alive) occupancy.set(posKey(actor.pos), actor.id);
+  }
+  return occupancy;
+}
+
+export function isOccupied(occupancy: Occupancy, pos: GridPos): boolean {
+  return occupancy.has(posKey(pos));
+}
+
+export function occupantAt(occupancy: Occupancy, pos: GridPos): string | null {
+  return occupancy.get(posKey(pos)) ?? null;
+}
+
+export function isBlocked(map: MapDefinition, occupancy: Occupancy, pos: GridPos): boolean {
+  return !isTerrainWalkable(map, pos) || isOccupied(occupancy, pos);
+}
@@ -0,0 +1,172 @@+import type { GridPos } from '../core/types';
+import { GLYPH_IDS, GLYPHS } from '../data/glyphs';
+import type { DialogueEffect } from '../data/dialogues/types';
+
+const GLYPH_LOOKUP = new Map(GLYPHS.map((g) => [g.id, g.glyph]));
+
+function glyphOf(id: string): string | null {
+  return GLYPH_LOOKUP.get(id) ?? null;
+}
+
+export type TerrainId =
+  | 'grass'
+  | 'deadGrass'
+  | 'dirt'
+  | 'stone'
+  | 'deadTree'
+  | 'tree'
+  | 'grave'
+  | 'rock'
+  | 'water'
+  | 'hut'
+  | 'unstable';
+
+export type HazardKind = 'water' | 'unstable' | 'fire';
+
+export interface TileDef {
+  terrain: TerrainId;
+  walkable: boolean;
+  blocksSight: boolean;
+  glyph: string | null;
+  hazard: HazardKind | null;
+  label: string;
+}
+
+// Single-character legend keyed into rows below — readable rows, typed tiles.
+export const TILE_LEGEND: Record<string, TileDef> = {
+  '.': { terrain: 'grass', walkable: true, blocksSight: false, glyph: null, hazard: null, label: 'Pale grass' },
+  ',': { terrain: 'deadGrass', walkable: true, blocksSight: false, glyph: null, hazard: null, label: 'Dead grass' },
+  d: { terrain: 'dirt', walkable: true, blocksSight: false, glyph: null, hazard: null, label: 'Dirt path' },
+  s: { terrain: 'stone', walkable: true, blocksSight: false, glyph: null, hazard: null, label: 'Cold stone' },
+  T: {
+    terrain: 'deadTree',
+    walkable: false,
+    blocksSight: true,
+    glyph: glyphOf(GLYPH_IDS.deadTree),
+    hazard: null,
+    label: 'Dead tree',
+  },
+  t: {
+    terrain: 'tree',
+    walkable: false,
+    blocksSight: true,
+    glyph: glyphOf(GLYPH_IDS.tree),
+    hazard: null,
+    label: 'Glasswood tree',
+  },
+  G: {
+    terrain: 'grave',
+    walkable: false,
+    blocksSight: false,
+    glyph: glyphOf(GLYPH_IDS.grave),
+    hazard: null,
+    label: 'A shallow grave',
+  },
+  r: {
+    terrain: 'rock',
+    walkable: false,
+    blocksSight: true,
+    glyph: glyphOf(GLYPH_IDS.rock),
+    hazard: null,
+    label: 'Rock',
+  },
+  '~': {
+    terrain: 'water',
+    walkable: false,
+    blocksSight: false,
+    glyph: glyphOf(GLYPH_IDS.water),
+    hazard: 'water',
+    label: 'Black water',
+  },
+  h: {
+    terrain: 'hut',
+    walkable: false,
+    blocksSight: true,
+    glyph: glyphOf(GLYPH_IDS.propHut),
+    hazard: null,
+    label: 'A hut',
+  },
+  u: {
+    terrain: 'unstable',
+    walkable: true,
+    blocksSight: false,
+    glyph: glyphOf(GLYPH_IDS.unstable),
+    hazard: 'unstable',
+    label: 'Unstable space',
+  },
+};
+
+export type InteractableAction =
+  | { type: 'dialogue'; dialogueId: string }
+  | { type: 'inspect'; text: string; effects?: DialogueEffect[] }
+  | { type: 'item'; itemId: string; onceFlag: string; afterText: string }
+  | { type: 'echo'; echoId: string };
+
+export interface InteractableDef {
+  id: string;
+  pos: GridPos;
+  glyph: string | null;
+  name: string;
+  action: InteractableAction;
+}
+
+export interface ActorSpawn {
+  defId: string;
+  pos: GridPos;
+  /** Hollow soldiers patrol these waypoints. */
+  patrol?: GridPos[];
+  /** Spawn only once this world flag is true (e.g. the Crown arrives). */
+  requiresFlag?: string;
+  /** Remove/never spawn once this world flag is true (the world forgets). */
+  hiddenFlag?: string;
+}
+
+export interface ExitDef {
+  pos: GridPos;
+  toMapId: string;
+  toPos: GridPos;
+  label: string;
+}
+
+export interface MapDefinition {
+  id: string;
+  name: string;
+  region: string;
+  width: number;
+  height: number;
+  rows: string[];
+  playerSpawn: GridPos;
+  npcs: ActorSpawn[];
+  enemies: ActorSpawn[];
+  interactables: InteractableDef[];
+  exits: ExitDef[];
+  objective: string;
+  /** Quest started automatically when the player first enters this map. */
+  onEnterQuest?: string;
+}
+
+/** Validates rows/legend and fails loudly — bad maps must not ship silently. */
+export function defineMap(def: MapDefinition): MapDefinition {
+  if (def.rows.length !== def.height) {
+    throw new Error(`Map ${def.id}: expected ${def.height} rows, got ${def.rows.length}`);
+  }
+  for (const row of def.rows) {
+    if (row.length !== def.width) {
+      throw new Error(`Map ${def.id}: row "${row}" has width ${row.length}, expected ${def.width}`);
+    }
+    for (const key of row) {
+      if (!TILE_LEGEND[key]) throw new Error(`Map ${def.id}: unknown tile key "${key}"`);
+    }
+  }
+  return def;
+}
+
+export function tileKeyAt(map: MapDefinition, pos: GridPos): string | null {
+  if (pos.x < 0 || pos.y < 0 || pos.x >= map.width || pos.y >= map.height) return null;
+  return map.rows[pos.y][pos.x];
+}
+
+export function tileAt(map: MapDefinition, pos: GridPos): TileDef | null {
+  const key = tileKeyAt(map, pos);
+  return key === null ? null : TILE_LEGEND[key];
+}
@@ -0,0 +1,20 @@+import type { MapDefinition } from './MapDefinition';
+
+export class MapManager {
+  private readonly maps = new Map<string, MapDefinition>();
+
+  register(def: MapDefinition): void {
+    if (this.maps.has(def.id)) throw new Error(`Duplicate map id: ${def.id}`);
+    this.maps.set(def.id, def);
+  }
+
+  get(id: string): MapDefinition {
+    const map = this.maps.get(id);
+    if (!map) throw new Error(`Unknown map id: ${id}`);
+    return map;
+  }
+
+  has(id: string): boolean {
+    return this.maps.has(id);
+  }
+}
@@ -0,0 +1,144 @@+import type { GridPos } from '../core/types';
+import { posKey } from '../core/types';
+import { isBlocked, type Occupancy } from './Collision';
+import { tileAt, type MapDefinition } from './MapDefinition';
+
+// Hand-written BFS over a 4-directional grid. Movement is strictly
+// orthogonal, so diagonal corner cutting cannot occur by construction.
+const ORTHOGONAL: GridPos[] = [
+  { x: 0, y: -1 },
+  { x: 0, y: 1 },
+  { x: -1, y: 0 },
+  { x: 1, y: 0 },
+];
+
+export interface PathOptions {
+  /** An actor that should not block its own tiles (usually the mover). */
+  ignoreOccupantId?: string;
+  /** Allow the final tile to be occupied (e.g. stepping next to a target). */
+  allowOccupiedGoal?: boolean;
+}
+
+function passable(
+  map: MapDefinition,
+  occupancy: Occupancy,
+  pos: GridPos,
+  options: PathOptions,
+  isGoal: boolean,
+): boolean {
+  if (!isBlocked(map, occupancy, pos)) return true;
+  if (isGoal && options.allowOccupiedGoal) {
+    const occupant = occupancy.get(posKey(pos));
+    if (occupant && occupant !== options.ignoreOccupantId) return true;
+  }
+  return false;
+}
+
+/** Shortest path from `from` to `to` (excluding `from`), or null if unreachable. */
+export function findPath(
+  map: MapDefinition,
+  occupancy: Occupancy,
+  from: GridPos,
+  to: GridPos,
+  options: PathOptions = {},
+): GridPos[] | null {
+  if (from.x === to.x && from.y === to.y) return [];
+  if (!passable(map, occupancy, to, options, true)) return null;
+
+  const cameFrom = new Map<string, string>();
+  const visited = new Set<string>([posKey(from)]);
+  const queue: GridPos[] = [from];
+
+  while (queue.length > 0) {
+    const current = queue.shift() as GridPos;
+    for (const step of ORTHOGONAL) {
+      const next = { x: current.x + step.x, y: current.y + step.y };
+      const key = posKey(next);
+      if (visited.has(key)) continue;
+      const isGoal = next.x === to.x && next.y === to.y;
+      if (!passable(map, occupancy, next, options, isGoal)) continue;
+      visited.add(key);
+      cameFrom.set(key, posKey(current));
+      if (isGoal) return reconstruct(cameFrom, from, to);
+      queue.push(next);
+    }
+  }
+  return null;
+}
+
+function reconstruct(cameFrom: Map<string, string>, from: GridPos, to: GridPos): GridPos[] {
+  const path: GridPos[] = [to];
+  let key = posKey(to);
+  const startKey = posKey(from);
+  while (key !== startKey) {
+    const prev = cameFrom.get(key);
+    if (prev === undefined || prev === startKey) break;
+    const [x, y] = prev.split(',').map(Number);
+    path.unshift({ x, y });
+    key = prev;
+  }
+  return path;
+}
+
+export interface ReachableTile {
+  pos: GridPos;
+  /** Steps needed to stand here. */
+  cost: number;
+}
+
+/** All tiles reachable within `maxCost` steps — the tactical highlight set. */
+export function reachableSet(
+  map: MapDefinition,
+  occupancy: Occupancy,
+  from: GridPos,
+  maxCost: number,
+): Map<string, ReachableTile> {
+  const result = new Map<string, ReachableTile>();
+  const visited = new Map<string, number>([[posKey(from), 0]]);
+  let frontier: GridPos[] = [from];
+
+  for (let cost = 1; cost <= maxCost; cost++) {
+    const nextFrontier: GridPos[] = [];
+    for (const current of frontier) {
+      for (const step of ORTHOGONAL) {
+        const next = { x: current.x + step.x, y: current.y + step.y };
+        const key = posKey(next);
+        if (visited.has(key)) continue;
+        if (isBlocked(map, occupancy, next)) continue;
+        visited.set(key, cost);
+        result.set(key, { pos: next, cost });
+        nextFrontier.push(next);
+      }
+    }
+    frontier = nextFrontier;
+  }
+  return result;
+}
+
+/** Bresenham line-of-sight; trees, rocks and walls block sight. */
+export function hasLineOfSight(map: MapDefinition, from: GridPos, to: GridPos): boolean {
+  let x0 = from.x;
+  let y0 = from.y;
+  const { x: x1, y: y1 } = to;
+  const dx = Math.abs(x1 - x0);
+  const dy = Math.abs(y1 - y0);
+  const sx = x0 < x1 ? 1 : -1;
+  const sy = y0 < y1 ? 1 : -1;
+  let err = dx - dy;
+
+  while (!(x0 === x1 && y0 === y1)) {
+    const e2 = 2 * err;
+    if (e2 > -dy) {
+      err -= dy;
+      x0 += sx;
+    }
+    if (e2 < dx) {
+      err += dx;
+      y0 += sy;
+    }
+    if (x0 === x1 && y0 === y1) break;
+    const tile = tileAt(map, { x: x0, y: y0 });
+    if (tile === null || tile.blocksSight) return false;
+  }
+  return true;
+}
@@ -0,0 +1,119 @@+import { describe, it, expect } from 'vitest';
+import { Rng } from '../src/core/Rng';
+import { computeDamage, performAttack, performCalm, flankingBonus } from '../src/systems/CombatSystem';
+import { applyCondition } from '../src/systems/ConditionSystem';
+import { createEnemyState, createPlayerState } from '../src/entities/EntityFactory';
+
+describe('computeDamage', () => {
+  it('is deterministic for a given seed', () => {
+    const a = computeDamage(10, 3, 1, new Rng(42));
+    const b = computeDamage(10, 3, 1, new Rng(42));
+    expect(a).toBe(b);
+  });
+
+  it('never deals less than 1', () => {
+    expect(computeDamage(1, 100, 1, new Rng(1))).toBe(1);
+  });
+
+  it('scales with the damage multiplier', () => {
+    const normal = computeDamage(10, 2, 1, new Rng(7));
+    const heavy = computeDamage(10, 2, 2, new Rng(7));
+    expect(heavy).toBeGreaterThan(normal);
+  });
+});
+
+describe('performAttack', () => {
+  it('applies damage and kills at 0 hp', () => {
+    const player = createPlayerState('neutral', { x: 0, y: 0 });
+    const wolf = createEnemyState('ashWolf', { x: 1, y: 0 });
+    wolf.hp = 2;
+    const outcome = performAttack(player, wolf, new Rng(5), [player, wolf], {});
+    expect(outcome.killed).toBe(true);
+    expect(wolf.alive).toBe(false);
+    expect(wolf.hp).toBe(0);
+  });
+
+  it('guarded targets take reduced damage', () => {
+    const attacker = createEnemyState('ashWolf', { x: 0, y: 0 });
+    const plain = createPlayerState('neutral', { x: 1, y: 0 });
+    const guarded = createPlayerState('neutral', { x: 1, y: 0 });
+    applyCondition(guarded, 'guarded');
+    const onPlain = performAttack(attacker, plain, new Rng(9), [attacker, plain], {});
+    const attacker2 = createEnemyState('ashWolf', { x: 0, y: 0 });
+    const onGuarded = performAttack(attacker2, guarded, new Rng(9), [attacker2, guarded], {});
+    expect(onGuarded.damage).toBeLessThan(onPlain.damage);
+  });
+
+  it('unstable targets take increased damage', () => {
+    const wolfA = createEnemyState('ashWolf', { x: 0, y: 0 });
+    const wolfB = createEnemyState('ashWolf', { x: 0, y: 0 });
+    const player = createPlayerState('neutral', { x: 1, y: 0 });
+    applyCondition(wolfB, 'unstable');
+    const normal = performAttack(player, wolfA, new Rng(3), [player, wolfA], {});
+    const vsUnstable = performAttack(player, wolfB, new Rng(3), [player, wolfB], {});
+    expect(vsUnstable.damage).toBeGreaterThan(normal.damage);
+  });
+
+  it('breaks frozen when the target is struck', () => {
+    const player = createPlayerState('neutral', { x: 0, y: 0 });
+    const wolf = createEnemyState('ashWolf', { x: 1, y: 0 });
+    applyCondition(wolf, 'frozen');
+    const outcome = performAttack(player, wolf, new Rng(2), [player, wolf], {});
+    expect(outcome.brokenConditions).toContain('frozen');
+    expect(wolf.conditions.some((c) => c.id === 'frozen')).toBe(false);
+  });
+
+  it('applies on-hit conditions to living targets', () => {
+    const player = createPlayerState('neutral', { x: 0, y: 0 });
+    const wolf = createEnemyState('ashWolf', { x: 1, y: 0 });
+    const outcome = performAttack(player, wolf, new Rng(2), [player, wolf], {
+      appliesCondition: { id: 'staggered', duration: 1 },
+    });
+    expect(outcome.conditionsApplied).toContain('staggered');
+    expect(wolf.conditions.some((c) => c.id === 'staggered')).toBe(true);
+  });
+});
+
+describe('flankingBonus', () => {
+  it('grants pack wolves +2 attack when an ally stands adjacent to the target', () => {
+    const wolfA = createEnemyState('ashWolf', { x: 0, y: 0 });
+    const wolfB = createEnemyState('ashWolf', { x: 2, y: 0 });
+    const player = createPlayerState('neutral', { x: 1, y: 0 });
+    expect(flankingBonus(wolfA, player, [wolfA, wolfB, player])).toBe(2);
+    expect(flankingBonus(wolfB, player, [wolfA, wolfB, player])).toBe(2);
+    wolfB.pos = { x: 2, y: 2 };
+    expect(flankingBonus(wolfA, player, [wolfA, wolfB, player])).toBe(0);
+  });
+
+  it('does not apply to non-pack enemies', () => {
+    const machine = createEnemyState('rustMachine', { x: 0, y: 0 });
+    const other = createEnemyState('rustMachine', { x: 2, y: 0 });
+    const player = createPlayerState('neutral', { x: 1, y: 0 });
+    expect(flankingBonus(machine, player, [machine, other, player])).toBe(0);
+  });
+});
+
+describe('performCalm', () => {
+  it('turns a calmable enemy neutral on success', () => {
+    const player = createPlayerState('neutral', { x: 0, y: 0 });
+    // seed search: find one where chance(0.35+) succeeds
+    let calmed = false;
+    for (let seed = 0; seed < 50 && !calmed; seed++) {
+      const freshWolf = createEnemyState('ashWolf', { x: 1, y: 0 });
+      calmed = performCalm(player, freshWolf, 3, new Rng(seed));
+      if (calmed) {
+        expect(freshWolf.faction).toBe('neutral');
+        expect(freshWolf.aggroed).toBe(false);
+      }
+    }
+    expect(calmed).toBe(true);
+  });
+
+  it('never calms non-calmable enemies or distant targets', () => {
+    const player = createPlayerState('neutral', { x: 0, y: 0 });
+    const machine = createEnemyState('rustMachine', { x: 1, y: 0 });
+    expect(performCalm(player, machine, 10, new Rng(1))).toBe(false);
+    const farWolf = createEnemyState('ashWolf', { x: 5, y: 5 });
+    expect(performCalm(player, farWolf, 10, new Rng(1))).toBe(false);
+  });
+});
@@ -0,0 +1,118 @@+import { describe, it, expect } from 'vitest';
+import {
+  applyCondition,
+  removeCondition,
+  tickConditions,
+  speedOf,
+  attackOf,
+  defenseOf,
+  abilitiesBlocked,
+} from '../src/systems/ConditionSystem';
+import { createEnemyState, createPlayerState } from '../src/entities/EntityFactory';
+import { CONDITIONS } from '../src/data/conditions';
+
+describe('conditions', () => {
+  it('every binding condition has effect, glyph, tooltip and duration', () => {
+    const required = [
+      'bleeding',
+      'burning',
+      'frozen',
+      'staggered',
+      'afraid',
+      'guarded',
+      'silenced',
+      'forgotten',
+      'remembered',
+      'unstable',
+    ] as const;
+    for (const id of required) {
+      const def = CONDITIONS[id];
+      expect(def.glyph.length).toBeGreaterThan(0);
+      expect(def.tooltip.length).toBeGreaterThan(0);
+      expect(def.defaultDuration).toBeGreaterThan(0);
+    }
+  });
+
+  it('apply adds a condition and re-applying refreshes duration', () => {
+    const wolf = createEnemyState('ashWolf', { x: 0, y: 0 });
+    expect(applyCondition(wolf, 'bleeding', 2)).toBe(true);
+    expect(applyCondition(wolf, 'bleeding', 5)).toBe(false); // already present
+    expect(wolf.conditions).toHaveLength(1);
+    expect(wolf.conditions[0].remaining).toBe(5);
+  });
+
+  it('bleeding ticks 2 damage per turn and expires after its duration', () => {
+    const wolf = createEnemyState('ashWolf', { x: 0, y: 0 });
+    const startHp = wolf.hp;
+    applyCondition(wolf, 'bleeding', 2);
+    const first = tickConditions(wolf);
+    expect(first.damage).toBe(2);
+    expect(wolf.hp).toBe(startHp - 2);
+    const second = tickConditions(wolf);
+    expect(second.damage).toBe(2);
+    expect(second.expired).toContain('bleeding');
+    expect(wolf.conditions).toHaveLength(0);
+    const third = tickConditions(wolf);
+    expect(third.damage).toBe(0);
+  });
+
+  it('tick damage can kill', () => {
+    const wolf = createEnemyState('ashWolf', { x: 0, y: 0 });
+    wolf.hp = 3;
+    applyCondition(wolf, 'burning', 3); // 3/turn
+    const tick = tickConditions(wolf);
+    expect(tick.damage).toBe(3);
+    expect(wolf.alive).toBe(false);
+  });
+
+  it('frozen prevents the action and sets speed to 0', () => {
+    const wolf = createEnemyState('ashWolf', { x: 0, y: 0 });
+    applyCondition(wolf, 'frozen');
+    expect(speedOf(wolf)).toBe(0);
+    const tick = tickConditions(wolf);
+    expect(tick.preventsAction).toBe(true);
+  });
+
+  it('staggered halves speed', () => {
+    const wolf = createEnemyState('ashWolf', { x: 0, y: 0 });
+    applyCondition(wolf, 'staggered');
+    expect(speedOf(wolf)).toBe(Math.round(wolf.stats.speed / 2));
+  });
+
+  it('guarded raises defense, remembered raises attack and restores focus', () => {
+    const player = createPlayerState('neutral', { x: 0, y: 0 });
+    applyCondition(player, 'guarded');
+    expect(defenseOf(player)).toBeCloseTo(player.stats.defense * 1.5);
+    removeCondition(player, 'guarded');
+    player.focus = 2;
+    applyCondition(player, 'remembered');
+    expect(attackOf(player)).toBeCloseTo(player.stats.attack * 1.25);
+    const tick = tickConditions(player);
+    expect(tick.focusDelta).toBe(1);
+    expect(player.focus).toBe(3);
+  });
+
+  it('silenced blocks abilities', () => {
+    const player = createPlayerState('neutral', { x: 0, y: 0 });
+    applyCondition(player, 'silenced');
+    expect(abilitiesBlocked(player)).toBe(true);
+  });
+
+  it('forgotten drains focus each turn', () => {
+    const player = createPlayerState('neutral', { x: 0, y: 0 });
+    applyCondition(player, 'forgotten');
+    const before = player.focus;
+    const tick = tickConditions(player);
+    expect(tick.focusDelta).toBe(-1);
+    expect(player.focus).toBe(before - 1);
+  });
+
+  it('removeCondition strips only the named condition', () => {
+    const player = createPlayerState('neutral', { x: 0, y: 0 });
+    applyCondition(player, 'burning');
+    applyCondition(player, 'bleeding');
+    expect(removeCondition(player, 'burning')).toBe(true);
+    expect(player.conditions.map((c) => c.id)).toEqual(['bleeding']);
+    expect(removeCondition(player, 'frozen')).toBe(false);
+  });
+});
@@ -0,0 +1,43 @@+import { describe, it, expect } from 'vitest';
+import { ALL_MAPS } from '../src/data/maps';
+import { GRAVE_CLEARING } from '../src/data/maps/graveClearing';
+import { OLD_ROAD } from '../src/data/maps/oldRoad';
+import { validateContent } from '../src/utils/validation';
+import { tileAt } from '../src/world/MapDefinition';
+
+// Importing a map module runs defineMap, which throws on malformed rows —
+// so this suite failing at import time is itself the signal.
+describe('content validation', () => {
+  it('all maps pass content validation with zero problems', () => {
+    expect(validateContent(ALL_MAPS)).toEqual([]);
+  });
+
+  it('every exit points at a registered map and a walkable arrival tile', () => {
+    for (const map of ALL_MAPS) {
+      for (const exit of map.exits) {
+        const target = ALL_MAPS.find((m) => m.id === exit.toMapId);
+        expect(target, `exit from ${map.id}`).toBeDefined();
+        expect(tileAt(target!, exit.toPos)?.walkable).toBe(true);
+      }
+    }
+  });
+
+  it('the grave clearing and the old road link to each other', () => {
+    expect(GRAVE_CLEARING.exits.some((e) => e.toMapId === OLD_ROAD.id)).toBe(true);
+    expect(OLD_ROAD.exits.some((e) => e.toMapId === GRAVE_CLEARING.id)).toBe(true);
+  });
+
+  it('spawns the player, crow and wolves on walkable tiles', () => {
+    expect(tileAt(GRAVE_CLEARING, GRAVE_CLEARING.playerSpawn)?.walkable).toBe(true);
+    for (const spawn of [...GRAVE_CLEARING.npcs, ...GRAVE_CLEARING.enemies]) {
+      expect(tileAt(GRAVE_CLEARING, spawn.pos)?.walkable).toBe(true);
+    }
+  });
+
+  it('has the grave and dead tree where the interactables point', () => {
+    const grave = GRAVE_CLEARING.interactables.find((i) => i.id === 'yourGrave');
+    const tree = GRAVE_CLEARING.interactables.find((i) => i.id === 'deadTree');
+    expect(tileAt(GRAVE_CLEARING, grave!.pos)?.terrain).toBe('grave');
+    expect(tileAt(GRAVE_CLEARING, tree!.pos)?.terrain).toBe('deadTree');
+  });
+});
@@ -0,0 +1,107 @@+import { describe, it, expect } from 'vitest';
+import { DialogueSystem, checkCondition, applyEffect } from '../src/systems/DialogueSystem';
+import { CROW_PROLOGUE } from '../src/data/dialogues/crowPrologue';
+import { createNewGameState } from '../src/entities/EntityFactory';
+import { FLAGS, MEMORY_EVENTS } from '../src/data/flags';
+import { ITEM_IDS } from '../src/data/items';
+import { questStatus } from '../src/systems/QuestSystem';
+import { remembers } from '../src/systems/RelationshipSystem';
+
+function freshState() {
+  return createNewGameState({
+    variantId: 'neutral',
+    mapId: 'graveClearing',
+    region: 'The Nameless Edge',
+    objective: 'Wake.',
+    playerSpawn: { x: 6, y: 3 },
+    seed: 1,
+  });
+}
+
+describe('dialogue conditions', () => {
+  it('evaluates flag, memory, tendency and quest conditions', () => {
+    const state = freshState();
+    expect(checkCondition({ type: 'flag', flag: FLAGS.metCrow }, state)).toBe(false);
+    state.flags[FLAGS.metCrow] = true;
+    expect(checkCondition({ type: 'flag', flag: FLAGS.metCrow }, state)).toBe(true);
+    expect(checkCondition({ type: 'flag', flag: FLAGS.metCrow, value: false }, state)).toBe(false);
+
+    expect(checkCondition({ type: 'memory', npcId: 'crow', event: 'metCrow' }, state)).toBe(false);
+    applyEffect({ type: 'addMemory', npcId: 'crow', event: 'metCrow' }, state);
+    expect(checkCondition({ type: 'memory', npcId: 'crow', event: 'metCrow' }, state)).toBe(true);
+
+    expect(checkCondition({ type: 'tendency', axis: 'resolve', min: 2 }, state)).toBe(false);
+    state.tendencies.resolve = 2;
+    expect(checkCondition({ type: 'tendency', axis: 'resolve', min: 2 }, state)).toBe(true);
+
+    expect(
+      checkCondition({ type: 'quest', questId: 'prologueAwakening', stage: 'active' }, state),
+    ).toBe(true);
+  });
+
+  it('hides choices whose conditions fail', () => {
+    const system = new DialogueSystem();
+    const state = freshState();
+    const node = {
+      id: 'n',
+      speakerId: 'crow',
+      text: 'x',
+      choices: [
+        { id: 'open', text: 'Open' },
+        { id: 'locked', text: 'Locked', conditions: [{ type: 'flag', flag: 'neverSet' } as const] },
+      ],
+    };
+    const choices = system.availableChoices(node, state);
+    expect(choices.map((c) => c.id)).toEqual(['open']);
+  });
+});
+
+describe('dialogue effects', () => {
+  it('gives items, sets flags, stores memories, advances quests', () => {
+    const state = freshState();
+    applyEffect({ type: 'giveItem', itemId: ITEM_IDS.crowFeather }, state);
+    expect(state.inventory.items[ITEM_IDS.crowFeather]).toBe(1);
+    applyEffect({ type: 'setFlag', flag: FLAGS.metCrow, value: true }, state);
+    expect(state.flags[FLAGS.metCrow]).toBe(true);
+    applyEffect({ type: 'addMemory', npcId: 'crow', event: MEMORY_EVENTS.metCrow }, state);
+    expect(remembers(state, 'crow', MEMORY_EVENTS.metCrow)).toBe(true);
+    const before = questStatus(state, 'prologueAwakening');
+    expect(before).toBe('active');
+    applyEffect({ type: 'completeQuest', questId: 'prologueAwakening' }, state);
+    expect(questStatus(state, 'prologueAwakening')).toBe('completed');
+  });
+});
+
+describe('DialogueSystem flow', () => {
+  it('walks the crow prologue and applies its consequences', () => {
+    const system = new DialogueSystem();
+    const state = freshState();
+    const start = system.start(CROW_PROLOGUE, state);
+    expect(start).not.toBeNull();
+    expect(state.flags[FLAGS.metCrow]).toBe(true); // node effect on enter
+    expect(remembers(state, 'crow', MEMORY_EVENTS.metCrow)).toBe(true);
+
+    const silence = system.availableChoices(start!, state).find((c) => c.silent);
+    expect(silence).toBeDefined();
+    const next = system.choose(silence!.id, state);
+    expect(next?.id).toBe('silence');
+    expect(remembers(state, 'crow', MEMORY_EVENTS.choseSilence)).toBe(true);
+
+    const gift = system.advance(state);
+    expect(gift?.id).toBe('gift');
+    expect(state.inventory.items[ITEM_IDS.crowFeather]).toBe(1);
+
+    const after = system.choose('thanks', state);
+    expect(after?.id).toBe('wolves');
+    expect(system.choose('end', state)).toBeNull(); // conversation over
+  });
+
+  it('once-nodes are skipped on repeat conversations', () => {
+    const system = new DialogueSystem();
+    const state = freshState();
+    system.start(CROW_PROLOGUE, state);
+    system.end();
+    const again = system.start(CROW_PROLOGUE, state);
+    expect(again?.id).toBe('repeat');
+  });
+});
@@ -0,0 +1,101 @@+import { describe, it, expect } from 'vitest';
+import { decideEnemyAction } from '../src/systems/EnemyAI';
+import { Rng } from '../src/core/Rng';
+import { createEnemyState, createPlayerState } from '../src/entities/EntityFactory';
+import { defineMap } from '../src/world/MapDefinition';
+
+const MAP = defineMap({
+  id: 'arena',
+  name: 'Arena',
+  region: 'Test',
+  width: 9,
+  height: 9,
+  rows: [
+    '.........',
+    '.........',
+    '.........',
+    '.........',
+    '.........',
+    '.........',
+    '.........',
+    '.........',
+    '.........',
+  ],
+  playerSpawn: { x: 4, y: 4 },
+  npcs: [],
+  enemies: [],
+  interactables: [],
+  exits: [],
+  objective: '',
+});
+
+function ctx(player: ReturnType<typeof createPlayerState>, actors: ReturnType<typeof createEnemyState>[], seed = 1) {
+  return { map: MAP, actors: [player, ...actors], player, flags: {}, rng: new Rng(seed) };
+}
+
+describe('enemy AI', () => {
+  it('wolf attacks when adjacent to the player', () => {
+    const player = createPlayerState('neutral', { x: 4, y: 4 });
+    const wolf = createEnemyState('ashWolf', { x: 4, y: 3 });
+    const intent = decideEnemyAction(wolf, ctx(player, [wolf]));
+    expect(intent).toEqual({ type: 'attack', targetId: 'player' });
+  });
+
+  it('wolf moves toward the player when far, and flees when badly hurt', () => {
+    const player = createPlayerState('neutral', { x: 4, y: 4 });
+    const wolf = createEnemyState('ashWolf', { x: 0, y: 0 });
+    const approach = decideEnemyAction(wolf, ctx(player, [wolf]));
+    expect(approach.type).toBe('move');
+
+    const hurtWolf = createEnemyState('ashWolf', { x: 3, y: 3 });
+    hurtWolf.hp = 2; // <= 30% of 14
+    const flee = decideEnemyAction(hurtWolf, ctx(player, [hurtWolf]));
+    if (flee.type === 'move') {
+      const end = flee.path.at(-1)!;
+      const startDistance = Math.abs(3 - 4) + Math.abs(3 - 4);
+      const endDistance = Math.abs(end.x - 4) + Math.abs(end.y - 4);
+      expect(endDistance).toBeGreaterThan(startDistance);
+    }
+  });
+
+  it('hollow soldier patrols until the player is close', () => {
+    const player = createPlayerState('neutral', { x: 8, y: 8 });
+    const soldier = createEnemyState('hollowSoldier', { x: 1, y: 1 }, [
+      { x: 1, y: 1 },
+      { x: 3, y: 1 },
+    ]);
+    // standing on its waypoint: advances patrol index and waits
+    const intent = decideEnemyAction(soldier, ctx(player, [soldier]));
+    expect(intent.type).toBe('wait');
+    expect(soldier.patrolIndex).toBe(1);
+  });
+
+  it('archer fires from range with line of sight, closes in when blind', () => {
+    const player = createPlayerState('neutral', { x: 4, y: 4 });
+    const archer = createEnemyState('crownArcher', { x: 4, y: 1 });
+    const shot = decideEnemyAction(archer, ctx(player, [archer]));
+    expect(shot).toEqual({ type: 'rangedAttack', targetId: 'player' });
+
+    const adjacentArcher = createEnemyState('crownArcher', { x: 4, y: 3 });
+    const tooClose = decideEnemyAction(adjacentArcher, ctx(player, [adjacentArcher]));
+    expect(tooClose.type).toBe('move'); // repositions away
+  });
+
+  it('rust machine follows the same predictable routine every time', () => {
+    const player = createPlayerState('neutral', { x: 4, y: 4 });
+    const a = createEnemyState('rustMachine', { x: 2, y: 2 });
+    const b = createEnemyState('rustMachine', { x: 2, y: 2 });
+    const seqA = [0, 1, 2, 3, 4, 5].map(() => decideEnemyAction(a, ctx(player, [a])).type);
+    const seqB = [0, 1, 2, 3, 4, 5].map(() => decideEnemyAction(b, ctx(player, [b])).type);
+    expect(seqA).toEqual(seqB);
+  });
+
+  it('AI decisions are deterministic for a fixed seed', () => {
+    const player = createPlayerState('neutral', { x: 4, y: 4 });
+    const wolfA = createEnemyState('ashWolf', { x: 1, y: 1 });
+    const wolfB = createEnemyState('ashWolf', { x: 1, y: 1 });
+    expect(decideEnemyAction(wolfA, ctx(player, [wolfA], 99))).toEqual(
+      decideEnemyAction(wolfB, ctx(player, [wolfB], 99)),
+    );
+  });
+});
@@ -0,0 +1,101 @@+import { describe, it, expect } from 'vitest';
+import {
+  addItem,
+  equipArmor,
+  equipWeapon,
+  unequipArmor,
+  unequipWeapon,
+  useConsumable,
+  hasItem,
+} from '../src/systems/InventorySystem';
+import { attackOf, defenseOf } from '../src/systems/ConditionSystem';
+import { createNewGameState } from '../src/entities/EntityFactory';
+import { ITEM_IDS } from '../src/data/items';
+
+function freshState() {
+  return createNewGameState({
+    variantId: 'neutral',
+    mapId: 'graveClearing',
+    region: 'The Nameless Edge',
+    objective: 'Wake.',
+    playerSpawn: { x: 6, y: 3 },
+    seed: 1,
+  });
+}
+
+describe('equipment', () => {
+  it('equipping a weapon raises attack by its bonus', () => {
+    const state = freshState();
+    const base = attackOf(state.player);
+    addItem(state, ITEM_IDS.rustedSword);
+    const result = equipWeapon(state, ITEM_IDS.rustedSword);
+    expect(result.ok).toBe(true);
+    expect(attackOf(state.player)).toBe(base + 2);
+    expect(state.player.appearance.equipmentGlyph).toBe('🗡️');
+  });
+
+  it('unequipping removes the bonus and the badge', () => {
+    const state = freshState();
+    addItem(state, ITEM_IDS.rustedSword);
+    equipWeapon(state, ITEM_IDS.rustedSword);
+    const result = unequipWeapon(state);
+    expect(result.ok).toBe(true);
+    expect(attackOf(state.player)).toBe(state.player.stats.attack);
+    expect(state.player.appearance.equipmentGlyph).toBeUndefined();
+  });
+
+  it('equipping armor raises defense by its bonus', () => {
+    const state = freshState();
+    const base = defenseOf(state.player);
+    addItem(state, ITEM_IDS.weatheredCloak);
+    expect(equipArmor(state, ITEM_IDS.weatheredCloak).ok).toBe(true);
+    expect(defenseOf(state.player)).toBe(base + 1);
+    expect(unequipArmor(state).ok).toBe(true);
+    expect(defenseOf(state.player)).toBe(base);
+  });
+
+  it('cannot equip what you do not have, or equip armor as a weapon', () => {
+    const state = freshState();
+    expect(equipWeapon(state, ITEM_IDS.rustedSword).ok).toBe(false);
+    addItem(state, ITEM_IDS.weatheredCloak);
+    expect(equipWeapon(state, ITEM_IDS.weatheredCloak).ok).toBe(false);
+    expect(equipArmor(state, ITEM_IDS.weatheredCloak).ok).toBe(true);
+  });
+});
+
+describe('consumables', () => {
+  it('using a bandage heals and consumes it', () => {
+    const state = freshState();
+    state.player.hp = 10;
+    const before = state.inventory.items[ITEM_IDS.bandageRoll];
+    const result = useConsumable(state, ITEM_IDS.bandageRoll);
+    expect(result.ok).toBe(true);
+    expect(state.player.hp).toBe(16);
+    expect(state.inventory.items[ITEM_IDS.bandageRoll]).toBe(before - 1);
+  });
+
+  it('refuses to heal at full health without consuming', () => {
+    const state = freshState();
+    state.player.hp = state.player.maxHp;
+    const before = state.inventory.items[ITEM_IDS.bandageRoll];
+    const result = useConsumable(state, ITEM_IDS.bandageRoll);
+    expect(result.ok).toBe(false);
+    expect(state.inventory.items[ITEM_IDS.bandageRoll]).toBe(before);
+  });
+
+  it('ember salve cures burning even at full health', () => {
+    const state = freshState();
+    state.player.hp = state.player.maxHp;
+    state.player.conditions.push({ id: 'burning', remaining: 2 });
+    const result = useConsumable(state, ITEM_IDS.emberSalve);
+    expect(result.ok).toBe(true);
+    expect(state.player.conditions.some((c) => c.id === 'burning')).toBe(false);
+    expect(hasItem(state, ITEM_IDS.emberSalve)).toBe(false);
+  });
+
+  it('relics are not usable', () => {
+    const state = freshState();
+    addItem(state, ITEM_IDS.crowFeather);
+    expect(useConsumable(state, ITEM_IDS.crowFeather).ok).toBe(false);
+  });
+});
@@ -0,0 +1,80 @@+import { describe, it, expect } from 'vitest';
+import { storeCurrentMap, takeMapState } from '../src/systems/MapTransitions';
+import { createNewGameState, createEnemyState } from '../src/entities/EntityFactory';
+
+function stateWithActors() {
+  const state = createNewGameState({
+    variantId: 'neutral',
+    mapId: 'graveClearing',
+    region: 'The Nameless Edge',
+    objective: 'Wake.',
+    playerSpawn: { x: 6, y: 3 },
+    seed: 1,
+  });
+  state.actors.push(createEnemyState('ashWolf', { x: 11, y: 6 }));
+  state.actors.push(createEnemyState('ashWolf', { x: 12, y: 7 }));
+  return state;
+}
+
+describe('map transitions', () => {
+  it('stores the current map and restores it exactly on return', () => {
+    const state = stateWithActors();
+    const snapshot = structuredClone(state.actors);
+    storeCurrentMap(state);
+
+    // player leaves: map is emptied for the next one
+    state.actors = [];
+    state.consumedInteractables = [];
+
+    const restored = takeMapState(state, 'graveClearing');
+    expect(restored).not.toBeNull();
+    expect(restored!.actors).toEqual(snapshot);
+    expect(state.mapStates['graveClearing']).toBeUndefined(); // taken, not copied
+  });
+
+  it('dead enemies stay dead across transitions', () => {
+    const state = stateWithActors();
+    const wolf = state.actors[0];
+    wolf.alive = false;
+    wolf.hp = 0;
+    storeCurrentMap(state);
+    state.actors = [];
+
+    const restored = takeMapState(state, 'graveClearing');
+    const deadWolf = restored!.actors.find((a) => a.id === wolf.id);
+    expect(deadWolf?.alive).toBe(false);
+    expect(deadWolf?.hp).toBe(0);
+  });
+
+  it('taken items stay taken across transitions', () => {
+    const state = stateWithActors();
+    state.consumedInteractables.push('yourGrave');
+    storeCurrentMap(state);
+    state.consumedInteractables = [];
+
+    const restored = takeMapState(state, 'graveClearing');
+    expect(restored!.consumedInteractables).toContain('yourGrave');
+  });
+
+  it('moved NPCs stay moved', () => {
+    const state = stateWithActors();
+    state.actors[0].pos = { x: 3, y: 8 };
+    storeCurrentMap(state);
+    state.actors = [];
+
+    const restored = takeMapState(state, 'graveClearing');
+    expect(restored!.actors[0].pos).toEqual({ x: 3, y: 8 });
+  });
+
+  it('returns null for maps never visited', () => {
+    const state = stateWithActors();
+    expect(takeMapState(state, 'oldRoad')).toBeNull();
+  });
+
+  it('mapStates survive a save round-trip', () => {
+    const state = stateWithActors();
+    storeCurrentMap(state);
+    const copy = structuredClone(state);
+    expect(copy.mapStates['graveClearing'].actors).toEqual(state.actors);
+  });
+});
@@ -0,0 +1,193 @@+import { describe, it, expect } from 'vitest';
+import { checkNewUnlocks, grantUnlocks } from '../src/systems/AbilitySystem';
+import { hearEcho } from '../src/systems/EchoSystem';
+import { validTargets } from '../src/systems/Targeting';
+import { performPush } from '../src/systems/CombatSystem';
+import { applyEffect, checkCondition } from '../src/systems/DialogueSystem';
+import { createNewGameState, createEnemyState } from '../src/entities/EntityFactory';
+import { ABILITY_IDS, tendencyUnlocks } from '../src/data/abilities';
+import { ECHO_IDS } from '../src/data/echoes';
+import { SHARD_IDS } from '../src/data/shards';
+import { defineMap } from '../src/world/MapDefinition';
+import { GRAVE_CLEARING } from '../src/data/maps/graveClearing';
+import type { AbilityDef } from '../src/data/abilities';
+
+function testAbility(overrides: Partial<AbilityDef> & { range: number }): AbilityDef {
+  return {
+    id: 'test',
+    name: 'Test',
+    glyph: '⚔️',
+    description: '',
+    costKind: 'attack',
+    damageMult: 1,
+    focusCost: 0,
+    effectGlyph: '⚔️',
+    ...overrides,
+  };
+}
+
+function freshState() {
+  return createNewGameState({
+    variantId: 'neutral',
+    mapId: 'graveClearing',
+    region: 'The Nameless Edge',
+    objective: 'Wake.',
+    playerSpawn: { x: 6, y: 3 },
+    seed: 1,
+  });
+}
+
+describe('ability unlocks via tendencies', () => {
+  it('grants the resolve ability at threshold 3, not before', () => {
+    const state = freshState();
+    state.tendencies.resolve = 2;
+    expect(checkNewUnlocks(state)).toEqual([]);
+    state.tendencies.resolve = 3;
+    expect(checkNewUnlocks(state)).toEqual([ABILITY_IDS.pushBack]);
+    const granted = grantUnlocks(state);
+    expect(granted).toEqual([ABILITY_IDS.pushBack]);
+    expect(state.player.abilityIds).toContain(ABILITY_IDS.pushBack);
+    expect(checkNewUnlocks(state)).toEqual([]); // not granted twice
+  });
+
+  it('grants every path independently — mixed approaches unlock everything', () => {
+    const state = freshState();
+    state.tendencies = { resolve: 6, insight: 6, empathy: 6 };
+    const granted = grantUnlocks(state);
+    expect(granted).toHaveLength(tendencyUnlocks().length);
+  });
+});
+
+describe('name echoes', () => {
+  it('first hearing applies effects and grants the ability', () => {
+    const state = freshState();
+    const insightBefore = state.tendencies.insight;
+    const result = hearEcho(state, ECHO_IDS.mirrorShard);
+    expect(result.alreadyHeard).toBe(false);
+    expect(result.grantedAbility).toBe(ABILITY_IDS.scrutinize);
+    expect(state.player.abilityIds).toContain(ABILITY_IDS.scrutinize);
+    expect(state.tendencies.insight).toBe(insightBefore + 1);
+    expect(state.discoveries).toContain('lore_mirrorsLie');
+    expect(state.echoesHeard).toContain(ECHO_IDS.mirrorShard);
+  });
+
+  it('second hearing replays lines but never re-applies effects', () => {
+    const state = freshState();
+    hearEcho(state, ECHO_IDS.mirrorShard);
+    const insightAfterFirst = state.tendencies.insight;
+    const second = hearEcho(state, ECHO_IDS.mirrorShard);
+    expect(second.alreadyHeard).toBe(true);
+    expect(second.grantedAbility).toBeNull();
+    expect(state.tendencies.insight).toBe(insightAfterFirst);
+    expect(state.discoveries.filter((d) => d === 'lore_mirrorsLie')).toHaveLength(1);
+  });
+});
+
+describe('targeting & line of sight', () => {
+  const arena = defineMap({
+    id: 'los',
+    name: 'LOS',
+    region: 'Test',
+    width: 7,
+    height: 3,
+    rows: ['.......', '..t....', '.......'],
+    playerSpawn: { x: 0, y: 0 },
+    npcs: [],
+    enemies: [],
+    interactables: [],
+    exits: [],
+    objective: '',
+  });
+
+  it('melee abilities only hit adjacent hostiles', () => {
+    const state = freshState();
+    const near = createEnemyState('ashWolf', { x: 7, y: 3 }); // adjacent to player at (6,3)
+    const far = createEnemyState('ashWolf', { x: 10, y: 3 });
+    state.actors.push(near, far);
+    const targets = validTargets(
+      GRAVE_CLEARING,
+      state.actors,
+      state.player,
+      testAbility({ range: 1 }),
+    );
+    expect(targets.map((t) => t.id)).toEqual([near.id]);
+  });
+
+  it('ranged abilities require line of sight', () => {
+    const player = createEnemyState('ashWolf', { x: 0, y: 1 });
+    player.faction = 'player';
+    const visible = createEnemyState('ashWolf', { x: 0, y: 2 });
+    const blocked = createEnemyState('ashWolf', { x: 4, y: 1 }); // tree at (2,1) sits between
+    const targets = validTargets(
+      arena,
+      [player, visible, blocked],
+      player,
+      testAbility({ range: 4 }),
+    );
+    const ids = targets.map((t) => t.id);
+    expect(ids).toContain(visible.id);
+    expect(ids).not.toContain(blocked.id);
+  });
+
+  it('dead and non-hostile actors are never valid targets', () => {
+    const state = freshState();
+    const dead = createEnemyState('ashWolf', { x: 7, y: 3 });
+    dead.alive = false;
+    const calmed = createEnemyState('ashWolf', { x: 6, y: 4 });
+    calmed.faction = 'neutral';
+    state.actors.push(dead, calmed);
+    expect(validTargets(GRAVE_CLEARING, state.actors, state.player, testAbility({ range: 1 }))).toEqual([]);
+  });
+});
+
+describe('push', () => {
+  it('moves the target one tile away when free', () => {
+    const player = createEnemyState('ashWolf', { x: 3, y: 3 });
+    const wolf = createEnemyState('ashWolf', { x: 4, y: 3 });
+    const result = performPush(player, wolf, () => true);
+    expect(result.moved).toBe(true);
+    expect(wolf.pos).toEqual({ x: 5, y: 3 });
+  });
+
+  it('staggers instead when the destination is blocked', () => {
+    const player = createEnemyState('ashWolf', { x: 3, y: 3 });
+    const wolf = createEnemyState('ashWolf', { x: 4, y: 3 });
+    const result = performPush(player, wolf, () => false);
+    expect(result.moved).toBe(false);
+    expect(wolf.pos).toEqual({ x: 4, y: 3 });
+    expect(wolf.conditions.some((c) => c.id === 'staggered')).toBe(true);
+  });
+});
+
+describe('journal & ending hooks via dialogue effects', () => {
+  it('discovery records a lore entry once', () => {
+    const state = freshState();
+    applyEffect({ type: 'discovery', loreId: 'lore_graves' }, state);
+    applyEffect({ type: 'discovery', loreId: 'lore_graves' }, state);
+    expect(state.discoveries).toEqual(['lore_graves']);
+  });
+
+  it('gainShard adds to shard progress and shard conditions read it', () => {
+    const state = freshState();
+    expect(checkCondition({ type: 'shard', shardId: SHARD_IDS.glasswood }, state)).toBe(false);
+    applyEffect({ type: 'gainShard', shardId: SHARD_IDS.glasswood }, state);
+    expect(state.shards).toContain(SHARD_IDS.glasswood);
+    expect(checkCondition({ type: 'shard', shardId: SHARD_IDS.glasswood }, state)).toBe(true);
+  });
+
+  it('openRoute, regionalOutcome and changeNpcAttitude write to state', () => {
+    const state = freshState();
+    applyEffect({ type: 'openRoute', routeId: 'glasswoodRoad' }, state);
+    applyEffect({ type: 'regionalOutcome', region: 'The Glasswood', outcome: 'protected' }, state);
+    applyEffect({ type: 'changeNpcAttitude', npcId: 'mira', attitude: 'wary' }, state);
+    expect(state.routes).toContain('glasswoodRoad');
+    expect(state.regionalOutcomes['The Glasswood']).toBe('protected');
+    expect(state.npcAttitudes.mira).toBe('wary');
+  });
+
+  it('stat conditions read player stats', () => {
+    const state = freshState();
+    expect(checkCondition({ type: 'stat', stat: 'attack', min: 6 }, state)).toBe(true);
+    expect(checkCondition({ type: 'stat', stat: 'attack', min: 7 }, state)).toBe(false);
+  });
+});
@@ -0,0 +1,140 @@+import { describe, it, expect } from 'vitest';
+import { defineMap, type MapDefinition } from '../src/world/MapDefinition';
+import { findPath, reachableSet, hasLineOfSight } from '../src/world/Pathfinding';
+import { buildOccupancy, isBlocked, isTerrainWalkable, occupantAt } from '../src/world/Collision';
+import { manhattan, type GridPos } from '../src/core/types';
+
+function makeMap(rows: string[]): MapDefinition {
+  return defineMap({
+    id: 'test',
+    name: 'Test',
+    region: 'Test',
+    width: rows[0].length,
+    height: rows.length,
+    rows,
+    playerSpawn: { x: 0, y: 0 },
+    npcs: [],
+    enemies: [],
+    interactables: [],
+    exits: [],
+    objective: '',
+  });
+}
+
+const OPEN_3x3 = makeMap(['...', '...', '...']);
+
+describe('findPath', () => {
+  it('finds an orthogonal path on an open map', () => {
+    const path = findPath(OPEN_3x3, new Map(), { x: 0, y: 0 }, { x: 2, y: 2 });
+    expect(path).not.toBeNull();
+    expect(path).toHaveLength(4); // manhattan distance
+    expect(path?.at(-1)).toEqual({ x: 2, y: 2 });
+  });
+
+  it('never moves diagonally, so corner cutting is impossible', () => {
+    const path = findPath(OPEN_3x3, new Map(), { x: 0, y: 0 }, { x: 2, y: 2 });
+    let prev: GridPos = { x: 0, y: 0 };
+    for (const step of path ?? []) {
+      expect(manhattan(prev, step)).toBe(1);
+      prev = step;
+    }
+  });
+
+  it('returns null when the goal is only reachable by cutting a corner', () => {
+    const map = makeMap(['.t', 't.']);
+    expect(findPath(map, new Map(), { x: 0, y: 0 }, { x: 1, y: 1 })).toBeNull();
+  });
+
+  it('returns null for a goal blocked by terrain', () => {
+    const map = makeMap(['..', '.t']);
+    expect(findPath(map, new Map(), { x: 0, y: 0 }, { x: 1, y: 1 })).toBeNull();
+  });
+
+  it('routes around blocked tiles', () => {
+    const map = makeMap(['.t.', '.t.', '...']);
+    const path = findPath(map, new Map(), { x: 0, y: 0 }, { x: 2, y: 0 });
+    expect(path).not.toBeNull();
+    for (const step of path ?? []) {
+      expect(map.rows[step.y][step.x]).toBe('.');
+    }
+  });
+
+  it('treats an occupied goal as unreachable unless allowed', () => {
+    const occupancy = buildOccupancy([{ id: 'wolf#1', pos: { x: 2, y: 2 }, alive: true }]);
+    expect(findPath(OPEN_3x3, occupancy, { x: 0, y: 0 }, { x: 2, y: 2 })).toBeNull();
+    const allowed = findPath(OPEN_3x3, occupancy, { x: 0, y: 0 }, { x: 2, y: 2 }, {
+      ignoreOccupantId: 'player',
+      allowOccupiedGoal: true,
+    });
+    expect(allowed).not.toBeNull();
+  });
+});
+
+describe('reachableSet', () => {
+  it('returns exactly the orthogonal ring within cost 1', () => {
+    const set = reachableSet(OPEN_3x3, new Map(), { x: 1, y: 1 }, 1);
+    expect(set.size).toBe(4);
+    expect(set.has('0,1')).toBe(true);
+    expect(set.has('2,1')).toBe(true);
+    expect(set.has('1,0')).toBe(true);
+    expect(set.has('1,2')).toBe(true);
+  });
+
+  it('respects maxCost', () => {
+    const set = reachableSet(OPEN_3x3, new Map(), { x: 1, y: 1 }, 2);
+    expect(set.size).toBe(8); // everything except the origin
+    expect(set.get('0,0')?.cost).toBe(2);
+  });
+
+  it('excludes blocked tiles and cannot flow through them', () => {
+    const map = makeMap(['...', '.t.', '...']);
+    const set = reachableSet(map, new Map(), { x: 0, y: 1 }, 2);
+    expect(set.has('1,1')).toBe(false); // the tree itself
+    expect(set.has('1,0')).toBe(true); // reachable around it
+    expect(set.has('2,1')).toBe(false); // costs 3 around the tree
+  });
+
+  it('excludes occupied tiles', () => {
+    const occupancy = buildOccupancy([{ id: 'crow#1', pos: { x: 1, y: 0 }, alive: true }]);
+    const set = reachableSet(OPEN_3x3, occupancy, { x: 0, y: 0 }, 1);
+    expect(set.has('1,0')).toBe(false);
+    expect(set.has('0,1')).toBe(true);
+  });
+});
+
+describe('hasLineOfSight', () => {
+  it('sees across open ground', () => {
+    expect(hasLineOfSight(OPEN_3x3, { x: 0, y: 0 }, { x: 2, y: 2 })).toBe(true);
+  });
+
+  it('is blocked by trees', () => {
+    const map = makeMap(['...', '.t.', '...']);
+    expect(hasLineOfSight(map, { x: 0, y: 1 }, { x: 2, y: 1 })).toBe(false);
+  });
+});
+
+describe('collision', () => {
+  it('rejects out-of-bounds and blocked terrain', () => {
+    const map = makeMap(['.t', '..']);
+    expect(isTerrainWalkable(map, { x: 0, y: 0 })).toBe(true);
+    expect(isTerrainWalkable(map, { x: 1, y: 0 })).toBe(false);
+    expect(isTerrainWalkable(map, { x: -1, y: 0 })).toBe(false);
+    expect(isTerrainWalkable(map, { x: 5, y: 5 })).toBe(false);
+  });
+
+  it('occupancy tracks only living actors', () => {
+    const occupancy = buildOccupancy([
+      { id: 'a', pos: { x: 0, y: 0 }, alive: true },
+      { id: 'b', pos: { x: 1, y: 0 }, alive: false },
+    ]);
+    expect(occupantAt(occupancy, { x: 0, y: 0 })).toBe('a');
+    expect(occupantAt(occupancy, { x: 1, y: 0 })).toBeNull();
+  });
+
+  it('isBlocked combines terrain and occupancy', () => {
+    const occupancy = buildOccupancy([{ id: 'a', pos: { x: 0, y: 0 }, alive: true }]);
+    expect(isBlocked(OPEN_3x3, occupancy, { x: 0, y: 0 })).toBe(true);
+    expect(isBlocked(OPEN_3x3, occupancy, { x: 1, y: 1 })).toBe(false);
+    expect(isBlocked(OPEN_3x3, occupancy, { x: 9, y: 9 })).toBe(true);
+  });
+});
@@ -0,0 +1,205 @@+import { describe, it, expect } from 'vitest';
+import { DialogueSystem } from '../src/systems/DialogueSystem';
+import { decideEnemyAction } from '../src/systems/EnemyAI';
+import { Rng } from '../src/core/Rng';
+import { createNewGameState, createNpcState, createEnemyState } from '../src/entities/EntityFactory';
+import { MAE_WAYREST, FEN_WAYREST } from '../src/data/dialogues/wayrest';
+import { MIRA_STANDOFF, HEART_MIRROR } from '../src/data/dialogues/glasswood';
+import { MARSH_KING } from '../src/data/dialogues/mire';
+import { WAYREST } from '../src/data/maps/wayrest';
+import { MIRE } from '../src/data/maps/mire';
+import { FLAGS, MEMORY_EVENTS } from '../src/data/flags';
+import { CHARACTER_IDS } from '../src/data/characters';
+import { SHARD_IDS } from '../src/data/shards';
+import { remembers } from '../src/systems/RelationshipSystem';
+import { defineMap } from '../src/world/MapDefinition';
+
+function freshState() {
+  return createNewGameState({
+    variantId: 'neutral',
+    mapId: 'wayrest',
+    region: 'The Nameless Edge',
+    objective: 'Wake.',
+    playerSpawn: { x: 8, y: 2 },
+    seed: 1,
+  });
+}
+
+function playTree(tree: Parameters<DialogueSystem['start']>[0], state: ReturnType<typeof freshState>, picks: string[]) {
+  const system = new DialogueSystem();
+  let node = system.start(tree, state);
+  const visited: string[] = [];
+  while (node) {
+    visited.push(node.id);
+    const pick = picks.shift();
+    if (!pick) break;
+    if (pick === 'advance') {
+      node = system.advance(state);
+    } else {
+      node = system.choose(pick, state);
+    }
+  }
+  return { system, visited, lastNode: node };
+}
+
+describe('prologue: the Wayrest forget event', () => {
+  it('Tam is forgotten mid-dialogue; only the protagonist remembers', () => {
+    const state = freshState();
+    state.actors = [
+      createNpcState(CHARACTER_IDS.tam, { x: 4, y: 2 }),
+      createNpcState(CHARACTER_IDS.mae, { x: 5, y: 5 }),
+      createNpcState(CHARACTER_IDS.fen, { x: 9, y: 6 }),
+    ];
+    const { visited } = playTree(MAE_WAYREST, state, ['who', 'insist']);
+    expect(visited).toContain('lives');
+    expect(state.flags[FLAGS.wayrestForgot]).toBe(true);
+    expect(state.actors.some((a) => a.defId === CHARACTER_IDS.tam)).toBe(false);
+    expect(state.flags[FLAGS.insistedTamExisted]).toBe(true);
+    expect(remembers(state, 'protagonist', MEMORY_EVENTS.insistedTamExisted)).toBe(true);
+    expect(state.discoveries).toContain('lore_forgetting');
+  });
+
+  it('the silence branch leaves Tam unsaid instead', () => {
+    const state = freshState();
+    state.actors = [createNpcState(CHARACTER_IDS.tam, { x: 4, y: 2 })];
+    playTree(MAE_WAYREST, state, ['who', 'slip']);
+    expect(state.flags[FLAGS.insistedTamExisted]).toBeUndefined();
+    expect(remembers(state, 'protagonist', MEMORY_EVENTS.letTamGoUnsaid)).toBe(true);
+    expect(state.tendencies.empathy).toBe(1);
+  });
+
+  it('Fen’s dialogue changes after the event (startVariants)', () => {
+    const state = freshState();
+    state.actors = [createNpcState(CHARACTER_IDS.fen, { x: 9, y: 6 })];
+    const before = new DialogueSystem().start(FEN_WAYREST, state);
+    expect(before?.id).toBe('start');
+    state.flags[FLAGS.wayrestForgot] = true;
+    const after = new DialogueSystem().start(FEN_WAYREST, state);
+    expect(after?.id).toBe('forgotStart');
+  });
+
+  it('the Crown patrol spawn is gated on the forget flag', () => {
+    const patrol = WAYREST.enemies.filter((e) => e.requiresFlag === FLAGS.wayrestForgot);
+    expect(patrol.length).toBe(2);
+    expect(WAYREST.npcs.find((n) => n.defId === CHARACTER_IDS.tam)?.hiddenFlag).toBe(FLAGS.wayrestForgot);
+  });
+});
+
+describe('glasswood: Mira’s standoff', () => {
+  function miraState() {
+    const state = freshState();
+    state.actors = [createNpcState(CHARACTER_IDS.mira, { x: 7, y: 6 })];
+    return state;
+  }
+
+  it('can be talked down via memories (calmed wolf)', () => {
+    const state = miraState();
+    state.npcMemories['protagonist'] = [MEMORY_EVENTS.calmedWolf];
+    const { visited } = playTree(MIRA_STANDOFF, state, ['unwritten', 'talkdown']);
+    expect(visited).toContain('talkdown');
+    expect(state.flags[FLAGS.miraPeaceful]).toBe(true);
+    expect(state.npcAttitudes[CHARACTER_IDS.mira]).toBe('rival');
+    expect(state.actors[0].faction).toBe('neutral'); // no combat
+  });
+
+  it('can be talked down via empathy', () => {
+    const state = miraState();
+    state.tendencies.empathy = 2;
+    const { visited } = playTree(MIRA_STANDOFF, state, ['unwritten', 'talkdown2']);
+    expect(visited).toContain('talkdown');
+    expect(state.flags[FLAGS.miraPeaceful]).toBe(true);
+  });
+
+  it('can tip into combat (turnHostile)', () => {
+    const state = miraState();
+    playTree(MIRA_STANDOFF, state, ['shoot', 'fight']);
+    const mira = state.actors[0];
+    expect(mira.faction).toBe('hostile');
+    expect(mira.aggroed).toBe(true);
+    expect(remembers(state, CHARACTER_IDS.mira, MEMORY_EVENTS.miraStandoffViolent)).toBe(true);
+  });
+
+  it('silence earns her respect and resolves peacefully', () => {
+    const state = miraState();
+    const { visited } = playTree(MIRA_STANDOFF, state, ['silence']);
+    expect(visited).toEqual(['start', 'assess']);
+    expect(state.tendencies.resolve).toBe(1);
+  });
+
+  it('post-resolution dialogue reflects how it went', () => {
+    const state = miraState();
+    state.flags[FLAGS.miraDefeated] = true;
+    const node = new DialogueSystem().start(MIRA_STANDOFF, state);
+    expect(node?.id).toBe('afterFight');
+  });
+});
+
+describe('shards: both are grantable through decisions', () => {
+  it('the Heart Mirror grants the Glasswood shard on either branch, with different outcomes', () => {
+    const truth = freshState();
+    playTree(HEART_MIRROR, truth, ['watch', 'truth']);
+    expect(truth.shards).toContain(SHARD_IDS.glasswood);
+    expect(truth.regionalOutcomes['The Glasswood']).toBe('restored');
+
+    const lie = freshState();
+    playTree(HEART_MIRROR, lie, ['watch', 'lie']);
+    expect(lie.shards).toContain(SHARD_IDS.glasswood);
+    expect(lie.regionalOutcomes['The Glasswood']).toBe('letLie');
+  });
+
+  it('the Marsh King grants the Mire shard on all three branches', () => {
+    for (const [pick, outcome] of [
+      ['affirm', 'affirmed'],
+      ['truth', 'truth'],
+      ['weighs', 'both'],
+    ] as const) {
+      const state = freshState();
+      playTree(MARSH_KING, state, ['nobucket', 'advance', pick]);
+      expect(state.shards, `branch ${pick}`).toContain(SHARD_IDS.mire);
+      expect(state.regionalOutcomes['The Mire of Bells']).toBe(outcome);
+    }
+  });
+});
+
+describe('new enemy AI', () => {
+  const arena = defineMap({
+    id: 'arena',
+    name: 'Arena',
+    region: 'Test',
+    width: 9,
+    height: 9,
+    rows: Array(9).fill('.........') as string[],
+    playerSpawn: { x: 4, y: 4 },
+    npcs: [],
+    enemies: [],
+    interactables: [],
+    exits: [],
+    objective: '',
+  });
+
+  it('crown soldier alternates guard and advance when hurt, deterministically', () => {
+    const player = createEnemyState('ashWolf', { x: 6, y: 4 });
+    player.faction = 'player';
+    const soldier = createEnemyState('crownSoldier', { x: 2, y: 4 });
+    soldier.hp = soldier.maxHp / 2 - 1;
+    const ctx = { map: arena, actors: [player, soldier], player, flags: {}, rng: new Rng(1) };
+    const first = decideEnemyAction(soldier, ctx);
+    const second = decideEnemyAction(soldier, ctx);
+    expect(first.type).toBe('move'); // advances
+    expect(second.type).toBe('guard'); // then holds
+  });
+
+  it('mire lurker ignores the player once a bell is rung', () => {
+    const player = createEnemyState('ashWolf', { x: 6, y: 4 });
+    player.faction = 'player';
+    const lurker = createEnemyState('mireLurker', { x: 5, y: 4 });
+    const before = decideEnemyAction(lurker, {
+      map: MIRE, actors: [player, lurker], player, flags: {}, rng: new Rng(1),
+    });
+    expect(before.type).toBe('attack'); // adjacent, unrepelled
+    const after = decideEnemyAction(lurker, {
+      map: MIRE, actors: [player, lurker], player, flags: { [FLAGS.mireBellRung]: true }, rng: new Rng(1),
+    });
+    expect(after.type).not.toBe('attack');
+  });
+});
@@ -0,0 +1,126 @@+import { describe, it, expect } from 'vitest';
+import {
+  SaveManager,
+  serializeState,
+  validateSave,
+  type KeyValueStorage,
+} from '../src/core/SaveManager';
+import { SAVE_VERSION } from '../src/core/constants';
+import { createNewGameState, createEnemyState } from '../src/entities/EntityFactory';
+
+class MemoryStorage implements KeyValueStorage {
+  private readonly data = new Map<string, string>();
+  getItem(key: string): string | null {
+    return this.data.get(key) ?? null;
+  }
+  setItem(key: string, value: string): void {
+    this.data.set(key, value);
+  }
+  removeItem(key: string): void {
+    this.data.delete(key);
+  }
+}
+
+function freshState() {
+  const state = createNewGameState({
+    variantId: 'medium',
+    mapId: 'graveClearing',
+    region: 'The Nameless Edge',
+    objective: 'Wake.',
+    playerSpawn: { x: 6, y: 3 },
+    seed: 1234,
+  });
+  state.actors.push(createEnemyState('ashWolf', { x: 11, y: 6 }));
+  state.flags.metCrow = true;
+  state.npcMemories.crow = ['metCrow'];
+  return state;
+}
+
+describe('save serialization', () => {
+  it('serialize → validate → deserialize round-trips losslessly', () => {
+    const state = freshState();
+    const file = serializeState(state, 'auto');
+    const parsed: unknown = JSON.parse(JSON.stringify(file));
+    const validated = validateSave(parsed);
+    expect(validated).not.toBeNull();
+    expect(validated?.data).toEqual(state);
+  });
+
+  it('SaveManager save/load round-trips through storage', () => {
+    const saves = new SaveManager(new MemoryStorage());
+    const state = freshState();
+    expect(saves.save('manual', state)).toBe(true);
+    expect(saves.hasSave('manual')).toBe(true);
+    expect(saves.load('manual')).toEqual(state);
+    saves.delete('manual');
+    expect(saves.hasSave('manual')).toBe(false);
+  });
+
+  it('export/import round-trips', () => {
+    const saves = new SaveManager(new MemoryStorage());
+    const state = freshState();
+    const json = saves.export(state, 'manual');
+    expect(saves.import(json)).toEqual(state);
+  });
+});
+
+describe('save validation rejects malformed data', () => {
+  it('rejects non-JSON imports without throwing', () => {
+    const saves = new SaveManager(new MemoryStorage());
+    expect(saves.import('not json at all {')).toBeNull();
+    expect(saves.import('')).toBeNull();
+    expect(saves.import('42')).toBeNull();
+  });
+
+  it('rejects wrong versions', () => {
+    const file = serializeState(freshState(), 'auto');
+    expect(validateSave({ ...file, version: SAVE_VERSION + 1 })).toBeNull();
+    const tamperedData = { ...file, data: { ...file.data, version: SAVE_VERSION + 1 } };
+    expect(validateSave(tamperedData)).toBeNull();
+  });
+
+  it('rejects old (v1) saves after the version bump', () => {
+    const file = serializeState(freshState(), 'auto');
+    const v1 = JSON.parse(JSON.stringify(file)) as { version: number; data: { version: number } };
+    v1.version = 1;
+    v1.data.version = 1;
+    expect(validateSave(v1)).toBeNull();
+  });
+
+  it('rejects saves missing the v2 narrative fields', () => {
+    const file = serializeState(freshState(), 'auto') as unknown as {
+      data: Record<string, unknown>;
+    };
+    const noShards = { ...file, data: { ...file.data, shards: undefined } };
+    expect(validateSave(noShards)).toBeNull();
+    const noMapStates = { ...file, data: { ...file.data, mapStates: undefined } };
+    expect(validateSave(noMapStates)).toBeNull();
+    const badMapStates = {
+      ...file,
+      data: { ...file.data, mapStates: { oldRoad: { actors: 'many' } } },
+    };
+    expect(validateSave(badMapStates)).toBeNull();
+  });
+
+  it('rejects missing or mistyped fields', () => {
+    const file = serializeState(freshState(), 'auto');
+    const withoutPlayer = { ...file, data: { ...file.data, player: undefined } };
+    expect(validateSave(withoutPlayer)).toBeNull();
+    const badHp = {
+      ...file,
+      data: { ...file.data, player: { ...file.data.player, hp: 'lots' } },
+    };
+    expect(validateSave(badHp)).toBeNull();
+    const badMode = { ...file, data: { ...file.data, mode: 'shopping' } };
+    expect(validateSave(badMode)).toBeNull();
+    const badFlags = { ...file, data: { ...file.data, flags: { metCrow: 'yes' } } };
+    expect(validateSave(badFlags)).toBeNull();
+  });
+
+  it('rejects corrupted storage contents on load', () => {
+    const storage = new MemoryStorage();
+    storage.setItem('tlg:save:auto', '{"version":1,"broken":');
+    const saves = new SaveManager(storage);
+    expect(saves.load('auto')).toBeNull();
+  });
+});
@@ -0,0 +1,74 @@+import { describe, it, expect } from 'vitest';
+import { TurnManager } from '../src/core/TurnManager';
+import { TURN_COSTS } from '../src/core/constants';
+
+function sequence(manager: TurnManager, turns: number): string[] {
+  const order: string[] = [];
+  for (let i = 0; i < turns; i++) {
+    const actor = manager.advance();
+    order.push(actor);
+    manager.commit(actor, TURN_COSTS.attack);
+  }
+  return order;
+}
+
+describe('TurnManager', () => {
+  it('is deterministic: identical setups produce identical orders', () => {
+    const a = new TurnManager();
+    const b = new TurnManager();
+    for (const manager of [a, b]) {
+      manager.addActor('player', 5);
+      manager.addActor('wolf#1', 6);
+      manager.addActor('wolf#2', 6);
+    }
+    expect(sequence(a, 20)).toEqual(sequence(b, 20));
+  });
+
+  it('faster actors act more often', () => {
+    const manager = new TurnManager();
+    manager.addActor('fast', 10);
+    manager.addActor('slow', 5);
+    const order = sequence(manager, 30);
+    const fastTurns = order.filter((id) => id === 'fast').length;
+    const slowTurns = order.filter((id) => id === 'slow').length;
+    expect(fastTurns).toBeGreaterThan(slowTurns);
+  });
+
+  it('higher action costs delay the next turn', () => {
+    const manager = new TurnManager();
+    manager.addActor('aa', 5);
+    manager.addActor('bb', 5);
+    // Both reach readiness together; 'aa' wins the id tie-break.
+    const first = manager.advance();
+    expect(first).toBe('aa');
+    manager.commit('aa', TURN_COSTS.heavy); // 140: deeper readiness debt
+    const order = [manager.advance(), manager.advance()];
+    manager.commit(order[0], TURN_COSTS.attack);
+    // bb must act at least once before aa comes back from the heavy action.
+    expect(order).toContain('bb');
+  });
+
+  it('peekOrder projects a deterministic future without mutating state', () => {
+    const manager = new TurnManager();
+    manager.addActor('player', 5);
+    manager.addActor('wolf#1', 6);
+    const first = manager.peekOrder(5);
+    const second = manager.peekOrder(5);
+    expect(first).toEqual(second);
+    expect(first.length).toBe(5);
+    // peeking did not consume anything
+    expect(manager.advance()).toBe(first[0]);
+  });
+
+  it('removed actors never act again', () => {
+    const manager = new TurnManager();
+    manager.addActor('aa', 5);
+    manager.addActor('bb', 5);
+    manager.removeActor('aa');
+    for (let i = 0; i < 6; i++) {
+      const actor = manager.advance();
+      expect(actor).toBe('bb');
+      manager.commit(actor, TURN_COSTS.attack);
+    }
+  });
+});
@@ -0,0 +1,21 @@+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "lib": ["ES2022", "DOM", "DOM.Iterable"],
+    "strict": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "noFallthroughCasesInSwitch": true,
+    "noImplicitReturns": true,
+    "exactOptionalPropertyTypes": false,
+    "forceConsistentCasingInFileNames": true,
+    "useDefineForClassFields": true,
+    "skipLibCheck": true,
+    "isolatedModules": true,
+    "noEmit": true,
+    "types": ["vite/client"]
+  },
+  "include": ["src", "tests"]
+}
@@ -0,0 +1,11 @@+import { defineConfig } from "vite";
+
+// base "./" keeps the production build working from a subdirectory
+// (GitHub Pages project sites, Nginx subpaths, etc.).
+export default defineConfig({
+  base: "./",
+  build: {
+    target: "es2022",
+    sourcemap: false,
+  },
+});