Files
Bastiaan 0854a2b7d5 Move the seed into exp_scenario and seed the permission groups (#457)
The seed roles were owned by exp_roles, but they describe the scenario, so
they now live in exp_scenario/seed.ts next to the permissions they grant.
Each seed role names the permission group its holders belong to, and the
five groups the legacy config defined (Admin, Trusted, Standard, Guest,
Restricted) are seeded through exp_groups along with one role mapping per
role. Mapping priorities follow how exp_roles ranks a player's highest
role, so Jail lands in Restricted regardless of other roles.

SeedRolesRequest becomes exp_scenario's SeedRequest behind a new
exp_scenario.seed permission, and the button moves to the scenario's web
plugin, which gets a web entrypoint for it. exp_roles no longer depends on
exp_scenario, and exp_scenario gains controller tests around the seed.

With groups seeded from the controller the legacy expcore.permission_groups
module and its config are removed, along with the Group rcon static.
2026-09-08 11:20:44 +01:00

419 lines
10 KiB
TypeScript

import * as lib from "@clusterio/lib";
import { Type, Static } from "@sinclair/typebox";
/*
Data records
*/
/** Colour used for a role's tag and name in game. */
export class RoleColor {
constructor(
public r: number,
public g: number,
public b: number,
) {}
static jsonSchema = Type.Object({
r: Type.Number(),
g: Type.Number(),
b: Type.Number(),
});
toJSON(): Static<typeof RoleColor.jsonSchema> {
return { r: this.r, g: this.g, b: this.b };
}
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.r, json.g, json.b);
}
}
/**
* In game properties of a role which clusterio's own role does not carry.
*
* Stored on the controller keyed by the clusterio role id, and created
* automatically for any role which does not have one yet.
*/
export class RoleMetaRecord {
constructor(
/** Id of the clusterio role these properties belong to. */
public id: number,
/** Position in the role order, a lower value is a more privileged role. */
public order: number,
/**
* Only the roles with the highest priority a player holds are considered,
* which allows a role such as Jail to suppress all others.
*/
public priority: number = 0,
/** Short form of the role name, used where space is limited. */
public shortHand: string = "",
/** Tag shown next to the names of players with this role. */
public tag: string = "",
/** Colour used for the role in game, null uses the default colour. */
public color: RoleColor | null = null,
/** Online time after which this role is granted, null never grants it. */
public autoAssignOnlineTimeMs: number | null = null,
/** When true holders of this role are never granted roles automatically. */
public blockAutoAssign: boolean = false,
public updatedAtMs: number = 0,
public isDeleted: boolean = false,
) {}
static jsonSchema = Type.Object({
id: Type.Integer(),
order: Type.Number(),
priority: Type.Optional(Type.Number()),
short_hand: Type.Optional(Type.String()),
tag: Type.Optional(Type.String()),
color: Type.Optional(Type.Union([RoleColor.jsonSchema, Type.Null()])),
auto_assign_online_time_ms: Type.Optional(Type.Union([Type.Number(), Type.Null()])),
block_auto_assign: Type.Optional(Type.Boolean()),
updated_at_ms: Type.Optional(Type.Number()),
is_deleted: Type.Optional(Type.Boolean()),
});
toJSON() {
const json: Static<typeof RoleMetaRecord.jsonSchema> = {
id: this.id,
order: this.order,
};
if (this.priority) {
json.priority = this.priority;
}
if (this.shortHand) {
json.short_hand = this.shortHand;
}
if (this.tag) {
json.tag = this.tag;
}
if (this.color) {
json.color = this.color.toJSON();
}
if (this.autoAssignOnlineTimeMs !== null) {
json.auto_assign_online_time_ms = this.autoAssignOnlineTimeMs;
}
if (this.blockAutoAssign) {
json.block_auto_assign = true;
}
if (this.updatedAtMs) {
json.updated_at_ms = this.updatedAtMs;
}
if (this.isDeleted) {
json.is_deleted = true;
}
return json;
}
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.id,
json.order,
json.priority ?? 0,
json.short_hand ?? "",
json.tag ?? "",
json.color ? RoleColor.fromJSON(json.color) : null,
json.auto_assign_online_time_ms ?? null,
json.block_auto_assign ?? false,
json.updated_at_ms ?? 0,
json.is_deleted ?? false,
);
}
}
/**
* A clusterio role combined with its in game properties.
*
* This is the form sent to instances, because instances can not subscribe to
* the core role updates which are only sent to control connections.
*/
export class RoleRecord {
constructor(
public id: number,
public name: string,
public permissions: string[],
public meta: RoleMetaRecord,
/** True for the role every player holds, taken from controller.default_role_id. */
public isDefault: boolean = false,
public updatedAtMs: number = 0,
public isDeleted: boolean = false,
) {}
static jsonSchema = Type.Object({
id: Type.Integer(),
name: Type.String(),
permissions: Type.Array(Type.String()),
meta: RoleMetaRecord.jsonSchema,
is_default: Type.Optional(Type.Boolean()),
updated_at_ms: Type.Optional(Type.Number()),
is_deleted: Type.Optional(Type.Boolean()),
});
toJSON() {
const json: Static<typeof RoleRecord.jsonSchema> = {
id: this.id,
name: this.name,
permissions: this.permissions,
meta: this.meta.toJSON(),
};
if (this.isDefault) {
json.is_default = true;
}
if (this.updatedAtMs) {
json.updated_at_ms = this.updatedAtMs;
}
if (this.isDeleted) {
json.is_deleted = true;
}
return json;
}
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.id,
json.name,
json.permissions,
RoleMetaRecord.fromJSON(json.meta),
json.is_default ?? false,
json.updated_at_ms ?? 0,
json.is_deleted ?? false,
);
}
}
/** The roles a single player holds, mirroring the roles of their cluster user. */
export class AssignmentRecord {
constructor(
public name: string,
public roleIds: Set<number>,
public updatedAtMs: number = 0,
public isDeleted: boolean = false,
) {}
get id() {
return this.name;
}
static jsonSchema = Type.Object({
name: Type.String(),
role_ids: Type.Array(Type.Integer()),
updated_at_ms: Type.Optional(Type.Number()),
is_deleted: Type.Optional(Type.Boolean()),
});
toJSON() {
const json: Static<typeof AssignmentRecord.jsonSchema> = {
name: this.name,
role_ids: [...this.roleIds],
};
if (this.updatedAtMs) {
json.updated_at_ms = this.updatedAtMs;
}
if (this.isDeleted) {
json.is_deleted = true;
}
return json;
}
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.name,
new Set(json.role_ids),
json.updated_at_ms ?? 0,
json.is_deleted ?? false,
);
}
}
/**
* Encode roles for the lua initialise call.
*
* Roles repeat the same permission names over and over, so each name is sent
* once and referenced by index. At 15 roles this is 17 KiB rather than 38 KiB
* and decodes in 0.37 ms rather than 0.50 ms; at 60 roles it is 30 KiB rather
* than 122 KiB and 0.87 ms rather than 1.52 ms.
*
* Single role updates stay in the plain form, they have nothing to share.
*/
export function encodeRolesForLua(roles: RoleRecord[]) {
const names: string[] = [];
const indexes = new Map<string, number>();
const encoded = roles.map(role => {
const permissions = role.permissions.map(permission => {
let index = indexes.get(permission);
if (index === undefined) {
index = names.length;
names.push(permission);
indexes.set(permission, index);
}
return index;
});
return { ...role.toJSON(), permissions };
});
return { permission_names: names, roles: encoded };
}
/*
Update events
*/
export class RoleUpdatedEvent {
declare ["constructor"]: typeof RoleUpdatedEvent;
static plugin = "exp_roles" as const;
static type = "event" as const;
static src = "controller" as const;
static dst = ["control", "instance"] as const;
static permission = "core.role.subscribe" as const;
constructor(
public updates: RoleRecord[],
) {}
static jsonSchema = Type.Object({
updates: Type.Array(RoleRecord.jsonSchema),
});
toJSON() {
return { updates: this.updates.map(role => role.toJSON()) };
}
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.updates.map(role => RoleRecord.fromJSON(role)));
}
}
export class AssignmentUpdatedEvent {
declare ["constructor"]: typeof AssignmentUpdatedEvent;
static plugin = "exp_roles" as const;
static type = "event" as const;
static src = "controller" as const;
static dst = "instance" as const;
constructor(
public updates: AssignmentRecord[],
) {}
static jsonSchema = Type.Object({
updates: Type.Array(AssignmentRecord.jsonSchema),
});
toJSON() {
return { updates: this.updates.map(assignment => assignment.toJSON()) };
}
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.updates.map(assignment => AssignmentRecord.fromJSON(assignment)));
}
}
/*
Role requests
*/
export class RoleListRequest {
declare ["constructor"]: typeof RoleListRequest;
static plugin = "exp_roles" as const;
static type = "request" as const;
static src = ["control", "instance"] as const;
static dst = "controller" as const;
static permission = "core.role.list" as const;
static Response = lib.jsonArray(RoleRecord);
constructor() {}
}
export class RoleMetaUpdateRequest {
declare ["constructor"]: typeof RoleMetaUpdateRequest;
static plugin = "exp_roles" as const;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.role.update" as const;
constructor(
public meta: RoleMetaRecord,
) {}
static jsonSchema = Type.Object({
meta: RoleMetaRecord.jsonSchema,
});
toJSON() {
return { meta: this.meta.toJSON() };
}
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(RoleMetaRecord.fromJSON(json.meta));
}
}
/*
Assignment requests
*/
export class AssignmentListRequest {
declare ["constructor"]: typeof AssignmentListRequest;
static plugin = "exp_roles" as const;
static type = "request" as const;
static src = "instance" as const;
static dst = "controller" as const;
static Response = lib.jsonArray(AssignmentRecord);
constructor() {}
}
/**
* Add or remove roles for a player, sent by an instance when a role is changed
* in game. Only accepted when the instance is configured as bidirectional.
*/
export class AssignmentUpdateRequest {
declare ["constructor"]: typeof AssignmentUpdateRequest;
static plugin = "exp_roles" as const;
static type = "request" as const;
static src = "instance" as const;
static dst = "controller" as const;
static permission = "core.user.update_roles" as const;
constructor(
public name: string,
public assign: number[],
public unassign: number[],
) {}
static jsonSchema = Type.Object({
name: Type.String(),
assign: Type.Array(Type.Integer()),
unassign: Type.Array(Type.Integer()),
});
toJSON() {
return {
name: this.name,
assign: this.assign,
unassign: this.unassign,
};
}
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.name, json.assign, json.unassign);
}
}