feat(control): spawn session NPCs and resize tokens in preview

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-08-18 14:42:06 +08:00
parent 1ab6ffd593
commit 1a9f0f2557
28 changed files with 1446 additions and 89 deletions
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { SceneTokensSessionStore } from './sceneTokensSessionStore';
void test('SceneTokensSessionStore setSize is session-only and survives move', () => {
const store = new SceneTokensSessionStore();
const s1 = store.dispatch({ kind: 'setSize', placementId: 't1', sizeN: 0.2 });
assert.equal(s1.byPlacementId.t1?.sizeN, 0.2);
assert.equal(s1.byPlacementId.t1?.nx, undefined);
const s2 = store.dispatch({ kind: 'move', placementId: 't1', nx: 0.3, ny: 0.4 });
assert.equal(s2.byPlacementId.t1?.sizeN, 0.2);
assert.equal(s2.byPlacementId.t1?.nx, 0.3);
const s3 = store.dispatch({ kind: 'setSize', placementId: 't1', sizeN: 0.01 });
assert.equal(s3.byPlacementId.t1?.sizeN, 0.02); // clamped min
});
void test('SceneTokensSessionStore rotateMapCwSteps keeps sizeN', () => {
const store = new SceneTokensSessionStore();
store.dispatch({ kind: 'move', placementId: 't1', nx: 0.2, ny: 0.1 });
store.dispatch({ kind: 'setSize', placementId: 't1', sizeN: 0.15 });
const s = store.rotateMapCwSteps(1);
assert.equal(s.byPlacementId.t1?.nx, 0.9);
assert.equal(s.byPlacementId.t1?.ny, 0.2);
assert.equal(s.byPlacementId.t1?.sizeN, 0.15);
});
+44 -4
View File
@@ -1,4 +1,9 @@
import type { SceneTokensSessionEvent, SceneTokensSessionState } from '../../shared/types';
import {
clampSceneTokenSizeN,
type SceneTokensSessionEvent,
type SceneTokensSessionPlacement,
type SceneTokensSessionState,
} from '../../shared/types';
import { rotateMapNormPointByCwSteps } from '../../shared/types/scenePreviewRotation';
function emptyState(): SceneTokensSessionState {
@@ -8,6 +13,18 @@ function emptyState(): SceneTokensSessionState {
};
}
function withMove(
prev: SceneTokensSessionPlacement | undefined,
nx: number,
ny: number,
): SceneTokensSessionPlacement {
return {
nx,
ny,
...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
};
}
export class SceneTokensSessionStore {
private state: SceneTokensSessionState = emptyState();
@@ -34,8 +51,12 @@ export class SceneTokensSessionStore {
for (const id of ids) {
const prev = this.state.byPlacementId[id];
if (!prev) continue;
const next = rotateMapNormPointByCwSteps(prev.nx, prev.ny, n);
byPlacementId[id] = { nx: next.nx, ny: next.ny };
if (typeof prev.nx === 'number' && typeof prev.ny === 'number') {
const next = rotateMapNormPointByCwSteps(prev.nx, prev.ny, n);
byPlacementId[id] = { ...prev, nx: next.nx, ny: next.ny };
} else {
byPlacementId[id] = prev;
}
}
this.state = {
revision: this.state.revision + 1,
@@ -60,7 +81,26 @@ export class SceneTokensSessionStore {
revision: this.state.revision + 1,
byPlacementId: {
...this.state.byPlacementId,
[placementId]: { nx, ny },
[placementId]: withMove(prev, nx, ny),
},
};
return this.state;
}
case 'setSize': {
const placementId = String(event.placementId ?? '');
if (!placementId) return this.state;
const sizeN = clampSceneTokenSizeN(event.sizeN);
const prev = this.state.byPlacementId[placementId];
if (prev?.sizeN === sizeN) return this.state;
this.state = {
revision: this.state.revision + 1,
byPlacementId: {
...this.state.byPlacementId,
[placementId]: {
...(typeof prev?.nx === 'number' ? { nx: prev.nx } : {}),
...(typeof prev?.ny === 'number' ? { ny: prev.ny } : {}),
sizeN,
},
},
};
return this.state;