Validate imported player data before storing it

An import with an unknown controller or malformed inventories replaced
the stored snapshot and bumped its epoch, then broke the player's next
join. SetPlayerDataRequest now checks the fields the scenario restores,
leaving item stacks to the Lua deserializer and allowing the partial
snapshots instances with disabled components upload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bbassieandClaude Opus 5.5 committed 2026-10-17 19:12:12 +00:00
1 parent 1ad5be0ff6
commit c260baaf03
2 files changed
+95 -1

No files matched your search

+44 -1
View File
@@ -34,6 +34,49 @@ const jsonPlayerData = Type.Object({
name: Type.String(),
});
// The parts of an import that module/serialize.lua relies on, item stacks are checked by the Lua deserializer
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 }),
]),
});
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 }),
]);
const jsonTable = Type.Union([Type.Array(Type.Unknown()), Type.Object({})]);
const jsonImportedPlayerData = Type.Object({
generation: Type.Integer({ minimum: 0 }),
epoch: Type.Optional(Type.Integer({ minimum: 0 })),
name: Type.String(),
// Any physical controller, remote view is never stored
controller: Type.Optional(StringEnum(["character", "god", "editor", "cutscene", "spectator", "ghost"])),
color: Type.Optional(jsonColor),
chat_color: Type.Optional(jsonColor),
tag: Type.Optional(Type.String()),
force: Type.Optional(Type.String()),
cheat_mode: Type.Optional(Type.Boolean()),
flashlight: Type.Optional(Type.Boolean()),
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) })),
inventories: Type.Optional(jsonInventories),
quick_bar: Type.Optional(jsonTable),
hotbar: Type.Optional(jsonTable),
personal_logistic_slots: Type.Optional(jsonTable),
crafting_queue: Type.Optional(jsonTable),
recipe_notifications: Type.Optional(Type.String()),
components: Type.Optional(jsonTable),
});
export class AcquireResponse {
constructor(
public status: string,
@@ -302,7 +345,7 @@ export class SetPlayerDataRequest {
static jsonSchema = Type.Object({
"playerName": Type.String(),
"playerData": jsonPlayerData,
"playerData": jsonImportedPlayerData,
});
static fromJSON(json: Static<typeof SetPlayerDataRequest.jsonSchema>) {
+51
View File
@@ -51,6 +51,57 @@ describe("player_sync plugin", function() {
assert(!validate(request), `accepted ${generation}`);
}
});
describe("SetPlayerDataRequest schema", function() {
const validate = lib.compile(msg.SetPlayerDataRequest.jsonSchema);
// Shaped like module/serialize.lua output
const exported = playerData("player", 7, {
epoch: 2,
color: { r: 1, g: 0.5, b: 0, a: 0.5 },
chat_color: { r: 1, g: 0.5, b: 0, a: 1 },
tag: "",
cheat_mode: false,
flashlight: true,
shortcuts: { "toggle-personal-roboport": true },
game_view_settings: { show_minimap: true },
character: {
character_running_speed_modifier: 0,
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: [] },
recipe_notifications: "",
components: { controller: true, inventories: true },
});
function accepts(data) {
return validate({ playerName: "player", playerData: data });
}
it("should accept exported and partial player data", function() {
assert(accepts(exported), JSON.stringify(validate.errors));
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] })));
});
it("should reject data the scenario can't restore", function() {
for (const [field, value] of [
["controller", "not-a-controller"],
["controller", "remote"],
["color", "red"],
["force", 1],
["ticks_to_respawn", -1],
["inventories", { main: { i: "iron-plate" } }],
["inventories", { main: [] }],
["character", { inventories: { main: { i: [{ s: 0 }] } } }],
["shortcuts", { "toggle-personal-roboport": "yes" }],
]) {
assert(!accepts({ ...exported, [field]: value }), `accepted ${field} ${JSON.stringify(value)}`);
}
});
});
it("SetPlayerDataResponse should be round trip json serialisable", function() {
testRoundTripJsonSerialisable(msg.SetPlayerDataResponse, testMatrix([1, 5]));
});