Files
factorio-clusterio/plugins/player_sync/components.ts
bbassieandClaude Opus 5.5 45fedf28a0 Strip downloads by the components the scenario started them with
The instance read its config when the controller answered, so a sync_
field changed during the download could strip a component the scenario
still expected, and the scenario treated it as empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:25:06 +00:00

83 lines
2.8 KiB
TypeScript

import type * as lib from "@clusterio/lib";
import type { IpcPlayerData } from "./messages.js";
/** Parts of the player data an instance can choose to sync, and the fields of the player data holding them. */
export const components = {
controller: ["controller", "ticks_to_respawn", "cheat_mode"],
force: ["force"],
appearance: ["color", "chat_color", "tag"],
// inventories holds the god inventory and the hidden ghost inventory
inventories: ["character", "crafting_queue", "inventories"],
logistics: ["personal_logistic_slots"],
quick_bar: ["quick_bar", "hotbar"],
settings: ["flashlight", "shortcuts", "game_view_settings"],
recipe_notifications: ["recipe_notifications"],
} as const;
export type Component = keyof typeof components;
function componentSet(isEnabled: (component: Component) => boolean) {
return new Set((Object.keys(components) as Component[]).filter(isEnabled));
}
/** Components synced on an instance. */
export function enabledComponents(config: lib.InstanceConfig) {
return componentSet(component => Boolean(config.get(`player_sync.sync_${component}`)));
}
/**
* Components the scenario sent with an upload or download, all of them when not given.
*
* Lua may encode an empty table as an array, which reads as no components.
*/
export function scenarioComponents(sent: Record<string, boolean> | unknown[] | undefined) {
if (sent === undefined) {
return componentSet(() => true);
}
return componentSet(component => !Array.isArray(sent) && sent[component] === true);
}
/** Remove the fields of components which are not synced. */
export function stripPlayerData(playerData: IpcPlayerData, enabled: Set<Component>) {
const fields = playerData as Record<string, unknown>;
for (const [component, keys] of Object.entries(components)) {
if (!enabled.has(component as Component)) {
for (const key of keys) {
delete fields[key];
}
}
}
return playerData;
}
/**
* Combine data uploaded by an instance with the stored data.
*
* Components the upload was not serialized with keep the stored fields.
* Logistic requests are kept when the player has nowhere to hold them.
*/
export function mergePlayerData(stored: IpcPlayerData | undefined, uploaded: IpcPlayerData) {
const { components: uploadComponents, ...merged } = uploaded as IpcPlayerData & Record<string, unknown>;
const enabled = scenarioComponents(uploadComponents);
function keep(keys: readonly string[]) {
for (const key of keys) {
const value = (stored as Record<string, unknown> | undefined)?.[key];
if (value !== undefined) {
merged[key] = value;
} else {
delete merged[key];
}
}
}
for (const [component, keys] of Object.entries(components)) {
if (!enabled.has(component as Component)) {
keep(keys);
}
}
if (!uploaded.character && !uploaded.personal_logistic_slots) {
keep(["personal_logistic_slots"]);
}
return merged as IpcPlayerData;
}