fix: scene clear race, path editor anchors, avatar cover, team color crash
Clear scene now patches traps/tokens/npcTokens atomically and updateScene merges against a fresh scene snapshot. Align NPC path-editor previews on token center, default new paths to fixed facing, size avatars by natural aspect for cover, and read team color synchronously from the picker. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -85,6 +85,18 @@ void test('zipStore: NPC parse defaults externalUrl', () => {
|
||||
assert.match(src, /externalUrl: normalizeNpcExternalUrl/);
|
||||
});
|
||||
|
||||
void test('zipStore: updateScene merges patch inside updateProject mutator (no stale base race)', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
||||
const i = src.indexOf('async updateScene(sceneId: SceneId, patch: ScenePatch)');
|
||||
assert.ok(i >= 0);
|
||||
const j = src.indexOf('async updateConnections(', i);
|
||||
assert.ok(j > i);
|
||||
const block = src.slice(i, j);
|
||||
assert.match(block, /await this\.updateProject\(\(p\) => \{/);
|
||||
assert.match(block, /const existing = p\.scenes\[sceneId\]/);
|
||||
assert.doesNotMatch(block, /const existing = open\.project\.scenes\[sceneId\]/);
|
||||
});
|
||||
|
||||
void test('zipStore: listProjects skips unreadable archives', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
||||
assert.match(
|
||||
|
||||
@@ -728,91 +728,98 @@ export class ZipProjectStore {
|
||||
async updateScene(sceneId: SceneId, patch: ScenePatch): Promise<Scene> {
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
const existing = open.project.scenes[sceneId];
|
||||
const base: Scene =
|
||||
existing ??
|
||||
({
|
||||
id: sceneId,
|
||||
title: '',
|
||||
description: '',
|
||||
media: { videos: [], audios: [] },
|
||||
settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false },
|
||||
connections: [],
|
||||
layout: { x: 0, y: 0 },
|
||||
previewAssetId: null,
|
||||
previewAssetType: null,
|
||||
previewThumbAssetId: null,
|
||||
previewVideoAutostart: false,
|
||||
previewRotationDeg: 0,
|
||||
darkenScene: false,
|
||||
traps: [],
|
||||
tokens: [],
|
||||
npcTokens: [],
|
||||
grid: { ...DEFAULT_SCENE_GRID },
|
||||
} satisfies Scene);
|
||||
|
||||
const next: Scene = {
|
||||
...base,
|
||||
traps: base.traps ?? [],
|
||||
tokens: base.tokens ?? [],
|
||||
npcTokens: base.npcTokens ?? [],
|
||||
grid: base.grid ?? { ...DEFAULT_SCENE_GRID },
|
||||
...(patch.title !== undefined ? { title: patch.title } : null),
|
||||
...(patch.description !== undefined ? { description: patch.description } : null),
|
||||
...(patch.previewAssetId !== undefined ? { previewAssetId: patch.previewAssetId } : null),
|
||||
...(patch.previewAssetType !== undefined ? { previewAssetType: patch.previewAssetType } : null),
|
||||
...(patch.previewThumbAssetId !== undefined
|
||||
? { previewThumbAssetId: patch.previewThumbAssetId }
|
||||
: null),
|
||||
...(patch.previewVideoAutostart !== undefined
|
||||
? { previewVideoAutostart: patch.previewVideoAutostart }
|
||||
: null),
|
||||
...(patch.previewRotationDeg !== undefined ? { previewRotationDeg: patch.previewRotationDeg } : null),
|
||||
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
|
||||
...(patch.traps !== undefined
|
||||
? {
|
||||
traps: patch.traps.map((t) => normalizeSceneTrap(t)).filter((t): t is SceneTrap => Boolean(t)),
|
||||
}
|
||||
: null),
|
||||
...(patch.tokens !== undefined
|
||||
? {
|
||||
tokens: patch.tokens
|
||||
.map((t) => normalizeSceneToken(t))
|
||||
.filter((t): t is SceneToken => Boolean(t)),
|
||||
}
|
||||
: null),
|
||||
...(patch.npcTokens !== undefined
|
||||
? {
|
||||
npcTokens: patch.npcTokens
|
||||
.map((t) => normalizeSceneNpcToken(t))
|
||||
.filter((t): t is SceneNpcToken => Boolean(t)),
|
||||
}
|
||||
: null),
|
||||
...(patch.grid !== undefined ? { grid: normalizeSceneGrid(patch.grid) } : null),
|
||||
...(patch.settings ? { settings: { ...base.settings, ...patch.settings } } : null),
|
||||
...(patch.media ? { media: { ...base.media, ...patch.media } } : null),
|
||||
...(patch.layout ? { layout: { ...base.layout, ...patch.layout } } : null),
|
||||
};
|
||||
// Apply patch inside the mutator so concurrent updateScene calls (e.g. clear scene
|
||||
// firing traps/tokens/npcTokens separately) each read a fresh scene snapshot.
|
||||
let result!: Scene;
|
||||
await this.updateProject((p) => {
|
||||
const existing = p.scenes[sceneId];
|
||||
const base: Scene =
|
||||
existing ??
|
||||
({
|
||||
id: sceneId,
|
||||
title: '',
|
||||
description: '',
|
||||
media: { videos: [], audios: [] },
|
||||
settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false },
|
||||
connections: [],
|
||||
layout: { x: 0, y: 0 },
|
||||
previewAssetId: null,
|
||||
previewAssetType: null,
|
||||
previewThumbAssetId: null,
|
||||
previewVideoAutostart: false,
|
||||
previewRotationDeg: 0,
|
||||
darkenScene: false,
|
||||
traps: [],
|
||||
tokens: [],
|
||||
npcTokens: [],
|
||||
grid: { ...DEFAULT_SCENE_GRID },
|
||||
} satisfies Scene);
|
||||
|
||||
// Keep map markers glued to the art when previewRotationDeg changes (image/video).
|
||||
if (patch.previewRotationDeg !== undefined) {
|
||||
const fromRot = asPreviewRotationDeg(base.previewRotationDeg);
|
||||
const toRot = asPreviewRotationDeg(patch.previewRotationDeg);
|
||||
const steps = previewRotationStepsCw(fromRot, toRot);
|
||||
if (steps !== 0) {
|
||||
if (patch.tokens === undefined) {
|
||||
next.tokens = rotateSceneTokensByCwSteps(base.tokens ?? [], steps);
|
||||
}
|
||||
if (patch.npcTokens === undefined) {
|
||||
next.npcTokens = rotateMapMarkersByCwSteps(base.npcTokens ?? [], steps);
|
||||
}
|
||||
if (patch.traps === undefined) {
|
||||
next.traps = rotateMapMarkersByCwSteps(base.traps ?? [], steps);
|
||||
const next: Scene = {
|
||||
...base,
|
||||
traps: base.traps ?? [],
|
||||
tokens: base.tokens ?? [],
|
||||
npcTokens: base.npcTokens ?? [],
|
||||
grid: base.grid ?? { ...DEFAULT_SCENE_GRID },
|
||||
...(patch.title !== undefined ? { title: patch.title } : null),
|
||||
...(patch.description !== undefined ? { description: patch.description } : null),
|
||||
...(patch.previewAssetId !== undefined ? { previewAssetId: patch.previewAssetId } : null),
|
||||
...(patch.previewAssetType !== undefined ? { previewAssetType: patch.previewAssetType } : null),
|
||||
...(patch.previewThumbAssetId !== undefined
|
||||
? { previewThumbAssetId: patch.previewThumbAssetId }
|
||||
: null),
|
||||
...(patch.previewVideoAutostart !== undefined
|
||||
? { previewVideoAutostart: patch.previewVideoAutostart }
|
||||
: null),
|
||||
...(patch.previewRotationDeg !== undefined
|
||||
? { previewRotationDeg: patch.previewRotationDeg }
|
||||
: null),
|
||||
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
|
||||
...(patch.traps !== undefined
|
||||
? {
|
||||
traps: patch.traps.map((t) => normalizeSceneTrap(t)).filter((t): t is SceneTrap => Boolean(t)),
|
||||
}
|
||||
: null),
|
||||
...(patch.tokens !== undefined
|
||||
? {
|
||||
tokens: patch.tokens
|
||||
.map((t) => normalizeSceneToken(t))
|
||||
.filter((t): t is SceneToken => Boolean(t)),
|
||||
}
|
||||
: null),
|
||||
...(patch.npcTokens !== undefined
|
||||
? {
|
||||
npcTokens: patch.npcTokens
|
||||
.map((t) => normalizeSceneNpcToken(t))
|
||||
.filter((t): t is SceneNpcToken => Boolean(t)),
|
||||
}
|
||||
: null),
|
||||
...(patch.grid !== undefined ? { grid: normalizeSceneGrid(patch.grid) } : null),
|
||||
...(patch.settings ? { settings: { ...base.settings, ...patch.settings } } : null),
|
||||
...(patch.media ? { media: { ...base.media, ...patch.media } } : null),
|
||||
...(patch.layout ? { layout: { ...base.layout, ...patch.layout } } : null),
|
||||
};
|
||||
|
||||
// Keep map markers glued to the art when previewRotationDeg changes (image/video).
|
||||
if (patch.previewRotationDeg !== undefined) {
|
||||
const fromRot = asPreviewRotationDeg(base.previewRotationDeg);
|
||||
const toRot = asPreviewRotationDeg(patch.previewRotationDeg);
|
||||
const steps = previewRotationStepsCw(fromRot, toRot);
|
||||
if (steps !== 0) {
|
||||
if (patch.tokens === undefined) {
|
||||
next.tokens = rotateSceneTokensByCwSteps(base.tokens ?? [], steps);
|
||||
}
|
||||
if (patch.npcTokens === undefined) {
|
||||
next.npcTokens = rotateMapMarkersByCwSteps(base.npcTokens ?? [], steps);
|
||||
}
|
||||
if (patch.traps === undefined) {
|
||||
next.traps = rotateMapMarkersByCwSteps(base.traps ?? [], steps);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.updateProject((p) => {
|
||||
result = next;
|
||||
const scenes = { ...p.scenes, [sceneId]: next };
|
||||
const sceneListOrder =
|
||||
existing != null
|
||||
@@ -820,7 +827,7 @@ export class ZipProjectStore {
|
||||
: prependSceneListOrder(reconcileSceneListOrder(p.scenes, p.sceneListOrder), sceneId);
|
||||
return { ...p, scenes, sceneListOrder };
|
||||
});
|
||||
return next;
|
||||
return result;
|
||||
}
|
||||
|
||||
async updateConnections(sceneId: SceneId, connections: SceneId[]): Promise<Scene> {
|
||||
|
||||
Reference in New Issue
Block a user