Keep recipe notifications when a recipe list is empty (#1170)

Factorio encodes an empty Lua table as {}, so a player with no cleared
recipes sent {} with the download request. new Set({}) threw, the
instance dropped the recipe notifications from the download, and the
next upload replaced the stored list on the controller. An empty stored
list was dropped the same way. Decode both lists treating {} as empty.
This commit is contained in:
Daniel authored and GitHub committed 2026-11-01 23:06:16 +00:00
1 parent 03ea6accc1
commit 1311df5759
2 files changed
+43 -3

No files matched your search

+18 -2
View File
@@ -87,6 +87,22 @@ export function recipeNotificationDelta(stored: string[], current: string[]): Re
return delta;
}
/**
* Decode a list of cleared recipe names, Factorio serializes an empty list as {}
* @param encoded - Encoded JSON list of recipe names
* @returns the recipe names
*/
async function decodeRecipeNames(encoded: string): Promise<string[]> {
const names = JSON.parse(await decodeLuaString(encoded));
if (Array.isArray(names)) {
return names;
}
if (names !== null && typeof names === "object" && Object.keys(names).length === 0) {
return [];
}
throw new Error(`Expected a list of recipe names, got ${JSON.stringify(names)}`);
}
/**
* Replace the stored recipe notifications with the difference from the
* current state on the instance to reduce the amount of data sent to Lua.
@@ -101,9 +117,9 @@ export async function applyRecipeNotificationDelta(
return;
}
try {
const stored = JSON.parse(await decodeLuaString(playerData.recipe_notifications));
const stored = await decodeRecipeNames(playerData.recipe_notifications);
const delta: RecipeNotificationDelta = current
? recipeNotificationDelta(stored, JSON.parse(await decodeLuaString(current)))
? recipeNotificationDelta(stored, await decodeRecipeNames(current))
: { ...recipeNotificationDelta(stored, []), full: true };
playerData.recipe_notifications = await encodeLuaString(JSON.stringify(delta));
} catch (err: any) {
+25 -1
View File
@@ -64,6 +64,29 @@ describe("player_sync", function() {
await apply(playerData, undefined);
assert.deepEqual(await decode(playerData.recipe_notifications), { full: true });
});
// helpers.table_to_json in Factorio encodes an empty list as {}
it("should treat an empty current list encoded as {} as empty", async function() {
const playerData = { name: "test", recipe_notifications: await encode(["a", "b"]) };
await apply(playerData, await encode({}));
assert.deepEqual(await decode(playerData.recipe_notifications), { add: ["a", "b"] });
});
it("should treat an empty stored list encoded as {} as empty", async function() {
const playerData = { name: "test", recipe_notifications: await encode({}) };
await apply(playerData, await encode(["a"]));
assert.deepEqual(await decode(playerData.recipe_notifications), { remove: ["a"] });
});
it("should mark an empty stored list encoded as {} full without a current list", async function() {
const playerData = { name: "test", recipe_notifications: await encode({}) };
await apply(playerData, undefined);
assert.deepEqual(await decode(playerData.recipe_notifications), { full: true });
});
it("should drop the field when the list is a non-empty object", async function() {
const playerData = { name: "test", recipe_notifications: await encode({ a: 1 }) };
const count = warnings.length;
await apply(playerData, await encode(["a"]));
assert.equal(playerData.recipe_notifications, undefined);
assert.equal(warnings.length, count + 1);
});
it("should do nothing without stored notifications", async function() {
const playerData = { name: "test" };
await apply(playerData, await encode(["a"]));
@@ -71,9 +94,10 @@ describe("player_sync", function() {
});
it("should drop the field on invalid input", async function() {
const playerData = { name: "test", recipe_notifications: "not base64 zlib" };
const count = warnings.length;
await apply(playerData, await encode(["a"]));
assert.equal(playerData.recipe_notifications, undefined);
assert.equal(warnings.length, 1);
assert.equal(warnings.length, count + 1);
});
});