mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 15:04:52 +09:00
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:
1 parent
5634651de1
commit
c06555c5da
4 files changed
+137
-18
No files matched your search
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user