@khorsheed/dsh-game 0.1.0 → 0.1.1

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.
@@ -7,7 +7,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
7
7
  * element kinds and style), so it writes an opener into the composer.
8
8
  */
9
9
  import { useEffect, useState } from 'react';
10
- import { artStyleLabel } from "../project.js";
10
+ import { artStyleLabel, artStyleName } from "../project.js";
11
11
  import { failureOf } from "./contract.js";
12
12
  import css from './GameTab.module.css';
13
13
  /** "3 分钟前" style relative time. */
@@ -70,6 +70,6 @@ export function ProjectSwitcher({ t, api, sessionId, currentId, rev, onChoose, o
70
70
  }
71
71
  return _jsxs("div", { className: css.projectCardWrap, children: [_jsxs("button", { type: "button", className: css.projectCard, "aria-current": current ? 'true' : undefined, title: card.root, onClick: () => { onChoose(card.id); }, children: [_jsxs("span", { className: css.projectThumb, children: [card.thumb !== undefined
72
72
  ? _jsx("img", { src: `data:image/webp;base64,${card.thumb}`, alt: "" })
73
- : _jsx("span", { className: css.projectInitial, children: card.title.slice(0, 1) }), current && _jsx("span", { className: css.projectHere, children: t('switcher.here') })] }), _jsxs("span", { className: css.projectBody, children: [_jsx("strong", { className: css.projectTitle, children: card.title }), _jsxs("span", { className: css.projectChips, children: [_jsx("span", { className: css.headChip, "data-tone": card.version === null ? 'muted' : 'ok', children: card.version === null ? t('switcher.noVersion') : `v${card.version}` }), card.artStyle !== '' && _jsx("span", { className: css.headChip, children: artStyleLabel(card.artStyle) }), _jsx("span", { className: css.headChip, children: t('switcher.elements', { count: card.elements }) })] }), card.pitch !== '' && _jsx("span", { className: css.projectPitch, children: card.pitch }), _jsxs("span", { className: css.metaLine, children: [ago(t, card.updatedAt), " \u00B7 ", folderOf(card.root)] })] })] }), _jsx("button", { type: "button", className: css.projectDelete, "aria-label": t('switcher.delete', { title: card.title }), title: t('switcher.delete', { title: card.title }), onClick: () => { setConfirming({ id: card.id }); }, children: "\u00D7" })] }, card.id);
73
+ : _jsx("span", { className: css.projectInitial, children: card.title.slice(0, 1) }), current && _jsx("span", { className: css.projectHere, children: t('switcher.here') })] }), _jsxs("span", { className: css.projectBody, children: [_jsx("strong", { className: css.projectTitle, children: card.title }), _jsxs("span", { className: css.projectChips, children: [_jsx("span", { className: css.headChip, "data-tone": card.version === null ? 'muted' : 'ok', children: card.version === null ? t('switcher.noVersion') : `v${card.version}` }), card.artStyle !== '' && _jsx("span", { className: css.headChip, title: artStyleLabel(card.artStyle), children: artStyleName(card.artStyle) }), _jsx("span", { className: css.headChip, children: t('switcher.elements', { count: card.elements }) })] }), card.pitch !== '' && _jsx("span", { className: css.projectPitch, children: card.pitch }), _jsxs("span", { className: css.metaLine, children: [ago(t, card.updatedAt), " \u00B7 ", folderOf(card.root)] })] })] }), _jsx("button", { type: "button", className: css.projectDelete, "aria-label": t('switcher.delete', { title: card.title }), title: t('switcher.delete', { title: card.title }), onClick: () => { setConfirming({ id: card.id }); }, children: "\u00D7" })] }, card.id);
74
74
  })] });
75
75
  }
@@ -5,7 +5,8 @@
5
5
  import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client';
6
6
  import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
7
7
  import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol';
8
- import type { BundleFile, CommitResult, DeleteProjectResult, WorkingChanges, GameResult, PlayBundle, ProjectCard, SessionGameState, TurnReport, VersionDiff } from '../types.ts';
8
+ import type { Reveal } from './reveal.ts';
9
+ import type { BundleFile, LiveVersion, CommitResult, DeleteProjectResult, WorkingChanges, GameResult, PlayBundle, ProjectCard, SessionGameState, TurnReport, VersionDiff } from '../types.ts';
9
10
  type R<T> = Promise<RemoteResult<T>>;
10
11
  type Ok<T extends object = object> = GameResult<{
11
12
  readonly ok: true;
@@ -13,6 +14,7 @@ type Ok<T extends object = object> = GameResult<{
13
14
  /** Every call the panel makes, already bound to the Remote. */
14
15
  export interface GameApi {
15
16
  state: (sid: SessionId) => R<SessionGameState>;
17
+ liveVersion: (sid: SessionId) => R<LiveVersion | null>;
16
18
  projectCards: () => R<ProjectCard[]>;
17
19
  turnReport: (sid: SessionId, turn: number) => R<TurnReport | null>;
18
20
  bind: (sid: SessionId, projectId: string | null) => R<Ok>;
@@ -97,6 +99,8 @@ export interface GameTabInjected {
97
99
  subscribeSessionEvents: (listener: (sessionId: string) => void) => () => void;
98
100
  /** Tell every game surface of this page that a session's state moved. */
99
101
  touch: (sessionId: SessionId) => void;
102
+ /** Carries out the agent's 「请用户看这里」 (page-wide). */
103
+ reveal: Reveal;
100
104
  }
101
105
  /** Full props of the game tab body. */
102
106
  export type GameTabProps = PropsRuntime<'sidebar.right.pane.tab'> & InjectFace<GameTabInjected> & PropsLocale<'game'>;
@@ -115,6 +119,8 @@ export interface ViewProps {
115
119
  readonly refresh: () => void;
116
120
  /** Show a short confirmation line at the panel's foot. */
117
121
  readonly toast: (text: string) => void;
122
+ /** Carries out the agent's 「请用户看这里」 (page-wide). */
123
+ readonly reveal: Reveal;
118
124
  }
119
125
  /** A Remote call's game-level failure code, or undefined on success. */
120
126
  export declare function failureOf(result: RemoteResult<{
@@ -1,6 +1,7 @@
1
1
  import gameRemote from '@khorsheed/dsh-game/remote';
2
2
  import { GAME_CHAT_KIND, GameChatRow, gameMessageDefinition } from "./chat-node.js";
3
3
  import { gameTurnDefinition, GameTurnCard } from "./TurnCard.js";
4
+ import { Reveal } from "./reveal.js";
4
5
  import { encodeGameRef, GAME_REF_SOURCE, gameRefCodec } from "./refs.js";
5
6
  import { GAME_KIND, GAME_TAB_ID, gameDefinition } from "./definition.js";
6
7
  import { GameTab } from "./GameTab.js";
@@ -49,6 +50,7 @@ export async function apply(ctx) {
49
50
  }, 'game: tab type visibility');
50
51
  const api = {
51
52
  state: sid => remote.state(sid),
53
+ liveVersion: sid => remote.liveVersion(sid),
52
54
  projectCards: () => remote.projectCards(),
53
55
  turnReport: (sid, turn) => remote.turnReport(sid, { turn }),
54
56
  bind: (sid, projectId) => remote.bind(sid, { projectId }),
@@ -118,6 +120,7 @@ export async function apply(ctx) {
118
120
  };
119
121
  const navListeners = new Set();
120
122
  let parkedNav = null;
123
+ const reveal = new Reveal(target => { face.openAt(target); });
121
124
  const face = {
122
125
  openAt: (target) => {
123
126
  parkedNav = target;
@@ -183,7 +186,38 @@ export async function apply(ctx) {
183
186
  return () => { listeners.delete(listener); };
184
187
  },
185
188
  touch: sessionId => { emit(sessionId); },
189
+ reveal,
186
190
  };
191
+ // The page follows the agent: game_publish leaves a 「请用户看这里」 request the host cannot push,
192
+ // so the page polls the on-screen session's bound project while visible (session events nudge
193
+ // it early). The same read notices a binding made with no event (a room member's game_open) and
194
+ // refreshes the tab — refresh only; where to go is the agent's to say.
195
+ const lastBound = new Map();
196
+ let lastLook = 0;
197
+ const look = () => {
198
+ const now = Date.now();
199
+ if (now - lastLook < 3000)
200
+ return;
201
+ lastLook = now;
202
+ const sid = mainSessionId(ctx.sessions.list.getSnapshot());
203
+ if (sid === undefined)
204
+ return;
205
+ void api.liveVersion(sid).then(r => {
206
+ if (!r.ok)
207
+ return;
208
+ const bound = r.value?.projectId ?? null;
209
+ const before = lastBound.get(sid);
210
+ lastBound.set(sid, bound);
211
+ if (before !== undefined && before !== bound)
212
+ emit(sid);
213
+ if (r.value?.show !== undefined)
214
+ reveal.request(r.value.projectId, r.value.show);
215
+ }).catch(() => { });
216
+ };
217
+ listeners.add(() => { look(); });
218
+ const pollTimer = setInterval(() => { if (document.visibilityState === 'visible')
219
+ look(); }, 6000);
220
+ disposers.push(async () => { clearInterval(pollTimer); });
187
221
  ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
188
222
  name: 'sidebar.right.pane.tab',
189
223
  key: GAME_TAB_ID,
@@ -128,6 +128,7 @@ export declare const zh: {
128
128
  'fields.note': string;
129
129
  'design.onePager': string;
130
130
  'design.style': string;
131
+ 'design.styleFull': string;
131
132
  'design.noPitch': string;
132
133
  'design.pillars': string;
133
134
  'design.noPillars': string;
@@ -360,6 +361,8 @@ export declare const zh: {
360
361
  'gltf.boneSearch': string;
361
362
  'gltf.rotate': string;
362
363
  'gltf.moreBones': string;
364
+ 'nav.go': string;
365
+ 'nav.dismiss': string;
363
366
  };
364
367
  /** English dictionary. */
365
368
  export declare const en: Record<keyof typeof zh, string>;
@@ -128,6 +128,7 @@ export const zh = {
128
128
  'fields.note': '改完离开输入框即保存;提交成新版本后进游戏。',
129
129
  'design.onePager': '一页纸',
130
130
  'design.style': '风格 · {style}',
131
+ 'design.styleFull': '画风:{style}',
131
132
  'design.noPitch': '还没有一句话玩法。',
132
133
  'design.pillars': '设计支柱',
133
134
  'design.noPillars': '还没定支柱:和 agent 聊出 2–3 条「这个游戏一定要做到的体验」。',
@@ -360,6 +361,8 @@ export const zh = {
360
361
  'gltf.boneSearch': '找骨头…',
361
362
  'gltf.rotate': '旋转',
362
363
  'gltf.moreBones': '另有 {count} 根,搜索名字找',
364
+ 'nav.go': '去看看',
365
+ 'nav.dismiss': '不用了',
363
366
  };
364
367
  /** English dictionary. */
365
368
  export const en = {
@@ -490,6 +493,7 @@ export const en = {
490
493
  'fields.note': 'Saved when you leave the box; reaches the game once you commit it as a new version.',
491
494
  'design.onePager': 'One-pager',
492
495
  'design.style': 'Style · {style}',
496
+ 'design.styleFull': 'Look: {style}',
493
497
  'design.noPitch': 'No pitch yet.',
494
498
  'design.pillars': 'Design pillars',
495
499
  'design.noPillars': 'No pillars yet: find 2–3 experiences this game must deliver with the agent.',
@@ -722,6 +726,8 @@ export const en = {
722
726
  'gltf.boneSearch': 'Find a bone…',
723
727
  'gltf.rotate': 'Rotation',
724
728
  'gltf.moreBones': '{count} more; search by name',
729
+ 'nav.go': 'Show me',
730
+ 'nav.dismiss': 'Not now',
725
731
  };
726
732
  /** The dictionary namespace this view binds. */
727
733
  export const NS = 'game';
@@ -18,7 +18,7 @@ export declare const PREVIEW_ID = "__preview";
18
18
  export declare const PREVIEW_YAW = 35;
19
19
  export declare const PREVIEW_PITCH = 18;
20
20
  /** The preview entry module (it runs in the frame). */
21
- export declare const PREVIEW_MAIN = "\nconst { createWorld, build, THREE } = await import('dsh3d')\ntry { await import('../src/looks.js') } catch (e) {}\nconst mode = (await (await fetch('_preview/mode.json')).json()).mode\nconst world = createWorld({ canvas: document.querySelector('canvas') })\nconst cam = new THREE.PerspectiveCamera(32, 1, 0.05, 500)\nworld.setCamera(cam)\nconst deg = Math.PI / 180\nlet model = null, yaw = 35, pitch = 18, radius = 4, focusY = 0.5, drag = null, spinning = mode === 'spin', viewKey = null\nconst lookNow = () => window.dshElements['__preview'].look\n/** The look's own preview angle, taken again only when it changes (an edit must not reset a dragged view). */\nfunction viewFrom(look) {\n const p = (look && look.preview) || {}, key = JSON.stringify(p)\n if (key === viewKey) return\n viewKey = key\n yaw = Number.isFinite(p.yaw) ? p.yaw : 35\n pitch = Number.isFinite(p.pitch) ? p.pitch : 18\n}\nfunction aim() {\n pitch = Math.max(-10, Math.min(80, pitch))\n const p = pitch * deg, y = yaw * deg\n cam.position.set(Math.cos(p) * Math.cos(y) * radius, focusY + Math.sin(p) * radius, Math.cos(p) * Math.sin(y) * radius)\n cam.lookAt(0, focusY, 0)\n}\n/** Stand the model on the origin and back the camera off far enough to frame it. */\nfunction place() {\n model.position.set(0, 0, 0)\n model.updateMatrixWorld(true)\n const box = new THREE.Box3().setFromObject(model)\n if (box.isEmpty()) return\n const center = box.getCenter(new THREE.Vector3()), size = box.getSize(new THREE.Vector3())\n model.position.set(-center.x, -box.min.y, -center.z)\n focusY = size.y / 2\n const r = Math.max(0.3, box.getBoundingSphere(new THREE.Sphere()).radius)\n radius = r / Math.sin(cam.fov * deg / 2) * 1.08\n aim()\n}\nasync function fit() {\n if (model) world.remove(model)\n const look = lookNow()\n viewFrom(look)\n // From the look object, not the id: the world's own rebuild-by-element must not touch the preview.\n model = build(look)\n world.add(model, { id: '__preview' })\n place()\n // A glTF look fills in later: measure again once it has, then say what it can be tuned by.\n if (model.userData.loaded) { await model.userData.loaded; place() }\n parent.postMessage({ gamedev: 'preview-clips', names: model.userData.animations ?? [], parts: model.userData.parts?.() ?? null }, '*')\n}\n/** What a rebuild depends on; anything else (tint, bones, morphs, angle, clip) is retuned in place. */\nconst rebuildKey = look => JSON.stringify(look && look.kind === 'gltf' ? [look.src, look.material, look.outline, look.scale] : look)\nlet builtKey = null\nfunction edited() {\n const look = lookNow(), key = rebuildKey(look)\n if (model && key === builtKey && model.userData.retune) { model.userData.retune(look); viewFrom(look); return }\n builtKey = key\n fit()\n}\naddEventListener('message', e => {\n const d = e.data\n if (!d || !model) return\n if (d.gamedev === 'preview-play') model.userData.play?.(d.name)\n if (d.gamedev === 'preview-view') { yaw = d.yaw; if (Number.isFinite(d.pitch)) pitch = d.pitch; spinning = false; aim(); world.render() }\n})\nbuiltKey = rebuildKey(lookNow())\nawait fit()\naddEventListener('dshelements', () => { setTimeout(edited) })\nconst canvas = world.canvas\ncanvas.addEventListener('pointerdown', e => { drag = { x: e.clientX, y: e.clientY }; spinning = false; canvas.setPointerCapture(e.pointerId) })\ncanvas.addEventListener('pointermove', e => {\n if (drag === null) return\n yaw -= (e.clientX - drag.x) * 0.5\n pitch += (e.clientY - drag.y) * 0.35\n drag = { x: e.clientX, y: e.clientY }\n})\ncanvas.addEventListener('pointerup', () => { drag = null })\nworld.onUpdate(dt => { if (spinning && drag === null) yaw += dt * 18; aim() })\n// The checks (and specs) can drive this page like a game.\nwindow.dshGame = { ready: true, step: dt => world.step(dt), input() {}, state: () => ({ yaw, pitch, radius, focusY, perspective: world.camera === cam, spinning }) }\nif (mode === 'still') {\n world.step(0.4)\n parent.postMessage({ gamedev: 'preview', png: canvas.toDataURL('image/png') }, '*')\n} else world.start()\n";
21
+ export declare const PREVIEW_MAIN = "\nconst { createWorld, build, THREE } = await import('dsh3d')\ntry { await import('../src/looks.js') } catch (e) {}\nconst mode = (await (await fetch('_preview/mode.json')).json()).mode\nconst world = createWorld({ canvas: document.querySelector('canvas') })\nconst cam = new THREE.PerspectiveCamera(32, 1, 0.05, 500)\nworld.setCamera(cam)\nconst deg = Math.PI / 180\nlet model = null, yaw = 35, pitch = 18, radius = 4, focusY = 0.5, drag = null, spinning = mode === 'spin', viewKey = null\nconst lookNow = () => window.dshElements['__preview'].look\n/** The look's own preview angle, taken again only when it changes (an edit must not reset a dragged view). */\nfunction viewFrom(look) {\n const p = (look && look.preview) || {}, key = JSON.stringify(p)\n if (key === viewKey) return\n viewKey = key\n yaw = Number.isFinite(p.yaw) ? p.yaw : 35\n pitch = Number.isFinite(p.pitch) ? p.pitch : 18\n}\nfunction aim() {\n pitch = Math.max(-10, Math.min(80, pitch))\n const p = pitch * deg, y = yaw * deg\n cam.position.set(Math.cos(p) * Math.cos(y) * radius, focusY + Math.sin(p) * radius, Math.cos(p) * Math.sin(y) * radius)\n cam.lookAt(0, focusY, 0)\n}\n/** Stand the model on the origin and back the camera off far enough to frame it. */\nfunction place() {\n model.position.set(0, 0, 0)\n model.updateMatrixWorld(true)\n const box = new THREE.Box3().setFromObject(model)\n if (box.isEmpty()) return\n const center = box.getCenter(new THREE.Vector3()), size = box.getSize(new THREE.Vector3())\n model.position.set(-center.x, -box.min.y, -center.z)\n focusY = size.y / 2\n const r = Math.max(0.3, box.getBoundingSphere(new THREE.Sphere()).radius)\n radius = r / Math.sin(cam.fov * deg / 2) * 1.08\n aim()\n}\nasync function fit() {\n if (model) world.remove(model)\n const look = lookNow()\n viewFrom(look)\n // From the look object, not the id: the world's own rebuild-by-element must not touch the preview.\n model = build(look)\n world.add(model, { id: '__preview' })\n place()\n // A glTF look fills in later: measure again once it has, then say what it can be tuned by.\n if (model.userData.loaded) { await model.userData.loaded; place() }\n parent.postMessage({ gamedev: 'preview-clips', names: model.userData.animations ?? [], parts: model.userData.parts?.() ?? null }, '*')\n}\n/** What a rebuild depends on; anything else (tint, bones, morphs, angle, clip) is retuned in place. */\nconst rebuildKey = look => JSON.stringify(look && look.kind === 'gltf' ? [look.src, look.material, look.outline, look.scale] : look)\nlet builtKey = null\nfunction edited() {\n const look = lookNow(), key = rebuildKey(look)\n if (model && key === builtKey && model.userData.retune) { model.userData.retune(look); viewFrom(look); return }\n builtKey = key\n fit()\n}\naddEventListener('message', e => {\n const d = e.data\n if (!d || !model) return\n if (d.gamedev === 'preview-play') model.userData.play?.(d.name)\n if (d.gamedev === 'preview-view') { yaw = d.yaw; if (Number.isFinite(d.pitch)) pitch = d.pitch; spinning = false; aim(); world.render() }\n})\nbuiltKey = rebuildKey(lookNow())\nawait fit()\naddEventListener('dshelements', () => { setTimeout(edited) })\nconst canvas = world.canvas\ncanvas.addEventListener('pointerdown', e => {\n drag = { x: e.clientX, y: e.clientY }; spinning = false; canvas.setPointerCapture(e.pointerId)\n parent.postMessage({ gamedev: 'preview-turned' }, '*')\n})\ncanvas.addEventListener('pointermove', e => {\n if (drag === null) return\n yaw -= (e.clientX - drag.x) * 0.5\n pitch += (e.clientY - drag.y) * 0.35\n drag = { x: e.clientX, y: e.clientY }\n})\ncanvas.addEventListener('pointerup', () => { drag = null })\nworld.onUpdate(dt => { if (spinning && drag === null) yaw += dt * 18; aim() })\n// The checks (and specs) can drive this page like a game.\nwindow.dshGame = { ready: true, step: dt => world.step(dt), input() {}, state: () => ({ yaw, pitch, radius, focusY, perspective: world.camera === cam, spinning }) }\nif (mode === 'still') {\n world.step(0.4)\n parent.postMessage({ gamedev: 'preview', png: canvas.toDataURL('image/png') }, '*')\n} else world.start()\n";
22
22
  /** The preview page: a full-size canvas and the entry above. */
23
23
  export declare const PREVIEW_HTML: string;
24
24
  //# sourceMappingURL=preview-entry.d.ts.map
@@ -86,7 +86,10 @@ builtKey = rebuildKey(lookNow())
86
86
  await fit()
87
87
  addEventListener('dshelements', () => { setTimeout(edited) })
88
88
  const canvas = world.canvas
89
- canvas.addEventListener('pointerdown', e => { drag = { x: e.clientX, y: e.clientY }; spinning = false; canvas.setPointerCapture(e.pointerId) })
89
+ canvas.addEventListener('pointerdown', e => {
90
+ drag = { x: e.clientX, y: e.clientY }; spinning = false; canvas.setPointerCapture(e.pointerId)
91
+ parent.postMessage({ gamedev: 'preview-turned' }, '*')
92
+ })
90
93
  canvas.addEventListener('pointermove', e => {
91
94
  if (drag === null) return
92
95
  yaw -= (e.clientX - drag.x) * 0.5
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The game tab follows the agent: `game_publish` says where the user should
3
+ * look (the play area, an element's page, the element grid…), the page picks
4
+ * the request up on its poll, and this controller carries it out — unless the
5
+ * user is busy in the panel (a game running, a touch in the last 20 s), in
6
+ * which case it waits as a notice. The page itself never decides where to go.
7
+ *
8
+ * Each request is carried out once; one found on a reload is history. Every
9
+ * decision leaves a `[game:reveal]` console line.
10
+ * @module @khorsheed/dsh-game/client
11
+ */
12
+ import type { NavTarget } from './contract.ts';
13
+ import type { ShowRequest } from '../types.ts';
14
+ /** A request held back while the user was busy. */
15
+ export interface RevealNotice {
16
+ readonly text: string;
17
+ readonly target: NavTarget;
18
+ }
19
+ /** The page-wide controller. */
20
+ export declare class Reveal {
21
+ private readonly open;
22
+ private lastTouch;
23
+ private playing;
24
+ private mounted;
25
+ private noticeNow;
26
+ private readonly listeners;
27
+ /** The last request sequence carried out per project. */
28
+ private readonly done;
29
+ /** @param open - opens the game tab at a place (the plugin's openAt). */
30
+ constructor(open: (target: NavTarget) => void);
31
+ get notice(): RevealNotice | null;
32
+ dismiss(): void;
33
+ follow(): void;
34
+ /** The tab mounted; the result unmounts it. A mounted tab is navigated at any width. */
35
+ mount(): () => void;
36
+ touched(): void;
37
+ setPlaying(on: boolean): void;
38
+ subscribe(listener: () => void): () => void;
39
+ private changed;
40
+ /** The agent's request for a project, as the poll returned it. */
41
+ request(projectId: string, request: ShowRequest, now?: number): 'opened' | 'notice' | 'skipped' | null;
42
+ }
43
+ //# sourceMappingURL=reveal.d.ts.map
@@ -0,0 +1,73 @@
1
+ /** A touch this recent means the user is busy in the panel. */
2
+ const BUSY_MS = 20_000;
3
+ /** Below this window width a closed panel is not opened: it would cover the conversation. */
4
+ const WIDE_PX = 820;
5
+ /** A request older than this, seen first on this page, is history (a reload). */
6
+ const FRESH_MS = 90_000;
7
+ /** The page-wide controller. */
8
+ export class Reveal {
9
+ open;
10
+ lastTouch = 0;
11
+ playing = false;
12
+ mounted = 0;
13
+ noticeNow = null;
14
+ listeners = new Set();
15
+ /** The last request sequence carried out per project. */
16
+ done = new Map();
17
+ /** @param open - opens the game tab at a place (the plugin's openAt). */
18
+ constructor(open) {
19
+ this.open = open;
20
+ }
21
+ get notice() { return this.noticeNow; }
22
+ dismiss() { this.noticeNow = null; this.changed(); }
23
+ follow() {
24
+ const n = this.noticeNow;
25
+ this.dismiss();
26
+ if (n !== null)
27
+ this.open(n.target);
28
+ }
29
+ /** The tab mounted; the result unmounts it. A mounted tab is navigated at any width. */
30
+ mount() {
31
+ this.mounted++;
32
+ return () => { this.mounted--; };
33
+ }
34
+ touched() { this.lastTouch = Date.now(); }
35
+ setPlaying(on) { this.playing = on; }
36
+ subscribe(listener) {
37
+ this.listeners.add(listener);
38
+ return () => { this.listeners.delete(listener); };
39
+ }
40
+ changed() { for (const l of this.listeners)
41
+ l(); }
42
+ /** The agent's request for a project, as the poll returned it. */
43
+ request(projectId, request, now = Date.now()) {
44
+ const seen = this.done.get(projectId);
45
+ this.done.set(projectId, Math.max(request.seq, seen ?? 0));
46
+ if (seen !== undefined ? request.seq <= seen : now - Date.parse(request.at) > FRESH_MS)
47
+ return null;
48
+ const target = {
49
+ view: request.target.view,
50
+ ...(request.target.elementId === undefined ? {} : { elementId: request.target.elementId }),
51
+ ...(request.target.chapterId === undefined ? {} : { chapterId: request.target.chapterId }),
52
+ };
53
+ const busy = this.playing ? 'a game is running' : now - this.lastTouch < BUSY_MS ? 'the panel was just used' : null;
54
+ const narrow = this.mounted === 0 && (typeof window === 'undefined' || window.innerWidth < WIDE_PX);
55
+ const outcome = busy !== null ? 'notice' : narrow ? 'skipped' : 'opened';
56
+ try {
57
+ console.info(`[game:reveal] #${request.seq} ${request.note} → ${outcome}${busy === null ? '' : ` (${busy})`}${narrow && busy === null ? ' (closed panel, narrow window)' : ''}`, target);
58
+ }
59
+ catch {
60
+ // No console.
61
+ }
62
+ if (outcome === 'notice') {
63
+ this.noticeNow = { text: request.note, target };
64
+ this.changed();
65
+ }
66
+ if (outcome === 'opened') {
67
+ this.noticeNow = null;
68
+ this.open(target);
69
+ this.changed();
70
+ }
71
+ return outcome;
72
+ }
73
+ }
@@ -29,6 +29,12 @@ export declare function listOf(value: readonly string[] | string | undefined): s
29
29
  export declare function resolveArtStyle(value: string | undefined, styles?: readonly ArtStyle[]): string | undefined;
30
30
  /** The style's human label (`custom:` keeps the user's own words). */
31
31
  export declare function artStyleLabel(id: string): string;
32
+ /**
33
+ * A style's short name for tight places (tags, chips): a custom description's
34
+ * lead — up to its first dash, colon, comma or bracket — at most 10 characters.
35
+ * The full label stays for the model and for hover text.
36
+ */
37
+ export declare function artStyleName(id: string): string;
32
38
  /** The kit a create request asks for: `3d` / `3d-three` → 3D, anything else 2D. */
33
39
  export declare function requestedKit(runtime: string | undefined): KitId;
34
40
  /** The example styles a kit offers. */
@@ -40,6 +40,16 @@ export function artStyleLabel(id) {
40
40
  return id.slice('custom:'.length);
41
41
  return [...ART_STYLES, ...ART_STYLES_3D].find(style => style.id === id)?.label ?? id;
42
42
  }
43
+ /**
44
+ * A style's short name for tight places (tags, chips): a custom description's
45
+ * lead — up to its first dash, colon, comma or bracket — at most 10 characters.
46
+ * The full label stays for the model and for hover text.
47
+ */
48
+ export function artStyleName(id) {
49
+ const full = artStyleLabel(id).trim();
50
+ const lead = full.split(/——|—|--|[::,,;;((]/)[0]?.trim() || full;
51
+ return [...lead].length > 10 ? `${[...lead].slice(0, 10).join('')}…` : lead;
52
+ }
43
53
  /** The kit a create request asks for: `3d` / `3d-three` → 3D, anything else 2D. */
44
54
  export function requestedKit(runtime) {
45
55
  return /^3d/i.test((runtime ?? '').trim()) ? '3d-three' : '2d-canvas';
@@ -8,7 +8,7 @@
8
8
  import type { SessionId } from '@deepseek-ai/dsh-session/types';
9
9
  import type { Context } from '@deepseek-ai/cordis';
10
10
  import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
11
- import type { BundleFile, CommitResult, DeleteProjectResult, WorkingChanges, GameResult, PlayBundle, ProjectCard, SessionGameState, TurnReport, VersionDiff } from './types.ts';
11
+ import type { BundleFile, LiveVersion, CommitResult, DeleteProjectResult, WorkingChanges, GameResult, PlayBundle, ProjectCard, SessionGameState, TurnReport, VersionDiff } from './types.ts';
12
12
  declare module '@deepseek-ai/cordis' {
13
13
  interface Context {
14
14
  /** The game panel's Remote face. */
@@ -29,6 +29,8 @@ export declare class GameRemoteService extends TypertRemoteService<GameRemoteCon
29
29
  private get store();
30
30
  /** The session's bound project (full view) and the project list. */
31
31
  state(sessionId: SessionId): Promise<SessionGameState>;
32
+ /** The bound project's newest version (cheap; the page polls it). */
33
+ liveVersion(sessionId: SessionId): Promise<LiveVersion | null>;
32
34
  /** What one of the session's turns changed (null when nothing, or not recorded). */
33
35
  turnReport(sessionId: SessionId, request: {
34
36
  turn: number;
@@ -39,6 +39,7 @@ let GameRemoteService = (() => {
39
39
  let _classSuper = TypertRemoteService;
40
40
  let _instanceExtraInitializers = [];
41
41
  let _state_decorators;
42
+ let _liveVersion_decorators;
42
43
  let _turnReport_decorators;
43
44
  let _projectCards_decorators;
44
45
  let _deleteProject_decorators;
@@ -68,6 +69,7 @@ let GameRemoteService = (() => {
68
69
  static {
69
70
  const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
70
71
  _state_decorators = [Remote('state')];
72
+ _liveVersion_decorators = [Remote('liveVersion')];
71
73
  _turnReport_decorators = [Remote('turnReport')];
72
74
  _projectCards_decorators = [Remote('projectCards')];
73
75
  _deleteProject_decorators = [Remote('deleteProject')];
@@ -94,6 +96,7 @@ let GameRemoteService = (() => {
94
96
  _checkShot_decorators = [Remote('checkShot')];
95
97
  _versionBundle_decorators = [Remote('versionBundle')];
96
98
  __esDecorate(this, null, _state_decorators, { kind: "method", name: "state", static: false, private: false, access: { has: obj => "state" in obj, get: obj => obj.state }, metadata: _metadata }, null, _instanceExtraInitializers);
99
+ __esDecorate(this, null, _liveVersion_decorators, { kind: "method", name: "liveVersion", static: false, private: false, access: { has: obj => "liveVersion" in obj, get: obj => obj.liveVersion }, metadata: _metadata }, null, _instanceExtraInitializers);
97
100
  __esDecorate(this, null, _turnReport_decorators, { kind: "method", name: "turnReport", static: false, private: false, access: { has: obj => "turnReport" in obj, get: obj => obj.turnReport }, metadata: _metadata }, null, _instanceExtraInitializers);
98
101
  __esDecorate(this, null, _projectCards_decorators, { kind: "method", name: "projectCards", static: false, private: false, access: { has: obj => "projectCards" in obj, get: obj => obj.projectCards }, metadata: _metadata }, null, _instanceExtraInitializers);
99
102
  __esDecorate(this, null, _deleteProject_decorators, { kind: "method", name: "deleteProject", static: false, private: false, access: { has: obj => "deleteProject" in obj, get: obj => obj.deleteProject }, metadata: _metadata }, null, _instanceExtraInitializers);
@@ -137,6 +140,10 @@ let GameRemoteService = (() => {
137
140
  state(sessionId) {
138
141
  return this.store.sessionState(sessionId);
139
142
  }
143
+ /** The bound project's newest version (cheap; the page polls it). */
144
+ liveVersion(sessionId) {
145
+ return this.store.liveVersion(sessionId);
146
+ }
140
147
  /** What one of the session's turns changed (null when nothing, or not recorded). */
141
148
  turnReport(sessionId, request) {
142
149
  return this.store.turnReport(sessionId, request.turn);
@@ -89,6 +89,7 @@ game_read 的总览会列出用户改过、你还没看过的东西(看过一
89
89
  - 不通过:工具返回问题清单,修好再调用;修好之前别请用户试玩。
90
90
  - 一个功能写完、能玩了就可以发,不必等到这一轮最后;文件还没写完时别调。
91
91
  - 纯设计问题(不需要玩)直接问,不用发版。
92
+ - **发版会把用户的右栏带到结果那里**:默认去这一版改的那个元素(改了好几个就是元素列表,改了代码就是试玩)。想指定就给 game_publish 加 \`show\`(\`play\`、\`versions\`、\`elements\`、\`elements:元素id或名字\`、\`design:章节\`)和 \`note\`(一句看什么,如「看看太阳船的船壳」)。用户正在面板里操作或试玩时,右栏只提示,不硬跳。
92
93
  - 和 Claude Code 这类成员一起做时:成员手里没有发版工具。它说改完了,你马上 game_publish 再回复用户;给成员写交接说明时写「改完告诉我,我来发版」,别写「发版不归你管」。
93
94
  - 用户也能在右栏点「检查并发版」,那样出的版本标「用户发布」,是用户认可过的,别随手改回去。
94
95
 
@@ -3,7 +3,7 @@ import type { Session } from '@deepseek-ai/dsh-session';
3
3
  import { type BrowserChecker } from './checks.ts';
4
4
  import { type TrashMover } from './trash.ts';
5
5
  import { type CreateRequest } from './project.ts';
6
- import { type BundleFile, type CommitResult, type VersionSource, type WorkingChanges, type DeleteProjectResult, type CheckRecord, type GameResult, type PlayBundle, type ProjectCard, type ProjectRow, type ProjectView, type TurnReport, type SessionGameState, type VersionDiff, type VersionRecord } from './types.ts';
6
+ import { type BundleFile, type CommitResult, type VersionSource, type WorkingChanges, type LiveVersion, type DeleteProjectResult, type CheckRecord, type GameResult, type PlayBundle, type ProjectCard, type ProjectRow, type ProjectView, type TurnReport, type SessionGameState, type VersionDiff, type VersionRecord } from './types.ts';
7
7
  /** Plugin config; every key optional. */
8
8
  export interface GameConfig {
9
9
  /** Registry root override (default `$DSH_HOME/state/game`). */
@@ -149,6 +149,27 @@ export declare class GameService {
149
149
  /** Elements the waiting candidate changed or added against the live version (null without both). */
150
150
  private candidateDiff;
151
151
  /** The tab's state for one session. */
152
+ /**
153
+ * The bound project's newest version (the waiting candidate, else the live
154
+ * one), read from two small files: the page polls it to announce a version
155
+ * the moment it lands, whoever's turn cut it. Version 0 means none yet.
156
+ */
157
+ liveVersion(sessionId: string): Promise<LiveVersion | null>;
158
+ /** The agent's latest 「请用户看这里」 per project (in memory: a request is for the moment, not history). */
159
+ private readonly shows;
160
+ private showSeq;
161
+ /**
162
+ * After a passing `game_publish`: leave the pages showing this project a
163
+ * request to take their game tab to the result. `show` is the agent's choice
164
+ * (play, versions, elements, elements:<id or name>, design:<chapter>); left
165
+ * out — or not found — the version decides: the one element it changed, the
166
+ * grid when several, else the play area (a candidate always opens play,
167
+ * where it waits for the user's OK). Pages pick it up on their poll.
168
+ * @returns a line for the tool's answer.
169
+ */
170
+ showAfterPublish(sessionId: string, version: number, candidate: boolean, show?: string, note?: string): Promise<string>;
171
+ /** Where a version's change is: the one element it changed, the grid for several, else play (code, assets, a first version). */
172
+ private changedBy;
152
173
  sessionState(sessionId: string): Promise<SessionGameState>;
153
174
  /**
154
175
  * One build file as it is in the working folder now (a model the user just
@@ -458,7 +458,11 @@ export class GameService {
458
458
  manifest,
459
459
  ...(manifestText === undefined ? { manifestError: `${MANIFEST_FILE} 不见了` } : parsed?.ok === false ? { manifestError: parsed.error } : {}),
460
460
  chapters,
461
- elements,
461
+ // Each element carries its file's last change, so the panel can list the newest first.
462
+ elements: await Promise.all(elements.map(async (e) => {
463
+ const info = await stat(join(row.root, e.file)).catch(() => undefined);
464
+ return info === undefined ? e : { ...e, updatedAt: info.mtimeMs };
465
+ })),
462
466
  elementErrors: failures,
463
467
  current: (await readJson(join(state, 'current.json'))) ?? null,
464
468
  candidate: (await readJson(join(state, 'candidate.json'))) ?? null,
@@ -493,6 +497,100 @@ export class GameService {
493
497
  return { changed, added };
494
498
  }
495
499
  /** The tab's state for one session. */
500
+ /**
501
+ * The bound project's newest version (the waiting candidate, else the live
502
+ * one), read from two small files: the page polls it to announce a version
503
+ * the moment it lands, whoever's turn cut it. Version 0 means none yet.
504
+ */
505
+ async liveVersion(sessionId) {
506
+ const row = await this.boundProject(sessionId);
507
+ if (row === undefined)
508
+ return null;
509
+ const state = join(row.root, STATE_DIR);
510
+ const candidate = await readJson(join(state, 'candidate.json'));
511
+ const record = candidate ?? (await readJson(join(state, 'current.json')));
512
+ const show = this.shows.get(row.id);
513
+ const extra = show === undefined ? {} : { show };
514
+ if (record === undefined)
515
+ return { projectId: row.id, version: 0, kind: 'none', at: null, ...extra };
516
+ return { projectId: row.id, version: record.version, kind: candidate !== undefined ? 'candidate' : 'deployed', at: record.at, ...(record.by === undefined ? {} : { by: record.by }), ...extra };
517
+ }
518
+ /** The agent's latest 「请用户看这里」 per project (in memory: a request is for the moment, not history). */
519
+ shows = new Map();
520
+ showSeq = 0;
521
+ /**
522
+ * After a passing `game_publish`: leave the pages showing this project a
523
+ * request to take their game tab to the result. `show` is the agent's choice
524
+ * (play, versions, elements, elements:<id or name>, design:<chapter>); left
525
+ * out — or not found — the version decides: the one element it changed, the
526
+ * grid when several, else the play area (a candidate always opens play,
527
+ * where it waits for the user's OK). Pages pick it up on their poll.
528
+ * @returns a line for the tool's answer.
529
+ */
530
+ async showAfterPublish(sessionId, version, candidate, show, note) {
531
+ const row = await this.boundProject(sessionId);
532
+ const project = row === undefined ? undefined : await this.view(row.id);
533
+ if (row === undefined || project === undefined)
534
+ return '';
535
+ let target = null;
536
+ let refused = '';
537
+ const asked = (show ?? '').trim();
538
+ if (asked !== '') {
539
+ const [view, ...rest] = asked.split(':');
540
+ const name = rest.join(':').trim();
541
+ if (view === 'play' || view === 'versions' || (view === 'elements' && name === ''))
542
+ target = { view };
543
+ else if (view === 'elements') {
544
+ const e = project.elements.find(x => x.id === name || x.name === name);
545
+ if (e !== undefined)
546
+ target = { view, elementId: e.id };
547
+ }
548
+ else if (view === 'design') {
549
+ const c = project.chapters.find(x => x.id === name || x.title === name);
550
+ if (c !== undefined)
551
+ target = { view, chapterId: c.id };
552
+ }
553
+ if (target === null)
554
+ refused = `(show「${asked}」找不到,改用默认位置)`;
555
+ }
556
+ let changedNames = [];
557
+ if (target === null) {
558
+ const decided = candidate ? { target: { view: 'play' }, names: [] } : await this.changedBy(row.root, version);
559
+ target = decided.target;
560
+ changedNames = decided.names;
561
+ }
562
+ const words = (note ?? '').trim();
563
+ const text = words !== '' ? words
564
+ : candidate ? `v${version} 等你确认上线`
565
+ : changedNames.length > 0 ? `v${version}:改了${changedNames.slice(0, 3).join('、')}${changedNames.length > 3 ? ` 等 ${changedNames.length} 个` : ''}`
566
+ : `v${version} 已上线`;
567
+ this.shows.set(row.id, { seq: ++this.showSeq, target, note: text.slice(0, 120), at: new Date().toISOString() });
568
+ this.notify(row.id);
569
+ const where = target.view === 'play' ? '试玩' : target.view === 'versions' ? '版本' : target.view === 'design' ? `策划·${target.chapterId}` : target.elementId === undefined ? '元素列表' : `元素「${target.elementId}」`;
570
+ return `右栏会打开:${where}${refused}。`;
571
+ }
572
+ /** Where a version's change is: the one element it changed, the grid for several, else play (code, assets, a first version). */
573
+ async changedBy(root, version) {
574
+ const earlier = (await this.readVersions(root)).map(v => v.version).filter(v => v < version);
575
+ const play = { target: { view: 'play' }, names: [] };
576
+ if (earlier.length === 0)
577
+ return play;
578
+ const prev = Math.max(...earlier);
579
+ const dir = (v) => join(root, STATE_DIR, 'versions', String(v));
580
+ if (!await stat(dir(prev)).then(() => true, () => false))
581
+ return play;
582
+ const [before, after] = await Promise.all([this.elementIndex(dir(prev)), this.elementIndex(dir(version))]);
583
+ const changed = [...after].filter(([id, e]) => before.get(id)?.json !== e.json);
584
+ const others = async (v) => new Map((await walk(dir(v), p => !p.startsWith('elements/'))).map(f => [f.path, f.data.toString('base64')]));
585
+ const [a, b] = await Promise.all([others(prev), others(version)]);
586
+ const otherChanged = a.size !== b.size || [...b].some(([p, d]) => a.get(p) !== d);
587
+ const names = changed.map(([, e]) => e.name);
588
+ if (otherChanged || changed.length === 0)
589
+ return { target: { view: 'play' }, names };
590
+ if (changed.length === 1)
591
+ return { target: { view: 'elements', elementId: changed[0][0] }, names };
592
+ return { target: { view: 'elements' }, names };
593
+ }
496
594
  async sessionState(sessionId) {
497
595
  const bound = await this.boundProject(sessionId);
498
596
  return {
@@ -2,8 +2,8 @@
2
2
  * The game mode's model tools, registered by the `./agent` entry:
3
3
  * `game_open` (list, switch to, create or adopt a project — the light gate
4
4
  * lives here), `game_read` (the bound project's state), `game_propose` (look
5
- * variants) and `game_publish` (run the checks now and put a passing build
6
- * into the play area). There is no write tool: the agent writes project files
5
+ * variants) and `game_publish` (run the checks now, put a passing build into
6
+ * the play area, and take the user's game tab to it). There is no write tool: the agent writes project files
7
7
  * with the ordinary fs tools. The checks also run by themselves at the end of
8
8
  * every turn — but a turn can stay open for a long time (asking the user a
9
9
  * question does not close it), so `game_publish` is how a build reaches the
@@ -247,13 +247,19 @@ export function gameToolDefinitions(store) {
247
247
  description: 'Run the checks on this session\'s game project now (data validation → load and a 2-second smoke run → tests/) and, '
248
248
  + 'when they pass, cut a new version into the play area (or a candidate when auto-confirm is off). Call it whenever a '
249
249
  + 'playable build is ready and ALWAYS before asking the user to play or try something: asking a question does not end '
250
- + 'the turn, so without it the play area still shows the previous version. A failure lists what to fix.',
251
- parameters: {},
250
+ + 'the turn, so without it the play area still shows the previous version. A failure lists what to fix. A passing '
251
+ + 'publish also takes the user\'s game tab to the result: say where with show (play, versions, elements, '
252
+ + 'elements:<element id or name>, design:<chapter id or title>) and what to look at with note; left out, it opens '
253
+ + 'the one element this version changed, the element grid when it changed several, else the play area.',
254
+ parameters: {
255
+ show: { type: 'string', description: 'Where the user\'s game tab goes after a pass (see above). Optional.' },
256
+ note: { type: 'string', description: 'What to look at, a short phrase shown with it (e.g. 看看阿努比斯的头饰). Optional.' },
257
+ },
252
258
  output: {
253
259
  schema: { type: 'string' },
254
260
  render: (_args, value) => [{ type: 'text', text: String(value) }],
255
261
  },
256
- execute: async (_args, exec) => {
262
+ execute: async (args, exec) => {
257
263
  const session = exec.agent?.session;
258
264
  if (session === undefined)
259
265
  return '失败:这次调用没有会话。';
@@ -277,10 +283,12 @@ export function gameToolDefinitions(store) {
277
283
  }
278
284
  if (outcome.kind === 'pass') {
279
285
  const tail = outcome.record.skipped === undefined ? '' : `(${outcome.record.skipped})`;
286
+ const cut = outcome.deployed ?? outcome.candidate;
287
+ const shown = cut === null ? '' : await store.showAfterPublish(session.id, cut.version, outcome.candidate !== null, args.show, args.note);
280
288
  if (outcome.deployed !== null)
281
- return `完成:v${outcome.deployed.version} 通过检查${outcome.record.passed > 0 ? `(${outcome.record.passed} 个用例)` : ''},已进试玩区,可以请用户试玩了。${tail}`;
289
+ return `完成:v${outcome.deployed.version} 通过检查${outcome.record.passed > 0 ? `(${outcome.record.passed} 个用例)` : ''},已进试玩区,可以请用户试玩了。${shown}${tail}`;
282
290
  if (outcome.candidate !== null)
283
- return `完成:v${outcome.candidate.version} 通过检查,作为候选等用户在右栏确认(自动上线关着或是换风格的版本);确认后才进试玩区。${tail}`;
291
+ return `完成:v${outcome.candidate.version} 通过检查,作为候选等用户在右栏确认(自动上线关着或是换风格的版本);确认后才进试玩区。${shown}${tail}`;
284
292
  return `检查通过,但这个会话是只读模式,没有生成版本。${tail}`;
285
293
  }
286
294
  // Skipped: no entry yet, or the build is the one already checked.