diff --git a/apps/website/public/queen/models/LICENSE-kenney-hexagon-kit.txt b/apps/website/public/queen/models/LICENSE-kenney-hexagon-kit.txt deleted file mode 100644 index 606821ae8f..0000000000 --- a/apps/website/public/queen/models/LICENSE-kenney-hexagon-kit.txt +++ /dev/null @@ -1,18 +0,0 @@ -Kenney Hexagon Kit — CC0 1.0 Universal (public domain) -https://kenney.nl/assets/hexagon-kit - -The Queen game field draws from this kit alone. Models shipped here: - - terrain hex-grass, hex-grass-forest, hex-grass-hill, hex-dirt, - hex-stone, hex-stone-rocks, hex-stone-mountain - settlements hex-building-cabin, hex-building-mill, hex-building-archery, - hex-building-house, hex-building-village, hex-building-market, - hex-building-farm, hex-building-mine, hex-building-smelter - castle hex-building-wall, hex-building-walls, hex-building-tower, - hex-building-wizard-tower, hex-building-castle - units hex-unit-tower, hex-unit-house, hex-unit-tree - -Every model references Textures/colormap.png, which ships beside them. - -The Kenney Space Kit was removed on 2026-09-06: two art kits in one scene -read as two games, and half of its models carried no fact from GitHub. diff --git a/apps/website/public/queen/models/README.txt b/apps/website/public/queen/models/README.txt new file mode 100644 index 0000000000..fc7cd37102 --- /dev/null +++ b/apps/website/public/queen/models/README.txt @@ -0,0 +1,7 @@ +The field draws no 3D models. + +On 2026-09-06 the operator asked for the 3D objects to go: "убери 3D объекты +а то шума много", "замки не красиво смотрятся, а соты мёд пчёлы классно +читается". Every fact is now a cell - its rim, its cap, its colour - so the +Kenney Hexagon Kit (24 GLB, CC0) was removed along with the Space Kit before +it. Nothing here is loaded at runtime. diff --git a/apps/website/public/queen/models/Textures/colormap.png b/apps/website/public/queen/models/Textures/colormap.png deleted file mode 100644 index d92d4bf772..0000000000 Binary files a/apps/website/public/queen/models/Textures/colormap.png and /dev/null differ diff --git a/apps/website/public/queen/models/hex-building-archery.glb b/apps/website/public/queen/models/hex-building-archery.glb deleted file mode 100644 index 1495cde75e..0000000000 Binary files a/apps/website/public/queen/models/hex-building-archery.glb and /dev/null differ diff --git a/apps/website/public/queen/models/hex-building-cabin.glb b/apps/website/public/queen/models/hex-building-cabin.glb deleted file mode 100644 index 76b37b5af4..0000000000 Binary files a/apps/website/public/queen/models/hex-building-cabin.glb and /dev/null differ diff --git a/apps/website/public/queen/models/hex-building-castle.glb b/apps/website/public/queen/models/hex-building-castle.glb deleted file mode 100644 index 495a1e0e69..0000000000 Binary files a/apps/website/public/queen/models/hex-building-castle.glb and /dev/null differ diff --git a/apps/website/public/queen/models/hex-building-farm.glb b/apps/website/public/queen/models/hex-building-farm.glb deleted file mode 100644 index 561c9d76c4..0000000000 Binary files a/apps/website/public/queen/models/hex-building-farm.glb and /dev/null differ diff --git a/apps/website/public/queen/models/hex-building-house.glb b/apps/website/public/queen/models/hex-building-house.glb deleted file mode 100644 index b823ed0e2e..0000000000 Binary files a/apps/website/public/queen/models/hex-building-house.glb and /dev/null differ diff --git a/apps/website/public/queen/models/hex-building-market.glb b/apps/website/public/queen/models/hex-building-market.glb deleted file mode 100644 index 62e1d77080..0000000000 Binary files a/apps/website/public/queen/models/hex-building-market.glb and /dev/null differ diff --git a/apps/website/public/queen/models/hex-building-mill.glb b/apps/website/public/queen/models/hex-building-mill.glb deleted file mode 100644 index 2ca71d4c18..0000000000 Binary files a/apps/website/public/queen/models/hex-building-mill.glb and /dev/null differ diff --git a/apps/website/public/queen/models/hex-building-mine.glb b/apps/website/public/queen/models/hex-building-mine.glb deleted file mode 100644 index 6d04846d65..0000000000 Binary files a/apps/website/public/queen/models/hex-building-mine.glb and /dev/null differ diff --git a/apps/website/public/queen/models/hex-building-smelter.glb b/apps/website/public/queen/models/hex-building-smelter.glb deleted file mode 100644 index 962ea9471f..0000000000 Binary files a/apps/website/public/queen/models/hex-building-smelter.glb and /dev/null differ diff --git a/apps/website/public/queen/models/hex-building-tower.glb b/apps/website/public/queen/models/hex-building-tower.glb deleted file mode 100644 index 1a76e832be..0000000000 Binary files a/apps/website/public/queen/models/hex-building-tower.glb and /dev/null differ diff --git a/apps/website/public/queen/models/hex-building-village.glb b/apps/website/public/queen/models/hex-building-village.glb deleted file mode 100644 index 6e843a4f5a..0000000000 Binary files a/apps/website/public/queen/models/hex-building-village.glb and /dev/null differ diff --git a/apps/website/public/queen/models/hex-building-wall.glb b/apps/website/public/queen/models/hex-building-wall.glb deleted file mode 100644 index 6973aa3043..0000000000 Binary files a/apps/website/public/queen/models/hex-building-wall.glb and /dev/null differ diff --git a/apps/website/public/queen/models/hex-building-walls.glb b/apps/website/public/queen/models/hex-building-walls.glb deleted file mode 100644 index 5977213d0a..0000000000 Binary files a/apps/website/public/queen/models/hex-building-walls.glb and /dev/null differ diff --git a/apps/website/public/queen/models/hex-building-wizard-tower.glb b/apps/website/public/queen/models/hex-building-wizard-tower.glb deleted file mode 100644 index 6acc63a523..0000000000 Binary files a/apps/website/public/queen/models/hex-building-wizard-tower.glb and /dev/null differ diff --git a/apps/website/public/queen/models/hex-dirt.glb b/apps/website/public/queen/models/hex-dirt.glb deleted file mode 100644 index 40fa4bc6e3..0000000000 Binary files a/apps/website/public/queen/models/hex-dirt.glb and /dev/null differ diff --git a/apps/website/public/queen/models/hex-grass-forest.glb b/apps/website/public/queen/models/hex-grass-forest.glb deleted file mode 100644 index 66d726f174..0000000000 Binary files a/apps/website/public/queen/models/hex-grass-forest.glb and /dev/null differ diff --git a/apps/website/public/queen/models/hex-grass-hill.glb b/apps/website/public/queen/models/hex-grass-hill.glb deleted file mode 100644 index 1ac899a6c4..0000000000 Binary files a/apps/website/public/queen/models/hex-grass-hill.glb and /dev/null differ diff --git a/apps/website/public/queen/models/hex-grass.glb b/apps/website/public/queen/models/hex-grass.glb deleted file mode 100644 index 6e8f1cc7d9..0000000000 Binary files a/apps/website/public/queen/models/hex-grass.glb and /dev/null differ diff --git a/apps/website/public/queen/models/hex-stone-mountain.glb b/apps/website/public/queen/models/hex-stone-mountain.glb deleted file mode 100644 index 121632ba35..0000000000 Binary files a/apps/website/public/queen/models/hex-stone-mountain.glb and /dev/null differ diff --git a/apps/website/public/queen/models/hex-stone-rocks.glb b/apps/website/public/queen/models/hex-stone-rocks.glb deleted file mode 100644 index 8ab862ae84..0000000000 Binary files a/apps/website/public/queen/models/hex-stone-rocks.glb and /dev/null differ diff --git a/apps/website/public/queen/models/hex-stone.glb b/apps/website/public/queen/models/hex-stone.glb deleted file mode 100644 index 2c199c76ff..0000000000 Binary files a/apps/website/public/queen/models/hex-stone.glb and /dev/null differ diff --git a/apps/website/public/queen/models/hex-unit-house.glb b/apps/website/public/queen/models/hex-unit-house.glb deleted file mode 100644 index 057d4a785c..0000000000 Binary files a/apps/website/public/queen/models/hex-unit-house.glb and /dev/null differ diff --git a/apps/website/public/queen/models/hex-unit-tower.glb b/apps/website/public/queen/models/hex-unit-tower.glb deleted file mode 100644 index 228b582bbf..0000000000 Binary files a/apps/website/public/queen/models/hex-unit-tower.glb and /dev/null differ diff --git a/apps/website/public/queen/models/hex-unit-tree.glb b/apps/website/public/queen/models/hex-unit-tree.glb deleted file mode 100644 index ff8ec31218..0000000000 Binary files a/apps/website/public/queen/models/hex-unit-tree.glb and /dev/null differ diff --git a/apps/website/qa/queen-honesty-contract.mjs b/apps/website/qa/queen-honesty-contract.mjs index d1e57a224a..f67bf74e27 100644 --- a/apps/website/qa/queen-honesty-contract.mjs +++ b/apps/website/qa/queen-honesty-contract.mjs @@ -3,7 +3,7 @@ // source facts a regex CAN state. Node 22 needs --experimental-strip-types // to import the TypeScript module; package.json passes it. import { readFileSync } from "node:fs"; -import { alertCount, alertSpan, beeSilence, buildingPlan, castlePlaces, epicProgress, familyTint, feedCoverage, ringFamily, ringOfEpic, ringSummary, towerStage, wallBetween, ringOfModulePath, epicOfIssue, hexRingCells, foundationCells, foundationOrder, hexCellCount, hexCentres, hexCorners, hexField, hexIndexAt, hexRing, hexRingStart, hexToWorld, honeyTone, layersFromSearch, spiralAxial, spiralIndex, spiralOrder, HEX_R, S_CELL, buildingTint, cellGeometry, countdownFor, eventIdentity, mergeActivity, serverOffsetMs, decisionDetail, fieldShape, moduleColumn, moduleFor, moduleId, pathInTitle, placeCards, rewriteEndpoints, ringOrder, ringTone, roundStrip, skipReasonWords, staleAge, territoryOf, planHash, withOpenIssues } from "../src/components/queenHud.ts"; +import { alertCount, alertSpan, beeSilence, buildingPlan, castlePlaces, epicProgress, familyTint, feedCoverage, ringFamily, ringOfEpic, ringSummary, towerStage, wallBetween, ringOfModulePath, epicOfIssue, hexRingCells, foundationCells, foundationOrder, hexCellCount, hexCentres, hexCorners, hexField, hexIndexAt, hexRing, hexRingStart, hexToWorld, honeyTone, layersFromSearch, spiralAxial, spiralIndex, spiralOrder, HEX_R, S_CELL, buildingTint, cellGeometry, countdownFor, eventIdentity, mergeActivity, serverOffsetMs, decisionDetail, fieldShape, moduleColumn, moduleFor, moduleId, pathInTitle, placeCards, rewriteEndpoints, ringOrder, ringTone, roundStrip, skipReasonWords, staleAge, territoryOf, planHash, withOpenIssues, HIVE_TONES, hiveCoverOf, hiveKey, hiveToneOf } from "../src/components/queenHud.ts"; const fails = []; let checks = 0; @@ -177,11 +177,23 @@ check(ringOfModulePath("rings/SR-00") === "SR-00" && ringOfModulePath("rings/RUS check(epicOfIssue(101, [ksEpic({ number: 9001, children: ksKids(3, 2) }), ksEpic({ number: 9002, children: [] })])?.number === 9001 && epicOfIssue(1, [ksEpic({ children: ksKids(3, 2) })]) === null, "epicOfIssue: the first epic listing the issue among its children, else null"); check(/data-working-age/.test(combSrc) && /kind === "finished" \|\| kind === "error"/.test(combSrc) && !/WORK_WINDOW_MS/.test(combSrc) && /const i = cellOfPath\(title\);/.test(combSrc), "work is a state, not an age: the cell is worked while the issue's LAST wire event is not terminal, and the age rides beside the count (measured 2026-09-06: the wire burst-delivers, newest row 18 min old while the swarm reported working)"); check(/if \(hover >= 0 && hover !== p && cells\[hover\]\) placeDashed/.test(combSrc), "every cell under the pointer lights up, not only the ones carrying a card"); +check(!/\.glb/.test(combSrc) && !/LoadAssetContainerAsync/.test(combSrc) && /data-look", "hive"/.test(combSrc) && /CreateLineSystem\("queen-mark"/.test(combSrc), "the hive draws no model at all: no .glb is named, no asset container is loaded, and the Queen at the centre is the mark itself (the user, 2026-09-06)"); const SPACE_KIT = ["platform_small", "machine_generatorLarge", "satelliteDish_large", "hangar_smallA", "hangar_roundA", "structure_closed", "gate_complex", "crater", "hangar_largeA", "rock_crystalsLargeA", "astronautA", "astronautB", "rover", "alien"]; check(SPACE_KIT.every((m) => !combSrc.includes(m)), `the field draws one kit: no space-kit model may be named (${SPACE_KIT.filter((m) => combSrc.includes(m)).join(", ") || "none"})`); check(/host\.setAttribute\("data-foundation-shape", "outline"\)/.test(combSrc) && /CreateLineSystem\("cells"/.test(combSrc) && !/CreateCylinder\("honey"/.test(combSrc) && !/hex-sand/.test(combSrc), "the cells are outlines: a line system per cell, no honey disc and no filled tile model (the user, 2026-09-06)"); check(/host\.setAttribute\("data-hover-issue"/.test(combSrc) && /queen27-hover-card/.test(combSrc), "a hovered cell names its GitHub issue on the host and in the card"); check(/host\.setAttribute\("data-castle-source", fdNow \? fdNow\.source : "none"\)/.test(combSrc) && /data-castle-stages/.test(combSrc) && /data-castle-unassigned/.test(combSrc), "the castle's testimony on the host comes from the snapshot's source, never a guess; stages and unassigned epics are named (K-2)"); +check(HIVE_TONES.t27 === "#FFD45A" && HIVE_TONES.awaiting === "#64DCFF" && HIVE_TONES.manual === "#FF4D5E" && HIVE_TONES.hover === "#FFC24D", "the hive's colour law is exact: yellow T27, neon blue awaiting, red manual, honey only for the hand"); +const claimed = new Set([hiveKey("Access Control")]); +check(hiveCoverOf("agent-server/apps/access-control", claimed) === "t27" && hiveCoverOf("agent-server/apps/manual", claimed) === "manual" && hiveCoverOf(null, claimed) === "awaiting" && hiveToneOf("manual")[0] > hiveToneOf("manual")[2], "cover is a claim against the module: T27, manual, or awaiting; no decoration chooses it"); +check(/fieldRoot\.rotation\.x = Math\.PI \/ 2/.test(combSrc) && /new ArcRotateCamera\("cam", Math\.PI \/ 2, Math\.PI \/ 2/.test(combSrc) && /data-orientation", "facing"/.test(combSrc), "the comb is a wall facing the player, not a horizontal board (the user, 2026-09-06)"); +check(/HIVE_TONES\.hover/.test(combSrc) && /hiveToneOf\(covers\[i\]\)/.test(combSrc) && /queen27-hive-law/.test(combSrc), "hover is honey while cell claims follow the law, and the legend names that law"); +check(/fieldRoot\.position\.y = Math\.sin\(nowMs \/ 6400\) \* 7/.test(combSrc), "the wall floats; the drift is small enough not to steal the cell from under the pointer"); +check(/camera\.target\.y \+= \(-anchor\.z - camera\.target\.y\) \* k/.test(combSrc) && /const p = planeAt\(x, y\)/.test(combSrc), "zoom and picking use the same vertical wall-plane coordinates"); +check(/host\.setAttribute\("data-event-cards", String\(latest\.length\)\)/.test(combSrc) && /const EVENT_CARD_POOL = 12/.test(combSrc) && /new DynamicTexture\(`event-card-\$\{k\}`, 256/.test(combSrc), "the latest wire events become bounded display cards on their cells, capped at a readable twelve"); +check(/function useT27Coverage\(\)/.test(src) && /fetch\("t27\/manifest\.json"/.test(src) && /spec\.repo !== "trinity"/.test(src) && /hiveKey\(value\)/.test(src), "yellow is claimed only by trinity rows read from the real T27 manifest, not by an issue label or a guess"); +check(/hiveLawT27:\s*"T27 covered"/.test(src) && /hiveLawManual:\s*"manual code"/.test(src) && /hiveLawAwaiting:\s*"awaiting T27"/.test(src) && /hiveLawBees:\s*"bees"/.test(src), "the English colour-law words exist"); +check(/hiveLawT27:\s*"покрыто T27"/.test(src) && /hiveLawManual:\s*"ручной код"/.test(src) && /hiveLawAwaiting:\s*"ждёт T27"/.test(src) && /hiveLawBees:\s*"пчёлы"/.test(src), "the Russian colour-law words exist"); check(decisionDetail({ allowed: false, refusal: null }, 0, 1, L) !== "0 executing now", "self-test"); if (fails.length) { for (const f of fails) console.log(" ✗ " + f); console.log(`Queen honesty contract: FAIL (${fails.length})`); process.exit(1); } diff --git a/apps/website/scripts/queen-kie-assets.mjs b/apps/website/scripts/queen-kie-assets.mjs new file mode 100644 index 0000000000..20720143c4 --- /dev/null +++ b/apps/website/scripts/queen-kie-assets.mjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node +// Optional KIE.AI asset front end for the hive display. +// +// Status, 2026-09-06: the supplied key is NOT usable. The API answers +// 401 Unauthorized / "Organization access is disabled", so this repository +// contains no generated cards and the Babylon comb remains pure geometry. +// Run this only after an organization-enabled KIE_API_KEY exists. The key is +// deliberately read from the environment and never written to git. +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const OUT_DIR = resolve(ROOT, "public/queen/kie"); +const API = "https://api.kie.ai/api/v1"; + +const ASSETS = { + "event-card": "square card face for a dark science-fiction strategy HUD, raised honeycomb cell, deep near-black green background, one subtle honey-yellow border, crisp geometric slots for text, no letters, no numbers, no logos", + "cell-frame": "single hexagonal honeycomb frame for a dark science-fiction strategy game, precise thin luminous rim, near-black green interior, subtle depth and wax material, centered with clear outer margin, no text, no logo", +}; + +const args = process.argv.slice(2); +const argOf = (name) => { + const at = args.indexOf(`--${name}`); + return at >= 0 && args[at + 1] ? args[at + 1] : null; +}; +const asset = argOf("asset") ?? "event-card"; +const promptOverride = argOf("prompt"); +const seed = Number.parseInt(argOf("seed") ?? "27050", 10); + +if (!Object.hasOwn(ASSETS, asset)) { + console.error(`unknown --asset ${asset}; choose ${Object.keys(ASSETS).join(", ")}`); + process.exit(2); +} +if (!Number.isInteger(seed) || seed < 0 || seed > 2_147_483_647) { + console.error("--seed must be an integer from 0 through 2147483647"); + process.exit(2); +} + +const key = process.env.KIE_API_KEY; +if (!key) { + console.error("KIE_API_KEY is required. Do not paste keys into this script."); + process.exit(2); +} + +const headers = { Authorization: `Bearer ${key}`, "Content-Type": "application/json" }; +const request = async (url, init = {}) => { + const response = await fetch(url, { ...init, headers: { ...headers, ...(init.headers ?? {}) } }); + const text = await response.text(); + let body; + try { body = JSON.parse(text); } catch { body = { raw: text }; } + if (!response.ok) { + throw new Error(`KIE ${response.status}: ${typeof body === "object" ? body.msg ?? body.error ?? text : text}`); + } + return body; +}; + +const created = await request(`${API}/jobs/createTask`, { + method: "POST", + body: JSON.stringify({ + model: "bytedance/seedream-v4-text-to-image", + input: { + prompt: promptOverride ?? ASSETS[asset], + image_size: "square_hd", + image_resolution: "1K", + max_images: 1, + seed, + nsfw_checker: true, + }, + }), +}); +const taskId = created?.data?.taskId ?? created?.result?.task_id ?? created?.task_id; +if (!taskId) throw new Error(`KIE created no task id: ${JSON.stringify(created)}`); +console.log(`KIE task ${taskId}`); + +const deadline = Date.now() + 5 * 60_000; +let urls = []; +for (;;) { + await new Promise((r) => setTimeout(r, 4_000)); + const record = await request(`${API}/jobs/recordInfo?taskId=${encodeURIComponent(taskId)}`); + const state = record?.data?.state ?? record?.state; + console.log(`state ${state}`); + if (state === "success") { + urls = record?.data?.resultUrls ?? record?.result_urls ?? record?.resultUrls ?? []; + break; + } + if (state === "failed" || Date.now() > deadline) { + throw new Error(`KIE task did not succeed: ${JSON.stringify(record).slice(0, 1000)}`); + } +} +if (urls.length === 0) throw new Error("KIE success carried no resultUrls"); + +await mkdir(OUT_DIR, { recursive: true }); +for (const [n, url] of urls.entries()) { + const image = await fetch(url); + if (!image.ok) throw new Error(`download failed (${image.status})`); + const file = resolve(OUT_DIR, `${asset}-${seed}-${taskId}${n ? `-${n}` : ""}.png`); + await writeFile(file, Buffer.from(await image.arrayBuffer())); + console.log(file); +} diff --git a/apps/website/src/components/QueenCombBabylon.tsx b/apps/website/src/components/QueenCombBabylon.tsx index 2058466037..86e77355b8 100644 --- a/apps/website/src/components/QueenCombBabylon.tsx +++ b/apps/website/src/components/QueenCombBabylon.tsx @@ -1,11 +1,11 @@ -// The comb in Babylon.js as a GAME FIELD (the user, 2026-09-04 11:10Z, with a -// StarCraft screenshot: "I want a field like THIS"): one continuous plate of -// steel tiles with the mark engraved in every tile, low-poly buildings with -// volume standing where the cards stand, bees as units on the ground, an RTS -// camera (fixed angle, zoom, no idle sway). The cells still come from -// summariseCells, so placement, picking, the pairing of bees to running cards -// and the event glints keep their rules; only the picture changed. Buildings -// are procedural until the user names an asset pack (B-5). +// The comb in Babylon.js as a HIVE DISPLAY (the user, 2026-09-06): a vertical +// honeycomb wall floating right in front of the player — not a board lying on +// the ground. No 3D models: every fact is a cell (rim, cap, colour, card). +// Cells rise toward the hand on hover and tap; the pointer is answered with +// honey. Colour is law: yellow T27 coverage, neon blue awaiting a spec, red +// hand-written code. The Queen at the centre is the mark itself — the logo +// inside the hub cell — and the latest wire events land in their cells as +// cards, so the comb works as the hive's display. import { ImageProcessingConfiguration } from "@babylonjs/core/Materials/imageProcessingConfiguration"; import { useEffect, useRef , useImperativeHandle } from "react"; import type { Ref } from "react"; @@ -14,34 +14,29 @@ import { Engine } from "@babylonjs/core/Engines/engine"; import { Scene } from "@babylonjs/core/scene"; import { ArcRotateCamera } from "@babylonjs/core/Cameras/arcRotateCamera"; import { Camera } from "@babylonjs/core/Cameras/camera"; -import { Vector3, Matrix, Quaternion } from "@babylonjs/core/Maths/math.vector"; +import { Vector3, Matrix } from "@babylonjs/core/Maths/math.vector"; import { Color3, Color4 } from "@babylonjs/core/Maths/math.color"; import { Mesh } from "@babylonjs/core/Meshes/mesh"; import { VertexBuffer } from "@babylonjs/core/Buffers/buffer"; import { StandardMaterial } from "@babylonjs/core/Materials/standardMaterial"; import { DynamicTexture } from "@babylonjs/core/Materials/Textures/dynamicTexture"; -import { SpriteManager } from "@babylonjs/core/Sprites/spriteManager"; -import { Sprite } from "@babylonjs/core/Sprites/sprite"; import { CreateLineSystem, CreateDashedLines } from "@babylonjs/core/Meshes/Builders/linesBuilder"; import { CreateCylinder } from "@babylonjs/core/Meshes/Builders/cylinderBuilder"; +import { CreateGround } from "@babylonjs/core/Meshes/Builders/groundBuilder"; import { HemisphericLight } from "@babylonjs/core/Lights/hemisphericLight"; import { DirectionalLight } from "@babylonjs/core/Lights/directionalLight"; import "@babylonjs/core/Lights/Shadows/shadowGeneratorSceneComponent"; import { GlowLayer } from "@babylonjs/core/Layers/glowLayer"; import "@babylonjs/core/Layers/effectLayerSceneComponent"; import "@babylonjs/core/Meshes/thinInstanceMesh"; -import { LoadAssetContainerAsync } from "@babylonjs/core/Loading/sceneLoader"; -import "@babylonjs/loaders/glTF"; -import type { AbstractMesh } from "@babylonjs/core/Meshes/abstractMesh"; -import type { PBRMaterial } from "@babylonjs/core/Materials/PBR/pbrMaterial"; -import type { BaseTexture } from "@babylonjs/core/Materials/Textures/baseTexture"; import type { LinesMesh } from "@babylonjs/core/Meshes/linesMesh"; import { PointerEventTypes } from "@babylonjs/core/Events/pointerEvents"; import { Ray } from "@babylonjs/core/Culling/ray"; -import { S } from "./QueenComb"; -import { buildingPlan, eventTone, ringTone, type BeeLine, type HudEvent, type HudModule, type HudPick, type Tone , eventIdentity , hexCellSummaries, hexIndexAt, hexCornersAt, HEX_HOME, HEX_R, foundationCells, honeyTone, spiralAxial, S_CELL, type FoundationIssue, +import { S, EDGES } from "./QueenComb"; +import { eventTone, ringTone, type BeeLine, type HudEvent, type HudModule, type HudPick, type Tone , eventIdentity , hexCellSummaries, hexIndexAt, hexCornersAt, HEX_HOME, HEX_R, foundationCells, spiralAxial, S_CELL, type FoundationIssue, FIELD_LAYERS, type FieldLayer, type CombHandle, - castlePlaces, ringOfEpic, towerStage, ringSummary, ringFamily, familyTint, ringOfModulePath, epicOfIssue, hexRingStart, CASTLE_RING, type TowerStage, type EpicRecord, + castlePlaces, ringOfEpic, towerStage, ringFamily, familyTint, ringOfModulePath, epicOfIssue, type TowerStage, type EpicRecord, + HIVE_TONES, hiveCoverOf, hiveToneOf, type HiveCover, } from "./queenHud"; interface SpikeCard { @@ -74,94 +69,23 @@ interface QueenCombBabylonProps { layers?: Record; /** The toolbar's zoom and fit, the same handle the canvas comb exposes. */ handleRef?: Ref; + /** hiveKey'd names the T27 corpus claims; a cell against this set is yellow, without it red (manual), no module blue. */ + t27Coverage?: ReadonlySet | null; + /** The colour law's words, in the page's language, for the legend above the field. */ + law?: { t27: string; manual: string; awaiting: string; bees: string }; } -// the plan's part kinds map to models the field loads: annexes are the -// platform, antennae the dish; the core is the plan's own key type Territory = "held" | "neutral" | "fog"; const LINES: readonly BeeLine[] = ["scribe", "wright", "lapidary"]; const TONE_HEX: Record = { gold: "#FFD45A", cold: "#FF6B6B", green: "#00FF88", cyan: "#64DCFF", muted: "#FFFFFF" }; const EMPTY_EVENTS: HudEvent[] = []; const RING_POOL = 24; -const COLUMNS = ["backlog", "running", "review", "done", "blocked", "dropped"] as const; -type Column = (typeof COLUMNS)[number]; -// Kenney Space Kit (CC0, kenney.nl), the user's "download it yourself, open -// source": one model per building state, three for done cards, the Queen's -// hangar, crystals, and units. Served from public/queen/models. -const MODELS = { - // ONE KIT (Kenney Hexagon Kit, CC0). The kingdom is built from one set of - // parts, so a silhouette on the field always means the same thing. - // the CODE layer: the settlement a module becomes, by the column it sits in - backlog: "hex-building-cabin", running: "hex-building-mill", review: "hex-building-archery", - done: "hex-building-house", doneDepot: "hex-building-market", doneSilo: "hex-building-farm", - blocked: "hex-building-wall", dropped: "hex-stone-rocks", - // the CASTLE layer: a plinth per ring, a tower by the stage of its epics - plinth: "hex-stone", walls: "hex-building-walls", tower: "hex-building-tower", - wizardTower: "hex-building-wizard-tower", unitTower: "hex-unit-tower", - // the throne at the hub, always visible: the Queen's own castle - keep: "hex-building-castle", -} as const; -type ModelKey = keyof typeof MODELS; -/** Point-in-down-triangle for the cell under a world point (x, z). */ -/** - * Load a Kenney GLB and merge it into one mesh with a multi-material, so it - * can be thin-instanced like the procedural templates. Returns the mesh and - * its footprint (the flat width: min of width and depth) and base (min y), both in model - * units, so the caller can scale it to a cell and stand it on the ground. - */ -async function loadTemplate(scene: Scene, key: ModelKey): Promise<{ mesh: Mesh; footprint: number; base: number; height: number; width: number; depth: number } | null> { - try { - if (scene.isDisposed) return null; - const container = await LoadAssetContainerAsync(`./queen/models/${MODELS[key]}.glb`, scene); - // the view may have switched while the file was in flight: a disposed - // scene has no engine program to compile materials on - if (scene.isDisposed) { container.dispose(); return null; } - const parts = container.meshes.filter((m): m is Mesh => m instanceof Mesh && m.getTotalVertices() > 0); - if (parts.length === 0) return null; - // Kenney's models are flat palette colours: a StandardMaterial carries - // them as well as the glTF PBR one, lights like the rest of the field, - // and needs no BRDF lookup texture - the async generation of that texture - // ran its callback on a disposed engine when the view switched mid-load - // (TypeError: reading 'program' in bindSamplers), which the viewport gate - // counted as a failure. - for (const m of parts) { - const src = m.material as (PBRMaterial & { albedoTexture?: BaseTexture | null; albedoColor?: Color3 }) | null; - if (src && src.getClassName() === "PBRMaterial") { - const flat = new StandardMaterial(`${src.name}-flat`, scene); - if (src.albedoTexture) flat.diffuseTexture = src.albedoTexture; - if (src.albedoColor) flat.diffuseColor = src.albedoColor.clone(); - flat.specularColor = new Color3(0.12, 0.12, 0.14); - flat.specularPower = 32; - m.material = flat; - src.dispose(false, false); - } - } - // bake the glTF root transform (right-handed -> left-handed flip) into the vertices - for (const m of parts) { m.computeWorldMatrix(true); m.bakeCurrentTransformIntoVertices(); } - const merged = parts.length === 1 ? parts[0] : Mesh.MergeMeshes(parts, true, true, undefined, false, true); - if (!merged) return null; - merged.setParent(null); - merged.position.set(0, 0, 0); merged.rotationQuaternion = null; merged.rotation.set(0, 0, 0); merged.scaling.set(1, 1, 1); - merged.computeWorldMatrix(true); - const b = merged.getBoundingInfo().boundingBox; - // a hex tile measures 1.000 flat-to-flat and 1.1547 corner-to-corner, so the - // FLAT width is the fit: dividing by the larger left a moat around every building - const footprint = Math.min(b.maximum.x - b.minimum.x, b.maximum.z - b.minimum.z) || 1; - merged.isVisible = false; merged.isPickable = false; - scene.addMesh(merged); - for (const m of container.meshes) if (m !== merged && !(m as AbstractMesh).isDisposed()) m.dispose(); - return { mesh: merged, footprint, base: b.minimum.y, height: b.maximum.y - b.minimum.y, width: b.maximum.x - b.minimum.x, depth: b.maximum.z - b.minimum.z }; - } catch { - return null; - } -} - const ALL_LAYERS: Record = { foundation: true, castle: true, code: true }; -export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fitInset = 0, events = EMPTY_EVENTS, modules, beeTargets, foundation = null, layers = ALL_LAYERS, handleRef }: QueenCombBabylonProps) { +export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fitInset = 0, events = EMPTY_EVENTS, modules, beeTargets, foundation = null, layers = ALL_LAYERS, handleRef, t27Coverage = null, law }: QueenCombBabylonProps) { const hostRef = useRef(null); const canvasRef = useRef(null); const cardRef = useRef(null); @@ -183,6 +107,9 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit workers?.slots.map((s) => [s.slot, s.state]) ?? null, // a new snapshot of the foundation rebuilds the honey like a modules change rebuilds the city foundation ? [foundation.generatedAt, foundation.issues.length] : null, + // a new claim from the spec corpus re-colours the law; the words re-word the legend + t27Coverage ? t27Coverage.size : null, + law ? [law.t27, law.manual, law.awaiting, law.bees] : null, ]); const cardsRef = useRef(cards); const workersRef = useRef(workers); @@ -190,6 +117,8 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit const targetsRef = useRef(beeTargets); const foundationRef = useRef(foundation); const layersRef = useRef(layers); + const coverageRef = useRef(t27Coverage); + const lawRef = useRef(law); const cameraRef = useRef(null); useEffect(() => { cardsRef.current = cards; @@ -198,7 +127,9 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit targetsRef.current = beeTargets; foundationRef.current = foundation; layersRef.current = layers; - }, [cards, workers, modules, beeTargets, foundation, layers]); + coverageRef.current = t27Coverage; + lawRef.current = law; + }, [cards, workers, modules, beeTargets, foundation, layers, t27Coverage, law]); useImperativeHandle(handleRef, () => ({ zoomIn: () => cameraRef.current?.zoomIn(), @@ -220,6 +151,14 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit host.setAttribute("data-castle-source", fdNow ? fdNow.source : "none"); if (!fdNow) { host.removeAttribute("data-castle-stages"); host.removeAttribute("data-castle-rings"); host.removeAttribute("data-castle-unassigned"); host.removeAttribute("data-castle-releases"); } const engine = new Engine(canvas, true, { preserveDrawingBuffer: false, stencil: false }, false); + // Sharpness (the user, 2026-09-06: "сделай качество выше"). The engine was + // rendering one sample per CSS pixel, so on a 2x display every line on the + // comb was drawn at half the resolution of the screen it lands on. Capped at + // 2x: past that the comb costs four times the pixels for nothing the eye + // gets. Babylon's own picking divides by the same scaling level, so the pan, + // the wheel and the pick keep working - proved by the pick, void and touch + // gates rather than assumed. + engine.setHardwareScalingLevel(1 / Math.min(2, Math.max(1, window.devicePixelRatio || 1))); const scene = new Scene(engine); scene.clearColor = new Color4(2 / 255, 8 / 255, 6 / 255, 1); scene.skipPointerMovePicking = true; @@ -240,34 +179,49 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit ipc.applyByPostProcess = true; scene.fogMode = Scene.FOGMODE_LINEAR; scene.fogColor = new Color3(2 / 255, 8 / 255, 6 / 255); - const glow = new GlowLayer("glow", scene, { blurKernelSize: 24 }); - glow.intensity = 0.55; + // a tighter, dimmer bloom: at kernel 24 the centre of the comb smeared into + // one haze because a thousand lit rims bled into each other + const glow = new GlowLayer("glow", scene, { blurKernelSize: 12 }); + glow.intensity = 0.42; - // ---- extents and the RTS camera: fixed angle, zoom, no idle sway ------ + // ---- extents and the floating wall: vertical, facing the player ------ let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity; for (const c of cells) { minX = Math.min(minX, c.x - S / 2); maxX = Math.max(maxX, c.x + S / 2); minZ = Math.min(minZ, c.y - HEX_R); maxZ = Math.max(maxZ, c.y + HEX_R); } const centre = new Vector3((minX + maxX) / 2, 0, (minZ + maxZ) / 2); - const camera = new ArcRotateCamera("cam", Math.PI / 2 + 0.55, 0.95, 4000, centre.clone(), scene); - // the ground is a plane, not a mesh: a screen point becomes a world point by - // solving the picking ray against y = PICK_Y, the height the comb is drawn on - const PICK_Y = 1.5; + // THE WALL (the user, 2026-09-06: "поле не по горизонтали лежащим, а прямо + // перед лицом парящее"). The comb keeps its own flat coordinates (x, z per + // cell, y as height toward the board); one root turns that plane into the + // wall the player faces — local (x, y, z) becomes world (x, -z, y). The + // camera sits dead in front at +Z, so the honeycomb hangs in the air at eye + // height and a cell's lift (its local y) comes TOWARD the hand instead of + // away from it. Every position below stays in comb coordinates; only the + // pick and the pan convert world to comb. + const fieldRoot = new TransformNode("field-root", scene); + fieldRoot.rotation.x = Math.PI / 2; + // the wall's world centre: the root maps comb (x, z) to world (x, -z) + const centreWorld = new Vector3(centre.x, -centre.z, 0); + const camera = new ArcRotateCamera("cam", Math.PI / 2, Math.PI / 2, 4000, centreWorld.clone(), scene); + // the wall is a plane, not a mesh: a screen point becomes a comb point by + // solving the picking ray against z = 0, the plane the comb is drawn on const pickRay = new Ray(Vector3.Zero(), Vector3.Up(), Number.MAX_VALUE); - const groundAt = (x: number, y: number): { x: number; z: number } | null => { + const planeAt = (x: number, y: number): { x: number; z: number } | null => { scene.createPickingRayToRef(x, y, null, pickRay, camera); - const dy = pickRay.direction.y; - if (Math.abs(dy) < 1e-6) return null; - const t = (PICK_Y - pickRay.origin.y) / dy; + const dz = pickRay.direction.z; + if (Math.abs(dz) < 1e-6) return null; + const t = -pickRay.origin.z / dz; if (t < 0) return null; - return { x: pickRay.origin.x + pickRay.direction.x * t, z: pickRay.origin.z + pickRay.direction.z * t }; + const wx = pickRay.origin.x + pickRay.direction.x * t; + const wy = pickRay.origin.y + pickRay.direction.y * t; + return { x: wx, z: -wy }; }; const depth = Math.max(maxX - minX, maxZ - minZ); scene.fogStart = 4000 - depth * 0.1; scene.fogEnd = 4000 + depth * 1.4; camera.mode = Camera.ORTHOGRAPHIC_CAMERA; - camera.lowerBetaLimit = 0.95; camera.upperBetaLimit = 0.95; + camera.lowerBetaLimit = Math.PI / 2; camera.upperBetaLimit = Math.PI / 2; camera.lowerAlphaLimit = camera.alpha; camera.upperAlphaLimit = camera.alpha; camera.panningSensibility = 0; let zoom = 1; @@ -278,13 +232,18 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit let castleLinks: { plinthOfRing: Map; ringOfCell: Map; cellsOfRing: Map; epics: EpicRecord[]; rings: string[] } | null = null; let linkLines: LinesMesh | null = null; let linkFor: string | null = null; let rootLines: LinesMesh | null = null; - // three transform nodes carry the layers: a disabled parent hides its thin-instanced children without touching a buffer - const layerNodes: Record = { foundation: new TransformNode("layer-foundation", scene), castle: new TransformNode("layer-castle", scene), code: new TransformNode("layer-code", scene) }; + // three transform nodes carry the layers: a disabled parent hides its children without touching a buffer + const layerNodes = Object.fromEntries(FIELD_LAYERS.map((layer) => { + const node = new TransformNode(`layer-${layer}`, scene); + node.parent = fieldRoot; + return [layer, node]; + })) as Record; const fit = () => { const w = host.clientWidth || 1, h = host.clientHeight || 1; const inset = insetRef.current; const band = h * (1 - inset); - const fieldW = (maxX - minX + S) * 1.02, fieldH = (maxZ - minZ + S) * Math.cos(camera.beta) * 1.02 + S * 1.3; + // the wall is parallel to the screen: width and height map one to one + const fieldW = (maxX - minX + S) * 1.04, fieldH = (maxZ - minZ + S) * 1.08; const aspect = w / band; let halfW = fieldW / 2, halfH = halfW / aspect; if (halfH < fieldH / 2) { halfH = fieldH / 2; halfW = halfH * aspect; } @@ -297,20 +256,18 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit host.setAttribute("data-zoom", zoom.toFixed(2)); }; fit(); - // the map answers the hand (the user, 2026-09-06). The camera had no inputs - // at all: attachControl was never called, so panningSensibility configured - // a manager that does not exist and a drag moved nothing. Both the drag and - // the wheel now work against the same y = 0 plane the pick uses, so the - // point under the cursor is the point that follows it. + // the wall answers the hand (the user, 2026-09-06). Both the drag and the + // wheel work against the same z = 0 plane the pick uses, so the point under + // the cursor is the point that follows it; pan runs in the wall's own axes. const ROAM = Math.max(maxX - minX, maxZ - minZ) * 0.55; const clampTarget = () => { camera.target.x = Math.min(Math.max(camera.target.x, centre.x - ROAM), centre.x + ROAM); - camera.target.z = Math.min(Math.max(camera.target.z, centre.z - ROAM), centre.z + ROAM); + camera.target.y = Math.min(Math.max(camera.target.y, -centre.z - ROAM), -centre.z + ROAM); }; const onWheel = (e: WheelEvent) => { e.preventDefault(); const r = canvas.getBoundingClientRect(); - anchor = groundAt(e.clientX - r.left, e.clientY - r.top); + anchor = planeAt(e.clientX - r.left, e.clientY - r.top); zoomGoal = Math.min(8, Math.max(0.5, zoomGoal * Math.exp(-e.deltaY * 0.0016))); host.setAttribute("data-zoom-goal", zoomGoal.toFixed(2)); }; @@ -320,8 +277,8 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit const onMove = (e: PointerEvent) => { if (!grab) return; const r = canvas.getBoundingClientRect(); - const a = groundAt(grab.x - r.left, grab.y - r.top), b = groundAt(e.clientX - r.left, e.clientY - r.top); - if (a && b) { camera.target.x += a.x - b.x; camera.target.z += a.z - b.z; clampTarget(); fit(); } + const a = planeAt(grab.x - r.left, grab.y - r.top), b = planeAt(e.clientX - r.left, e.clientY - r.top); + if (a && b) { camera.target.x += a.x - b.x; camera.target.y += b.z - a.z; clampTarget(); fit(); } grab = { x: e.clientX, y: e.clientY }; }; const onUp = (e: PointerEvent) => { grab = null; if (canvas.hasPointerCapture(e.pointerId)) canvas.releasePointerCapture(e.pointerId); }; @@ -334,7 +291,7 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit zoomIn: () => { anchor = null; zoom = zoomGoal = Math.min(8, zoom * 1.25); fit(); }, zoomOut: () => { anchor = null; zoom = zoomGoal = Math.max(0.5, zoom / 1.25); fit(); }, // FIT VIEW is the way home: it undoes the roam as well as the zoom - fit: () => { anchor = null; zoom = zoomGoal = 1; camera.target.copyFrom(centre); fit(); }, + fit: () => { anchor = null; zoom = zoomGoal = 1; camera.target.copyFrom(centreWorld); fit(); }, }; // ---- light: a sun from the upper left and a soft sky, shadows on ----- @@ -353,48 +310,20 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit // sun where there is no field. It carried no fact and it was the only // straight edge on the board. y = 0 is a plane; a plane has no vertices, // no material and no draw call, and the pick solves against it in maths. - // ---- buildings: one Hexagon Kit model per cell, thin-instanced --------- - // No stand-ins (V-10). Until a GLB resolves the cell stays empty, and a - // model that fails to load reports itself on the host, instead of leaving - // 26 boxes and cylinders behind in a second, sci-fi art language. - interface Template { matrices: number[]; scales: number[]; turns: number[]; heights: number[]; colors: number[] } - const emptyTemplate = (): Template => ({ matrices: [], scales: [], turns: [], heights: [], colors: [] }); - const mods = modulesRef.current; - const nowWall = Date.now(); - const templates: Record = Object.fromEntries(COLUMNS.map((c) => [c, emptyTemplate()])) as Record; - const doneDepot = emptyTemplate(), doneSilo = emptyTemplate(); - cells.forEach((c, i) => { - if (i === home) return; - const card = cards[i]; - if (!card) return; - const col = (COLUMNS as readonly string[]).includes(card.column) ? (card.column as Column) : "backlog"; - const m = mods?.get(card.number); - const templateOf = (key: string) => (key === "doneDepot" ? doneDepot : key === "doneSilo" ? doneSilo : templates[(key in templates ? key : "backlog") as Column]); - // no tint on the settlements: the kit's own palette reads, and colour on - // this field means one thing only - gold is done (the comb carries it) - const tint: [number, number, number, number] = [1, 1, 1, 1]; - const push = (t: Template, x: number, z: number, k: number, h: number, turn: number, y = 0) => { - t.matrices.push(...Matrix.Compose(new Vector3(k, k * h, k), Quaternion.RotationAxis(Vector3.Up(), turn), new Vector3(x, y, z)).toArray()); - t.scales.push(k); t.turns.push(turn); t.heights.push(h); t.colors.push(...tint); - }; - if (m) { - // ONE model per cell. Every Hexagon Kit building carries its own hex - // base, so the old multi-part plan stacked hex on hex and the middle of - // the field turned to mush. The plan still chooses the core and the - // turn; a module with an open issue shows the wall instead of its trade. - const plan = buildingPlan(m); - const key = m.openIssues.length > 0 && plan.core !== "blocked" ? "blocked" : plan.core; - push(templateOf(key), c.x, c.y, 1, 1, plan.turn); - } else { - const key = col === "done" ? (["done", "doneDepot", "doneSilo"] as const)[card.number % 3] : col; - push(templateOf(key), c.x, c.y, 1, 1, 0); - } - }); + // ---- no buildings: the kit is gone, the cell IS the fact --------------- + // (the user, 2026-09-06: "убери 3D объекты а то шума много"). The whole + // Hexagon Kit block — templates, thin instances, per-column depots — went + // with the GLBs; what remains is pure comb geometry. + // the colour law's cover for a cell: T27 if the corpus claims the module, + // manual if there is hand-written code and no claim, awaiting otherwise + const covered = coverageRef.current ?? new Set(); + const coverOf = (i: number): HiveCover => hiveCoverOf(cards[i]?.title ?? null, covered); + const covers: HiveCover[] = cells.map((_, i) => coverOf(i)); - // ---- rings: picked (gold), hover (dashed, territory colour) --------- + // ---- rings: picked (gold), hover (dashed, honey) -------------------- const ring7 = () => [Array.from({ length: 7 }, () => new Vector3(0, 0, 0))]; const picked = CreateLineSystem("picked", { lines: ring7(), updatable: true }, scene); - picked.color = Color3.FromHexString("#FFD45A"); picked.isPickable = false; + picked.color = Color3.FromHexString(HIVE_TONES.t27); picked.isPickable = false; picked.parent = fieldRoot; // a cell's outline is its hexagon, inset a little so the ring reads as the cell's, not the neighbour's const hexOf = (i: number): [number, number][] => hexCornersAt(cells[i].x, cells[i].y, HEX_R, 4).map((c) => [c.x, c.y]); const placeRing = (mesh: LinesMesh, i: number, y: number) => { @@ -406,7 +335,7 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit mesh.isVisible = true; }; const hovered = CreateDashedLines("hover", { points: Array.from({ length: 7 }, (_, k) => new Vector3(Math.cos(k), 0, Math.sin(k))), dashSize: 3, gapSize: 2, dashNb: 60, updatable: true }, scene); - hovered.alpha = 0.8; hovered.isPickable = false; hovered.isVisible = false; + hovered.alpha = 0.9; hovered.isPickable = false; hovered.isVisible = false; hovered.parent = fieldRoot; // the hover card (the user, 2026-09-06): the hovered cell's issue, named beside the pointer let hoverIssue = -1; let pointerXY: [number, number] = [0, 0]; @@ -422,7 +351,10 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit hoverIssue = issue.number; const head = document.createElement("b"); head.textContent = `#${issue.number} ${issue.title}`; const meta = document.createElement("span"); - meta.textContent = [issue.closedAt.slice(0, 16).replace("T", " "), ...issue.labels].join(" \u00b7 "); + const cover = coverOf(i); + const coverWord = cover === "t27" ? "T27" : cover === "manual" ? "MANUAL CODE" : "AWAITING T27"; + meta.textContent = [issue.closedAt.slice(0, 16).replace("T", " "), ...issue.labels, coverWord].join(" \u00b7 "); + meta.dataset.cover = cover; card.replaceChildren(head, meta); card.setAttribute("aria-hidden", "false"); host.setAttribute("data-hover-issue", String(issue.number)); @@ -434,30 +366,16 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit const placeDashed = (i: number, y: number) => { const p = hexOf(i); CreateDashedLines("hover", { points: [...p, p[0]].map(([px, pz]) => new Vector3(px, y, pz)), dashSize: 3, gapSize: 2, dashNb: 60, instance: hovered }, scene); - hovered.color = Color3.FromHexString(ringTone(cells[i].own as Territory)); + // honey, not territory: the hand is looking for nectar (the user, + // 2026-09-06: "не цветом говна выделять карточку! я медоносным цветом!") + hovered.color = Color3.FromHexString(HIVE_TONES.hover); hovered.isVisible = true; }; - // ---- sprites: the Queen's portrait, crystals, bees as units ---------- - const managers = new Map(); - const manager = (name: string, capacity: number) => { - let m = managers.get(name); - if (!m) { - m = new SpriteManager(name, `./queen/${name}-256.png`, capacity, { width: 256, height: 256 }, scene); - m.isPickable = false; - managers.set(name, m); - } - return m; - }; - const put = (name: string, cell: number, size: number, lift: number, capacity = 64) => { - const c = cells[cell]; - if (!c) return null; - const s = new Sprite(name, manager(name, capacity)); - s.width = size; s.height = size; - s.position = new Vector3(c.x, lift + size / 2, c.y); - return s; - }; - put("queen", home, S * 0.6, S * 0.28, 1); + // ---- no sprites: the Queen is the mark, the swarm is motes ----------- + // (the user, 2026-09-06: "а королева в центре наш логотип! это и есть + // королева"). The portrait sprite went with the buildings; the mark in the + // hub cell below IS the Queen. const ring = cells .map((c, i) => ({ i, d: (c.x - cells[home].x) ** 2 + (c.y - cells[home].y) ** 2 })) .filter((e) => e.i !== home) @@ -474,7 +392,7 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit interface Bee { slot: number; busy: boolean; work: boolean; from: number; to: number; t: number; speed: number; hover: number; line: BeeLine; mote: Mesh } const bees: Bee[] = (workers?.slots ?? []).map((slot, k) => { const mote = CreateCylinder(`bee-${slot.slot}`, { tessellation: 6, diameter: S * 0.2, height: 5 }, scene); - mote.material = moteMat; mote.isPickable = false; mote.isVisible = false; + mote.material = moteMat; mote.isPickable = false; mote.isVisible = false; mote.parent = fieldRoot; glow.referenceMeshToUseItsOwnMaterial(mote); return { slot: slot.slot, busy: false, work: false, from: home, to: home, t: 1, speed: 0, hover: k * 1.7, line: LINES[k % LINES.length], mote }; }); @@ -494,160 +412,146 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit }; aimBees(); const beeAt = (index: number) => bees.find((b) => (b.t < 0.5 ? b.from : b.to) === index) ?? null; - const flightLines = bees.map((b) => { const m = CreateDashedLines(`flight-${b.slot}`, { points: [new Vector3(0, 0, 0), new Vector3(1, 0, 1)], dashSize: 6, gapSize: 4, dashNb: 40, updatable: true }, scene); m.color = Color3.FromHexString("#64DCFF"); m.alpha = 0.55; m.isPickable = false; m.isVisible = false; return m; }); + const flightLines = bees.map((b) => { const m = CreateDashedLines(`flight-${b.slot}`, { points: [new Vector3(0, 0, 0), new Vector3(1, 0, 1)], dashSize: 6, gapSize: 4, dashNb: 40, updatable: true }, scene); m.color = Color3.FromHexString(HIVE_TONES.awaiting); m.alpha = 0.55; m.isPickable = false; m.isVisible = false; m.parent = fieldRoot; return m; }); - // The glTF loader creates PBR materials, and a PBR material's first use - // schedules the environment BRDF lookup texture, generated asynchronously - // through executeWhenCompiled; when the view switches mid-load that - // callback ran on a disposed engine (TypeError: reading 'program'). The - // models become StandardMaterials anyway, so the scene gets a 2x2 stand-in - // BRDF texture up front and nothing is ever generated. - if (!scene.environmentBRDFTexture) scene.environmentBRDFTexture = new DynamicTexture("brdf-none", 2, scene, false); - // ---- the Kenney models replace the procedural templates as they load; - // a model that fails to load leaves its procedural stand-in -------- - let disposed = false; - const modelInstances = new Map(); - const placeModel = (t: { mesh: Mesh; footprint: number; base: number }, matrices: number[], target: number, scales: number[] = [], turns: number[] = [], heights: number[] = [], colors: number[] = []) => { - if (disposed || scene.isDisposed || matrices.length === 0) return; - const out: number[] = []; - for (let i = 0, j = 0; i < matrices.length; i += 16, j += 1) { - const tx = matrices[i + 12], tz = matrices[i + 14]; - const k = (target / t.footprint) * (scales[j] ?? 1), h = heights[j] ?? 1; - out.push(...Matrix.Compose(new Vector3(k, k * h, k), Quaternion.RotationAxis(Vector3.Up(), turns[j] ?? 0), new Vector3(tx, -t.base * k * h, tz)).toArray()); - } - t.mesh.thinInstanceSetBuffer("matrix", new Float32Array(out), 16, true); - // the per-instance tint (M-4): StandardMaterial multiplies its palette by it - if (colors.length === (matrices.length / 16) * 4) t.mesh.thinInstanceSetBuffer("color", new Float32Array(colors), 4, true); - t.mesh.isVisible = true; - }; - void (async () => { - const keys: ModelKey[] = ["backlog", "running", "review", "done", "doneDepot", "doneSilo", "blocked", "dropped", "plinth", "walls", "tower", "wizardTower", "keep", "unitTower"]; - const loaded = await Promise.all(keys.map(async (key) => [key, await loadTemplate(scene, key)] as const)); - const missing = loaded.filter(([, t]) => !t).map(([k]) => k); - host.setAttribute("data-models", `${loaded.length - missing.length}/${loaded.length}`); - if (missing.length > 0) host.setAttribute("data-model-error", missing.join(",")); else host.removeAttribute("data-model-error"); - if (disposed || scene.isDisposed) { for (const [, t] of loaded) t?.mesh.dispose(); return; } - for (const [key, t] of loaded) if (t) { modelInstances.set(key, t); if (["plinth", "wall", "walls", "tower", "wizardTower", "keep", "unitTower"].includes(key)) t.mesh.parent = layerNodes.castle; else if (!["hub", "crystal", "scribe", "wright", "lapidary", "larva"].includes(key)) t.mesh.parent = layerNodes.code; } - const bind = (key: ModelKey, tpl: Template, target: number) => { const t = modelInstances.get(key); if (t) placeModel(t, tpl.matrices, target, tpl.scales, tpl.turns, tpl.heights, tpl.colors); }; - // a building takes about half a cell, so the roads between them still show - // every Hexagon Kit building carries its own hex base, so a settlement is - // scaled to the cell, not to a fraction of it: one cell, one tile, one roof - const CELL_FIT = S_CELL * 0.94; - bind("backlog", templates.backlog, CELL_FIT); bind("running", templates.running, CELL_FIT); bind("review", templates.review, CELL_FIT); - bind("done", templates.done, CELL_FIT); bind("doneDepot", doneDepot, CELL_FIT); bind("doneSilo", doneSilo, CELL_FIT); - bind("blocked", templates.blocked, CELL_FIT); bind("dropped", templates.dropped, CELL_FIT); - // ---- the foundation: one cell per closed GitHub issue, drawn as an outline (the user, 2026-09-06) ---- - // Every hexagon on the field IS a GitHub issue: no filled tile, no honey disc, only the cell's border. - // A cell that carries a closed issue burns in its age's gold (honeyTone); an empty cell is a dim wax line. - host.setAttribute("data-tile", "outline"); - host.setAttribute("data-hex-orient", "pointy"); - try { - if (cells.length > 1) { - const lines: Vector3[][] = []; const lineColours: Color4[][] = []; - let count = 0; let first: string | null = null; let last: string | null = null; - for (let i = 1; i < cells.length; i += 1) { - const corners = hexCornersAt(cells[i].x, cells[i].y, HEX_R, 4); - lines.push([...corners, corners[0]].map((c) => new Vector3(c.x, 1.5, c.y))); - const issue = fCells?.[i] ?? null; - const tone = issue ? honeyTone(issue.closedAt, nowWall) : [0.24, 0.3, 0.28, 1]; - const c4 = new Color4(tone[0], tone[1], tone[2], issue ? 0.95 : 0.28); - lineColours.push(Array.from({ length: 7 }, () => c4)); - if (issue) { const a = spiralAxial(i); const tag = `${issue.number}@${a.q},${a.r}`; if (first === null) first = tag; last = tag; count += 1; } + // ---- the hive: everything is comb, nothing is a model -------------------- + // The user, 2026-09-06: "убери 3D объекты а то шума много", "замки не красиво + // смотрятся, а соты мёд пчёлы классно читается", and a reference image of a + // dark hexagon field with glowing edges. So the kit is gone. Every fact is a + // cell: its rim, its cap, its colour. A hive has no towers. + host.setAttribute("data-look", "hive"); + host.setAttribute("data-models", "0/0"); + host.setAttribute("data-orientation", "facing"); + // A CAPPED cell: in a hive a finished cell is sealed with wax and reads as a + // dark lid inside a bright rim. That is exactly the reference picture, and it + // is what a closed issue is: work sealed. + const capMat = new StandardMaterial("cap", scene); + capMat.diffuseColor = Color3.Black(); + capMat.specularColor = Color3.Black(); + capMat.emissiveColor = new Color3(0.055, 0.075, 0.072); + const caps = CreateCylinder("caps", { tessellation: 6, diameter: 2 * HEX_R * 0.92, height: 2 }, scene); + const cb = caps.getBoundingInfo().boundingBox; + if (cb.maximum.x - cb.minimum.x > cb.maximum.z - cb.minimum.z) { caps.rotation.y = Math.PI / 6; caps.bakeCurrentTransformIntoVertices(); } + caps.material = capMat; caps.isPickable = false; caps.parent = layerNodes.foundation; caps.isVisible = false; + // A cell RISES under the pointer and stays up while it is picked (the user, + // 2026-09-06). This is the reference picture's raised hex, and it is the only + // motion on the field that answers the hand rather than the wire. + const liftMat = new StandardMaterial("lift", scene); + liftMat.diffuseColor = Color3.Black(); liftMat.specularColor = Color3.Black(); + liftMat.emissiveColor = new Color3(0.1, 0.14, 0.135); + const lift = CreateCylinder("lift", { tessellation: 6, diameter: 2 * HEX_R * 0.92, height: 3 }, scene); + const lb = lift.getBoundingInfo().boundingBox; + if (lb.maximum.x - lb.minimum.x > lb.maximum.z - lb.minimum.z) { lift.rotation.y = Math.PI / 6; lift.bakeCurrentTransformIntoVertices(); } + lift.material = liftMat; lift.isPickable = false; lift.isVisible = false; lift.parent = fieldRoot; + let liftIndex = -1; let liftY = 0; + // ---- the comb: one cell per GitHub issue, rim by state ------------------ + try { + if (cells.length > 1) { + const lines: Vector3[][] = []; const lineColours: Color4[][] = []; + const capM: number[] = []; + let count = 0; let first: string | null = null; let last: string | null = null; + for (let i = 1; i < cells.length; i += 1) { + const corners = hexCornersAt(cells[i].x, cells[i].y, HEX_R, 4); + lines.push([...corners, corners[0]].map((c) => new Vector3(c.x, 1.5, c.y))); + const issue = fCells?.[i] ?? null; + // the empty comb is the reference's dim teal wax; a cell with an + // issue burns in the colour LAW: yellow T27, neon blue awaiting, + // red manual code (the user, 2026-09-06) + const tone = issue ? hiveToneOf(covers[i]) : [0.11, 0.34, 0.33, 1]; + const c4 = new Color4(tone[0], tone[1], tone[2], issue ? 0.95 : 0.34); + lineColours.push(Array.from({ length: 7 }, () => c4)); + if (issue) { + capM.push(...Matrix.Translation(cells[i].x, 0.6, cells[i].y).toArray()); + const a = spiralAxial(i); const tag = `${issue.number}@${a.q},${a.r}`; + if (first === null) first = tag; last = tag; count += 1; } - const comb = CreateLineSystem("cells", { lines, colors: lineColours, useVertexAlpha: true }, scene); - comb.isPickable = false; comb.parent = layerNodes.foundation; comb.alwaysSelectAsActiveMesh = true; - glow.referenceMeshToUseItsOwnMaterial(comb); - host.setAttribute("data-foundation-shape", "outline"); - host.setAttribute("data-foundation-cells", String(count)); - host.setAttribute("data-foundation-shown", layersRef.current.foundation ? String(count) : "0"); - if (first) host.setAttribute("data-foundation-first", first); - if (last) host.setAttribute("data-foundation-last", last); } - } catch (e) { - // the field reports its own failure instead of drawing nothing silently - host.setAttribute("data-foundation-error", e instanceof Error ? e.message : String(e)); + const comb = CreateLineSystem("cells", { lines, colors: lineColours, useVertexAlpha: true }, scene); + comb.isPickable = false; comb.parent = layerNodes.foundation; comb.alwaysSelectAsActiveMesh = true; + glow.referenceMeshToUseItsOwnMaterial(comb); + if (capM.length > 0) { caps.thinInstanceSetBuffer("matrix", new Float32Array(capM), 16, true); caps.isVisible = true; } + host.setAttribute("data-foundation-shape", "outline"); + host.setAttribute("data-foundation-cells", String(count)); + host.setAttribute("data-foundation-shown", layersRef.current.foundation ? String(count) : "0"); + const law = { t27: covers.filter((v) => v === "t27").length, manual: covers.filter((v) => v === "manual").length, awaiting: covers.filter((v) => v === "awaiting").length }; + host.setAttribute("data-cover-t27", String(law.t27)); + host.setAttribute("data-cover-manual", String(law.manual)); + host.setAttribute("data-cover-awaiting", String(law.awaiting)); + if (first) host.setAttribute("data-foundation-first", first); + if (last) host.setAttribute("data-foundation-last", last); } - // ---- the castle of the rings (K-2/K-3): a plinth per ring directory on spiral ring 7, a tower by the stage of its epics ---- - try { - const plinthT = modelInstances.get("plinth"); - const rings = fdNow?.rings ?? []; - if (fdNow && plinthT && rings.length > 0 && cells.length > hexRingStart(CASTLE_RING)) { - const places = castlePlaces(rings).filter((pl) => pl.plinth < cells.length); - const epics = fdNow.epics ?? []; - const flatTopP = plinthT.width > plinthT.depth; - const kp = (S_CELL * 0.92) / Math.min(plinthT.width, plinthT.depth); - // the cells are outlines now, so a plinth stands on the plate itself - const lift = 2; - const pm: number[] = []; const pc: number[] = []; - const stageOf = (ring: string): TowerStage => { - const order: TowerStage[] = ["plinth", "walls", "tower", "wizardTower"]; - let best: TowerStage = "plinth"; - for (const e of epics) if (ringOfEpic(e, rings).ring === ring) { const st = towerStage(e); if (order.indexOf(st) > order.indexOf(best)) best = st; } - return best; - }; - const towerMats = new Map(); - const stages: string[] = []; - for (const pl of places) { - const c = cells[pl.plinth]; - pm.push(...Matrix.Compose(new Vector3(kp, kp, kp), Quaternion.RotationAxis(Vector3.Up(), flatTopP ? Math.PI / 6 : 0), new Vector3(c.x, lift - plinthT.base * kp, c.y)).toArray()); - pc.push(...familyTint(ringFamily(pl.ring))); - const st = stageOf(pl.ring); - stages.push(`${pl.ring}:${st}`); - if (st !== "plinth") { - const summary = ringSummary(pl.ring, epics, rings); - const h = 0.6 + 1.4 * (summary.ratio ?? 0); - const list = towerMats.get(st) ?? []; - list.push(...Matrix.Compose(new Vector3(1, h, 1), Quaternion.RotationAxis(Vector3.Up(), 0), new Vector3(c.x, lift + plinthT.height * kp, c.y)).toArray()); - towerMats.set(st, list); - } - } - plinthT.mesh.parent = layerNodes.castle; - placeModel(plinthT, pm, S_CELL * 0.92, places.map(() => 1), places.map(() => (flatTopP ? Math.PI / 6 : 0)), places.map(() => 1), pc); - plinthT.mesh.position.y = lift; // placeModel lifts by -base*k; the plinth stands above the tile - for (const [st, mats] of towerMats) { - const key: ModelKey = st === "walls" ? "walls" : st === "tower" ? "tower" : "wizardTower"; - const t = modelInstances.get(key); - if (!t) continue; - t.mesh.parent = layerNodes.castle; - const hs = Array.from({ length: mats.length / 16 }, (_, j) => mats[j * 16 + 5]); - placeModel(t, mats, S_CELL * 0.6, hs.map(() => 1), hs.map(() => 0), hs, hs.flatMap(() => [1, 1, 1, 1])); - t.mesh.position.y = lift + plinthT.height * kp; - } - const unassigned = epics.filter((e) => ringOfEpic(e, rings).ring === null).length; - // the keep at the hub (K-4): the castle's heart on its own layer; the unassigned epics stand around it as small towers - const keepT = modelInstances.get("keep"); - if (keepT) { - keepT.mesh.parent = layerNodes.castle; - placeModel(keepT, [...Matrix.Translation(cells[home].x, 0, cells[home].y).toArray()], S_CELL * 1.05); - host.setAttribute("data-castle-keep", "1"); - // the unassigned epics were drawn at radius 93 inside the keep's 112.5 - // reach, so nobody ever saw them (V-6): the count testifies instead - } - // the ring names live in the hover card and the panel, not as billboards - // over the comb (V-4): the count still testifies, nothing occludes - host.setAttribute("data-castle-plates", String(places.length)); - // the ring's own cells (K-5) are named by the hover link and the panel. - // The mark discs were flat-top hexes on a pointy-top grid, 30 degrees off - // every one, and a filled tile on a field pinned as outlines (V-3). - const cellsOfRing = new Map(); - cards.forEach((cd, ci) => { if (!cd) return; const rn = ringOfModulePath(cd.title); if (rn && rings.includes(rn)) cellsOfRing.set(rn, [...(cellsOfRing.get(rn) ?? []), ci]); }); - host.setAttribute("data-castle-marks", String([...cellsOfRing.values()].reduce((n, l) => n + l.length, 0))); - castleLinks = { plinthOfRing: new Map(places.map((pl) => [pl.ring, pl.plinth])), ringOfCell: new Map(places.map((pl) => [pl.plinth, pl.ring])), cellsOfRing, epics, rings }; - // the release tags belong in the HUD, not on 19px flags (V-5) - const releases = fdNow.releases ?? []; - host.setAttribute("data-castle-banners", String(Math.min(releases.length, 8))); - host.setAttribute("data-castle-rings", String(places.length)); - host.setAttribute("data-castle-stages", stages.sort().join(";")); - host.setAttribute("data-castle-unassigned", String(unassigned)); - host.setAttribute("data-castle-releases", String((fdNow.releases ?? []).length)); + } catch (e) { + host.setAttribute("data-foundation-error", e instanceof Error ? e.message : String(e)); + } + // ---- the rings of the hive: a frame per ring directory, drawn flat ------- + // A ring used to be a stone plinth carrying a tower. In a hive it is a FRAME: + // the cells of spiral ring 7 wear their family's colour, and an epic's stage + // sets how brightly the frame burns. No geometry, only the comb's own rim. + try { + const fdNow2 = foundationRef.current; + host.setAttribute("data-castle-source", fdNow2 ? fdNow2.source : "none"); + if (fdNow2 && (fdNow2.rings?.length ?? 0) > 0) { + const rings = fdNow2.rings ?? []; + const epics = fdNow2.epics ?? []; + const places = castlePlaces(rings); + const stageOf = (ring: string): TowerStage => { + const order: TowerStage[] = ["plinth", "walls", "tower", "wizardTower"]; + let best: TowerStage = "plinth"; + for (const e of epics) if (ringOfEpic(e, rings).ring === ring) { const st = towerStage(e); if (order.indexOf(st) > order.indexOf(best)) best = st; } + return best; + }; + const BURN: Record = { plinth: 0.3, walls: 0.55, tower: 0.8, wizardTower: 1 }; + const fl: Vector3[][] = []; const fc: Color4[][] = []; const stages: string[] = []; + for (const pl of places) { + const c = cells[pl.plinth]; if (!c) continue; + const st = stageOf(pl.ring); stages.push(`${pl.ring}:${st}`); + const t = familyTint(ringFamily(pl.ring)); const b = BURN[st]; + const corners = hexCornersAt(c.x, c.y, HEX_R * 0.94, 0); + fl.push([...corners, corners[0]].map((q) => new Vector3(q.x, 3, q.y))); + const col = new Color4(t[0] * (0.4 + 0.6 * b), t[1] * (0.4 + 0.6 * b), t[2] * (0.4 + 0.6 * b), 0.45 + 0.55 * b); + fc.push(Array.from({ length: 7 }, () => col)); } - } catch (e) { - host.setAttribute("data-castle-error", e instanceof Error ? e.message : String(e)); + if (fl.length > 0) { + const frames = CreateLineSystem("frames", { lines: fl, colors: fc, useVertexAlpha: true }, scene); + frames.isPickable = false; frames.parent = layerNodes.castle; + glow.referenceMeshToUseItsOwnMaterial(frames); + } + const unassigned = epics.filter((e) => ringOfEpic(e, rings).ring === null).length; + castleLinks = { plinthOfRing: new Map(places.map((pl) => [pl.ring, pl.plinth])), ringOfCell: new Map(places.map((pl) => [pl.plinth, pl.ring])), cellsOfRing: new Map(), epics, rings }; + const cellsOfRing = castleLinks.cellsOfRing; + cards.forEach((cd, ci) => { if (!cd) return; const rn = ringOfModulePath(cd.title); if (rn && rings.includes(rn)) cellsOfRing.set(rn, [...(cellsOfRing.get(rn) ?? []), ci]); }); + host.setAttribute("data-castle-marks", String([...cellsOfRing.values()].reduce((n, l) => n + l.length, 0))); + host.setAttribute("data-castle-plates", String(places.length)); + host.setAttribute("data-castle-banners", String(Math.min((fdNow2.releases ?? []).length, 8))); + host.setAttribute("data-castle-rings", String(places.length)); + host.setAttribute("data-castle-stages", stages.sort().join(";")); + host.setAttribute("data-castle-unassigned", String(unassigned)); + host.setAttribute("data-castle-releases", String((fdNow2.releases ?? []).length)); + host.setAttribute("data-castle-keep", "1"); + } else { + host.removeAttribute("data-castle-stages"); host.removeAttribute("data-castle-rings"); + host.removeAttribute("data-castle-unassigned"); host.removeAttribute("data-castle-releases"); } - // the throne: the Queen's castle at the hub, on no layer, always visible - const keepT = modelInstances.get("keep"); - if (keepT) { keepT.mesh.parent = null; placeModel(keepT, [...Matrix.Translation(cells[home].x, 0, cells[home].y).toArray()], S_CELL * 1.05); host.setAttribute("data-castle-keep", "1"); } - })(); + } catch (e) { + host.setAttribute("data-castle-error", e instanceof Error ? e.message : String(e)); + } + // ---- the Queen at the centre: the mark itself ---------------------------- + // The user, 2026-09-06: "а королева в центре наш логотип! это и есть королева" + { + // the mark sits INSIDE the hub cell (the user, 2026-09-06): the Queen is a + // cell of the comb like every other fact, not a thing laid over it + const k = S_CELL * 0.62; + const markLines = EDGES.map(([ax, ay, bx, by]) => [ + new Vector3(cells[home].x + ax * k, 8, cells[home].y - ay * k), + new Vector3(cells[home].x + bx * k, 8, cells[home].y - by * k), + ]); + const mark = CreateLineSystem("queen-mark", { lines: markLines }, scene); + mark.color = new Color3(1, 0.86, 0.42); + mark.alpha = 0.95; + mark.isPickable = false; mark.parent = fieldRoot; + glow.referenceMeshToUseItsOwnMaterial(mark); + host.setAttribute("data-queen-mark", String(EDGES.length)); + } // ---- event glints: a ring that grows and fades on the issue's cell ---- interface Effect { index: number; tone: Tone; start: number; flip: boolean; flare?: string } @@ -658,7 +562,7 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit const pts: Vector3[] = []; for (let a = 0; a <= 16; a += 1) pts.push(new Vector3(Math.cos((a / 16) * Math.PI * 2), 0, Math.sin((a / 16) * Math.PI * 2))); const m = CreateLineSystem(`fx-${k}`, { lines: [pts], updatable: false }, scene); - m.isPickable = false; m.isVisible = false; + m.isPickable = false; m.isVisible = false; m.parent = fieldRoot; rings.push(m); } let lastEvents: HudEvent[] | null = null; @@ -685,7 +589,7 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit // ---- picking and hover --------------------------------------------------- const cellUnder = (x: number, y: number) => { - const p = groundAt(x, y); + const p = planeAt(x, y); return p ? hexIndexAt(p.x, p.z, cells.length) : -1; }; let hover = -1; @@ -763,11 +667,95 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit return Array.from({ length: 7 }, () => c); }); workingRing = CreateLineSystem("working", { lines, colors: colours, useVertexAlpha: true }, scene); - workingRing.isPickable = false; + workingRing.isPickable = false; workingRing.parent = fieldRoot; glow.referenceMeshToUseItsOwnMaterial(workingRing); } }; + // ---- the latest wire events as cards inside their cells ----------------- + // The comb is a display, not only a map (the user, 2026-09-06). The newest + // event per module becomes a texture card on that module's cell: issue, + // kind, module and age. Twelve is the honest limit because a hex cannot + // carry an unreadable poster; older work stays in the intel feed. + const EVENT_CARD_POOL = 12; + interface EventCard { mesh: Mesh; material: StandardMaterial; surface: DynamicTexture; index: number; issue: number | null; kind: string; ageMinute: number } + const eventCards: EventCard[] = Array.from({ length: EVENT_CARD_POOL }, (_, k) => { + const surface = new DynamicTexture(`event-card-${k}`, 256, scene, true); + surface.hasAlpha = true; + const material = new StandardMaterial(`event-card-material-${k}`, scene); + material.diffuseTexture = surface; + material.emissiveTexture = surface; + material.opacityTexture = surface; + material.emissiveColor = new Color3(1, 1, 1); + material.specularColor = Color3.Black(); + material.disableLighting = true; + const mesh = CreateGround(`event-card-${k}`, { width: S * 0.72, height: S * 0.72 }, scene); + mesh.material = material; mesh.isPickable = false; mesh.isVisible = false; mesh.parent = fieldRoot; + return { mesh, material, surface, index: -1, issue: null, kind: "", ageMinute: -1 }; + }); + const eventCardAge = (ageMs: number): string => { + if (ageMs < 60_000) return `${Math.max(0, Math.round(ageMs / 1000))}s`; + if (ageMs < 3_600_000) return `${Math.round(ageMs / 60_000)}m`; + if (ageMs < 86_400_000) return `${Math.round(ageMs / 3_600_000)}h`; + return `${Math.round(ageMs / 86_400_000)}d`; + }; + const drawEventCard = (card: EventCard, event: HudEvent, ageMs: number) => { + const ctx = card.surface.getContext() as CanvasRenderingContext2D; + const tone = TONE_HEX[eventTone(event.kind)]; + const path = cards[card.index]?.title ?? ""; + const shortPath = path.split("/").filter(Boolean).slice(-2).join("/") || "—"; + ctx.clearRect(0, 0, 256, 256); + ctx.fillStyle = "rgba(3, 9, 11, 0.88)"; + ctx.fillRect(10, 10, 236, 236); + ctx.strokeStyle = tone; + ctx.lineWidth = 6; + ctx.strokeRect(13, 13, 230, 230); + ctx.textAlign = "left"; + ctx.textBaseline = "middle"; + ctx.fillStyle = tone; + ctx.font = "700 36px ui-monospace, Menlo, monospace"; + ctx.fillText(`#${event.issue ?? "—"}`, 30, 58, 196); + ctx.fillStyle = "rgba(230, 240, 236, 0.88)"; + ctx.font = "600 29px ui-monospace, Menlo, monospace"; + ctx.fillText(event.kind.toUpperCase(), 30, 108, 196); + ctx.fillStyle = "rgba(230, 240, 236, 0.68)"; + ctx.fillText(shortPath, 30, 154, 196); + ctx.fillStyle = tone; + ctx.fillText(eventCardAge(ageMs), 30, 202, 196); + card.surface.update(); + }; + let eventCardSeen: readonly HudEvent[] | null = null; + let eventCardMinute = -1; + const refreshEventCards = (nowWallMs: number) => { + const list = eventsRef.current; + const minute = Math.floor(nowWallMs / 60_000); + if (list === eventCardSeen && minute === eventCardMinute) return; + eventCardSeen = list; + eventCardMinute = minute; + const newest = new Map(); + for (const event of list) { + const atMs = Date.parse(event.at); + if (!Number.isFinite(atMs)) continue; + const index = cellOfPath(event.title); + if (index < 0) continue; + const prior = newest.get(index); + if (!prior || atMs >= prior.atMs) newest.set(index, { ...event, atMs }); + } + const latest = [...newest.values()].sort((a, b) => b.atMs - a.atMs).slice(0, EVENT_CARD_POOL); + eventCards.forEach((card, k) => { + const event = latest[k]; + if (!event) { card.mesh.isVisible = false; card.index = -1; card.issue = null; card.kind = ""; card.ageMinute = -1; return; } + card.index = cellOfPath(event.title); + card.issue = event.issue; + card.kind = event.kind; + card.ageMinute = Math.floor(event.atMs / 60_000); + drawEventCard(card, event, Math.max(0, nowWallMs - event.atMs)); + card.mesh.position.set(cells[card.index].x, 4, cells[card.index].y); + card.mesh.isVisible = layersRef.current.foundation || layersRef.current.code; + }); + host.setAttribute("data-event-cards", String(latest.length)); + }; + // the roots (K-5): a picked closed issue that an epic lists draws lines from the epic's tower (the keep when unassigned) to every closed child on the field const drawRoots = (number: number | null) => { if (rootLines) { rootLines.dispose(); rootLines = null; } @@ -826,8 +814,12 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit engine.runRenderLoop(() => { const nowMs = performance.now(); const dt = Math.min(nowMs - lastMs, 32); lastMs = nowMs; + // the wall hangs in the air; the drift is small enough not to move a + // cell out from under the pointer while the player is reading it + fieldRoot.position.y = Math.sin(nowMs / 6400) * 7; // the working cells breathe so a still frame still says which are live refreshWorking(Date.now()); + refreshEventCards(Date.now()); if (workingRing) workingRing.alpha = 0.45 + 0.45 * Math.abs(Math.sin(nowMs / 620)); // the zoom glides instead of snapping, and it glides toward the cursor: // an orthographic frustum is a uniform scale about the target, so moving @@ -839,7 +831,7 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit if (anchor && f !== 1) { const k = 1 - 1 / f; camera.target.x += (anchor.x - camera.target.x) * k; - camera.target.z += (anchor.z - camera.target.z) * k; + camera.target.y += (-anchor.z - camera.target.y) * k; clampTarget(); } fit(); @@ -909,6 +901,13 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit // 2026-09-06). It used to light only a module, the hub or a honey cell, // so most of the comb answered nothing and the board felt dead. if (hover >= 0 && hover !== p && cells[hover]) placeDashed(hover, 1.4); else hovered.isVisible = false; + // the cell under the hand rises; the picked cell stays up + const wantLift = p !== null && cells[p] ? p : hover >= 0 && cells[hover] ? hover : -1; + if (wantLift !== liftIndex) { liftIndex = wantLift; if (liftIndex >= 0) lift.position.set(cells[liftIndex].x, liftY, cells[liftIndex].y); } + const goalY = liftIndex >= 0 ? 16 : 0; + liftY += (goalY - liftY) * (1 - Math.pow(2, -dt / 55)); + if (liftIndex >= 0) { lift.isVisible = true; lift.position.set(cells[liftIndex].x, liftY, cells[liftIndex].y); } + else if (liftY < 0.4) lift.isVisible = false; else lift.position.y = liftY; showHoverCard(layersRef.current.foundation && hover >= 0 ? hover : -1); scene.render(); frames += 1; @@ -921,7 +920,6 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit document.addEventListener("visibilitychange", onVisible); document.addEventListener("fullscreenchange", onVisible); return () => { - disposed = true; document.removeEventListener("visibilitychange", onVisible); document.removeEventListener("fullscreenchange", onVisible); canvas.removeEventListener("wheel", onWheel); @@ -935,9 +933,17 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit return (
-
+
); diff --git a/apps/website/src/components/queenHud.ts b/apps/website/src/components/queenHud.ts index bde9fd300a..4837dc61e5 100644 --- a/apps/website/src/components/queenHud.ts +++ b/apps/website/src/components/queenHud.ts @@ -613,6 +613,51 @@ export function ringTone(territory: Territory): string { return "#64DCFF"; } +// ---- THE HIVE'S COLOUR LAW (the user, 2026-09-06) ------------------------- +// The game's objective is to rewrite the repository from hand-written code to +// T27 specs while the bugs are fixed in parallel, so colour on the comb may +// mean exactly one of three things — never decoration: +// yellow the cell's issue is covered by T27 functionality; +// neon blue the cell is still waiting for its T27 spec; +// red the cell carries hand-written ("manual") code that is not +// spec-generated and is therefore the debt the game must clear. +// Honey is reserved for the pointer: the hand is looking for nectar. +export const HIVE_TONES = { + t27: "#FFD45A", + awaiting: "#64DCFF", + manual: "#FF4D5E", + hover: "#FFC24D", +} as const; +export type HiveCover = "t27" | "awaiting" | "manual"; + +/** Lower-case letters and digits only: the comparable form of a path or name. */ +export function hiveKey(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]+/g, ""); +} + +/** + * A cell's cover. `covered` holds hiveKey'd names the trinity spec corpus + * itself claims (spec module/name, plus the corpus' own directories). A module + * with no claim against it is manual code; no module at all is awaiting — the + * cell has no code fact yet, only the wire's issue. + */ +export function hiveCoverOf(modulePath: string | null, covered: ReadonlySet): HiveCover { + if (!modulePath) return "awaiting"; + const segments = modulePath.split("/").filter(Boolean); + const whole = hiveKey(segments.join("/")); + const base = hiveKey(segments[segments.length - 1] ?? ""); + if (covered.has(whole) || covered.has(base)) return "t27"; + if (/^rings\/t27\d*(-|$)/i.test(modulePath) || /^specs?(\/|$)/i.test(modulePath) || /^tests\/t27(\/|$)/i.test(modulePath)) return "t27"; + return "manual"; +} + +/** The cover's rgba tone for Babylon line colours. */ +export function hiveToneOf(cover: HiveCover): [number, number, number, number] { + const hex = HIVE_TONES[cover]; + const n = parseInt(hex.slice(1), 16); + return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255, 1]; +} + /** * A code module of the repository the Queen supervises: the unit of place * on the field (the user, 2026-09-04: "modules in rings from the centre; diff --git a/apps/website/src/pages/Queen.css b/apps/website/src/pages/Queen.css index befd12abc4..5387d62a20 100644 --- a/apps/website/src/pages/Queen.css +++ b/apps/website/src/pages/Queen.css @@ -2996,6 +2996,43 @@ .queen27-hover-card b { display: block; color: #ffd45a; font-weight: 500; margin-bottom: 2px; } .queen27-hover-card span { display: block; color: rgba(230, 240, 236, 0.72); } +/* the colour law sits on the wall itself: each word carries its own swatch */ +.queen27-hive-law { + position: absolute; + z-index: 5; + top: 0.65rem; + left: 0.8rem; + display: flex; + flex-wrap: wrap; + gap: 0.35rem 0.7rem; + max-width: min(520px, calc(100% - 1.6rem)); + pointer-events: none; + color: rgba(230, 240, 236, 0.72); + font: 10px/1.7 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.queen27-hive-law span { + display: inline-flex; + align-items: center; + gap: 0.3rem; +} + +.queen27-hive-law i { + width: 0.5rem; + height: 0.5rem; + flex: 0 0 auto; + border: 1px solid rgba(0, 0, 0, 0.55); + box-shadow: 0 0 8px currentColor; + background: currentColor; +} + +.queen27-hive-law [data-law="t27"] { color: #ffd45a; } +.queen27-hive-law [data-law="manual"] { color: #ff4d5e; } +.queen27-hive-law [data-law="awaiting"] { color: #64dcff; } +.queen27-hive-law [data-law="bees"] { color: #ffc24d; } + .queen27-comb-viewport canvas { display: block; width: 100%; diff --git a/apps/website/src/pages/Queen.tsx b/apps/website/src/pages/Queen.tsx index 11dc7dc268..aefaef8536 100644 --- a/apps/website/src/pages/Queen.tsx +++ b/apps/website/src/pages/Queen.tsx @@ -51,6 +51,7 @@ import { foundationCells, hexCellCount, CASTLE_RING, + hiveKey, } from "../components/queenHud"; import { verifyHardwareEnvelope, @@ -320,6 +321,10 @@ const COPY = { combNoBee: "no bee here", combPick: "Click a cell. A bee on it, or the Queen's own cell, shows a portrait; every cell is one card of the board.", combHint2: "drag to orbit · wheel to zoom · click a cell", + hiveLawT27: "T27 covered", + hiveLawManual: "manual code", + hiveLawAwaiting: "awaiting T27", + hiveLawBees: "bees", factoryFlow: "ISSUE → SPEC → BEE → REVIEW → EVIDENCE", factoryThroughput: "Live utilization", factoryQueueDensity: "queue density peak", @@ -577,6 +582,10 @@ const COPY = { combNoBee: "пчелы здесь нет", combPick: "Нажмите на ячейку. Пчела на ней или ячейка Королевы покажет портрет; каждая ячейка — одна карточка доски.", combHint2: "тяните — вращать · колесо — масштаб · клик — выбрать", + hiveLawT27: "покрыто T27", + hiveLawManual: "ручной код", + hiveLawAwaiting: "ждёт T27", + hiveLawBees: "пчёлы", factoryFlow: "ISSUE → SPEC → BEE → REVIEW → EVIDENCE", factoryThroughput: "Живая загрузка", factoryQueueDensity: "пик плотности очереди", @@ -836,6 +845,45 @@ function useQueenModules(): { data: { commit: string | null; generatedAt: string return { data, error }; } +interface T27ManifestSpec { + path?: string | null; + module?: string | null; + name?: string | null; + repo?: string | null; +} + +/** + * What T27 really claims in this repository. Only trinity rows from the + * manifest may turn a cell yellow; every other module stays manual or awaiting. + * A failed read is not zero coverage — it is unknown coverage, so the set stays + * null and the comb keeps its previous law rather than painting a false result. + */ +function useT27Coverage(): ReadonlySet | null { + const [coverage, setCoverage] = useState | null>(null); + useEffect(() => { + let active = true; + fetch("t27/manifest.json", { headers: { Accept: "application/json" }, cache: "default" }) + .then((response) => { + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise<{ specs?: T27ManifestSpec[] }>; + }) + .then((manifest) => { + if (!active || !Array.isArray(manifest.specs)) return; + const next = new Set(["specs", "tests/t27", "rings/t27"]); + for (const spec of manifest.specs) { + if (spec.repo !== "trinity") continue; + for (const value of [spec.module, spec.name]) { + if (typeof value === "string" && value.length > 0) next.add(hiveKey(value)); + } + } + setCoverage(next); + }) + .catch(() => {}); + return () => { active = false; }; + }, []); + return coverage; +} + /** The loop's GitHub snapshot: closed issues (the foundation), epics (the castle), rings, releases. */ interface FoundationSnapshot { generatedAt: string; @@ -1869,6 +1917,7 @@ export default function Queen() { // dormant), so territories and the buildings' kinds follow. const modulesState = useQueenModules(); const foundationState = useQueenFoundation(); + const t27Coverage = useT27Coverage(); const closedCount = foundationState.data?.closedIssues.length ?? 0; const rawModules = modulesState.data?.modules ?? EMPTY_MODULES; // the board knows the issues; every row gets its open issues from the cards @@ -2707,6 +2756,8 @@ export default function Queen() { pickIndex={pickIndex} fitInset={contextOpen ? (isPhone ? 0.56 : 0.46) : 0} events={events} + t27Coverage={t27Coverage} + law={{ t27: c.hiveLawT27, manual: c.hiveLawManual, awaiting: c.hiveLawAwaiting, bees: c.hiveLawBees }} /> ) : ( diff --git a/docs/QUEEN_HIVE_GAMEPLAY.md b/docs/QUEEN_HIVE_GAMEPLAY.md new file mode 100644 index 0000000000..83d9d9a768 --- /dev/null +++ b/docs/QUEEN_HIVE_GAMEPLAY.md @@ -0,0 +1,91 @@ +# Queen Hive gameplay + +Queen Hive is not an ornament around the repository. It is the operational game +whose win condition is the repository's migration: replace hand-written code +with T27 specifications while fixing real bugs in parallel. + +## Campaign objective and priority + +The first campaign target is **`trios`**. Its modules are the first ring of +manual code to claim, specify, generate, test and review. Other repositories may +join only after `trios` has an auditable spec-to-evidence trail. No visual state +may claim progress without a wire event, merged diff, test result or dated +snapshot behind it. + +The full cycle is: + +1. **Issue** — a real bug, missing behaviour or manual-code boundary. +2. **Spec** — write the `.t27` source of truth and its acceptance criteria. +3. **Bee** — an isolated worker makes the smallest generated change. +4. **Review** — Queen examines the diff, tests and invariants. +5. **Evidence** — accepted work records the proof and seals the corresponding + cell. + +A cell becomes capped only when its issue is closed. A pretty yellow rim never +substitutes for an accepted spec and evidence. + +## The field + +The comb is a vertical wall floating directly in front of the player. It is not +a horizontal board. The Queen is the Trinity logo drawn inside the hub cell; she +is not a 3D character or a separate object. Every module, issue and event is a +honeycomb cell. + +The pointer is part of the game language. Hover or tap raises the cell and draws +the honey-colored nectar ring. A click selects it and opens its context. Pan, +wheel zoom and FIT VIEW use the same wall-plane geometry as picking, so the cell +under the hand remains the cell the game reads. + +## Colour law + +There are exactly three claim colors: + +- **Yellow `#FFD45A`** — functionality is covered by a T27 specification. +- **Neon blue `#64DCFF`** — no code module is claimed yet; the cell awaits its + T27 boundary. +- **Red `#FF4D5E`** — hand-written/manual code that T27 does not yet generate. + +Honey `#FFC24D` is reserved for pointer hover. It is not a progress state. + +The manifest is the source for yellow: only `repo: "trinity"` spec claims may +turn a module yellow. A missing, stale or unparseable manifest never becomes +zero coverage; it remains unknown coverage. + +## Hive display + +The latest wire event per module becomes a compact card on its cell, bounded by +the hexagon: issue number, event kind, module path and age. The display is +capped at twelve newest cards so the wall remains readable. Older facts remain +available through the feed and the repository record. + +Event cards use the existing hive architecture: a dark cell face, one event-tone +border and monospaced slots. KIE.AI may generate optional card/frame textures +through `apps/website/scripts/queen-kie-assets.mjs`, but generated art may never +add a state or number that the wire did not send. The current KIE key returns +401 “Organization access is disabled”; no generated asset is shipped or claimed. + +## Progression + +Each round Queen prioritizes: + +1. an open `trios` bug whose module is red, so repair and migration proceed + together; +2. a manual module with the highest real blast radius; +3. a review that can unblock a capped cell; +4. only then work outside `trios`. + +Bees are real worker slots. They fly toward the module named by their current +event, never toward decorative targets. An event without a resolvable module +lights nothing rather than guessing. + +## Winning and losing + +A module is won when its behaviour has a `.t27` spec, generated implementation, +passing tests, Queen acceptance and no orphaned hand-written implementation +left behind. A ring is won when every module it owns is won and its epics are +closed with evidence. The first campaign is won when every `trios` module +satisfies that rule. + +The hive loses honesty, not a match: if a number, color, cap or bee cannot be +traced to a source, it must be removed or shown as unknown. That honesty rule +overrides every visual choice.