diff --git a/server/lib/schemaVersions.js b/server/lib/schemaVersions.js index cd828f8472..a197b6c480 100644 --- a/server/lib/schemaVersions.js +++ b/server/lib/schemaVersions.js @@ -364,7 +364,11 @@ export const PORTOS_SCHEMA_VERSIONS = Object.freeze({ // scene records whether that protagonist is on-screen or speaking through a // side-device off-screen. A v3 peer would silently strip those fields on a // whole-record round trip and could restore wardrobe drift or put a removed - // protagonist back into a scene. + // protagonist back into a scene. v4 ALSO introduced per-episode beat outlines + // (`episode.storyOutline`) and the series delivery plan + // (`seriesPlan.deliveryOptions` / `.interEpisodeVoicemails` / + // `.nextSeasonTeaser`) — a v3 peer strips both, so the receiver restores them + // from the local record rather than treating the omission as a delete. // v5 = one loom-level render format pins the aspect ratio and concrete // dimensions shared by storyboard stills and motion clips; plot points and // scenes gained durable playable-challenge kinds/phase mappings; and durable diff --git a/server/services/fableLoom/records.js b/server/services/fableLoom/records.js index 065636b076..8607e61a5b 100644 --- a/server/services/fableLoom/records.js +++ b/server/services/fableLoom/records.js @@ -633,6 +633,53 @@ export async function deleteLoom(id) { emitRecordDeleted('fableLoom', id); } +// Episode beat outlines arrived with schema v4, alongside the series delivery +// plan. A <=v3 sender round-trips the record through an outline-unaware +// sanitizer, so an absent outline there means "cannot represent" rather than +// "the author deleted it" — restore the local one whole. A v4 sender knows the +// outline but not the v5 plot-point/challenge mapping on its scenes, so its +// outline wins except for those two per-scene fields. +const preserveLegacyStoryOutline = (episode, localEpisode, senderVersion) => { + if (!episode.storyOutline) { + return senderVersion < 4 && localEpisode?.storyOutline + ? { storyOutline: localEpisode.storyOutline } + : {}; + } + if (senderVersion >= 5) return {}; + const localScenes = new Map((localEpisode?.storyOutline?.scenes || []) + .map((scene) => [scene.key, scene])); + return { + storyOutline: { + ...episode.storyOutline, + scenes: episode.storyOutline.scenes.map((scene) => ({ + ...scene, + ...(localScenes.has(scene.key) ? { + plotPointId: localScenes.get(scene.key).plotPointId, + challengePhase: localScenes.get(scene.key).challengePhase, + } : {}), + })), + }, + }; +}; + +// The series delivery plan (overnight voicemails, inter-episode voicemails, and +// the next-season teaser) also arrived with v4. `sanitizeSeriesPlan` omits the +// three keys entirely when a record predates the feature, so a <=v3 sender that +// omits them means "cannot represent" and the local plan must survive. A v4+ +// sender that omits them describes a record with genuinely no delivery plan. +const preserveLegacyDeliveryPlan = (remotePlan, localPlan, senderVersion) => ( + senderVersion < 4 + && localPlan + && Object.prototype.hasOwnProperty.call(localPlan, 'deliveryOptions') + && !Object.prototype.hasOwnProperty.call(remotePlan || {}, 'deliveryOptions') + ? { + deliveryOptions: localPlan.deliveryOptions, + interEpisodeVoicemails: localPlan.interEpisodeVoicemails, + nextSeasonTeaser: localPlan.nextSeasonTeaser, + } + : {} +); + // An older peer cannot represent newer scene production fields. When that // peer wins whole-record LWW after an unrelated edit, retain the local fields // on nodes that still exist instead of letting its unaware sanitizer clear @@ -662,6 +709,7 @@ const preserveLegacyVisualProduction = (remote, local, senderVersion) => { } : {}), seriesPlan: senderVersion < 5 ? { ...remote.seriesPlan, + ...preserveLegacyDeliveryPlan(remote.seriesPlan, local.seriesPlan, senderVersion), plotPoints: (remote.seriesPlan?.plotPoints || []).map((item) => ({ ...item, ...(localPlotPoints.has(item.id) ? { kind: localPlotPoints.get(item.id).kind } : {}), @@ -675,22 +723,9 @@ const preserveLegacyVisualProduction = (remote, local, senderVersion) => { episodes: remote.episodes.map((episode) => { const localEpisode = localEpisodes.get(episode.id); const localNodes = new Map((localEpisode?.nodes || []).map((node) => [node.id, node])); - const localOutlineScenes = new Map((localEpisode?.storyOutline?.scenes || []) - .map((scene) => [scene.key, scene])); return { ...episode, - ...(senderVersion < 5 && episode.storyOutline ? { - storyOutline: { - ...episode.storyOutline, - scenes: episode.storyOutline.scenes.map((scene) => ({ - ...scene, - ...(localOutlineScenes.has(scene.key) ? { - plotPointId: localOutlineScenes.get(scene.key).plotPointId, - challengePhase: localOutlineScenes.get(scene.key).challengePhase, - } : {}), - })), - }, - } : {}), + ...preserveLegacyStoryOutline(episode, localEpisode, senderVersion), nodes: episode.nodes.map((node) => { const localNode = localNodes.get(node.id); return localNode ? { diff --git a/server/services/fableLoom/records.test.js b/server/services/fableLoom/records.test.js index a262248abc..7af457150e 100644 --- a/server/services/fableLoom/records.test.js +++ b/server/services/fableLoom/records.test.js @@ -492,6 +492,84 @@ describe('loom CRUD', () => { }); }); + // Beat outlines and the series delivery plan both arrived with fableLoom + // wire schema v4, so a v3 sender omitting them means "cannot represent" while + // a v4 sender omitting them means the author cleared them. + const seedDeliveryAndOutlineLoom = async () => { + let loom = await makeLoom({ name: 'Local delivery plan' }); + loom = await addEpisode(loom.id, { title: 'Pilot' }); + const episodeId = loom.episodes[0].id; + loom = await addNode(loom.id, episodeId, { title: 'The keypad' }); + const nodeId = loom.episodes[0].nodes[0].id; + loom = await updateLoom(loom.id, { + seriesPlan: { + storyArc: 'The courier crosses the first blockade.', + plotPoints: [], + sideQuests: [], + deliveryOptions: { overnightVoicemails: true, nextSeasonTeaser: true }, + interEpisodeVoicemails: [{ + id: 'voicemail-1', + fromEpisodeId: episodeId, + toEpisodeId: null, + title: 'Between blockades', + transcript: 'Call the courier when the lights go out.', + }], + nextSeasonTeaser: { title: 'Season two', transcript: 'The blockade moves north.' }, + }, + }); + loom = await updateEpisode(loom.id, episodeId, { + storyOutline: { + startKey: nodeId, + scenes: [{ + key: nodeId, title: 'The keypad', summary: 'The planted code becomes actionable.', + isEnding: true, transitions: [], + }], + validation: { status: 'draft', issues: [] }, + }, + }); + return loom; + }; + + const stripDeliveryAndOutline = (loom, name) => ({ + ...loom, + name, + updatedAt: '2099-01-01T00:00:00.000Z', + seriesPlan: { storyArc: loom.seriesPlan.storyArc, plotPoints: [], sideQuests: [] }, + episodes: loom.episodes.map(({ storyOutline: _outline, ...episode }) => episode), + }); + + it('preserves the delivery plan and beat outline when a v3 peer wins an unrelated LWW edit', async () => { + const loom = await seedDeliveryAndOutlineLoom(); + const remoteV3 = stripDeliveryAndOutline(loom, 'Renamed by v3 peer'); + + await mergeLoomsFromSync([remoteV3], { senderSchemaVersions: { fableLoom: 3 } }); + + const merged = await getLoom(loom.id); + expect(merged.name).toBe('Renamed by v3 peer'); + expect(merged.seriesPlan).toMatchObject({ + deliveryOptions: { overnightVoicemails: true, nextSeasonTeaser: true }, + interEpisodeVoicemails: [{ + id: 'voicemail-1', transcript: 'Call the courier when the lights go out.', + }], + nextSeasonTeaser: { title: 'Season two', transcript: 'The blockade moves north.' }, + }); + expect(merged.episodes[0].storyOutline).toEqual(loom.episodes[0].storyOutline); + }); + + it('lets a v4 peer clear the delivery plan and beat outline it fully understands', async () => { + const loom = await seedDeliveryAndOutlineLoom(); + const remoteV4 = stripDeliveryAndOutline(loom, 'Renamed by v4 peer'); + + await mergeLoomsFromSync([remoteV4], { senderSchemaVersions: { fableLoom: 4 } }); + + const merged = await getLoom(loom.id); + expect(merged.name).toBe('Renamed by v4 peer'); + expect(merged.seriesPlan.deliveryOptions).toBeUndefined(); + expect(merged.seriesPlan.interEpisodeVoicemails).toBeUndefined(); + expect(merged.seriesPlan.nextSeasonTeaser).toBeUndefined(); + expect(merged.episodes[0].storyOutline).toBeUndefined(); + }); + it('journals divergent story edits and can restore the authored snapshot', async () => { const local = await makeLoom({ name: 'Local story', premise: 'Local premise' }); const base = { ...local, name: 'Shared story', premise: 'Shared premise' };