Validate supplied components in imported player data

An import with `crafting_queue: {}` passed the schema and broke the
next restore in deserialize_crafting_queue. The import schema now
checks the members module/serialize.lua reads from the crafting queue,
quick bar, personal logistic slots and character stats, while still
allowing a component to be left out.

Character stats missing from the data are left as they are instead of
being set to nil.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bbassieandClaude Opus 5.5 committed 2026-10-17 20:18:51 +00:00
1 parent 5634651de1
commit c06555c5da
4 files changed
+137 -18

No files matched your search

+81 -14
View File
@@ -1,4 +1,4 @@
import { Type, Static } from "@sinclair/typebox";
import { Type, Static, TSchema } from "@sinclair/typebox";
import { StringEnum, jsonArray } from "@clusterio/lib";
// .\module\serialize.lua:serialize.serialize_player()
@@ -38,22 +38,89 @@ const jsonPlayerData = Type.Object({
});
// The parts of an import that module/serialize.lua relies on, item stacks are checked by the Lua deserializer
// An empty Lua table is encoded as an object
function jsonList<T extends TSchema>(item: T) {
return Type.Union([Type.Array(item), Type.Object({}, { maxProperties: 0 })]);
}
const jsonInventory = Type.Object({
b: Type.Optional(Type.Integer({ minimum: 0 })),
// An empty Lua table is encoded as an object
i: Type.Union([
Type.Array(Type.Object({
s: Type.Optional(Type.Integer({ minimum: 1 })),
r: Type.Optional(Type.Integer({ minimum: 0 })),
})),
Type.Object({}, { maxProperties: 0 }),
]),
i: jsonList(Type.Object({
s: Type.Optional(Type.Integer({ minimum: 1 })),
r: Type.Optional(Type.Integer({ minimum: 0 })),
})),
});
const jsonInventories = Type.Record(Type.String(), jsonInventory);
const jsonColor = Type.Union([
Type.Object({ r: Type.Number(), g: Type.Number(), b: Type.Number(), a: Type.Optional(Type.Number()) }),
Type.Array(Type.Number(), { minItems: 3, maxItems: 4 }),
]);
// module/define_player_stat_keys.lua, missing stats are left as they are
const jsonCharacter = Type.Object({
character_crafting_speed_modifier: Type.Optional(Type.Number()),
character_mining_speed_modifier: Type.Optional(Type.Number()),
character_additional_mining_categories: Type.Optional(jsonList(Type.String())),
character_running_speed_modifier: Type.Optional(Type.Number()),
character_build_distance_bonus: Type.Optional(Type.Number()),
character_item_drop_distance_bonus: Type.Optional(Type.Number()),
character_reach_distance_bonus: Type.Optional(Type.Number()),
character_resource_reach_distance_bonus: Type.Optional(Type.Number()),
character_item_pickup_distance_bonus: Type.Optional(Type.Number()),
character_loot_pickup_distance_bonus: Type.Optional(Type.Number()),
character_inventory_slots_bonus: Type.Optional(Type.Number()),
character_trash_slot_count_bonus: Type.Optional(Type.Number()),
character_maximum_following_robot_count_bonus: Type.Optional(Type.Number()),
character_health_bonus: Type.Optional(Type.Number()),
character_personal_logistic_requests_enabled: Type.Optional(Type.Boolean()),
allow_dispatching_robots: Type.Optional(Type.Boolean()),
inhibit_movement_bonus: Type.Optional(Type.Boolean()),
inventories: Type.Optional(jsonInventories),
});
const jsonLogisticSlot = Type.Object({
name: Type.String(),
min: Type.Optional(Type.Integer({ minimum: 0 })),
max: Type.Optional(Type.Integer({ minimum: 0 })),
});
const jsonPersonalLogisticSlots = Type.Union([
Type.Object({
enabled: Type.Boolean(),
trash_not_requested: Type.Boolean(),
sections: jsonList(Type.Object({
group: Type.String(),
active: Type.Boolean(),
multiplier: Type.Number({ minimum: 0 }),
filters: jsonList(Type.Object({})),
})),
}),
// From before 2.0, slot index to request
Type.Record(Type.String({ pattern: "^[1-9][0-9]*$" }), jsonLogisticSlot, { additionalProperties: false }),
Type.Array(jsonLogisticSlot),
]);
// QuickBarSlotEncoded in module/serialize.lua
const jsonQuickBarSlot = Type.Union([
Type.String(),
Type.Object({ t: Type.Literal("f"), n: Type.String(), q: Type.String(), c: Type.String() }),
Type.Object({
t: Type.Literal("i"),
n: Type.String(),
s: Type.Integer({ minimum: 1 }),
b: Type.Optional(jsonList(Type.Integer({ minimum: 1 }))),
}),
Type.Object({
t: Type.Literal("r"),
p: jsonList(Type.Integer({ minimum: 1 })),
y: Type.String(),
l: Type.Optional(Type.String()),
}),
]);
const jsonCraftingQueue = Type.Object({
crafting_queue: jsonList(Type.Object({ recipe: Type.String(), count: Type.Integer({ minimum: 1 }) })),
crafting_queue_progress: Type.Number({ minimum: 0, maximum: 1 }),
ingredients: jsonList(Type.Object({
name: Type.String(),
count: Type.Integer({ minimum: 1 }),
quality: Type.Optional(Type.String()),
})),
});
const jsonTable = Type.Union([Type.Array(Type.Unknown()), Type.Object({})]);
const jsonImportedPlayerData = Type.Object({
generation: Type.Integer({ minimum: 0 }),
@@ -70,12 +137,12 @@ const jsonImportedPlayerData = Type.Object({
shortcuts: Type.Optional(Type.Record(Type.String(), Type.Boolean())),
game_view_settings: Type.Optional(Type.Record(Type.String(), Type.Boolean())),
ticks_to_respawn: Type.Optional(Type.Integer({ minimum: 0 })),
character: Type.Optional(Type.Object({ inventories: Type.Optional(jsonInventories) })),
character: Type.Optional(jsonCharacter),
inventories: Type.Optional(jsonInventories),
quick_bar: Type.Optional(jsonTable),
hotbar: Type.Optional(jsonTable),
personal_logistic_slots: Type.Optional(jsonTable),
crafting_queue: Type.Optional(jsonTable),
quick_bar: Type.Optional(Type.Record(Type.String(), jsonQuickBarSlot)),
hotbar: Type.Optional(Type.Record(Type.String(), Type.String())),
personal_logistic_slots: Type.Optional(jsonPersonalLogisticSlots),
crafting_queue: Type.Optional(jsonCraftingQueue),
recipe_notifications: Type.Optional(Type.String()),
components: Type.Optional(jsonTable),
});
+4 -1
View File
@@ -69,7 +69,10 @@ end
--- Restore the stats of a character, the inventories are restored with place_items
function serialize.deserialize_character(character, serialized)
for _, key in pairs(character_stat_keys) do
character[key] = serialized[key]
-- Missing from imports and from data saved by another Factorio version
if serialized[key] ~= nil then
character[key] = serialized[key]
end
end
end
+17
View File
@@ -208,6 +208,23 @@ describe("player_sync controller", function() {
await assert.rejects(set("stored", playerData("stored", 3)), acquiredError);
assert.equal((await saved()).get("stored").generation, 3);
});
it("should keep the stored data when an import fails validation", async function() {
// The link validates requests before the handler runs
const fromJSON = lib.Link.requestFromJSON(msg.SetPlayerDataRequest, "SetPlayerDataRequest");
async function setJSON(name, data) {
const request = fromJSON({ playerName: name, playerData: data });
return await handler(msg.SetPlayerDataRequest)(request);
}
await release("stored");
await setJSON("stored", playerData("stored", 0));
const before = (await saved()).get("stored");
assert.equal(before.epoch, 1);
await assert.rejects(
setJSON("stored", playerData("stored", 0, { crafting_queue: {} })),
{ message: "Request SetPlayerDataRequest failed validation" },
);
assert.deepEqual((await saved()).get("stored"), before);
});
});
describe("DeletePlayerDataRequest", function() {
+35 -3
View File
@@ -65,14 +65,29 @@ describe("player_sync plugin", function() {
game_view_settings: { show_minimap: true },
character: {
character_running_speed_modifier: 0,
character_additional_mining_categories: {},
allow_dispatching_robots: true,
inventories: {
main: { b: 80, i: [{ n: "iron-plate", c: 100, r: 2 }, { s: 10, f: { name: "coal" } }] },
armor: { i: {} },
},
},
personal_logistic_slots: [],
quick_bar: { 1: { n: "iron-plate" } },
crafting_queue: { crafting_queue: [], ingredients: [] },
personal_logistic_slots: {
enabled: true,
trash_not_requested: false,
sections: [{ group: "", active: true, multiplier: 1, filters: [{ index: 1, min: 50 }] }],
},
quick_bar: {
1: "iron-plate",
2: { t: "f", n: "iron-gear-wheel", q: "normal", c: "=" },
3: { t: "i", n: "main", s: 4, b: [2] },
4: { t: "r", p: [1], y: "blueprint" },
},
crafting_queue: {
crafting_queue: [{ recipe: "iron-gear-wheel", count: 5 }],
crafting_queue_progress: 0.25,
ingredients: [{ name: "iron-plate", count: 10 }],
},
recipe_notifications: "",
components: { controller: true, inventories: true },
});
@@ -85,6 +100,11 @@ describe("player_sync plugin", function() {
assert(accepts({ name: "player", generation: 0 }));
assert(accepts(playerData("player", 0, { controller: "ghost", ticks_to_respawn: 300 })));
assert(accepts(playerData("player", 0, { color: [1, 0, 0] })));
assert(accepts(playerData("player", 0, {
crafting_queue: { crafting_queue: {}, crafting_queue_progress: 0, ingredients: {} },
personal_logistic_slots: { 1: { name: "iron-plate", min: 50 } },
hotbar: { 1: "iron-plate" },
})));
});
it("should reject data the scenario can't restore", function() {
for (const [field, value] of [
@@ -97,6 +117,18 @@ describe("player_sync plugin", function() {
["inventories", { main: [] }],
["character", { inventories: { main: { i: [{ s: 0 }] } } }],
["shortcuts", { "toggle-personal-roboport": "yes" }],
["character", { character_health_bonus: "100" }],
["crafting_queue", {}],
["crafting_queue", { crafting_queue: [], crafting_queue_progress: 0 }],
["crafting_queue", {
crafting_queue: [{ recipe: "iron-gear-wheel" }], crafting_queue_progress: 0, ingredients: [],
}],
["quick_bar", { 1: { t: "i", n: "main" } }],
["quick_bar", { 1: { t: "r", y: "blueprint" } }],
["quick_bar", { 1: 5 }],
["personal_logistic_slots", { enabled: true, sections: [] }],
["personal_logistic_slots", { enabled: true, trash_not_requested: false, sections: [{}] }],
["personal_logistic_slots", { 1: { min: 50 } }],
]) {
assert(!accepts({ ...exported, [field]: value }), `accepted ${field} ${JSON.stringify(value)}`);
}