From 502d9e7448eccd28e2c882b1d4836647fc33c211 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Apr 2026 13:52:42 +0000 Subject: [PATCH 1/5] Fix object link connections not persisting through save/load state LinksManager did not serialize or deserialize object links, so any links created with the LinkedObjects extension were lost after saving and loading game state. Add getSerializedLinks/restoreSerializedLinks methods to LinksManager, wire them into createGameSaveState and restoreGameSaveState, and add a test verifying round-trip persistence. https://claude.ai/code/session_01CbHABHad9Ci3VpG1uMqDH8 --- Extensions/LinkedObjects/linkedobjects.ts | 91 ++++++++++++ Extensions/SaveState/SaveStateTools.ts | 17 +++ Extensions/SaveState/tests/SaveState.spec.js | 145 +++++++++++++++++++ GDJS/Runtime/types/save-state.d.ts | 1 + 4 files changed, 254 insertions(+) diff --git a/Extensions/LinkedObjects/linkedobjects.ts b/Extensions/LinkedObjects/linkedobjects.ts index 398c438ee6bf..12dcb119efcc 100644 --- a/Extensions/LinkedObjects/linkedobjects.ts +++ b/Extensions/LinkedObjects/linkedobjects.ts @@ -159,6 +159,97 @@ namespace gdjs { } } } + + /** + * Serialize all links between objects that have a networkId, + * so they can be persisted and restored later. + * Each link is stored once as a pair of networkIds. + */ + getSerializedLinks(): Array<{ a: string; b: string }> { + const serializedLinks: Array<{ a: string; b: string }> = []; + const processedPairs = new Set(); + + for (const [objectId, iterableLinkedObjects] of this._links) { + for (const linkedObjects of iterableLinkedObjects.linkedObjectMap.values()) { + for (const linkedObject of linkedObjects) { + // Find the source object: look it up via the linked object's back-links + // to get the source object reference. + // Actually, we need the source object. We can find it by iterating + // through the linked object's links back to us. + // A simpler approach: we have the objectId key, we need the object reference. + // We can get it from any of the reverse links. + const sourceNetworkId = this._getNetworkIdForObjectId( + objectId, + linkedObject + ); + const targetNetworkId = linkedObject.networkId; + + if (!sourceNetworkId || !targetNetworkId) continue; + + // Create a canonical key to avoid duplicates (bidirectional links). + const pairKey = + sourceNetworkId < targetNetworkId + ? `${sourceNetworkId}:${targetNetworkId}` + : `${targetNetworkId}:${sourceNetworkId}`; + + if (!processedPairs.has(pairKey)) { + processedPairs.add(pairKey); + serializedLinks.push({ a: sourceNetworkId, b: targetNetworkId }); + } + } + } + } + + return serializedLinks; + } + + /** + * Find the networkId for an object identified by its runtime id, + * by looking at the reverse links from a linked object. + */ + private _getNetworkIdForObjectId( + objectId: integer, + anyLinkedObject: gdjs.RuntimeObject + ): string | null { + // Look through the reverse links of the linked object to find + // the source object with the matching id. + const reverseLinks = this._links.get(anyLinkedObject.id); + if (reverseLinks) { + for (const objects of reverseLinks.linkedObjectMap.values()) { + for (const obj of objects) { + if (obj.id === objectId) { + return obj.networkId; + } + } + } + } + return null; + } + + /** + * Restore links from serialized data. Objects must already exist + * in the scene with their networkId set. + */ + restoreSerializedLinks( + links: Array<{ a: string; b: string }>, + runtimeScene: gdjs.RuntimeScene + ): void { + // Build a map from networkId to object instance for quick lookup. + const networkIdToObject = new Map(); + for (const object of runtimeScene.getAdhocListOfAllInstances()) { + if (object.networkId) { + networkIdToObject.set(object.networkId, object); + } + } + + for (const link of links) { + const objA = networkIdToObject.get(link.a); + const objB = networkIdToObject.get(link.b); + if (objA && objB) { + this.linkObjects(objA, objB); + } + } + } } class IterableLinkedObjects implements Iterable { diff --git a/Extensions/SaveState/SaveStateTools.ts b/Extensions/SaveState/SaveStateTools.ts index 2bfebd379b96..372e8cb4e1e9 100644 --- a/Extensions/SaveState/SaveStateTools.ts +++ b/Extensions/SaveState/SaveStateTools.ts @@ -283,6 +283,13 @@ namespace gdjs { } } + // Collect linked object links after the objects + // (so that networkIds are assigned). + const linksManager = + gdjs.LinksManager.getManager(runtimeScene); + gameSaveState.layoutNetworkSyncDatas[index].linkedObjectLinks = + linksManager.getSerializedLinks(); + // Collect scene data after the objects: const shouldPersistSceneData = checkIfIsPersistedInProfiles( options.profileNames, @@ -570,6 +577,16 @@ namespace gdjs { } } + // Restore linked object links after objects are created and updated. + if (layoutSyncData.linkedObjectLinks) { + const linksManager = + gdjs.LinksManager.getManager(runtimeScene); + linksManager.restoreSerializedLinks( + layoutSyncData.linkedObjectLinks, + runtimeScene + ); + } + // Update the rest of the scene last. if ( checkIfIsPersistedInProfiles( diff --git a/Extensions/SaveState/tests/SaveState.spec.js b/Extensions/SaveState/tests/SaveState.spec.js index 7159112c5e23..0ed732639518 100644 --- a/Extensions/SaveState/tests/SaveState.spec.js +++ b/Extensions/SaveState/tests/SaveState.spec.js @@ -1232,4 +1232,149 @@ describe('SaveState', () => { // Game data was restored: expect(runtimeGame1.getSoundManager().getGlobalVolume()).to.be(75); }); + + describe('Save State with linked objects', () => { + it('saves and restores linked objects connections', async () => { + const sceneData = getFakeSceneData({ + name: 'Scene1', + objects: [ + // @ts-ignore + { + type: 'Sprite', + name: 'ObjectA', + behaviors: [], + effects: [], + variables: [], + animations: [], + updateIfNotVisible: false, + }, + // @ts-ignore + { + type: 'Sprite', + name: 'ObjectB', + behaviors: [], + effects: [], + variables: [], + animations: [], + updateIfNotVisible: false, + }, + ], + }); + + // Start a game and create linked objects. + const runtimeGame1 = gdjs.getPixiRuntimeGame({ + layouts: [sceneData], + }); + await runtimeGame1._resourcesLoader.loadAllResources(() => {}); + + const runtimeScene1 = runtimeGame1.getSceneStack().push({ + sceneName: 'Scene1', + }); + if (!runtimeScene1) throw new Error('No current scene was created.'); + + const objectA1 = runtimeScene1.createObject('ObjectA'); + const objectA2 = runtimeScene1.createObject('ObjectA'); + const objectB1 = runtimeScene1.createObject('ObjectB'); + const objectB2 = runtimeScene1.createObject('ObjectB'); + + if (!objectA1 || !objectA2 || !objectB1 || !objectB2) { + throw new Error('Objects were not created'); + } + + objectA1.setX(10); + objectA1.setY(20); + objectA2.setX(30); + objectA2.setY(40); + objectB1.setX(50); + objectB1.setY(60); + objectB2.setX(70); + objectB2.setY(80); + + // Link objectA1 <-> objectB1 and objectA1 <-> objectB2. + const manager1 = gdjs.LinksManager.getManager(runtimeScene1); + manager1.linkObjects(objectA1, objectB1); + manager1.linkObjects(objectA1, objectB2); + // Link objectA2 <-> objectB2. + manager1.linkObjects(objectA2, objectB2); + + // Save the game state. + const saveState = gdjs.saveState.createGameSaveState(runtimeGame1, { + profileNames: ['default'], + }); + + // Verify links are in the save state. + expect(saveState.layoutNetworkSyncDatas[0].linkedObjectLinks).not.to.be( + undefined + ); + expect( + saveState.layoutNetworkSyncDatas[0].linkedObjectLinks.length + ).to.be(3); + + // Start a new game and restore. + const runtimeGame2 = gdjs.getPixiRuntimeGame({ + layouts: [sceneData], + }); + await runtimeGame2._resourcesLoader.loadAllResources(() => {}); + + gdjs.saveState.restoreGameSaveState(runtimeGame2, saveState, { + profileNames: ['default'], + clearSceneStack: false, + }); + + const runtimeScene2 = runtimeGame2.getSceneStack().getCurrentScene(); + if (!runtimeScene2) throw new Error('No current scene was restored.'); + + // Find the restored objects by their positions. + const allA = runtimeScene2.getObjects('ObjectA'); + const allB = runtimeScene2.getObjects('ObjectB'); + expect(allA.length).to.be(2); + expect(allB.length).to.be(2); + + const restoredA1 = allA.find( + (obj) => obj.getX() === 10 && obj.getY() === 20 + ); + const restoredA2 = allA.find( + (obj) => obj.getX() === 30 && obj.getY() === 40 + ); + const restoredB1 = allB.find( + (obj) => obj.getX() === 50 && obj.getY() === 60 + ); + const restoredB2 = allB.find( + (obj) => obj.getX() === 70 && obj.getY() === 80 + ); + + if (!restoredA1 || !restoredA2 || !restoredB1 || !restoredB2) { + throw new Error( + 'Objects not found at the proper positions after restore.' + ); + } + + // Verify links are restored: objectA1 should be linked to objectB1 and objectB2. + const manager2 = gdjs.LinksManager.getManager(runtimeScene2); + const a1LinkedMap = manager2._getMapOfObjectsLinkedWith(restoredA1); + expect(a1LinkedMap.has('ObjectB')).to.be(true); + expect(a1LinkedMap.get('ObjectB').length).to.be(2); + expect(a1LinkedMap.get('ObjectB')).to.contain(restoredB1); + expect(a1LinkedMap.get('ObjectB')).to.contain(restoredB2); + + // Verify objectA2 is linked to objectB2 only. + const a2LinkedMap = manager2._getMapOfObjectsLinkedWith(restoredA2); + expect(a2LinkedMap.has('ObjectB')).to.be(true); + expect(a2LinkedMap.get('ObjectB').length).to.be(1); + expect(a2LinkedMap.get('ObjectB')[0]).to.be(restoredB2); + + // Verify bidirectional: objectB1 linked to objectA1. + const b1LinkedMap = manager2._getMapOfObjectsLinkedWith(restoredB1); + expect(b1LinkedMap.has('ObjectA')).to.be(true); + expect(b1LinkedMap.get('ObjectA').length).to.be(1); + expect(b1LinkedMap.get('ObjectA')[0]).to.be(restoredA1); + + // Verify objectB2 linked to both objectA1 and objectA2. + const b2LinkedMap = manager2._getMapOfObjectsLinkedWith(restoredB2); + expect(b2LinkedMap.has('ObjectA')).to.be(true); + expect(b2LinkedMap.get('ObjectA').length).to.be(2); + expect(b2LinkedMap.get('ObjectA')).to.contain(restoredA1); + expect(b2LinkedMap.get('ObjectA')).to.contain(restoredA2); + }); + }); }); diff --git a/GDJS/Runtime/types/save-state.d.ts b/GDJS/Runtime/types/save-state.d.ts index b8dd873c9de4..1c7c153365e0 100644 --- a/GDJS/Runtime/types/save-state.d.ts +++ b/GDJS/Runtime/types/save-state.d.ts @@ -1,6 +1,7 @@ declare type SceneSaveState = { sceneData: LayoutNetworkSyncData; objectDatas: { [objectId: integer]: ObjectNetworkSyncData }; + linkedObjectLinks?: Array<{ a: string; b: string }>; }; declare type GameSaveState = { From 0ac8336336a78b3aa32babf53fa62d32f9e295ce Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Apr 2026 14:04:08 +0000 Subject: [PATCH 2/5] Simplify link serialization using ownerObject and conventions Adopt improvements from alternative patch: - Store ownerObject on IterableLinkedObjects, eliminating the reverse-lookup hack for finding networkIds during serialization - Rename methods to getNetworkSyncData/updateFromNetworkSyncData to match codebase conventions - Add clearAllLinks() and call it before restore to prevent stale links - Use compact [string, string] tuple format instead of {a, b} objects - Use public APIs (evtTools.linkedObjects.linkObjects, getObjectsLinkedWith) in tests instead of internal methods - Add test for same-type linked objects persistence https://claude.ai/code/session_01CbHABHad9Ci3VpG1uMqDH8 --- Extensions/LinkedObjects/linkedobjects.ts | 109 ++++++--------- Extensions/SaveState/SaveStateTools.ts | 4 +- Extensions/SaveState/tests/SaveState.spec.js | 137 +++++++++++++------ GDJS/Runtime/types/save-state.d.ts | 2 +- 4 files changed, 143 insertions(+), 109 deletions(-) diff --git a/Extensions/LinkedObjects/linkedobjects.ts b/Extensions/LinkedObjects/linkedobjects.ts index 12dcb119efcc..efcf2f4c4ef2 100644 --- a/Extensions/LinkedObjects/linkedobjects.ts +++ b/Extensions/LinkedObjects/linkedobjects.ts @@ -41,7 +41,7 @@ namespace gdjs { objA: gdjs.RuntimeObject ): Map { if (!this._links.has(objA.id)) { - this._links.set(objA.id, new IterableLinkedObjects()); + this._links.set(objA.id, new IterableLinkedObjects(objA)); } return this._links.get(objA.id)!.linkedObjectMap; } @@ -55,7 +55,7 @@ namespace gdjs { objA: gdjs.RuntimeObject ): Iterable { if (!this._links.has(objA.id)) { - this._links.set(objA.id, new IterableLinkedObjects()); + this._links.set(objA.id, new IterableLinkedObjects(objA)); } return this._links.get(objA.id)!; } @@ -160,105 +160,80 @@ namespace gdjs { } } + clearAllLinks() { + this._links.clear(); + } + /** * Serialize all links between objects that have a networkId, * so they can be persisted and restored later. * Each link is stored once as a pair of networkIds. */ - getSerializedLinks(): Array<{ a: string; b: string }> { - const serializedLinks: Array<{ a: string; b: string }> = []; - const processedPairs = new Set(); - - for (const [objectId, iterableLinkedObjects] of this._links) { - for (const linkedObjects of iterableLinkedObjects.linkedObjectMap.values()) { - for (const linkedObject of linkedObjects) { - // Find the source object: look it up via the linked object's back-links - // to get the source object reference. - // Actually, we need the source object. We can find it by iterating - // through the linked object's links back to us. - // A simpler approach: we have the objectId key, we need the object reference. - // We can get it from any of the reverse links. - const sourceNetworkId = this._getNetworkIdForObjectId( - objectId, - linkedObject - ); - const targetNetworkId = linkedObject.networkId; - - if (!sourceNetworkId || !targetNetworkId) continue; - - // Create a canonical key to avoid duplicates (bidirectional links). - const pairKey = - sourceNetworkId < targetNetworkId - ? `${sourceNetworkId}:${targetNetworkId}` - : `${targetNetworkId}:${sourceNetworkId}`; - - if (!processedPairs.has(pairKey)) { - processedPairs.add(pairKey); - serializedLinks.push({ a: sourceNetworkId, b: targetNetworkId }); - } - } + getNetworkSyncData(): Array<[string, string]> { + const linkedObjects: Array<[string, string]> = []; + const serializedLinks = new Set(); + + for (const iterableLinkedObjects of this._links.values()) { + const objectA = iterableLinkedObjects.ownerObject; + if (!objectA || !objectA.networkId) { + continue; } - } - return serializedLinks; - } + for (const objectB of iterableLinkedObjects) { + if (!objectB.networkId) continue; + const pairKey = + objectA.networkId < objectB.networkId + ? `${objectA.networkId}|${objectB.networkId}` + : `${objectB.networkId}|${objectA.networkId}`; + if (serializedLinks.has(pairKey)) continue; + serializedLinks.add(pairKey); - /** - * Find the networkId for an object identified by its runtime id, - * by looking at the reverse links from a linked object. - */ - private _getNetworkIdForObjectId( - objectId: integer, - anyLinkedObject: gdjs.RuntimeObject - ): string | null { - // Look through the reverse links of the linked object to find - // the source object with the matching id. - const reverseLinks = this._links.get(anyLinkedObject.id); - if (reverseLinks) { - for (const objects of reverseLinks.linkedObjectMap.values()) { - for (const obj of objects) { - if (obj.id === objectId) { - return obj.networkId; - } - } + linkedObjects.push([objectA.networkId, objectB.networkId]); } } - return null; + + return linkedObjects; } /** * Restore links from serialized data. Objects must already exist * in the scene with their networkId set. */ - restoreSerializedLinks( - links: Array<{ a: string; b: string }>, + updateFromNetworkSyncData( + linksNetworkSyncData: Array<[string, string]>, runtimeScene: gdjs.RuntimeScene ): void { + this.clearAllLinks(); + + if (!linksNetworkSyncData) return; + // Build a map from networkId to object instance for quick lookup. - const networkIdToObject = new Map(); + const objectsByNetworkId = new Map(); for (const object of runtimeScene.getAdhocListOfAllInstances()) { if (object.networkId) { - networkIdToObject.set(object.networkId, object); + objectsByNetworkId.set(object.networkId, object); } } - for (const link of links) { - const objA = networkIdToObject.get(link.a); - const objB = networkIdToObject.get(link.b); - if (objA && objB) { - this.linkObjects(objA, objB); - } + for (const [networkIdA, networkIdB] of linksNetworkSyncData) { + const objectA = objectsByNetworkId.get(networkIdA); + const objectB = objectsByNetworkId.get(networkIdB); + if (!objectA || !objectB) continue; + + this.linkObjects(objectA, objectB); } } } class IterableLinkedObjects implements Iterable { + ownerObject: gdjs.RuntimeObject; linkedObjectMap: Map; static emptyItr: Iterator = { next: () => ({ value: undefined, done: true }), }; - constructor() { + constructor(ownerObject: gdjs.RuntimeObject) { + this.ownerObject = ownerObject; this.linkedObjectMap = new Map(); } diff --git a/Extensions/SaveState/SaveStateTools.ts b/Extensions/SaveState/SaveStateTools.ts index 372e8cb4e1e9..45c0e084f1c8 100644 --- a/Extensions/SaveState/SaveStateTools.ts +++ b/Extensions/SaveState/SaveStateTools.ts @@ -288,7 +288,7 @@ namespace gdjs { const linksManager = gdjs.LinksManager.getManager(runtimeScene); gameSaveState.layoutNetworkSyncDatas[index].linkedObjectLinks = - linksManager.getSerializedLinks(); + linksManager.getNetworkSyncData(); // Collect scene data after the objects: const shouldPersistSceneData = checkIfIsPersistedInProfiles( @@ -581,7 +581,7 @@ namespace gdjs { if (layoutSyncData.linkedObjectLinks) { const linksManager = gdjs.LinksManager.getManager(runtimeScene); - linksManager.restoreSerializedLinks( + linksManager.updateFromNetworkSyncData( layoutSyncData.linkedObjectLinks, runtimeScene ); diff --git a/Extensions/SaveState/tests/SaveState.spec.js b/Extensions/SaveState/tests/SaveState.spec.js index 0ed732639518..25b786c67039 100644 --- a/Extensions/SaveState/tests/SaveState.spec.js +++ b/Extensions/SaveState/tests/SaveState.spec.js @@ -1234,7 +1234,76 @@ describe('SaveState', () => { }); describe('Save State with linked objects', () => { - it('saves and restores linked objects connections', async () => { + it('saves and restores linked objects relations (same type)', async () => { + // Start a game. + const runtimeGame1 = gdjs.getPixiRuntimeGame({ + layouts: [getFakeSceneData({ name: 'Scene1' })], + }); + await runtimeGame1._resourcesLoader.loadAllResources(() => {}); + + const runtimeScene1 = runtimeGame1.getSceneStack().push({ + sceneName: 'Scene1', + }); + if (!runtimeScene1) throw new Error('No current scene was created.'); + + const object1 = runtimeScene1.createObject('MySpriteObject'); + const object2 = runtimeScene1.createObject('MySpriteObject'); + if (!object1 || !object2) { + throw new Error('Objects were not created'); + } + + object1.setPosition(100, 200); + object2.setPosition(300, 400); + gdjs.evtTools.linkedObjects.linkObjects(runtimeScene1, object1, object2); + + // Save the game state. + const saveState = gdjs.saveState.createGameSaveState(runtimeGame1, { + profileNames: ['default'], + }); + + // Start a new game. + const runtimeGame2 = gdjs.getPixiRuntimeGame({ + layouts: [getFakeSceneData({ name: 'Scene1' })], + }); + await runtimeGame2._resourcesLoader.loadAllResources(() => {}); + + // Load the saved state. + gdjs.saveState.restoreGameSaveState(runtimeGame2, saveState, { + profileNames: ['default'], + clearSceneStack: false, + }); + + const runtimeScene2 = runtimeGame2.getSceneStack().getCurrentScene(); + if (!runtimeScene2) throw new Error('No current scene was restored.'); + + const restoredObjects = runtimeScene2.getObjects('MySpriteObject'); + expect(restoredObjects.length).to.be(2); + + const restoredObject1 = restoredObjects.find( + (obj) => obj.getX() === 100 && obj.getY() === 200 + ); + const restoredObject2 = restoredObjects.find( + (obj) => obj.getX() === 300 && obj.getY() === 400 + ); + if (!restoredObject1 || !restoredObject2) { + throw new Error( + 'Objects not found at the proper positions after restore.' + ); + } + + const linksManager = gdjs.LinksManager.getManager(runtimeScene2); + const object1Links = Array.from( + linksManager.getObjectsLinkedWith(restoredObject1) + ); + const object2Links = Array.from( + linksManager.getObjectsLinkedWith(restoredObject2) + ); + + expect(object1Links).to.eql([restoredObject2]); + expect(object2Links).to.eql([restoredObject1]); + }); + + it('saves and restores multiple linked objects across types', async () => { const sceneData = getFakeSceneData({ name: 'Scene1', objects: [ @@ -1281,21 +1350,15 @@ describe('SaveState', () => { throw new Error('Objects were not created'); } - objectA1.setX(10); - objectA1.setY(20); - objectA2.setX(30); - objectA2.setY(40); - objectB1.setX(50); - objectB1.setY(60); - objectB2.setX(70); - objectB2.setY(80); - - // Link objectA1 <-> objectB1 and objectA1 <-> objectB2. - const manager1 = gdjs.LinksManager.getManager(runtimeScene1); - manager1.linkObjects(objectA1, objectB1); - manager1.linkObjects(objectA1, objectB2); - // Link objectA2 <-> objectB2. - manager1.linkObjects(objectA2, objectB2); + objectA1.setPosition(10, 20); + objectA2.setPosition(30, 40); + objectB1.setPosition(50, 60); + objectB2.setPosition(70, 80); + + // Link objectA1 <-> objectB1, objectA1 <-> objectB2, objectA2 <-> objectB2. + gdjs.evtTools.linkedObjects.linkObjects(runtimeScene1, objectA1, objectB1); + gdjs.evtTools.linkedObjects.linkObjects(runtimeScene1, objectA1, objectB2); + gdjs.evtTools.linkedObjects.linkObjects(runtimeScene1, objectA2, objectB2); // Save the game state. const saveState = gdjs.saveState.createGameSaveState(runtimeGame1, { @@ -1349,32 +1412,28 @@ describe('SaveState', () => { ); } - // Verify links are restored: objectA1 should be linked to objectB1 and objectB2. + // Verify links via the public API. const manager2 = gdjs.LinksManager.getManager(runtimeScene2); - const a1LinkedMap = manager2._getMapOfObjectsLinkedWith(restoredA1); - expect(a1LinkedMap.has('ObjectB')).to.be(true); - expect(a1LinkedMap.get('ObjectB').length).to.be(2); - expect(a1LinkedMap.get('ObjectB')).to.contain(restoredB1); - expect(a1LinkedMap.get('ObjectB')).to.contain(restoredB2); - - // Verify objectA2 is linked to objectB2 only. - const a2LinkedMap = manager2._getMapOfObjectsLinkedWith(restoredA2); - expect(a2LinkedMap.has('ObjectB')).to.be(true); - expect(a2LinkedMap.get('ObjectB').length).to.be(1); - expect(a2LinkedMap.get('ObjectB')[0]).to.be(restoredB2); + + // objectA1 should be linked to objectB1 and objectB2. + const a1Links = Array.from(manager2.getObjectsLinkedWith(restoredA1)); + expect(a1Links.length).to.be(2); + expect(a1Links).to.contain(restoredB1); + expect(a1Links).to.contain(restoredB2); + + // objectA2 should be linked to objectB2 only. + const a2Links = Array.from(manager2.getObjectsLinkedWith(restoredA2)); + expect(a2Links).to.eql([restoredB2]); // Verify bidirectional: objectB1 linked to objectA1. - const b1LinkedMap = manager2._getMapOfObjectsLinkedWith(restoredB1); - expect(b1LinkedMap.has('ObjectA')).to.be(true); - expect(b1LinkedMap.get('ObjectA').length).to.be(1); - expect(b1LinkedMap.get('ObjectA')[0]).to.be(restoredA1); - - // Verify objectB2 linked to both objectA1 and objectA2. - const b2LinkedMap = manager2._getMapOfObjectsLinkedWith(restoredB2); - expect(b2LinkedMap.has('ObjectA')).to.be(true); - expect(b2LinkedMap.get('ObjectA').length).to.be(2); - expect(b2LinkedMap.get('ObjectA')).to.contain(restoredA1); - expect(b2LinkedMap.get('ObjectA')).to.contain(restoredA2); + const b1Links = Array.from(manager2.getObjectsLinkedWith(restoredB1)); + expect(b1Links).to.eql([restoredA1]); + + // objectB2 linked to both objectA1 and objectA2. + const b2Links = Array.from(manager2.getObjectsLinkedWith(restoredB2)); + expect(b2Links.length).to.be(2); + expect(b2Links).to.contain(restoredA1); + expect(b2Links).to.contain(restoredA2); }); }); }); diff --git a/GDJS/Runtime/types/save-state.d.ts b/GDJS/Runtime/types/save-state.d.ts index 1c7c153365e0..e61fc6bd7a99 100644 --- a/GDJS/Runtime/types/save-state.d.ts +++ b/GDJS/Runtime/types/save-state.d.ts @@ -1,7 +1,7 @@ declare type SceneSaveState = { sceneData: LayoutNetworkSyncData; objectDatas: { [objectId: integer]: ObjectNetworkSyncData }; - linkedObjectLinks?: Array<{ a: string; b: string }>; + linkedObjectLinks?: Array<[string, string]>; }; declare type GameSaveState = { From f13c2f467a0276ec96f6742f18403255122def27 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Apr 2026 14:16:55 +0000 Subject: [PATCH 3/5] Only clear links for save-state-managed objects on restore clearAllLinks() was destroying links between DoNotSave objects that should have been left untouched. Instead, only clear links for objects whose networkId is in the save state. This preserves links between non-saved objects while still correctly removing stale links between saved objects. Add test verifying DoNotSave object links survive. https://claude.ai/code/session_01CbHABHad9Ci3VpG1uMqDH8 --- Extensions/LinkedObjects/linkedobjects.ts | 6 +- Extensions/SaveState/SaveStateTools.ts | 15 ++- Extensions/SaveState/tests/SaveState.spec.js | 114 +++++++++++++++++++ 3 files changed, 131 insertions(+), 4 deletions(-) diff --git a/Extensions/LinkedObjects/linkedobjects.ts b/Extensions/LinkedObjects/linkedobjects.ts index efcf2f4c4ef2..a6402c7314f1 100644 --- a/Extensions/LinkedObjects/linkedobjects.ts +++ b/Extensions/LinkedObjects/linkedobjects.ts @@ -198,13 +198,15 @@ namespace gdjs { /** * Restore links from serialized data. Objects must already exist * in the scene with their networkId set. + * + * Links for objects managed by the save state should be cleared + * before calling this method, so that stale links are removed. + * This method only adds the saved links back. */ updateFromNetworkSyncData( linksNetworkSyncData: Array<[string, string]>, runtimeScene: gdjs.RuntimeScene ): void { - this.clearAllLinks(); - if (!linksNetworkSyncData) return; // Build a map from networkId to object instance for quick lookup. diff --git a/Extensions/SaveState/SaveStateTools.ts b/Extensions/SaveState/SaveStateTools.ts index 45c0e084f1c8..2f6aa8a869a8 100644 --- a/Extensions/SaveState/SaveStateTools.ts +++ b/Extensions/SaveState/SaveStateTools.ts @@ -578,9 +578,20 @@ namespace gdjs { } // Restore linked object links after objects are created and updated. + // First, clear links only for objects managed by the save state + // (those with a networkId in the save state), so that links between + // non-saved objects are preserved. + const linksManager = + gdjs.LinksManager.getManager(runtimeScene); + for (const object of runtimeScene.getAdhocListOfAllInstances()) { + if ( + object.networkId && + allLoadedNetworkIds.has(object.networkId) + ) { + linksManager.removeAllLinksOf(object); + } + } if (layoutSyncData.linkedObjectLinks) { - const linksManager = - gdjs.LinksManager.getManager(runtimeScene); linksManager.updateFromNetworkSyncData( layoutSyncData.linkedObjectLinks, runtimeScene diff --git a/Extensions/SaveState/tests/SaveState.spec.js b/Extensions/SaveState/tests/SaveState.spec.js index 25b786c67039..98846a0d8042 100644 --- a/Extensions/SaveState/tests/SaveState.spec.js +++ b/Extensions/SaveState/tests/SaveState.spec.js @@ -1435,5 +1435,119 @@ describe('SaveState', () => { expect(b2Links).to.contain(restoredA1); expect(b2Links).to.contain(restoredA2); }); + + it('preserves links between non-saved objects when restoring', async () => { + const sceneData = getFakeSceneData({ + name: 'Scene1', + objects: [ + // @ts-ignore - SavedObject is persisted in the default profile. + { + type: 'Sprite', + name: 'SavedObject', + behaviors: [ + { + name: 'SaveConfiguration', + type: 'SaveState::SaveConfiguration', + defaultProfilePersistence: 'Persisted', + }, + ], + effects: [], + variables: [], + animations: [], + updateIfNotVisible: false, + }, + // @ts-ignore - NotSavedObject is excluded from save. + { + type: 'Sprite', + name: 'NotSavedObject', + behaviors: [ + { + name: 'SaveConfiguration', + type: 'SaveState::SaveConfiguration', + defaultProfilePersistence: 'DoNotSave', + }, + ], + effects: [], + variables: [], + animations: [], + updateIfNotVisible: false, + }, + ], + }); + + const runtimeGame1 = gdjs.getPixiRuntimeGame({ + layouts: [sceneData], + }); + await runtimeGame1._resourcesLoader.loadAllResources(() => {}); + + const runtimeScene1 = runtimeGame1.getSceneStack().push({ + sceneName: 'Scene1', + }); + if (!runtimeScene1) throw new Error('No current scene was created.'); + + const saved1 = runtimeScene1.createObject('SavedObject'); + const saved2 = runtimeScene1.createObject('SavedObject'); + const notSaved1 = runtimeScene1.createObject('NotSavedObject'); + const notSaved2 = runtimeScene1.createObject('NotSavedObject'); + + if (!saved1 || !saved2 || !notSaved1 || !notSaved2) { + throw new Error('Objects were not created'); + } + + saved1.setPosition(10, 20); + saved2.setPosition(30, 40); + notSaved1.setPosition(50, 60); + notSaved2.setPosition(70, 80); + + // Create links: + // saved1 <-> saved2 (both saved — should be preserved) + // saved1 <-> notSaved1 (mixed — link is lost because notSaved1 has no networkId) + // notSaved1 <-> notSaved2 (both not saved — should be preserved) + gdjs.evtTools.linkedObjects.linkObjects(runtimeScene1, saved1, saved2); + gdjs.evtTools.linkedObjects.linkObjects( + runtimeScene1, + saved1, + notSaved1 + ); + gdjs.evtTools.linkedObjects.linkObjects( + runtimeScene1, + notSaved1, + notSaved2 + ); + + // Save — only SavedObject instances are included. + const saveState = gdjs.saveState.createGameSaveState(runtimeGame1, { + profileNames: ['default'], + }); + + // The save state should only contain the link between the two saved objects. + expect( + saveState.layoutNetworkSyncDatas[0].linkedObjectLinks.length + ).to.be(1); + + // Restore into the same game (clearSceneStack: false). + gdjs.saveState.restoreGameSaveState(runtimeGame1, saveState, { + profileNames: ['default'], + clearSceneStack: false, + }); + + const linksManager = gdjs.LinksManager.getManager(runtimeScene1); + + // saved1 <-> saved2 link should be restored. + const saved1Links = Array.from( + linksManager.getObjectsLinkedWith(saved1) + ); + expect(saved1Links).to.contain(saved2); + // saved1 <-> notSaved1 link is lost (notSaved1 had no networkId during save). + expect(saved1Links).not.to.contain(notSaved1); + + // notSaved1 <-> notSaved2 link should be preserved (neither is managed by save state). + const notSaved1Links = Array.from( + linksManager.getObjectsLinkedWith(notSaved1) + ); + expect(notSaved1Links).to.contain(notSaved2); + // notSaved1 <-> saved1 was cleared from both sides. + expect(notSaved1Links).not.to.contain(saved1); + }); }); }); From 916334db6c411163074eed709ce46eba0395615d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Apr 2026 15:40:53 +0000 Subject: [PATCH 4/5] Decouple LinkedObjects from SaveState via sync callbacks Move link save/restore logic from SaveStateTools.ts into the LinkedObjects extension using registerRuntimeSceneGetSyncDataCallback and registerRuntimeSceneUpdateFromSyncDataCallback, following the established TweenBehavior pattern. SaveState no longer references gdjs.LinksManager, so games without the LinkedObjects extension don't crash. Link data moves from SceneSaveState.linkedObjectLinks to LayoutNetworkSyncData.linkedObjects, gated by a new syncLinkedObjects option in GetNetworkSyncDataOptions. https://claude.ai/code/session_01CbHABHad9Ci3VpG1uMqDH8 --- Extensions/LinkedObjects/linkedobjects.ts | 30 ++++++++++++++++++++ Extensions/SaveState/SaveStateTools.ts | 29 +------------------ Extensions/SaveState/tests/SaveState.spec.js | 6 ++-- GDJS/Runtime/types/project-data.d.ts | 2 ++ GDJS/Runtime/types/save-state.d.ts | 1 - 5 files changed, 36 insertions(+), 32 deletions(-) diff --git a/Extensions/LinkedObjects/linkedobjects.ts b/Extensions/LinkedObjects/linkedobjects.ts index a6402c7314f1..b5fcf987d569 100644 --- a/Extensions/LinkedObjects/linkedobjects.ts +++ b/Extensions/LinkedObjects/linkedobjects.ts @@ -269,6 +269,36 @@ namespace gdjs { } ); + gdjs.registerRuntimeSceneGetSyncDataCallback( + function (runtimeScene, currentLayoutSyncData, syncOptions) { + if (!syncOptions.syncLinkedObjects) return; + + currentLayoutSyncData.linkedObjects = + LinksManager.getManager(runtimeScene).getNetworkSyncData(); + } + ); + + gdjs.registerRuntimeSceneUpdateFromSyncDataCallback( + function (runtimeScene, receivedSyncData, _syncOptions) { + if (!receivedSyncData.linkedObjects) return; + + const linksManager = LinksManager.getManager(runtimeScene); + + // Clear links only for objects with a networkId (managed by save state). + // DoNotSave objects don't have networkIds, so their links are preserved. + for (const object of runtimeScene.getAdhocListOfAllInstances()) { + if (object.networkId) { + linksManager.removeAllLinksOf(object); + } + } + + linksManager.updateFromNetworkSyncData( + receivedSyncData.linkedObjects, + runtimeScene + ); + } + ); + export const linkObjects = function ( instanceContainer: gdjs.RuntimeInstanceContainer, objA: gdjs.RuntimeObject | null, diff --git a/Extensions/SaveState/SaveStateTools.ts b/Extensions/SaveState/SaveStateTools.ts index 2f6aa8a869a8..93eb6c354b1c 100644 --- a/Extensions/SaveState/SaveStateTools.ts +++ b/Extensions/SaveState/SaveStateTools.ts @@ -218,6 +218,7 @@ namespace gdjs { syncAsyncTasks: true, syncSceneVisualProps: true, syncFullTileMaps: true, + syncLinkedObjects: true, }; const shouldPersistGameData = checkIfIsPersistedInProfiles( @@ -283,13 +284,6 @@ namespace gdjs { } } - // Collect linked object links after the objects - // (so that networkIds are assigned). - const linksManager = - gdjs.LinksManager.getManager(runtimeScene); - gameSaveState.layoutNetworkSyncDatas[index].linkedObjectLinks = - linksManager.getNetworkSyncData(); - // Collect scene data after the objects: const shouldPersistSceneData = checkIfIsPersistedInProfiles( options.profileNames, @@ -577,27 +571,6 @@ namespace gdjs { } } - // Restore linked object links after objects are created and updated. - // First, clear links only for objects managed by the save state - // (those with a networkId in the save state), so that links between - // non-saved objects are preserved. - const linksManager = - gdjs.LinksManager.getManager(runtimeScene); - for (const object of runtimeScene.getAdhocListOfAllInstances()) { - if ( - object.networkId && - allLoadedNetworkIds.has(object.networkId) - ) { - linksManager.removeAllLinksOf(object); - } - } - if (layoutSyncData.linkedObjectLinks) { - linksManager.updateFromNetworkSyncData( - layoutSyncData.linkedObjectLinks, - runtimeScene - ); - } - // Update the rest of the scene last. if ( checkIfIsPersistedInProfiles( diff --git a/Extensions/SaveState/tests/SaveState.spec.js b/Extensions/SaveState/tests/SaveState.spec.js index 98846a0d8042..99ba9a007010 100644 --- a/Extensions/SaveState/tests/SaveState.spec.js +++ b/Extensions/SaveState/tests/SaveState.spec.js @@ -1366,11 +1366,11 @@ describe('SaveState', () => { }); // Verify links are in the save state. - expect(saveState.layoutNetworkSyncDatas[0].linkedObjectLinks).not.to.be( + expect(saveState.layoutNetworkSyncDatas[0].sceneData.linkedObjects).not.to.be( undefined ); expect( - saveState.layoutNetworkSyncDatas[0].linkedObjectLinks.length + saveState.layoutNetworkSyncDatas[0].sceneData.linkedObjects.length ).to.be(3); // Start a new game and restore. @@ -1522,7 +1522,7 @@ describe('SaveState', () => { // The save state should only contain the link between the two saved objects. expect( - saveState.layoutNetworkSyncDatas[0].linkedObjectLinks.length + saveState.layoutNetworkSyncDatas[0].sceneData.linkedObjects.length ).to.be(1); // Restore into the same game (clearSceneStack: false). diff --git a/GDJS/Runtime/types/project-data.d.ts b/GDJS/Runtime/types/project-data.d.ts index 8280b7f83a9c..ef7ac9a5ed51 100644 --- a/GDJS/Runtime/types/project-data.d.ts +++ b/GDJS/Runtime/types/project-data.d.ts @@ -55,6 +55,7 @@ declare type GetNetworkSyncDataOptions = { syncAsyncTasks?: boolean; syncSceneVisualProps?: boolean; syncFullTileMaps?: boolean; + syncLinkedObjects?: boolean; }; declare type UpdateFromNetworkSyncDataOptions = { @@ -322,6 +323,7 @@ declare interface LayoutNetworkSyncData { }; async?: AsyncTasksManagerNetworkSyncData; color?: integer; + linkedObjects?: Array<[string, string]>; } declare interface SceneStackSceneNetworkSyncData { diff --git a/GDJS/Runtime/types/save-state.d.ts b/GDJS/Runtime/types/save-state.d.ts index e61fc6bd7a99..b8dd873c9de4 100644 --- a/GDJS/Runtime/types/save-state.d.ts +++ b/GDJS/Runtime/types/save-state.d.ts @@ -1,7 +1,6 @@ declare type SceneSaveState = { sceneData: LayoutNetworkSyncData; objectDatas: { [objectId: integer]: ObjectNetworkSyncData }; - linkedObjectLinks?: Array<[string, string]>; }; declare type GameSaveState = { From fb39cb3fcecc8d19730931062aa55be77bf72a78 Mon Sep 17 00:00:00 2001 From: Florian Rival Date: Wed, 8 Apr 2026 18:03:35 +0200 Subject: [PATCH 5/5] Fix formatting and types --- Extensions/SaveState/tests/SaveState.spec.js | 49 ++++++++++++-------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/Extensions/SaveState/tests/SaveState.spec.js b/Extensions/SaveState/tests/SaveState.spec.js index 99ba9a007010..b55025d2fdf3 100644 --- a/Extensions/SaveState/tests/SaveState.spec.js +++ b/Extensions/SaveState/tests/SaveState.spec.js @@ -1356,9 +1356,21 @@ describe('SaveState', () => { objectB2.setPosition(70, 80); // Link objectA1 <-> objectB1, objectA1 <-> objectB2, objectA2 <-> objectB2. - gdjs.evtTools.linkedObjects.linkObjects(runtimeScene1, objectA1, objectB1); - gdjs.evtTools.linkedObjects.linkObjects(runtimeScene1, objectA1, objectB2); - gdjs.evtTools.linkedObjects.linkObjects(runtimeScene1, objectA2, objectB2); + gdjs.evtTools.linkedObjects.linkObjects( + runtimeScene1, + objectA1, + objectB1 + ); + gdjs.evtTools.linkedObjects.linkObjects( + runtimeScene1, + objectA1, + objectB2 + ); + gdjs.evtTools.linkedObjects.linkObjects( + runtimeScene1, + objectA2, + objectB2 + ); // Save the game state. const saveState = gdjs.saveState.createGameSaveState(runtimeGame1, { @@ -1366,12 +1378,12 @@ describe('SaveState', () => { }); // Verify links are in the save state. - expect(saveState.layoutNetworkSyncDatas[0].sceneData.linkedObjects).not.to.be( - undefined - ); - expect( - saveState.layoutNetworkSyncDatas[0].sceneData.linkedObjects.length - ).to.be(3); + const linkedObjects = + saveState.layoutNetworkSyncDatas[0].sceneData.linkedObjects; + if (!linkedObjects) { + throw new Error('Linked objects not found in save state.'); + } + expect(linkedObjects.length).to.be(3); // Start a new game and restore. const runtimeGame2 = gdjs.getPixiRuntimeGame({ @@ -1504,11 +1516,7 @@ describe('SaveState', () => { // saved1 <-> notSaved1 (mixed — link is lost because notSaved1 has no networkId) // notSaved1 <-> notSaved2 (both not saved — should be preserved) gdjs.evtTools.linkedObjects.linkObjects(runtimeScene1, saved1, saved2); - gdjs.evtTools.linkedObjects.linkObjects( - runtimeScene1, - saved1, - notSaved1 - ); + gdjs.evtTools.linkedObjects.linkObjects(runtimeScene1, saved1, notSaved1); gdjs.evtTools.linkedObjects.linkObjects( runtimeScene1, notSaved1, @@ -1521,9 +1529,12 @@ describe('SaveState', () => { }); // The save state should only contain the link between the two saved objects. - expect( - saveState.layoutNetworkSyncDatas[0].sceneData.linkedObjects.length - ).to.be(1); + const linkedObjects = + saveState.layoutNetworkSyncDatas[0].sceneData.linkedObjects; + if (!linkedObjects) { + throw new Error('Linked objects not found in save state.'); + } + expect(linkedObjects.length).to.be(1); // Restore into the same game (clearSceneStack: false). gdjs.saveState.restoreGameSaveState(runtimeGame1, saveState, { @@ -1534,9 +1545,7 @@ describe('SaveState', () => { const linksManager = gdjs.LinksManager.getManager(runtimeScene1); // saved1 <-> saved2 link should be restored. - const saved1Links = Array.from( - linksManager.getObjectsLinkedWith(saved1) - ); + const saved1Links = Array.from(linksManager.getObjectsLinkedWith(saved1)); expect(saved1Links).to.contain(saved2); // saved1 <-> notSaved1 link is lost (notSaved1 had no networkId during save). expect(saved1Links).not.to.contain(notSaved1);