diff --git a/plugins/player_sync/messages.ts b/plugins/player_sync/messages.ts index 07019686..3557ac62 100644 --- a/plugins/player_sync/messages.ts +++ b/plugins/player_sync/messages.ts @@ -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) { diff --git a/plugins/player_sync/test/plugin.js b/plugins/player_sync/test/plugin.js index 3c30f6be..aabfbe57 100644 --- a/plugins/player_sync/test/plugin.js +++ b/plugins/player_sync/test/plugin.js @@ -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])); });