@idosgames/mcp 0.1.14 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@idosgames/mcp",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "MCP server that serves the iDosGames Module & Skills Registry to AI coding agents (Claude Code, Codex, Cursor…): list/pull composable game modules and the host scaffold, and load skills for @idosgames/core, the module contract, and composition.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,7 +23,7 @@
23
23
  },
24
24
  {
25
25
  "path": "package.json",
26
- "content": "{\n \"name\": \"@idosgames/host-starter\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"type\": \"module\",\n \"description\": \"The seed project for the AI Coder: a host shell that composes feature modules. Fresh projects start here with zero modules; the developer/agent plugs modules into src/modules.ts.\",\n \"scripts\": {\n \"dev\": \"vite\",\n \"build\": \"vite build\",\n \"preview\": \"vite preview\",\n \"typecheck\": \"tsc --noEmit -p tsconfig.json\"\n },\n \"//\": \"Versions are pinned exactly: this is a seed for AI Coder projects, which build offline against a dependency allowlist baked at these versions (see scripts/pack-builder.mjs). Modules bring their own engine deps (three/phaser) when added.\",\n \"dependencies\": {\n \"@idosgames/app-shell\": \"0.3.1\",\n \"@idosgames/core\": \"0.14.1\",\n \"@idosgames/module-sdk\": \"0.3.1\",\n \"@idosgames/react\": \"0.2.7\",\n \"@idosgames/wallet\": \"0.2.7\",\n \"@tanstack/react-query\": \"5.101.2\",\n \"react\": \"19.2.7\",\n \"react-dom\": \"19.2.7\",\n \"wagmi\": \"3.7.2\"\n },\n \"devDependencies\": {\n \"@types/react\": \"19.2.17\",\n \"@types/react-dom\": \"19.2.3\",\n \"@vitejs/plugin-react\": \"6.0.4\",\n \"typescript\": \"5.9.3\",\n \"vite\": \"8.1.5\"\n }\n}\n"
26
+ "content": "{\n \"name\": \"@idosgames/host-starter\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"type\": \"module\",\n \"description\": \"The seed project for the AI Coder: a host shell that composes feature modules. Fresh projects start here with zero modules; the developer/agent plugs modules into src/modules.ts.\",\n \"scripts\": {\n \"dev\": \"vite\",\n \"build\": \"vite build\",\n \"preview\": \"vite preview\",\n \"typecheck\": \"tsc --noEmit -p tsconfig.json\"\n },\n \"//\": \"Versions are pinned exactly: this is a seed for AI Coder projects, which build offline against a dependency allowlist baked at these versions (see scripts/pack-builder.mjs). Modules bring their own engine deps (three/phaser) when added.\",\n \"dependencies\": {\n \"@idosgames/app-shell\": \"0.4.0\",\n \"@idosgames/core\": \"0.17.0\",\n \"@idosgames/module-sdk\": \"0.4.0\",\n \"@idosgames/react\": \"0.2.10\",\n \"@idosgames/wallet\": \"0.4.1\",\n \"@tanstack/react-query\": \"5.101.2\",\n \"react\": \"19.2.7\",\n \"react-dom\": \"19.2.7\",\n \"wagmi\": \"3.7.2\"\n },\n \"devDependencies\": {\n \"@types/react\": \"19.2.17\",\n \"@types/react-dom\": \"19.2.3\",\n \"@vitejs/plugin-react\": \"6.0.4\",\n \"typescript\": \"5.9.3\",\n \"vite\": \"8.1.5\"\n }\n}\n"
27
27
  },
28
28
  {
29
29
  "path": "public/sw.js",
@@ -1,11 +1,11 @@
1
1
  {
2
- "generatedFromCommit": "0855010b072723ca1b748a9af3e4153c22627b09",
2
+ "generatedFromCommit": "3ade0a9ffb940a254cb292020d5316ff15ae13e1",
3
3
  "runtimePackages": {
4
- "@idosgames/core": "0.14.1",
5
- "@idosgames/wallet": "0.2.7",
6
- "@idosgames/module-sdk": "0.3.1",
7
- "@idosgames/react": "0.2.7",
8
- "@idosgames/app-shell": "0.3.1"
4
+ "@idosgames/core": "0.17.0",
5
+ "@idosgames/wallet": "0.4.1",
6
+ "@idosgames/module-sdk": "0.4.0",
7
+ "@idosgames/react": "0.2.10",
8
+ "@idosgames/app-shell": "0.4.0"
9
9
  },
10
10
  "host": {
11
11
  "id": "host-starter",
@@ -54,10 +54,10 @@
54
54
  ]
55
55
  },
56
56
  "dependencies": {
57
- "@idosgames/core": "0.14.1",
58
- "@idosgames/module-sdk": "0.3.1",
59
- "@idosgames/react": "0.2.7",
60
- "@idosgames/wallet": "0.2.7",
57
+ "@idosgames/core": "0.17.0",
58
+ "@idosgames/module-sdk": "0.4.0",
59
+ "@idosgames/react": "0.2.10",
60
+ "@idosgames/wallet": "0.4.1",
61
61
  "@tanstack/react-query": "5.101.2",
62
62
  "react": "19.2.7",
63
63
  "three": "0.185.1",
@@ -65,7 +65,7 @@
65
65
  "wagmi": "3.7.2"
66
66
  },
67
67
  "fileCount": 27,
68
- "contentHash": "c33e4df315755b53354de2ca5973cba5c31c05df352606e1481e4354f93c5541"
68
+ "contentHash": "a7a360f0ecb5106bb5c5565d06b13845a8f1838bdb475d626f33ca89c73cec29"
69
69
  },
70
70
  {
71
71
  "id": "idle-rpg",
@@ -113,10 +113,10 @@
113
113
  ]
114
114
  },
115
115
  "dependencies": {
116
- "@idosgames/core": "0.14.1",
117
- "@idosgames/module-sdk": "0.3.1",
118
- "@idosgames/react": "0.2.7",
119
- "@idosgames/wallet": "0.2.7",
116
+ "@idosgames/core": "0.17.0",
117
+ "@idosgames/module-sdk": "0.4.0",
118
+ "@idosgames/react": "0.2.10",
119
+ "@idosgames/wallet": "0.4.1",
120
120
  "@tanstack/react-query": "5.101.2",
121
121
  "phaser": "4.2.1",
122
122
  "react": "19.2.7",
@@ -124,7 +124,7 @@
124
124
  "wagmi": "3.7.2"
125
125
  },
126
126
  "fileCount": 19,
127
- "contentHash": "c395a20365d53fd5aee509d40dfd9dfad5976b822ec12e808e460f0d0cba432d"
127
+ "contentHash": "143c4595784974ca4d58d5032e77b26e93d1d96c2455974c796c7d5c1d710ba6"
128
128
  },
129
129
  {
130
130
  "id": "voxelcraft",
@@ -183,12 +183,12 @@
183
183
  },
184
184
  "version": "0.3.0",
185
185
  "dependencies": {
186
- "@idosgames/core": "0.14.1",
187
- "@idosgames/module-sdk": "0.3.1",
186
+ "@idosgames/core": "0.17.0",
187
+ "@idosgames/module-sdk": "0.4.0",
188
188
  "three": "0.185.1"
189
189
  },
190
- "fileCount": 388,
191
- "contentHash": "45c798f6bcb03c28ff60e9b60a7d85002f3b031302b2d28c34b9ae633cb45ed9"
190
+ "fileCount": 524,
191
+ "contentHash": "0de6a76e323311ee027133892e4fa66c72540ced75d21cb9e441025c06feea6c"
192
192
  },
193
193
  {
194
194
  "id": "game-hud",
@@ -235,10 +235,10 @@
235
235
  ]
236
236
  },
237
237
  "dependencies": {
238
- "@idosgames/core": "0.14.1",
239
- "@idosgames/module-sdk": "0.3.1",
240
- "@idosgames/react": "0.2.7",
241
- "@idosgames/wallet": "0.2.7",
238
+ "@idosgames/core": "0.17.0",
239
+ "@idosgames/module-sdk": "0.4.0",
240
+ "@idosgames/react": "0.2.10",
241
+ "@idosgames/wallet": "0.4.1",
242
242
  "@tanstack/react-query": "5.101.2",
243
243
  "react": "19.2.7",
244
244
  "viem": "2.55.2",
@@ -283,13 +283,13 @@
283
283
  },
284
284
  "version": "0.1.0",
285
285
  "dependencies": {
286
- "@idosgames/core": "0.14.1",
287
- "@idosgames/module-sdk": "0.3.1",
288
- "@idosgames/react": "0.2.7",
286
+ "@idosgames/core": "0.17.0",
287
+ "@idosgames/module-sdk": "0.4.0",
288
+ "@idosgames/react": "0.2.10",
289
289
  "react": "19.2.7"
290
290
  },
291
291
  "fileCount": 4,
292
- "contentHash": "9b0128630509059fdfff91598bdb2e8ab7accc1fba452358caac04248d8165ba"
292
+ "contentHash": "01b9b8439ce44320e7ea8a1aa0a679b3773c291c784a5999c9eab7114c80339d"
293
293
  }
294
294
  ],
295
295
  "skills": [
@@ -451,7 +451,7 @@
451
451
  },
452
452
  {
453
453
  "name": "social-system",
454
- "description": "Build a friends / social system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.social (SocialService): load the friends list, incoming friend requests, and recommended friends, send/accept/decline friend requests, remove a friend, and read the social activity timeline (attacks, raids, friend-adds). Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a friends list screen, friend-request inbox, add-friend flow, recommended friends / player search, or an activity feed, or otherwise touches client.social, SocialService, SocialModels, FriendPublicProfile, or the social timeline — even if they don't name the module explicitly."
454
+ "description": "Build social features in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.social (SocialService): friends (requests, accept/decline, remove, recommendations), the notification inbox (attacks, raids, new friends, sales, likes, follows, comments, replies; unread badge), and the generic social layer follow/unfollow players, block/unblock, like/favorite any object (Workshop items, comments), comments with one level of replies, reports with auto-hide, and batch counters (followers, likes, comments) for cards. Use this whenever the user wants a friends screen, a notification bell / inbox, follow buttons, likes, comments, a report button, a block list, or touches client.social, SocialService, SocialModels, SocialEntityState, SocialInboxItem — even if they don't name the module."
455
455
  },
456
456
  {
457
457
  "name": "store-system",
@@ -45,17 +45,17 @@
45
45
  }
46
46
  },
47
47
  "dependencies": {
48
- "@idosgames/core": "0.14.1",
49
- "@idosgames/module-sdk": "0.3.1",
50
- "@idosgames/react": "0.2.7",
51
- "@idosgames/wallet": "0.2.7",
48
+ "@idosgames/core": "0.17.0",
49
+ "@idosgames/module-sdk": "0.4.0",
50
+ "@idosgames/react": "0.2.10",
51
+ "@idosgames/wallet": "0.4.1",
52
52
  "@tanstack/react-query": "5.101.2",
53
53
  "react": "19.2.7",
54
54
  "three": "0.185.1",
55
55
  "viem": "2.55.2",
56
56
  "wagmi": "3.7.2"
57
57
  },
58
- "contentHash": "c33e4df315755b53354de2ca5973cba5c31c05df352606e1481e4354f93c5541",
58
+ "contentHash": "a7a360f0ecb5106bb5c5565d06b13845a8f1838bdb475d626f33ca89c73cec29",
59
59
  "files": [
60
60
  {
61
61
  "path": "chrome.ts",
@@ -127,7 +127,7 @@
127
127
  },
128
128
  {
129
129
  "path": "game/BoardController.ts",
130
- "content": "import * as THREE from \"three\";\nimport type { TileInfo } from \"../data/boardConfig\";\n\nconst TILE_COLORS: Record<string, number> = {\n Chance: 0xf0a020,\n Reward: 0x4caf50,\n Special: 0x9c5cff,\n RandomAction: 0xe8534e,\n Shield: 0x4a90e2,\n};\nconst DEFAULT_TILE_COLOR = 0x6b6b7a;\n\n// Glyphs drawn on top of non-reward tiles so the board reads at a glance.\nconst TILE_SYMBOLS: Record<string, string> = {\n Chance: \"?\",\n Special: \"★\",\n RandomAction: \"✖\",\n Shield: \"✚\",\n};\n\nconst BUILDING_COLORS = [\n 0x6c5ce7, 0x4caf50, 0xe8534e, 0xf0a020, 0x4a90e2, 0x42c9c2,\n];\n\nconst TOKEN_Y = 0.85;\nconst TILE_Y = 0.13;\nconst STEP_MS = 240;\nconst HIGHLIGHT_MS = 850;\nconst BODY_H = 1.0;\nconst DICE_MS = 950;\nconst DICE_BASE_Y = 3.2;\nconst DICE_DROP = 3.4;\n\n// Standard bounce-out easing (0 → 1 with decaying bounces) for the dice toss.\nfunction bounceOut(t: number): number {\n const n1 = 7.5625;\n const d1 = 2.75;\n if (t < 1 / d1) return n1 * t * t;\n if (t < 2 / d1) {\n const u = t - 1.5 / d1;\n return n1 * u * u + 0.75;\n }\n if (t < 2.5 / d1) {\n const u = t - 2.25 / d1;\n return n1 * u * u + 0.9375;\n }\n const u = t - 2.625 / d1;\n return n1 * u * u + 0.984375;\n}\n\n// BoxGeometry face order is +X, -X, +Y, -Y, +Z, -Z. Assign pip values so opposite faces sum to 7.\nconst FACE_VALUES = [2, 5, 3, 4, 1, 6];\n// Resting rotation that brings each value's face to the top (+Y).\nconst REST_ROTATION: Record<number, [number, number, number]> = {\n 1: [-Math.PI / 2, 0, 0],\n 2: [0, 0, Math.PI / 2],\n 3: [0, 0, 0],\n 4: [Math.PI, 0, 0],\n 5: [0, 0, -Math.PI / 2],\n 6: [Math.PI / 2, 0, 0],\n};\n\nconst PIP_LAYOUTS: Record<number, string[]> = {\n 1: [\"C\"],\n 2: [\"TL\", \"BR\"],\n 3: [\"TL\", \"C\", \"BR\"],\n 4: [\"TL\", \"TR\", \"BL\", \"BR\"],\n 5: [\"TL\", \"TR\", \"C\", \"BL\", \"BR\"],\n 6: [\"TL\", \"TR\", \"ML\", \"MR\", \"BL\", \"BR\"],\n};\nconst PIP_COORDS: Record<string, [number, number]> = {\n TL: [0.28, 0.28],\n TR: [0.72, 0.28],\n ML: [0.28, 0.5],\n C: [0.5, 0.5],\n MR: [0.72, 0.5],\n BL: [0.28, 0.72],\n BR: [0.72, 0.72],\n};\n\ninterface Tween {\n from: THREE.Vector3;\n to: THREE.Vector3;\n start: number;\n resolve: () => void;\n}\n\ninterface DiceTween {\n entries: {\n mesh: THREE.Mesh;\n from: THREE.Euler;\n to: THREE.Euler;\n rest: THREE.Euler;\n }[];\n start: number;\n resolve: () => void;\n}\n\ninterface Building {\n body: THREE.Mesh;\n roof: THREE.Mesh;\n}\n\n/**\n * Three.js renderer for the board: a square loop of tiles (colored + glyph-marked by type), the\n * player's city as towers that grow with their level, an animated token, and a pair of pip dice that\n * tumble to the rolled total. Engine-only — it knows nothing about the SDK.\n */\nexport class BoardController {\n private readonly renderer: THREE.WebGLRenderer;\n private readonly scene: THREE.Scene;\n private readonly camera: THREE.PerspectiveCamera;\n private readonly token: THREE.Mesh;\n private readonly tileGroup = new THREE.Group();\n private readonly cityGroup = new THREE.Group();\n private readonly lookAt = new THREE.Vector3(0, 0, 0);\n private readonly diceTextures: THREE.CanvasTexture[] = [];\n private readonly diceMaterials: THREE.MeshStandardMaterial[] = [];\n private readonly dice: THREE.Mesh[] = [];\n private readonly tileDecalTextures = new Map<string, THREE.CanvasTexture>();\n\n private tilePositions: THREE.Vector3[] = [];\n private tileMeshes: THREE.Mesh[] = [];\n private tileColors: THREE.Color[] = [];\n private buildings: Building[] = [];\n private tween: Tween | null = null;\n private diceTween: DiceTween | null = null;\n private highlight: { index: number; start: number } | null = null;\n private frame = 0;\n private disposed = false;\n private running = true;\n private resizeObserver?: ResizeObserver;\n\n private readonly loop = (): void => {\n if (this.disposed) return;\n this.frame = requestAnimationFrame(this.loop);\n\n if (this.tween) {\n const t = Math.min(1, (performance.now() - this.tween.start) / STEP_MS);\n const e = t * t * (3 - 2 * t); // smoothstep\n const { from, to } = this.tween;\n this.token.position.x = from.x + (to.x - from.x) * e;\n this.token.position.z = from.z + (to.z - from.z) * e;\n this.token.position.y = TOKEN_Y + Math.sin(t * Math.PI) * 0.6;\n if (t >= 1) {\n this.token.position.set(to.x, TOKEN_Y, to.z);\n const done = this.tween.resolve;\n this.tween = null;\n done();\n }\n }\n\n this.updateDice();\n this.updateHighlight();\n this.token.rotation.y += 0.02;\n\n // Gentle camera lean: drift the look-at point a fraction toward the token.\n this.lookAt.lerp(\n new THREE.Vector3(\n this.token.position.x * 0.35,\n 0,\n this.token.position.z * 0.35,\n ),\n 0.05,\n );\n this.camera.lookAt(this.lookAt);\n\n this.renderer.render(this.scene, this.camera);\n };\n\n constructor(private readonly host: HTMLElement) {\n this.renderer = new THREE.WebGLRenderer({ antialias: true });\n this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));\n this.renderer.domElement.style.display = \"block\";\n host.appendChild(this.renderer.domElement);\n\n this.scene = new THREE.Scene();\n this.scene.background = new THREE.Color(0x063d99);\n\n this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 200);\n this.camera.position.set(0, 24, 19);\n this.camera.lookAt(0, 0, 0);\n\n const ambient = new THREE.AmbientLight(0xffffff, 0.75);\n const dir = new THREE.DirectionalLight(0xffffff, 0.85);\n dir.position.set(12, 22, 8);\n this.scene.add(ambient, dir, this.tileGroup, this.cityGroup);\n\n const tokenMat = new THREE.MeshStandardMaterial({\n color: 0xffd479,\n emissive: 0x6b5a1f,\n });\n this.token = new THREE.Mesh(\n new THREE.SphereGeometry(0.5, 24, 16),\n tokenMat,\n );\n this.token.position.set(0, TOKEN_Y, 0);\n this.scene.add(this.token);\n\n this.buildDice();\n\n this.resize();\n this.resizeObserver = new ResizeObserver(() => {\n this.resize();\n });\n this.resizeObserver.observe(host);\n\n this.loop();\n }\n\n // ---- dice ----\n\n private buildDice(): void {\n const textureByValue: Record<number, THREE.CanvasTexture> = {};\n for (let v = 1; v <= 6; v++) {\n const texture = new THREE.CanvasTexture(this.makePipCanvas(v));\n this.diceTextures.push(texture);\n textureByValue[v] = texture;\n }\n for (const value of FACE_VALUES) {\n this.diceMaterials.push(\n new THREE.MeshStandardMaterial({\n map: textureByValue[value],\n color: 0xffffff,\n }),\n );\n }\n\n const geo = new THREE.BoxGeometry(1.2, 1.2, 1.2);\n for (let i = 0; i < 2; i++) {\n const die = new THREE.Mesh(geo, this.diceMaterials);\n die.position.set(i === 0 ? -1.0 : 1.0, 3.2, 0);\n const rest = REST_ROTATION[3] ?? [0, 0, 0];\n die.rotation.set(rest[0], 0, rest[2]);\n this.scene.add(die);\n this.dice.push(die);\n }\n }\n\n private makePipCanvas(value: number): HTMLCanvasElement {\n const canvas = document.createElement(\"canvas\");\n canvas.width = 128;\n canvas.height = 128;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return canvas;\n const s = canvas.width;\n ctx.fillStyle = \"#f6f3ff\";\n ctx.fillRect(0, 0, s, s);\n ctx.strokeStyle = \"#d9d2f0\";\n ctx.lineWidth = 4;\n ctx.strokeRect(4, 4, s - 8, s - 8);\n ctx.fillStyle = \"#052e7e\";\n for (const slot of PIP_LAYOUTS[value] ?? []) {\n const coord = PIP_COORDS[slot];\n if (!coord) continue;\n ctx.beginPath();\n ctx.arc(coord[0] * s, coord[1] * s, s * 0.1, 0, Math.PI * 2);\n ctx.fill();\n }\n return canvas;\n }\n\n private splitTotal(total: number): [number, number] {\n const clamped = Math.max(2, Math.min(12, Math.round(total)));\n const min1 = Math.max(1, clamped - 6);\n const max1 = Math.min(6, clamped - 1);\n const v1 = min1 + Math.floor(Math.random() * (max1 - min1 + 1));\n return [v1, clamped - v1];\n }\n\n /** Tumble both dice and land them showing two faces that sum to the rolled total. */\n rollDice(total: number): Promise<void> {\n const values = this.splitTotal(total);\n const entries = this.dice.map((mesh, i) => {\n const r = REST_ROTATION[values[i] ?? 3] ?? [0, 0, 0];\n const rest = new THREE.Euler(r[0], 0, r[2]);\n const turns = (n: number): number => Math.PI * 2 * n;\n const to = new THREE.Euler(\n rest.x + turns(2 + i),\n rest.y + turns(2),\n rest.z + turns(2 + i),\n );\n return { mesh, from: mesh.rotation.clone(), to, rest };\n });\n return new Promise<void>((resolve) => {\n this.diceTween = { entries, start: performance.now(), resolve };\n });\n }\n\n private updateDice(): void {\n if (!this.diceTween) return;\n const t = Math.min(1, (performance.now() - this.diceTween.start) / DICE_MS);\n const e = 1 - Math.pow(1 - t, 3); // easeOutCubic for the tumble\n const y = DICE_BASE_Y + DICE_DROP * (1 - bounceOut(t)); // drop + bounce for the toss\n for (const entry of this.diceTween.entries) {\n entry.mesh.rotation.set(\n entry.from.x + (entry.to.x - entry.from.x) * e,\n entry.from.y + (entry.to.y - entry.from.y) * e,\n entry.from.z + (entry.to.z - entry.from.z) * e,\n );\n entry.mesh.position.y = y;\n }\n if (t >= 1) {\n for (const entry of this.diceTween.entries) {\n entry.mesh.rotation.copy(entry.rest);\n entry.mesh.position.y = DICE_BASE_Y;\n }\n const done = this.diceTween.resolve;\n this.diceTween = null;\n done();\n }\n }\n\n // ---- board ----\n\n loadBoard(tiles: TileInfo[], buildingCount: number): void {\n this.disposeGroup(this.tileGroup);\n this.disposeGroup(this.cityGroup);\n this.tilePositions = [];\n this.tileMeshes = [];\n this.tileColors = [];\n this.buildings = [];\n\n const count = tiles.length;\n const perSide = Math.max(1, Math.round(count / 4));\n const spacing = 1.4;\n const extent = perSide * spacing;\n const half = extent / 2;\n\n for (const tile of tiles) {\n const pos = this.ringPosition(tile.index, perSide, extent, half);\n const colorHex = TILE_COLORS[tile.type] ?? DEFAULT_TILE_COLOR;\n const mesh = new THREE.Mesh(\n new THREE.BoxGeometry(1.1, 0.25, 1.1),\n new THREE.MeshStandardMaterial({ color: colorHex }),\n );\n mesh.position.set(pos.x, TILE_Y, pos.z);\n\n const symbol = TILE_SYMBOLS[tile.type];\n if (symbol) {\n const decal = new THREE.Mesh(\n new THREE.PlaneGeometry(0.7, 0.7),\n new THREE.MeshBasicMaterial({\n map: this.getDecalTexture(tile.type, symbol),\n transparent: true,\n }),\n );\n decal.rotation.x = -Math.PI / 2;\n decal.position.set(0, 0.14, 0);\n mesh.add(decal);\n }\n\n this.tileGroup.add(mesh);\n this.tilePositions[tile.index] = new THREE.Vector3(pos.x, TOKEN_Y, pos.z);\n this.tileMeshes[tile.index] = mesh;\n this.tileColors[tile.index] = new THREE.Color(colorHex);\n }\n\n const startX = -((buildingCount - 1) * 1.7) / 2;\n for (let i = 0; i < buildingCount; i++) {\n const color = BUILDING_COLORS[i % BUILDING_COLORS.length] ?? 0x6c5ce7;\n const body = new THREE.Mesh(\n new THREE.BoxGeometry(0.9, BODY_H, 0.9),\n new THREE.MeshStandardMaterial({ color }),\n );\n const roof = new THREE.Mesh(\n new THREE.BoxGeometry(1.05, 0.22, 1.05),\n new THREE.MeshStandardMaterial({\n color: new THREE.Color(color).multiplyScalar(0.6),\n }),\n );\n body.position.set(startX + i * 1.7, BODY_H / 2, 0);\n roof.position.set(startX + i * 1.7, BODY_H + 0.11, 0);\n this.cityGroup.add(body, roof);\n this.buildings[i] = { body, roof };\n }\n }\n\n private getDecalTexture(type: string, symbol: string): THREE.CanvasTexture {\n const cached = this.tileDecalTextures.get(type);\n if (cached) return cached;\n const canvas = document.createElement(\"canvas\");\n canvas.width = 96;\n canvas.height = 96;\n const ctx = canvas.getContext(\"2d\");\n if (ctx) {\n ctx.clearRect(0, 0, 96, 96);\n ctx.fillStyle = \"rgba(20,16,40,0.85)\";\n ctx.font = \"bold 64px system-ui, sans-serif\";\n ctx.textAlign = \"center\";\n ctx.textBaseline = \"middle\";\n ctx.fillText(symbol, 48, 52);\n }\n const texture = new THREE.CanvasTexture(canvas);\n this.tileDecalTextures.set(type, texture);\n return texture;\n }\n\n private ringPosition(\n index: number,\n perSide: number,\n extent: number,\n half: number,\n ): THREE.Vector3 {\n const side = Math.floor(index / perSide) % 4;\n const f = (index % perSide) / perSide;\n let x = 0;\n let z = 0;\n switch (side) {\n case 0:\n x = -half + f * extent;\n z = half;\n break;\n case 1:\n x = half;\n z = half - f * extent;\n break;\n case 2:\n x = half - f * extent;\n z = -half;\n break;\n default:\n x = -half;\n z = -half + f * extent;\n break;\n }\n return new THREE.Vector3(x, 0, z);\n }\n\n /** Start/stop the render loop. The host calls this so a suspended mode stops ticking\n * (the Mode Router invariant: only the active mode runs its RAF). */\n setRunning(running: boolean): void {\n const shouldRun = running && !this.disposed;\n if (shouldRun === this.running) return;\n this.running = shouldRun;\n if (shouldRun) this.frame = requestAnimationFrame(this.loop);\n else cancelAnimationFrame(this.frame);\n }\n\n /** Snap the token to a tile with no animation. */\n setPosition(index: number): void {\n const p = this.tilePositions[index];\n if (p) this.token.position.set(p.x, TOKEN_Y, p.z);\n }\n\n /**\n * What the scene currently shows. Read by the module's agent surface (`agent.ts`): a rendered\n * board is a single canvas, so this is the only way the AI Coder's agent can tell where the\n * token stands and whether the board was loaded at all.\n */\n describeScene(): {\n running: boolean;\n tileCount: number;\n buildingCount: number;\n token: { x: number; z: number };\n } {\n return {\n running: this.running,\n tileCount: this.tilePositions.length,\n buildingCount: this.buildings.length,\n token: {\n x: Math.round(this.token.position.x * 100) / 100,\n z: Math.round(this.token.position.z * 100) / 100,\n },\n };\n }\n\n /** Animate the token through the given tile indices, one hop at a time. */\n async moveToken(path: number[]): Promise<void> {\n for (const idx of path) {\n const to = this.tilePositions[idx];\n if (!to) continue;\n await this.tweenTo(to);\n }\n }\n\n /** Pulse a tile (emissive + lift) to mark where the token landed. */\n highlightTile(index: number): void {\n if (this.tileMeshes[index])\n this.highlight = { index, start: performance.now() };\n }\n\n /** Grow the city towers to reflect each building's level. */\n setBuildingLevels(levels: number[]): void {\n for (let i = 0; i < this.buildings.length; i++) {\n const building = this.buildings[i];\n if (!building) continue;\n const level = levels[i] ?? 0;\n const scaleY = 0.5 + level * 0.9;\n const height = BODY_H * scaleY;\n building.body.scale.y = scaleY;\n building.body.position.y = height / 2;\n building.roof.position.y = height + 0.11;\n }\n }\n\n private updateHighlight(): void {\n if (!this.highlight) return;\n const mesh = this.tileMeshes[this.highlight.index];\n const baseColor = this.tileColors[this.highlight.index];\n if (!mesh || !baseColor) {\n this.highlight = null;\n return;\n }\n const mat = mesh.material as THREE.MeshStandardMaterial;\n const t = (performance.now() - this.highlight.start) / HIGHLIGHT_MS;\n if (t >= 1) {\n mat.emissive.setHex(0x000000);\n mat.emissiveIntensity = 1;\n mesh.position.y = TILE_Y;\n this.highlight = null;\n return;\n }\n const pulse = Math.sin(Math.min(1, t) * Math.PI); // 0 → 1 → 0\n mat.emissive.copy(baseColor);\n mat.emissiveIntensity = pulse * 0.9;\n mesh.position.y = TILE_Y + pulse * 0.35;\n }\n\n private tweenTo(to: THREE.Vector3): Promise<void> {\n return new Promise<void>((resolve) => {\n this.tween = {\n from: this.token.position.clone(),\n to: to.clone(),\n start: performance.now(),\n resolve,\n };\n });\n }\n\n private resize(): void {\n const w = this.host.clientWidth || 640;\n const h = this.host.clientHeight || 640;\n this.renderer.setSize(w, h, false);\n this.camera.aspect = w / h;\n this.camera.updateProjectionMatrix();\n }\n\n private disposeGroup(group: THREE.Group): void {\n group.traverse((obj) => {\n if (obj instanceof THREE.Mesh) {\n obj.geometry.dispose();\n const material = obj.material;\n if (Array.isArray(material)) material.forEach((m) => m.dispose());\n else material.dispose();\n }\n });\n group.clear();\n }\n\n destroy(): void {\n this.disposed = true;\n cancelAnimationFrame(this.frame);\n this.resizeObserver?.disconnect();\n this.disposeGroup(this.tileGroup);\n this.disposeGroup(this.cityGroup);\n this.token.geometry.dispose();\n (this.token.material as THREE.Material).dispose();\n if (this.dice[0]) this.dice[0].geometry.dispose();\n this.diceMaterials.forEach((m) => m.dispose());\n this.diceTextures.forEach((t) => t.dispose());\n this.tileDecalTextures.forEach((t) => t.dispose());\n this.renderer.dispose();\n this.renderer.domElement.remove();\n }\n}\n"
130
+ "content": "import * as THREE from \"three\";\nimport type { TileInfo } from \"../data/boardConfig\";\n\nconst TILE_COLORS: Record<string, number> = {\n Chance: 0xf0a020,\n Reward: 0x4caf50,\n Special: 0x9c5cff,\n RandomAction: 0xe8534e,\n Shield: 0x4a90e2,\n};\nconst DEFAULT_TILE_COLOR = 0x6b6b7a;\n\n// Glyphs drawn on top of non-reward tiles so the board reads at a glance.\nconst TILE_SYMBOLS: Record<string, string> = {\n Chance: \"?\",\n Special: \"★\",\n RandomAction: \"✖\",\n Shield: \"✚\",\n};\n\nconst BUILDING_COLORS = [\n 0x6c5ce7, 0x4caf50, 0xe8534e, 0xf0a020, 0x4a90e2, 0x42c9c2,\n];\n\nconst TOKEN_Y = 0.85;\nconst TILE_Y = 0.13;\nconst STEP_MS = 240;\nconst HIGHLIGHT_MS = 850;\nconst BODY_H = 1.0;\nconst DICE_MS = 950;\nconst DICE_BASE_Y = 3.2;\nconst DICE_DROP = 3.4;\n\n// Standard bounce-out easing (0 → 1 with decaying bounces) for the dice toss.\nfunction bounceOut(t: number): number {\n const n1 = 7.5625;\n const d1 = 2.75;\n if (t < 1 / d1) return n1 * t * t;\n if (t < 2 / d1) {\n const u = t - 1.5 / d1;\n return n1 * u * u + 0.75;\n }\n if (t < 2.5 / d1) {\n const u = t - 2.25 / d1;\n return n1 * u * u + 0.9375;\n }\n const u = t - 2.625 / d1;\n return n1 * u * u + 0.984375;\n}\n\n// BoxGeometry face order is +X, -X, +Y, -Y, +Z, -Z. Assign pip values so opposite faces sum to 7.\nconst FACE_VALUES = [2, 5, 3, 4, 1, 6];\n// Resting rotation that brings each value's face to the top (+Y).\nconst REST_ROTATION: Record<number, [number, number, number]> = {\n 1: [-Math.PI / 2, 0, 0],\n 2: [0, 0, Math.PI / 2],\n 3: [0, 0, 0],\n 4: [Math.PI, 0, 0],\n 5: [0, 0, -Math.PI / 2],\n 6: [Math.PI / 2, 0, 0],\n};\n\nconst PIP_LAYOUTS: Record<number, string[]> = {\n 1: [\"C\"],\n 2: [\"TL\", \"BR\"],\n 3: [\"TL\", \"C\", \"BR\"],\n 4: [\"TL\", \"TR\", \"BL\", \"BR\"],\n 5: [\"TL\", \"TR\", \"C\", \"BL\", \"BR\"],\n 6: [\"TL\", \"TR\", \"ML\", \"MR\", \"BL\", \"BR\"],\n};\nconst PIP_COORDS: Record<string, [number, number]> = {\n TL: [0.28, 0.28],\n TR: [0.72, 0.28],\n ML: [0.28, 0.5],\n C: [0.5, 0.5],\n MR: [0.72, 0.5],\n BL: [0.28, 0.72],\n BR: [0.72, 0.72],\n};\n\ninterface Tween {\n from: THREE.Vector3;\n to: THREE.Vector3;\n start: number;\n resolve: () => void;\n}\n\ninterface DiceTween {\n entries: {\n mesh: THREE.Mesh;\n from: THREE.Euler;\n to: THREE.Euler;\n rest: THREE.Euler;\n }[];\n start: number;\n resolve: () => void;\n}\n\ninterface Building {\n body: THREE.Mesh;\n roof: THREE.Mesh;\n}\n\n/**\n * Three.js renderer for the board: a square loop of tiles (colored + glyph-marked by type), the\n * player's city as towers that grow with their level, an animated token, and a pair of pip dice that\n * tumble to the rolled total. Engine-only — it knows nothing about the SDK.\n */\nexport class BoardController {\n private readonly renderer: THREE.WebGLRenderer;\n private readonly scene: THREE.Scene;\n private readonly camera: THREE.PerspectiveCamera;\n private readonly token: THREE.Mesh;\n private readonly tileGroup = new THREE.Group();\n private readonly cityGroup = new THREE.Group();\n private readonly lookAt = new THREE.Vector3(0, 0, 0);\n private readonly diceTextures: THREE.CanvasTexture[] = [];\n private readonly diceMaterials: THREE.MeshStandardMaterial[] = [];\n private readonly dice: THREE.Mesh[] = [];\n private readonly tileDecalTextures = new Map<string, THREE.CanvasTexture>();\n\n private tilePositions: THREE.Vector3[] = [];\n private tileMeshes: THREE.Mesh[] = [];\n private tileColors: THREE.Color[] = [];\n private buildings: Building[] = [];\n private tween: Tween | null = null;\n private diceTween: DiceTween | null = null;\n private highlight: { index: number; start: number } | null = null;\n private frame = 0;\n private disposed = false;\n private running = true;\n private resizeObserver?: ResizeObserver;\n\n private readonly loop = (): void => {\n if (this.disposed) return;\n this.frame = requestAnimationFrame(this.loop);\n\n if (this.tween) {\n const t = Math.min(1, (performance.now() - this.tween.start) / STEP_MS);\n const e = t * t * (3 - 2 * t); // smoothstep\n const { from, to } = this.tween;\n this.token.position.x = from.x + (to.x - from.x) * e;\n this.token.position.z = from.z + (to.z - from.z) * e;\n this.token.position.y = TOKEN_Y + Math.sin(t * Math.PI) * 0.6;\n if (t >= 1) {\n this.token.position.set(to.x, TOKEN_Y, to.z);\n const done = this.tween.resolve;\n this.tween = null;\n done();\n }\n }\n\n this.updateDice();\n this.updateHighlight();\n this.token.rotation.y += 0.02;\n\n // Gentle camera lean: drift the look-at point a fraction toward the token.\n this.lookAt.lerp(\n new THREE.Vector3(\n this.token.position.x * 0.35,\n 0,\n this.token.position.z * 0.35,\n ),\n 0.05,\n );\n this.camera.lookAt(this.lookAt);\n\n this.renderer.render(this.scene, this.camera);\n };\n\n constructor(private readonly host: HTMLElement) {\n this.renderer = new THREE.WebGLRenderer({ antialias: true });\n this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));\n this.renderer.domElement.style.display = \"block\";\n host.appendChild(this.renderer.domElement);\n\n this.scene = new THREE.Scene();\n this.scene.background = new THREE.Color(0x063d99);\n\n this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 200);\n this.camera.position.set(0, 24, 19);\n this.camera.lookAt(0, 0, 0);\n\n const ambient = new THREE.AmbientLight(0xffffff, 0.75);\n const dir = new THREE.DirectionalLight(0xffffff, 0.85);\n dir.position.set(12, 22, 8);\n this.scene.add(ambient, dir, this.tileGroup, this.cityGroup);\n\n const tokenMat = new THREE.MeshStandardMaterial({\n color: 0xffd479,\n emissive: 0x6b5a1f,\n });\n this.token = new THREE.Mesh(\n new THREE.SphereGeometry(0.5, 24, 16),\n tokenMat,\n );\n this.token.position.set(0, TOKEN_Y, 0);\n this.scene.add(this.token);\n\n this.buildDice();\n\n this.resize();\n this.resizeObserver = new ResizeObserver(() => {\n this.resize();\n });\n this.resizeObserver.observe(host);\n\n this.loop();\n }\n\n // ---- dice ----\n\n private buildDice(): void {\n const textureByValue: Record<number, THREE.CanvasTexture> = {};\n for (let v = 1; v <= 6; v++) {\n const texture = new THREE.CanvasTexture(this.makePipCanvas(v));\n this.diceTextures.push(texture);\n textureByValue[v] = texture;\n }\n for (const value of FACE_VALUES) {\n this.diceMaterials.push(\n new THREE.MeshStandardMaterial({\n map: textureByValue[value],\n color: 0xffffff,\n }),\n );\n }\n\n const geo = new THREE.BoxGeometry(1.2, 1.2, 1.2);\n for (let i = 0; i < 2; i++) {\n const die = new THREE.Mesh(geo, this.diceMaterials);\n die.position.set(i === 0 ? -1.0 : 1.0, 3.2, 0);\n const rest = REST_ROTATION[3] ?? [0, 0, 0];\n die.rotation.set(rest[0], 0, rest[2]);\n this.scene.add(die);\n this.dice.push(die);\n }\n }\n\n private makePipCanvas(value: number): HTMLCanvasElement {\n const canvas = document.createElement(\"canvas\");\n canvas.width = 128;\n canvas.height = 128;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return canvas;\n const s = canvas.width;\n ctx.fillStyle = \"#f6f3ff\";\n ctx.fillRect(0, 0, s, s);\n ctx.strokeStyle = \"#d9d2f0\";\n ctx.lineWidth = 4;\n ctx.strokeRect(4, 4, s - 8, s - 8);\n ctx.fillStyle = \"#052e7e\";\n for (const slot of PIP_LAYOUTS[value] ?? []) {\n const coord = PIP_COORDS[slot];\n if (!coord) continue;\n ctx.beginPath();\n ctx.arc(coord[0] * s, coord[1] * s, s * 0.1, 0, Math.PI * 2);\n ctx.fill();\n }\n return canvas;\n }\n\n private splitTotal(total: number): [number, number] {\n const clamped = Math.max(2, Math.min(12, Math.round(total)));\n const min1 = Math.max(1, clamped - 6);\n const max1 = Math.min(6, clamped - 1);\n const v1 = min1 + Math.floor(Math.random() * (max1 - min1 + 1));\n return [v1, clamped - v1];\n }\n\n /** Tumble both dice and land them showing two faces that sum to the rolled total. */\n rollDice(total: number): Promise<void> {\n const values = this.splitTotal(total);\n const entries = this.dice.map((mesh, i) => {\n const r = REST_ROTATION[values[i] ?? 3] ?? [0, 0, 0];\n const rest = new THREE.Euler(r[0], 0, r[2]);\n const turns = (n: number): number => Math.PI * 2 * n;\n const to = new THREE.Euler(\n rest.x + turns(2 + i),\n rest.y + turns(2),\n rest.z + turns(2 + i),\n );\n return { mesh, from: mesh.rotation.clone(), to, rest };\n });\n return new Promise<void>((resolve) => {\n this.diceTween = { entries, start: performance.now(), resolve };\n });\n }\n\n private updateDice(): void {\n if (!this.diceTween) return;\n const t = Math.min(1, (performance.now() - this.diceTween.start) / DICE_MS);\n const e = 1 - Math.pow(1 - t, 3); // easeOutCubic for the tumble\n const y = DICE_BASE_Y + DICE_DROP * (1 - bounceOut(t)); // drop + bounce for the toss\n for (const entry of this.diceTween.entries) {\n entry.mesh.rotation.set(\n entry.from.x + (entry.to.x - entry.from.x) * e,\n entry.from.y + (entry.to.y - entry.from.y) * e,\n entry.from.z + (entry.to.z - entry.from.z) * e,\n );\n entry.mesh.position.y = y;\n }\n if (t >= 1) {\n for (const entry of this.diceTween.entries) {\n entry.mesh.rotation.copy(entry.rest);\n entry.mesh.position.y = DICE_BASE_Y;\n }\n const done = this.diceTween.resolve;\n this.diceTween = null;\n done();\n }\n }\n\n // ---- board ----\n\n loadBoard(tiles: TileInfo[], buildingCount: number): void {\n this.disposeGroup(this.tileGroup);\n this.disposeGroup(this.cityGroup);\n this.tilePositions = [];\n this.tileMeshes = [];\n this.tileColors = [];\n this.buildings = [];\n\n const count = tiles.length;\n const perSide = Math.max(1, Math.round(count / 4));\n const spacing = 1.4;\n const extent = perSide * spacing;\n const half = extent / 2;\n\n for (const tile of tiles) {\n const pos = this.ringPosition(tile.index, perSide, extent, half);\n const colorHex = TILE_COLORS[tile.type] ?? DEFAULT_TILE_COLOR;\n const mesh = new THREE.Mesh(\n new THREE.BoxGeometry(1.1, 0.25, 1.1),\n new THREE.MeshStandardMaterial({ color: colorHex }),\n );\n mesh.position.set(pos.x, TILE_Y, pos.z);\n\n const symbol = TILE_SYMBOLS[tile.type];\n if (symbol) {\n const decal = new THREE.Mesh(\n new THREE.PlaneGeometry(0.7, 0.7),\n new THREE.MeshBasicMaterial({\n map: this.getDecalTexture(tile.type, symbol),\n transparent: true,\n }),\n );\n decal.rotation.x = -Math.PI / 2;\n decal.position.set(0, 0.14, 0);\n mesh.add(decal);\n }\n\n this.tileGroup.add(mesh);\n this.tilePositions[tile.index] = new THREE.Vector3(pos.x, TOKEN_Y, pos.z);\n this.tileMeshes[tile.index] = mesh;\n this.tileColors[tile.index] = new THREE.Color(colorHex);\n }\n\n const startX = -((buildingCount - 1) * 1.7) / 2;\n for (let i = 0; i < buildingCount; i++) {\n const color = BUILDING_COLORS[i % BUILDING_COLORS.length] ?? 0x6c5ce7;\n const body = new THREE.Mesh(\n new THREE.BoxGeometry(0.9, BODY_H, 0.9),\n new THREE.MeshStandardMaterial({ color }),\n );\n const roof = new THREE.Mesh(\n new THREE.BoxGeometry(1.05, 0.22, 1.05),\n new THREE.MeshStandardMaterial({\n color: new THREE.Color(color).multiplyScalar(0.6),\n }),\n );\n body.position.set(startX + i * 1.7, BODY_H / 2, 0);\n roof.position.set(startX + i * 1.7, BODY_H + 0.11, 0);\n this.cityGroup.add(body, roof);\n this.buildings[i] = { body, roof };\n }\n }\n\n private getDecalTexture(type: string, symbol: string): THREE.CanvasTexture {\n const cached = this.tileDecalTextures.get(type);\n if (cached) return cached;\n const canvas = document.createElement(\"canvas\");\n canvas.width = 96;\n canvas.height = 96;\n const ctx = canvas.getContext(\"2d\");\n if (ctx) {\n ctx.clearRect(0, 0, 96, 96);\n ctx.fillStyle = \"rgba(20,16,40,0.85)\";\n ctx.font = \"bold 64px system-ui, sans-serif\";\n ctx.textAlign = \"center\";\n ctx.textBaseline = \"middle\";\n ctx.fillText(symbol, 48, 52);\n }\n const texture = new THREE.CanvasTexture(canvas);\n this.tileDecalTextures.set(type, texture);\n return texture;\n }\n\n private ringPosition(\n index: number,\n perSide: number,\n extent: number,\n half: number,\n ): THREE.Vector3 {\n const side = Math.floor(index / perSide) % 4;\n const f = (index % perSide) / perSide;\n let x = 0;\n let z = 0;\n switch (side) {\n case 0:\n x = -half + f * extent;\n z = half;\n break;\n case 1:\n x = half;\n z = half - f * extent;\n break;\n case 2:\n x = half - f * extent;\n z = -half;\n break;\n default:\n x = -half;\n z = -half + f * extent;\n break;\n }\n return new THREE.Vector3(x, 0, z);\n }\n\n /** Start/stop the render loop. The host calls this so a suspended mode stops ticking\n * (the Mode Router invariant: only the active mode runs its RAF). */\n setRunning(running: boolean): void {\n const shouldRun = running && !this.disposed;\n if (shouldRun === this.running) return;\n this.running = shouldRun;\n if (shouldRun) this.frame = requestAnimationFrame(this.loop);\n else cancelAnimationFrame(this.frame);\n }\n\n /** Snap the token to a tile with no animation. */\n setPosition(index: number): void {\n const p = this.tilePositions[index];\n if (p) this.token.position.set(p.x, TOKEN_Y, p.z);\n }\n\n /**\n * What the scene currently shows. Read by the module's agent surface (`agent.ts`): a rendered\n * board is a single canvas, so this is the only way the AI Coder's agent can tell where the\n * token stands and whether the board was loaded at all.\n */\n describeScene(): {\n running: boolean;\n tileCount: number;\n buildingCount: number;\n token: { x: number; z: number };\n } {\n return {\n running: this.running,\n tileCount: this.tilePositions.length,\n buildingCount: this.buildings.length,\n token: {\n x: Math.round(this.token.position.x * 100) / 100,\n z: Math.round(this.token.position.z * 100) / 100,\n },\n };\n }\n\n /** Animate the token through the given tile indices, one hop at a time. */\n async moveToken(path: number[]): Promise<void> {\n for (const idx of path) {\n const to = this.tilePositions[idx];\n if (!to) continue;\n await this.tweenTo(to);\n }\n }\n\n /** Pulse a tile (emissive + lift) to mark where the token landed. */\n highlightTile(index: number): void {\n if (this.tileMeshes[index])\n this.highlight = { index, start: performance.now() };\n }\n\n /** Grow the city towers to reflect each building's level. */\n setBuildingLevels(levels: number[]): void {\n for (let i = 0; i < this.buildings.length; i++) {\n const building = this.buildings[i];\n if (!building) continue;\n const level = levels[i] ?? 0;\n const scaleY = 0.5 + level * 0.9;\n const height = BODY_H * scaleY;\n building.body.scale.y = scaleY;\n building.body.position.y = height / 2;\n building.roof.position.y = height + 0.11;\n }\n }\n\n private updateHighlight(): void {\n if (!this.highlight) return;\n const mesh = this.tileMeshes[this.highlight.index];\n const baseColor = this.tileColors[this.highlight.index];\n if (!mesh || !baseColor) {\n this.highlight = null;\n return;\n }\n const mat = mesh.material as THREE.MeshStandardMaterial;\n const t = (performance.now() - this.highlight.start) / HIGHLIGHT_MS;\n if (t >= 1) {\n mat.emissive.setHex(0x000000);\n mat.emissiveIntensity = 1;\n mesh.position.y = TILE_Y;\n this.highlight = null;\n return;\n }\n const pulse = Math.sin(Math.min(1, t) * Math.PI); // 0 → 1 → 0\n mat.emissive.copy(baseColor);\n mat.emissiveIntensity = pulse * 0.9;\n mesh.position.y = TILE_Y + pulse * 0.35;\n }\n\n private tweenTo(to: THREE.Vector3): Promise<void> {\n return new Promise<void>((resolve) => {\n this.tween = {\n from: this.token.position.clone(),\n to: to.clone(),\n start: performance.now(),\n resolve,\n };\n });\n }\n\n private resize(): void {\n const w = this.host.clientWidth || 640;\n const h = this.host.clientHeight || 640;\n this.renderer.setSize(w, h, false);\n this.camera.aspect = w / h;\n this.camera.updateProjectionMatrix();\n }\n\n private disposeGroup(group: THREE.Group): void {\n group.traverse((obj) => {\n if (obj instanceof THREE.Mesh) {\n obj.geometry.dispose();\n const material = obj.material;\n if (Array.isArray(material)) material.forEach((m) => m.dispose());\n else material.dispose();\n }\n });\n group.clear();\n }\n\n /**\n * One still frame of the board, for the platform's share-a-shot button.\n *\n * ⚠ Renders and reads in the SAME turn. The renderer is created without\n * `preserveDrawingBuffer`, so by the time anyone else touches this canvas its drawing buffer is\n * already cleared and `toBlob` hands back a black rectangle without failing.\n */\n async capture(): Promise<Blob | null> {\n if (this.disposed) return null;\n this.renderer.render(this.scene, this.camera);\n return await new Promise<Blob | null>((resolve) => {\n this.renderer.domElement.toBlob(resolve, \"image/png\");\n });\n }\n\n destroy(): void {\n this.disposed = true;\n cancelAnimationFrame(this.frame);\n this.resizeObserver?.disconnect();\n this.disposeGroup(this.tileGroup);\n this.disposeGroup(this.cityGroup);\n this.token.geometry.dispose();\n (this.token.material as THREE.Material).dispose();\n if (this.dice[0]) this.dice[0].geometry.dispose();\n this.diceMaterials.forEach((m) => m.dispose());\n this.diceTextures.forEach((t) => t.dispose());\n this.tileDecalTextures.forEach((t) => t.dispose());\n this.renderer.dispose();\n this.renderer.domElement.remove();\n }\n}\n"
131
131
  },
132
132
  {
133
133
  "path": "index.ts",
@@ -159,7 +159,7 @@
159
159
  },
160
160
  {
161
161
  "path": "scene.ts",
162
- "content": "import type { EngineScene, SceneMountContext } from \"@idosgames/module-sdk\";\nimport { BoardController } from \"./game/BoardController\";\nimport type { ControllerBox } from \"./controller-box\";\n\n// Wraps the Three.js board renderer as a host-driven EngineScene. Created on mount (it appends its\n// own <canvas> to the host element and starts a RAF loop), published into the shared box for the\n// React overlay, and paused/resumed by the Mode Router via activate/suspend.\nexport function createBoardScene(\n box: ControllerBox<BoardController>,\n): EngineScene {\n let controller: BoardController | null = null;\n return {\n surface: \"fullbleed-canvas\",\n mount(ctx: SceneMountContext): void {\n controller = new BoardController(ctx.host);\n box.set(controller);\n },\n activate(): void {\n controller?.setRunning(true);\n },\n suspend(): void {\n controller?.setRunning(false);\n },\n destroy(): void {\n box.set(null);\n controller?.destroy();\n controller = null;\n },\n };\n}\n"
162
+ "content": "import type { EngineScene, SceneMountContext } from \"@idosgames/module-sdk\";\nimport { BoardController } from \"./game/BoardController\";\nimport type { ControllerBox } from \"./controller-box\";\n\n// Wraps the Three.js board renderer as a host-driven EngineScene. Created on mount (it appends its\n// own <canvas> to the host element and starts a RAF loop), published into the shared box for the\n// React overlay, and paused/resumed by the Mode Router via activate/suspend.\nexport function createBoardScene(\n box: ControllerBox<BoardController>,\n): EngineScene {\n let controller: BoardController | null = null;\n return {\n surface: \"fullbleed-canvas\",\n mount(ctx: SceneMountContext): void {\n controller = new BoardController(ctx.host);\n box.set(controller);\n },\n activate(): void {\n controller?.setRunning(true);\n },\n suspend(): void {\n controller?.setRunning(false);\n },\n capture(): Promise<Blob | null> {\n return controller?.capture() ?? Promise.resolve(null);\n },\n destroy(): void {\n box.set(null);\n controller?.destroy();\n controller = null;\n },\n };\n}\n"
163
163
  },
164
164
  {
165
165
  "path": "module.meta.json",
@@ -48,10 +48,10 @@
48
48
  }
49
49
  },
50
50
  "dependencies": {
51
- "@idosgames/core": "0.14.1",
52
- "@idosgames/module-sdk": "0.3.1",
53
- "@idosgames/react": "0.2.7",
54
- "@idosgames/wallet": "0.2.7",
51
+ "@idosgames/core": "0.17.0",
52
+ "@idosgames/module-sdk": "0.4.0",
53
+ "@idosgames/react": "0.2.10",
54
+ "@idosgames/wallet": "0.4.1",
55
55
  "@tanstack/react-query": "5.101.2",
56
56
  "react": "19.2.7",
57
57
  "viem": "2.55.2",
@@ -49,17 +49,17 @@
49
49
  }
50
50
  },
51
51
  "dependencies": {
52
- "@idosgames/core": "0.14.1",
53
- "@idosgames/module-sdk": "0.3.1",
54
- "@idosgames/react": "0.2.7",
55
- "@idosgames/wallet": "0.2.7",
52
+ "@idosgames/core": "0.17.0",
53
+ "@idosgames/module-sdk": "0.4.0",
54
+ "@idosgames/react": "0.2.10",
55
+ "@idosgames/wallet": "0.4.1",
56
56
  "@tanstack/react-query": "5.101.2",
57
57
  "phaser": "4.2.1",
58
58
  "react": "19.2.7",
59
59
  "viem": "2.55.2",
60
60
  "wagmi": "3.7.2"
61
61
  },
62
- "contentHash": "c395a20365d53fd5aee509d40dfd9dfad5976b822ec12e808e460f0d0cba432d",
62
+ "contentHash": "143c4595784974ca4d58d5032e77b26e93d1d96c2455974c796c7d5c1d710ba6",
63
63
  "files": [
64
64
  {
65
65
  "path": "chrome.ts",
@@ -107,7 +107,7 @@
107
107
  },
108
108
  {
109
109
  "path": "game/IdleRpgController.ts",
110
- "content": "import Phaser from \"phaser\";\nimport { IdleRpgScene, type CharacterDisplay } from \"../phaser/IdleRpgScene\";\n\n/** Owns the Phaser game instance and bridges React → scene (selecting a character). */\nexport class IdleRpgController {\n private readonly game: Phaser.Game;\n private readonly scene: IdleRpgScene;\n\n constructor(parent: HTMLElement) {\n this.scene = new IdleRpgScene();\n this.game = new Phaser.Game({\n type: Phaser.AUTO,\n parent,\n width: 480,\n height: 640,\n backgroundColor: \"#063d99\",\n scene: this.scene,\n scale: {\n // Hug the left of the full-bleed host so the host's UI panel can dock on the right without\n // covering the character card.\n mode: Phaser.Scale.FIT,\n autoCenter: Phaser.Scale.CENTER_VERTICALLY,\n },\n });\n\n // Makes the game observable in the AI Coder's live preview, which reads this exact global.\n //\n // Phaser sets it itself — but only in its debug build (`Game.boot` guards it behind\n // WEBGL_DEBUG), and the release build shipped by npm has that branch stripped. So a Phaser game\n // is invisible to any outside observer unless it hands itself over, which is this one line.\n // Verified live: without it the preview reports no engine; with it, scenes, object counts,\n // camera and loop fps all come through.\n (globalThis as unknown as Record<string, unknown>).PHASER_GAME = this.game;\n }\n\n selectCharacter(display: CharacterDisplay): void {\n this.scene.setSelected(display);\n this.selected = display;\n }\n\n /** Последний выбранный персонаж — единственное, что сцена показывает и чего нет в DOM. */\n private selected: CharacterDisplay | null = null;\n\n /**\n * Что сейчас на канвасе. Читается поверхностью для агента (`module.ts`): у отрисованной сцены\n * нет DOM, и без этого агент не может сказать, дошёл ли выбор персонажа до Phaser.\n */\n describeScene(): { running: boolean; selected: CharacterDisplay | null } {\n return { running: this.game.loop.running, selected: this.selected };\n }\n\n /** Pause/resume the Phaser RAF loop. The host calls this so a suspended mode stops ticking\n * (the Mode Router invariant: only the active mode runs). */\n setRunning(running: boolean): void {\n if (running) this.game.loop.wake();\n else this.game.loop.sleep();\n }\n\n destroy(): void {\n this.game.destroy(true);\n // Drop the debug global with the game itself: the Mode Router destroys a suspended mode, and a\n // stale reference here would show an observer a game that no longer exists.\n const globals = globalThis as unknown as Record<string, unknown>;\n if (globals[\"PHASER_GAME\"] === this.game) delete globals[\"PHASER_GAME\"];\n }\n}\n"
110
+ "content": "import Phaser from \"phaser\";\nimport { IdleRpgScene, type CharacterDisplay } from \"../phaser/IdleRpgScene\";\n\n/** Owns the Phaser game instance and bridges React → scene (selecting a character). */\nexport class IdleRpgController {\n private readonly game: Phaser.Game;\n private readonly scene: IdleRpgScene;\n\n constructor(parent: HTMLElement) {\n this.scene = new IdleRpgScene();\n this.game = new Phaser.Game({\n type: Phaser.AUTO,\n parent,\n width: 480,\n height: 640,\n backgroundColor: \"#063d99\",\n scene: this.scene,\n scale: {\n // Hug the left of the full-bleed host so the host's UI panel can dock on the right without\n // covering the character card.\n mode: Phaser.Scale.FIT,\n autoCenter: Phaser.Scale.CENTER_VERTICALLY,\n },\n });\n\n // Makes the game observable in the AI Coder's live preview, which reads this exact global.\n //\n // Phaser sets it itself — but only in its debug build (`Game.boot` guards it behind\n // WEBGL_DEBUG), and the release build shipped by npm has that branch stripped. So a Phaser game\n // is invisible to any outside observer unless it hands itself over, which is this one line.\n // Verified live: without it the preview reports no engine; with it, scenes, object counts,\n // camera and loop fps all come through.\n (globalThis as unknown as Record<string, unknown>).PHASER_GAME = this.game;\n }\n\n selectCharacter(display: CharacterDisplay): void {\n this.scene.setSelected(display);\n this.selected = display;\n }\n\n /** Последний выбранный персонаж — единственное, что сцена показывает и чего нет в DOM. */\n private selected: CharacterDisplay | null = null;\n\n /**\n * Что сейчас на канвасе. Читается поверхностью для агента (`module.ts`): у отрисованной сцены\n * нет DOM, и без этого агент не может сказать, дошёл ли выбор персонажа до Phaser.\n */\n describeScene(): { running: boolean; selected: CharacterDisplay | null } {\n return { running: this.game.loop.running, selected: this.selected };\n }\n\n /** Pause/resume the Phaser RAF loop. The host calls this so a suspended mode stops ticking\n * (the Mode Router invariant: only the active mode runs). */\n setRunning(running: boolean): void {\n if (running) this.game.loop.wake();\n else this.game.loop.sleep();\n }\n\n /**\n * Один кадр игры для платформенной кнопки «поделиться снимком».\n *\n * ⚠ Через `renderer.snapshot`, а НЕ `canvas.toBlob`. У WebGL-рендерера Phaser (как и у любого\n * другого) буфер отрисовки очищен к моменту, когда до канвы доберётся посторонний код, и чтение\n * снаружи отдаёт ЧЁРНЫЙ прямоугольник, ничем не пожаловавшись — проверено живьём на стенде,\n * ровно так этот модуль и снимался, пока снимал его общий запасной путь хоста. `snapshot`\n * встроен в Phaser именно для этого: он забирает пиксели сразу после следующей отрисовки.\n *\n * Поэтому же сначала `wake()`: у остановленного цикла (режим только что был скрыт) следующей\n * отрисовки не случится вовсе, и обещание висело бы до таймаута.\n */\n capture(): Promise<Blob | null> {\n return new Promise<Blob | null>((resolve) => {\n let settled = false;\n const finish = (value: Blob | null) => {\n if (settled) return;\n settled = true;\n resolve(value);\n };\n\n // Кадр не пришёл (контекст потерян, игра уничтожена между вызовом и отрисовкой) — отвечаем\n // «нет», а не висим: у вызывающего за нами стоит следующий способ снять игру.\n setTimeout(() => finish(null), 1500);\n\n try {\n this.game.loop.wake();\n this.game.renderer.snapshot((result) => {\n if (!(result instanceof HTMLImageElement)) return finish(null);\n const canvas = document.createElement(\"canvas\");\n canvas.width = result.naturalWidth || result.width;\n canvas.height = result.naturalHeight || result.height;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return finish(null);\n ctx.drawImage(result, 0, 0);\n canvas.toBlob(finish, \"image/png\");\n });\n } catch {\n finish(null);\n }\n });\n }\n\n destroy(): void {\n this.game.destroy(true);\n // Drop the debug global with the game itself: the Mode Router destroys a suspended mode, and a\n // stale reference here would show an observer a game that no longer exists.\n const globals = globalThis as unknown as Record<string, unknown>;\n if (globals[\"PHASER_GAME\"] === this.game) delete globals[\"PHASER_GAME\"];\n }\n}\n"
111
111
  },
112
112
  {
113
113
  "path": "index.ts",
@@ -131,7 +131,7 @@
131
131
  },
132
132
  {
133
133
  "path": "scene.ts",
134
- "content": "import type { EngineScene, SceneMountContext } from \"@idosgames/module-sdk\";\nimport { IdleRpgController } from \"./game/IdleRpgController\";\nimport type { ControllerBox } from \"./controller-box\";\n\n// Wraps the Phaser controller as a host-driven EngineScene. Created lazily on mount (Phaser needs a\n// parent element), published into the shared box for the React panel, and paused/resumed by the\n// Mode Router via activate/suspend so a hidden mode stops ticking.\nexport function createIdleRpgScene(\n box: ControllerBox<IdleRpgController>,\n): EngineScene {\n let controller: IdleRpgController | null = null;\n return {\n surface: \"fullbleed-canvas\",\n mount(ctx: SceneMountContext): void {\n controller = new IdleRpgController(ctx.host);\n box.set(controller);\n },\n activate(): void {\n controller?.setRunning(true);\n },\n suspend(): void {\n controller?.setRunning(false);\n },\n destroy(): void {\n box.set(null);\n controller?.destroy();\n controller = null;\n },\n };\n}\n"
134
+ "content": "import type { EngineScene, SceneMountContext } from \"@idosgames/module-sdk\";\nimport { IdleRpgController } from \"./game/IdleRpgController\";\nimport type { ControllerBox } from \"./controller-box\";\n\n// Wraps the Phaser controller as a host-driven EngineScene. Created lazily on mount (Phaser needs a\n// parent element), published into the shared box for the React panel, and paused/resumed by the\n// Mode Router via activate/suspend so a hidden mode stops ticking.\nexport function createIdleRpgScene(\n box: ControllerBox<IdleRpgController>,\n): EngineScene {\n let controller: IdleRpgController | null = null;\n return {\n surface: \"fullbleed-canvas\",\n mount(ctx: SceneMountContext): void {\n controller = new IdleRpgController(ctx.host);\n box.set(controller);\n },\n activate(): void {\n controller?.setRunning(true);\n },\n suspend(): void {\n controller?.setRunning(false);\n },\n capture(): Promise<Blob | null> {\n return controller?.capture() ?? Promise.resolve(null);\n },\n destroy(): void {\n box.set(null);\n controller?.destroy();\n controller = null;\n },\n };\n}\n"
135
135
  },
136
136
  {
137
137
  "path": "module.meta.json",