Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion server/lib/schemaVersions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 49 additions & 14 deletions server/services/fableLoom/records.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 } : {}),
Expand All @@ -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 ? {
Expand Down
78 changes: 78 additions & 0 deletions server/services/fableLoom/records.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' };
Expand Down