feat(control): spawn session NPCs and resize tokens in preview
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -46,6 +46,29 @@ void test('SceneNpcTokensSessionStore setDisposition without inventing position'
|
||||
assert.equal(s2.byPlacementId.a?.disposition, 'hostile');
|
||||
});
|
||||
|
||||
void test('SceneNpcTokensSessionStore spawn is session-only and despawn removes it', () => {
|
||||
const store = new SceneNpcTokensSessionStore();
|
||||
const s1 = store.dispatch({
|
||||
kind: 'spawn',
|
||||
npcId: 'npc1',
|
||||
sceneId: 'sceneA',
|
||||
nx: 0.4,
|
||||
ny: 0.6,
|
||||
disposition: 'hostile',
|
||||
});
|
||||
const ids = Object.keys(s1.spawned);
|
||||
assert.equal(ids.length, 1);
|
||||
const spawn = s1.spawned[ids[0]!];
|
||||
assert.equal(spawn?.npcId, 'npc1');
|
||||
assert.equal(spawn?.sceneId, 'sceneA');
|
||||
assert.equal(spawn?.nx, 0.4);
|
||||
const s2 = store.dispatch({ kind: 'move', placementId: ids[0]!, nx: 0.7, ny: 0.2 });
|
||||
assert.equal(s2.spawned[ids[0]!]?.nx, 0.7);
|
||||
const s3 = store.dispatch({ kind: 'despawn', placementId: ids[0]! });
|
||||
assert.deepEqual(s3.spawned, {});
|
||||
assert.equal(s3.byPlacementId[ids[0]!], undefined);
|
||||
});
|
||||
|
||||
void test('SceneNpcTokensSessionStore setInactive is session flag', () => {
|
||||
const store = new SceneNpcTokensSessionStore();
|
||||
const s1 = store.dispatch({ kind: 'setInactive', placementId: 'a', inactive: true });
|
||||
@@ -58,3 +81,24 @@ void test('SceneNpcTokensSessionStore setInactive is session flag', () => {
|
||||
assert.equal(s3.byPlacementId.a?.nx, 0.2);
|
||||
assert.equal(s3.byPlacementId.a?.inactive, undefined);
|
||||
});
|
||||
|
||||
void test('SceneNpcTokensSessionStore setSize is session-only and survives move', () => {
|
||||
const store = new SceneNpcTokensSessionStore();
|
||||
const s1 = store.dispatch({ kind: 'setSize', placementId: 'a', sizeN: 0.2 });
|
||||
assert.equal(s1.byPlacementId.a?.sizeN, 0.2);
|
||||
assert.equal(s1.byPlacementId.a?.nx, undefined);
|
||||
const s2 = store.dispatch({ kind: 'move', placementId: 'a', nx: 0.3, ny: 0.4 });
|
||||
assert.equal(s2.byPlacementId.a?.sizeN, 0.2);
|
||||
assert.equal(s2.byPlacementId.a?.nx, 0.3);
|
||||
const spawned = store.dispatch({
|
||||
kind: 'spawn',
|
||||
npcId: 'npc1',
|
||||
sceneId: 'sceneA',
|
||||
nx: 0.5,
|
||||
ny: 0.5,
|
||||
});
|
||||
const id = Object.keys(spawned.spawned)[0]!;
|
||||
const s3 = store.dispatch({ kind: 'setSize', placementId: id, sizeN: 0.18 });
|
||||
assert.equal(s3.spawned[id]?.sizeN, 0.18);
|
||||
assert.equal(s3.byPlacementId[id]?.sizeN, 0.18);
|
||||
});
|
||||
|
||||
@@ -1,23 +1,38 @@
|
||||
import {
|
||||
clampNpcTokenSessionScale,
|
||||
clampSceneNpcTokenSizeN,
|
||||
DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
|
||||
asSceneNpcTokenId,
|
||||
emptySceneNpcTokensSessionState,
|
||||
newSceneNpcSessionSpawnId,
|
||||
type SceneNpcSessionSpawn,
|
||||
type SceneNpcTokensSessionEvent,
|
||||
type SceneNpcTokensSessionPlacement,
|
||||
type SceneNpcTokensSessionState,
|
||||
} from '../../shared/types/appPlayers';
|
||||
import { asNpcId } from '../../shared/types/ids';
|
||||
import { normalizeNpcDisposition } from '../../shared/types/npcDisposition';
|
||||
import { rotateMapNormPointByCwSteps } from '../../shared/types/scenePreviewRotation';
|
||||
|
||||
function emptyState(revision = 1): SceneNpcTokensSessionState {
|
||||
return {
|
||||
revision,
|
||||
byPlacementId: {},
|
||||
scale: DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
};
|
||||
function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean {
|
||||
return (
|
||||
p.nx === undefined &&
|
||||
p.ny === undefined &&
|
||||
p.sizeN === undefined &&
|
||||
!p.disposition &&
|
||||
!p.inactive
|
||||
);
|
||||
}
|
||||
|
||||
function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean {
|
||||
return p.nx === undefined && p.ny === undefined && !p.disposition && !p.inactive;
|
||||
function restOf(prev: SceneNpcTokensSessionPlacement | undefined): SceneNpcTokensSessionPlacement {
|
||||
return {
|
||||
...(prev?.nx !== undefined ? { nx: prev.nx } : {}),
|
||||
...(prev?.ny !== undefined ? { ny: prev.ny } : {}),
|
||||
...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
|
||||
...(prev?.disposition ? { disposition: prev.disposition } : {}),
|
||||
...(prev?.inactive ? { inactive: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function withMove(
|
||||
@@ -26,10 +41,9 @@ function withMove(
|
||||
ny: number,
|
||||
): SceneNpcTokensSessionPlacement {
|
||||
return {
|
||||
...restOf(prev),
|
||||
nx,
|
||||
ny,
|
||||
...(prev?.disposition ? { disposition: prev.disposition } : {}),
|
||||
...(prev?.inactive ? { inactive: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,11 +57,12 @@ function withoutPlacement(
|
||||
revision: state.revision + 1,
|
||||
byPlacementId: rest,
|
||||
scale: state.scale,
|
||||
spawned: state.spawned,
|
||||
};
|
||||
}
|
||||
|
||||
export class SceneNpcTokensSessionStore {
|
||||
private state: SceneNpcTokensSessionState = emptyState();
|
||||
private state: SceneNpcTokensSessionState = emptySceneNpcTokensSessionState();
|
||||
|
||||
getState(): SceneNpcTokensSessionState {
|
||||
return this.state;
|
||||
@@ -56,11 +71,12 @@ export class SceneNpcTokensSessionStore {
|
||||
reset(): SceneNpcTokensSessionState {
|
||||
if (
|
||||
Object.keys(this.state.byPlacementId).length === 0 &&
|
||||
Object.keys(this.state.spawned).length === 0 &&
|
||||
this.state.scale === DEFAULT_NPC_TOKEN_SESSION_SCALE
|
||||
) {
|
||||
return this.state;
|
||||
}
|
||||
this.state = emptyState(this.state.revision + 1);
|
||||
this.state = emptySceneNpcTokensSessionState(this.state.revision + 1);
|
||||
return this.state;
|
||||
}
|
||||
|
||||
@@ -69,7 +85,8 @@ export class SceneNpcTokensSessionStore {
|
||||
const n = ((steps % 4) + 4) % 4;
|
||||
if (n === 0) return this.state;
|
||||
const ids = Object.keys(this.state.byPlacementId);
|
||||
if (ids.length === 0) return this.state;
|
||||
const spawnIds = Object.keys(this.state.spawned);
|
||||
if (ids.length === 0 && spawnIds.length === 0) return this.state;
|
||||
let changed = false;
|
||||
const byPlacementId: SceneNpcTokensSessionState['byPlacementId'] = {};
|
||||
for (const id of ids) {
|
||||
@@ -83,11 +100,20 @@ export class SceneNpcTokensSessionStore {
|
||||
byPlacementId[id] = prev;
|
||||
}
|
||||
}
|
||||
const spawned: SceneNpcTokensSessionState['spawned'] = {};
|
||||
for (const id of spawnIds) {
|
||||
const prev = this.state.spawned[id];
|
||||
if (!prev) continue;
|
||||
const p = rotateMapNormPointByCwSteps(prev.nx, prev.ny, n);
|
||||
spawned[id] = { ...prev, nx: p.nx, ny: p.ny };
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) return this.state;
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
byPlacementId,
|
||||
scale: this.state.scale,
|
||||
spawned,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
@@ -106,6 +132,51 @@ export class SceneNpcTokensSessionStore {
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'spawn': {
|
||||
const npcId = String(event.npcId ?? '');
|
||||
const sceneId = String(event.sceneId ?? '');
|
||||
if (!npcId || !sceneId) return this.state;
|
||||
const nx = Math.max(0, Math.min(1, event.nx));
|
||||
const ny = Math.max(0, Math.min(1, event.ny));
|
||||
if (!Number.isFinite(nx) || !Number.isFinite(ny)) return this.state;
|
||||
const id = event.placementId
|
||||
? asSceneNpcTokenId(String(event.placementId))
|
||||
: newSceneNpcSessionSpawnId();
|
||||
const spawn: SceneNpcSessionSpawn = {
|
||||
id,
|
||||
sceneId,
|
||||
npcId: asNpcId(npcId),
|
||||
nx,
|
||||
ny,
|
||||
sizeN: clampSceneNpcTokenSizeN(
|
||||
typeof event.sizeN === 'number' ? event.sizeN : DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
|
||||
),
|
||||
disposition: normalizeNpcDisposition(event.disposition),
|
||||
};
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
byPlacementId: this.state.byPlacementId,
|
||||
scale: this.state.scale,
|
||||
spawned: { ...this.state.spawned, [String(spawn.id)]: spawn },
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'despawn': {
|
||||
const placementId = String(event.placementId ?? '');
|
||||
if (!placementId) return this.state;
|
||||
if (!(placementId in this.state.spawned) && !(placementId in this.state.byPlacementId)) {
|
||||
return this.state;
|
||||
}
|
||||
const { [placementId]: _s, ...spawned } = this.state.spawned;
|
||||
const { [placementId]: _p, ...byPlacementId } = this.state.byPlacementId;
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
byPlacementId,
|
||||
scale: this.state.scale,
|
||||
spawned,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'move': {
|
||||
const placementId = String(event.placementId ?? '');
|
||||
if (!placementId) return this.state;
|
||||
@@ -113,7 +184,11 @@ export class SceneNpcTokensSessionStore {
|
||||
const ny = Math.max(0, Math.min(1, event.ny));
|
||||
if (!Number.isFinite(nx) || !Number.isFinite(ny)) return this.state;
|
||||
const prev = this.state.byPlacementId[placementId];
|
||||
const spawn = this.state.spawned[placementId];
|
||||
if (prev && prev.nx === nx && prev.ny === ny) return this.state;
|
||||
const spawned = spawn
|
||||
? { ...this.state.spawned, [placementId]: { ...spawn, nx, ny } }
|
||||
: this.state.spawned;
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
byPlacementId: {
|
||||
@@ -121,6 +196,7 @@ export class SceneNpcTokensSessionStore {
|
||||
[placementId]: withMove(prev, nx, ny),
|
||||
},
|
||||
scale: this.state.scale,
|
||||
spawned,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
@@ -129,12 +205,13 @@ export class SceneNpcTokensSessionStore {
|
||||
if (!placementId) return this.state;
|
||||
const disposition = normalizeNpcDisposition(event.disposition);
|
||||
const prev = this.state.byPlacementId[placementId];
|
||||
if (prev?.disposition === disposition) return this.state;
|
||||
const spawn = this.state.spawned[placementId];
|
||||
if (prev?.disposition === disposition && (!spawn || spawn.disposition === disposition)) {
|
||||
return this.state;
|
||||
}
|
||||
const next: SceneNpcTokensSessionPlacement = {
|
||||
...(prev?.nx !== undefined ? { nx: prev.nx } : {}),
|
||||
...(prev?.ny !== undefined ? { ny: prev.ny } : {}),
|
||||
...restOf(prev),
|
||||
disposition,
|
||||
...(prev?.inactive ? { inactive: true } : {}),
|
||||
};
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
@@ -143,6 +220,9 @@ export class SceneNpcTokensSessionStore {
|
||||
[placementId]: next,
|
||||
},
|
||||
scale: this.state.scale,
|
||||
spawned: spawn
|
||||
? { ...this.state.spawned, [placementId]: { ...spawn, disposition } }
|
||||
: this.state.spawned,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
@@ -155,6 +235,7 @@ export class SceneNpcTokensSessionStore {
|
||||
const next: SceneNpcTokensSessionPlacement = {
|
||||
...(prev?.nx !== undefined ? { nx: prev.nx } : {}),
|
||||
...(prev?.ny !== undefined ? { ny: prev.ny } : {}),
|
||||
...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
|
||||
...(prev?.disposition ? { disposition: prev.disposition } : {}),
|
||||
...(inactive ? { inactive: true } : {}),
|
||||
};
|
||||
@@ -169,6 +250,27 @@ export class SceneNpcTokensSessionStore {
|
||||
[placementId]: next,
|
||||
},
|
||||
scale: this.state.scale,
|
||||
spawned: this.state.spawned,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'setSize': {
|
||||
const placementId = String(event.placementId ?? '');
|
||||
if (!placementId) return this.state;
|
||||
const sizeN = clampSceneNpcTokenSizeN(event.sizeN);
|
||||
const prev = this.state.byPlacementId[placementId];
|
||||
const spawn = this.state.spawned[placementId];
|
||||
if (prev?.sizeN === sizeN && (!spawn || spawn.sizeN === sizeN)) return this.state;
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
byPlacementId: {
|
||||
...this.state.byPlacementId,
|
||||
[placementId]: { ...restOf(prev), sizeN },
|
||||
},
|
||||
scale: this.state.scale,
|
||||
spawned: spawn
|
||||
? { ...this.state.spawned, [placementId]: { ...spawn, sizeN } }
|
||||
: this.state.spawned,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
|
||||
@@ -47,3 +47,15 @@ void test('ScenePlayerTokensSessionStore rotateMapCwSteps', () => {
|
||||
assert.equal(s.byPlayerId.p1?.nx, 0.9);
|
||||
assert.equal(s.byPlayerId.p1?.ny, 0.2);
|
||||
});
|
||||
|
||||
void test('ScenePlayerTokensSessionStore setSize updates placement', () => {
|
||||
const store = new ScenePlayerTokensSessionStore();
|
||||
store.dispatch({ kind: 'setSelection', playerIds: ['p1'] });
|
||||
store.dispatch({ kind: 'show', sizeN: 0.1 });
|
||||
const nxBefore = store.getState().byPlayerId.p1?.nx;
|
||||
const s1 = store.dispatch({ kind: 'setSize', playerId: 'p1', sizeN: 0.22 });
|
||||
assert.equal(s1.byPlayerId.p1?.sizeN, 0.22);
|
||||
assert.equal(s1.byPlayerId.p1?.nx, nxBefore);
|
||||
const s2 = store.dispatch({ kind: 'move', playerId: 'p1', nx: 0.4, ny: 0.5 });
|
||||
assert.equal(s2.byPlayerId.p1?.sizeN, 0.22);
|
||||
});
|
||||
|
||||
@@ -155,6 +155,23 @@ export class ScenePlayerTokensSessionStore {
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'setSize': {
|
||||
const playerId = String(event.playerId ?? '');
|
||||
if (!playerId || !this.state.selectedPlayerIds.includes(playerId)) return this.state;
|
||||
const prev = this.state.byPlayerId[playerId];
|
||||
if (!prev) return this.state;
|
||||
const sizeN = clampSceneNpcTokenSizeN(event.sizeN);
|
||||
if (prev.sizeN === sizeN) return this.state;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
byPlayerId: {
|
||||
...this.state.byPlayerId,
|
||||
[playerId]: { ...prev, sizeN },
|
||||
},
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
default: {
|
||||
const _exhaustive: never = event;
|
||||
void _exhaustive;
|
||||
|
||||
Reference in New Issue
Block a user