mirror of
https://github.com/PHIDIAS0303/ExpCluster.git
synced 2026-11-11 15:54:52 +09:00
Merge branch 'explosivegaming:main' into translate
This commit is contained in:
117 files changed
+5017
-1456
No files matched your search
@@ -0,0 +1 @@
|
||||
# Without an .npmignore pnpm packs using the repository .gitignore, which leaves out dist/
|
||||
@@ -13,12 +13,21 @@
|
||||
"coverage": "tap --coverage-report=text test/*.test.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@clusterio/controller": "workspace:^",
|
||||
"@clusterio/host": "workspace:^",
|
||||
"@clusterio/lib": "workspace:^"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@clusterio/controller": {
|
||||
"optional": true
|
||||
},
|
||||
"@clusterio/host": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/controller": "workspace:^",
|
||||
"@clusterio/lib": "workspace:^",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Without an .npmignore pnpm packs using the repository .gitignore, which leaves out dist/
|
||||
@@ -11,11 +11,21 @@
|
||||
"prepare": "tsc --build && webpack-cli --env production"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@clusterio/controller": "workspace:^",
|
||||
"@clusterio/host": "workspace:^",
|
||||
"@clusterio/lib": "workspace:^"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@clusterio/controller": {
|
||||
"optional": true
|
||||
},
|
||||
"@clusterio/host": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/lib": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Without an .npmignore pnpm packs using the repository .gitignore, which leaves out dist/
|
||||
@@ -0,0 +1,43 @@
|
||||
# ExpGaming - Discord Auth
|
||||
|
||||
Lets players log in to the web interface with Discord, and links their Factorio accounts to a Discord account. It only uses the OAuth2 `identify` scope, so no bot is needed.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Create an application in the [Discord developer portal](https://discord.com/developers/applications).
|
||||
2. Under OAuth2, copy the Client ID and reset the Client Secret to get one.
|
||||
3. Under OAuth2 → Redirects, add `<controller.public_url>api/exp_discord_auth/callback`. For a controller with `controller.public_url` set to `https://example.com/cluster/` that is `https://example.com/cluster/api/exp_discord_auth/callback`. It must match exactly, including the trailing path and http or https.
|
||||
4. Set the config on the controller:
|
||||
|
||||
```
|
||||
npx clusterioctl controller config set controller.public_url https://example.com/cluster/
|
||||
npx clusterioctl controller config set exp_discord_auth.client_id <client id>
|
||||
npx clusterioctl controller config set exp_discord_auth.client_secret <client secret>
|
||||
```
|
||||
|
||||
`controller.public_url` has to be set, because the redirect URL is built from it and Discord rejects any URL that isn't registered.
|
||||
|
||||
## Config
|
||||
|
||||
All fields are on the controller.
|
||||
|
||||
| Field | Default | Description |
|
||||
|---|---|---|
|
||||
| `exp_discord_auth.client_id` | | Application ID of the Discord application. |
|
||||
| `exp_discord_auth.client_secret` | | OAuth2 client secret of the Discord application. Only readable on the controller. |
|
||||
| `exp_discord_auth.max_accounts` | 3 | How many Factorio accounts one Discord account can link. |
|
||||
|
||||
## Linking
|
||||
|
||||
There are two ways to link a Factorio account:
|
||||
|
||||
- On the user page, a logged in user clicks Link Discord and approves it on Discord.
|
||||
- On the login page, a player logs in with Discord and gets a code, then types `/link-discord CODE` on any server. This is how a player who can't log in to the web interface yet links their account.
|
||||
|
||||
Linking a Factorio account that is already linked to another Discord account moves it. `/link-discord` without a code shows the current link, and `/unlink-discord` removes it. The user page has an Unlink button too.
|
||||
|
||||
Users can always see and remove their own link. The `exp_discord_auth.link.get` and `exp_discord_auth.link.delete` permissions allow it for other users.
|
||||
|
||||
Once linked, the Discord tab on the login page has a "Log in as" button for each linked account. Banned users can't log in this way.
|
||||
|
||||
Links are stored in `database/exp_discord_auth/accounts.json` on the controller.
|
||||
@@ -0,0 +1,444 @@
|
||||
import type { Controller, ControllerPluginContext } from "@clusterio/controller";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import * as messages from "./messages.js";
|
||||
import * as crypto from "node:crypto";
|
||||
import * as path from "node:path";
|
||||
|
||||
const discordApi = "https://discord.com/api/v10";
|
||||
const stateTimeoutS = 10 * 60;
|
||||
const sessionTimeoutMs = 30 * 60e3;
|
||||
const codeTimeoutMs = 10 * 60e3;
|
||||
const codeLength = 6;
|
||||
// 0, O, 1 and I are left out because they are easy to mix up when typing the code in game
|
||||
const codeLetters = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
|
||||
type DiscordUser = { id: string, name: string };
|
||||
type Session = DiscordUser & { expiresMs: number, code?: string };
|
||||
type LinkCode = { sessionId: string, expiresMs: number };
|
||||
type WebLink = { playerName: string, expiresMs: number };
|
||||
|
||||
/** What an http route answers with, kept apart from express so it can be tested. */
|
||||
export type RouteResult =
|
||||
| { redirect: string, cookie?: string }
|
||||
| { status: number, json: unknown };
|
||||
|
||||
function readCookie(header: string | string[] | undefined, name: string) {
|
||||
for (const part of String(header ?? "").split(";")) {
|
||||
const [key, ...value] = part.trim().split("=");
|
||||
if (key === name) {
|
||||
return value.join("=");
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function randomCode() {
|
||||
let code = "";
|
||||
for (let i = 0; i < codeLength; i++) {
|
||||
code += codeLetters[crypto.randomInt(codeLetters.length)];
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
export class ControllerPlugin {
|
||||
/** Logins which have been through Discord, by session id */
|
||||
sessions = new Map<string, Session>();
|
||||
/** Codes waiting to be used in game, by code */
|
||||
codes = new Map<string, LinkCode>();
|
||||
/** Links started from the user page, by ticket until authorize turns them into a state */
|
||||
linkTickets = new Map<string, WebLink>();
|
||||
/** Links waiting for the Discord callback, by OAuth state */
|
||||
pendingLinks = new Map<string, WebLink>();
|
||||
cleanupInterval = setInterval(() => this.removeExpired(), 60e3).unref();
|
||||
|
||||
private constructor(
|
||||
public controller: Controller,
|
||||
public logger: lib.Logger,
|
||||
public name: string,
|
||||
public accounts: lib.SubscribableDatastore<messages.DiscordAccountRecord>,
|
||||
) {
|
||||
controller.handle(messages.DiscordLinkRequest, this.handleLinkRequest.bind(this));
|
||||
controller.handle(messages.DiscordUnlinkRequest, this.handleUnlinkRequest.bind(this));
|
||||
controller.handle(messages.DiscordLinkGetRequest, this.handleLinkGetRequest.bind(this));
|
||||
controller.handle(messages.DiscordLinkStartRequest, this.handleLinkStartRequest.bind(this));
|
||||
|
||||
const route = (handler: (req: Request) => Promise<RouteResult>) => (
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
handler(req).then(result => {
|
||||
if ("redirect" in result) {
|
||||
if (result.cookie) {
|
||||
res.setHeader("Set-Cookie", result.cookie);
|
||||
}
|
||||
res.redirect(result.redirect);
|
||||
} else {
|
||||
res.status(result.status).json(result.json);
|
||||
}
|
||||
}).catch(next);
|
||||
}
|
||||
);
|
||||
const app = controller.app;
|
||||
app.get("/api/exp_discord_auth/status", route(async () => this.status()));
|
||||
app.get("/api/exp_discord_auth/authorize", route(async req => this.authorize(req.query.link)));
|
||||
app.get("/api/exp_discord_auth/callback", route(async req => this.callback(
|
||||
req.query, readCookie(req.headers.cookie, "exp_discord_auth_state"),
|
||||
)));
|
||||
app.get("/api/exp_discord_auth/accounts", route(async req => this.listAccounts(this.sessionId(req))));
|
||||
app.post("/api/exp_discord_auth/login", route(async req => this.login(this.sessionId(req), req.query.name)));
|
||||
app.post("/api/exp_discord_auth/link_code", route(async req => this.linkCode(this.sessionId(req))));
|
||||
|
||||
controller.hooks.save.attach(name, this.onSave.bind(this));
|
||||
controller.hooks.shutdown.attach(name, this.onShutdown.bind(this));
|
||||
}
|
||||
|
||||
static async fromContext(context: ControllerPluginContext) {
|
||||
const controller = context.controller;
|
||||
const databaseDirectory = controller.config.get("controller.database_directory");
|
||||
|
||||
const accounts = new lib.SubscribableDatastore(
|
||||
...await new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "exp_discord_auth", "accounts.json"),
|
||||
messages.DiscordAccountRecord.fromJSON.bind(messages.DiscordAccountRecord),
|
||||
).bootstrap()
|
||||
);
|
||||
|
||||
return new ControllerPlugin(controller, context.logger, context.plugin.name, accounts);
|
||||
}
|
||||
|
||||
async onSave() {
|
||||
await this.accounts.save();
|
||||
}
|
||||
|
||||
async onShutdown() {
|
||||
clearInterval(this.cleanupInterval);
|
||||
await this.accounts.save();
|
||||
}
|
||||
|
||||
removeExpired(now = Date.now()) {
|
||||
for (const [id, session] of this.sessions) {
|
||||
if (session.expiresMs < now) {
|
||||
this.sessions.delete(id);
|
||||
}
|
||||
}
|
||||
for (const map of [this.codes, this.linkTickets, this.pendingLinks]) {
|
||||
for (const [key, entry] of map) {
|
||||
if (entry.expiresMs < now) {
|
||||
map.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Http routes
|
||||
*/
|
||||
|
||||
sessionId(req: Request) {
|
||||
const header = String(req.headers.authorization ?? "");
|
||||
return header.startsWith("Bearer ") ? header.slice(7) : "";
|
||||
}
|
||||
|
||||
redirectUri() {
|
||||
return new URL("api/exp_discord_auth/callback", this.controller.getControllerUrl()).href;
|
||||
}
|
||||
|
||||
/** Back to the web ui, with the outcome in the fragment so it never reaches a server log. */
|
||||
webRedirect(key: "session" | "error", value: string, page = ""): RouteResult {
|
||||
const url = new URL(page, this.controller.getControllerUrl());
|
||||
url.hash = `exp_discord_auth_${key}=${encodeURIComponent(value)}`;
|
||||
return { redirect: url.href };
|
||||
}
|
||||
|
||||
userPage(playerName: string) {
|
||||
return `users/${encodeURIComponent(playerName)}/view`;
|
||||
}
|
||||
|
||||
isConfigured() {
|
||||
return Boolean(
|
||||
this.controller.config.get("exp_discord_auth.client_id")
|
||||
&& this.controller.config.get("exp_discord_auth.client_secret")
|
||||
);
|
||||
}
|
||||
|
||||
/** Lets the login form say Discord is not set up before anyone presses the button. */
|
||||
async status(): Promise<RouteResult> {
|
||||
return { status: 200, json: { configured: this.isConfigured() } };
|
||||
}
|
||||
|
||||
async authorize(linkTicket?: unknown): Promise<RouteResult> {
|
||||
let link: WebLink | undefined;
|
||||
if (linkTicket !== undefined) {
|
||||
link = typeof linkTicket === "string" ? this.linkTickets.get(linkTicket) : undefined;
|
||||
if (!link || link.expiresMs < Date.now()) {
|
||||
return this.webRedirect("error", "Discord link expired, please try again", "users");
|
||||
}
|
||||
this.linkTickets.delete(linkTicket as string);
|
||||
}
|
||||
const page = link ? this.userPage(link.playerName) : "";
|
||||
|
||||
if (!this.isConfigured()) {
|
||||
return this.webRedirect("error", "Discord login is not configured on this controller", page);
|
||||
}
|
||||
|
||||
const state = crypto.randomBytes(16).toString("base64url");
|
||||
if (link) {
|
||||
this.pendingLinks.set(state, { playerName: link.playerName, expiresMs: Date.now() + stateTimeoutS * 1e3 });
|
||||
}
|
||||
const url = new URL("https://discord.com/oauth2/authorize");
|
||||
url.search = new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: this.controller.config.get("exp_discord_auth.client_id")!,
|
||||
scope: "identify",
|
||||
state,
|
||||
redirect_uri: this.redirectUri(),
|
||||
// Linking always shows the Discord consent screen, so it can't happen without the user noticing
|
||||
...(link ? {} : { prompt: "none" }),
|
||||
}).toString();
|
||||
|
||||
const callbackUrl = new URL(this.redirectUri());
|
||||
const secure = callbackUrl.protocol === "https:" ? "; Secure" : "";
|
||||
return {
|
||||
redirect: url.href,
|
||||
cookie: `exp_discord_auth_state=${state}; Path=${callbackUrl.pathname}; Max-Age=${stateTimeoutS}; HttpOnly; SameSite=Lax${secure}`,
|
||||
};
|
||||
}
|
||||
|
||||
async callback(query: Record<string, unknown>, cookieState: string | undefined): Promise<RouteResult> {
|
||||
const link = typeof query.state === "string" ? this.pendingLinks.get(query.state) : undefined;
|
||||
if (link) {
|
||||
this.pendingLinks.delete(query.state as string);
|
||||
}
|
||||
const page = link ? this.userPage(link.playerName) : "";
|
||||
|
||||
if (typeof query.error === "string") {
|
||||
const message = query.error === "access_denied" ? "Discord login was cancelled" : query.error;
|
||||
return this.webRedirect("error", message, page);
|
||||
}
|
||||
if (
|
||||
typeof query.state !== "string" || !cookieState || query.state !== cookieState
|
||||
|| (link && link.expiresMs < Date.now())
|
||||
) {
|
||||
return this.webRedirect("error", "Discord login expired, please try again", page);
|
||||
}
|
||||
if (typeof query.code !== "string") {
|
||||
return this.webRedirect("error", "Discord did not return a login code", page);
|
||||
}
|
||||
|
||||
let discordUser;
|
||||
try {
|
||||
discordUser = await this.fetchDiscordUser(query.code);
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`Discord login failed: ${err.message}`);
|
||||
return this.webRedirect("error", "Discord login failed, please try again", page);
|
||||
}
|
||||
|
||||
if (link) {
|
||||
try {
|
||||
this.linkAccount(discordUser, link.playerName);
|
||||
} catch (err: any) {
|
||||
return this.webRedirect("error", err.message, page);
|
||||
}
|
||||
return { redirect: new URL(page, this.controller.getControllerUrl()).href };
|
||||
}
|
||||
|
||||
const account = this.accounts.getMutable(discordUser.id);
|
||||
if (account && account.name !== discordUser.name) {
|
||||
account.name = discordUser.name;
|
||||
this.accounts.set(account);
|
||||
}
|
||||
|
||||
const sessionId = crypto.randomBytes(32).toString("base64url");
|
||||
this.sessions.set(sessionId, { ...discordUser, expiresMs: Date.now() + sessionTimeoutMs });
|
||||
return this.webRedirect("session", sessionId);
|
||||
}
|
||||
|
||||
/** Exchange the code from the callback for the Discord user who logged in. */
|
||||
async fetchDiscordUser(code: string): Promise<DiscordUser> {
|
||||
const tokenResponse = await fetch(`${discordApi}/oauth2/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: this.controller.config.get("exp_discord_auth.client_id") ?? "",
|
||||
client_secret: this.controller.config.get("exp_discord_auth.client_secret") ?? "",
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: this.redirectUri(),
|
||||
}),
|
||||
});
|
||||
if (!tokenResponse.ok) {
|
||||
throw new Error(`token exchange responded with ${tokenResponse.status}: ${await tokenResponse.text()}`);
|
||||
}
|
||||
const token = await tokenResponse.json() as { access_token: string, token_type: string };
|
||||
|
||||
const userResponse = await fetch(`${discordApi}/users/@me`, {
|
||||
headers: { Authorization: `${token.token_type} ${token.access_token}` },
|
||||
});
|
||||
if (!userResponse.ok) {
|
||||
throw new Error(`users/@me responded with ${userResponse.status}`);
|
||||
}
|
||||
const user = await userResponse.json() as { id: string, username: string, global_name?: string | null };
|
||||
return { id: user.id, name: user.global_name || user.username };
|
||||
}
|
||||
|
||||
getSession(sessionId: string) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session || session.expiresMs < Date.now()) {
|
||||
return undefined;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
async listAccounts(sessionId: string): Promise<RouteResult> {
|
||||
const session = this.getSession(sessionId);
|
||||
if (!session) {
|
||||
return { status: 401, json: { error: "Discord login expired, please log in again" } };
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
json: {
|
||||
discord: { id: session.id, name: session.name },
|
||||
accounts: this.accounts.get(session.id)?.factorioNames ?? [],
|
||||
max_accounts: this.controller.config.get("exp_discord_auth.max_accounts"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async login(sessionId: string, name: unknown): Promise<RouteResult> {
|
||||
const session = this.getSession(sessionId);
|
||||
if (!session) {
|
||||
return { status: 401, json: { error: "Discord login expired, please log in again" } };
|
||||
}
|
||||
|
||||
const factorioNames = this.accounts.get(session.id)?.factorioNames ?? [];
|
||||
if (typeof name !== "string" || !factorioNames.some(linked => linked.toLowerCase() === name.toLowerCase())) {
|
||||
return { status: 403, json: { error: "That Factorio account is not linked to your Discord account" } };
|
||||
}
|
||||
|
||||
const user = this.controller.users.getByName(name);
|
||||
if (!user) {
|
||||
return { status: 404, json: { error: `There is no user called ${name}` } };
|
||||
}
|
||||
if (user.isBanned) {
|
||||
return { status: 403, json: { error: `${user.name} is banned` } };
|
||||
}
|
||||
|
||||
this.sessions.delete(sessionId);
|
||||
if (session.code) {
|
||||
this.codes.delete(session.code);
|
||||
}
|
||||
return { status: 200, json: { token: this.controller.users.signUserToken(user) } };
|
||||
}
|
||||
|
||||
/** A code which links the player who types it in game to this Discord account. */
|
||||
async linkCode(sessionId: string): Promise<RouteResult> {
|
||||
const session = this.getSession(sessionId);
|
||||
if (!session) {
|
||||
return { status: 401, json: { error: "Discord login expired, please log in again" } };
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
let entry = session.code ? this.codes.get(session.code) : undefined;
|
||||
if (!entry || entry.expiresMs < now) {
|
||||
let code = randomCode();
|
||||
while (this.codes.has(code)) {
|
||||
code = randomCode();
|
||||
}
|
||||
entry = { sessionId, expiresMs: now + codeTimeoutMs };
|
||||
this.codes.set(code, entry);
|
||||
session.code = code;
|
||||
}
|
||||
return { status: 200, json: { code: session.code, expires_ms: entry.expiresMs } };
|
||||
}
|
||||
|
||||
/*
|
||||
Links
|
||||
*/
|
||||
|
||||
findAccount(playerName: string) {
|
||||
const name = playerName.toLowerCase();
|
||||
for (const account of this.accounts.values()) {
|
||||
if (account.factorioNames.some(linked => linked.toLowerCase() === name)) {
|
||||
return account;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Remove a player from the account they are linked to, the account goes once nothing is left. */
|
||||
removeLink(playerName: string) {
|
||||
const found = this.findAccount(playerName);
|
||||
if (!found) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const account = this.accounts.getMutable(found.id)!;
|
||||
const name = playerName.toLowerCase();
|
||||
account.factorioNames = account.factorioNames.filter(linked => linked.toLowerCase() !== name);
|
||||
if (account.factorioNames.length) {
|
||||
this.accounts.set(account);
|
||||
} else {
|
||||
this.accounts.delete(account);
|
||||
}
|
||||
return { id: account.id, name: account.name };
|
||||
}
|
||||
|
||||
async handleLinkRequest(request: messages.DiscordLinkRequest) {
|
||||
const code = request.code.trim().toUpperCase();
|
||||
const entry = this.codes.get(code);
|
||||
const session = entry ? this.sessions.get(entry.sessionId) : undefined;
|
||||
if (!entry || !session || entry.expiresMs < Date.now()) {
|
||||
throw new lib.RequestError(
|
||||
`This code is invalid or has expired, get a new one from ${this.controller.getControllerUrl()}`
|
||||
);
|
||||
}
|
||||
|
||||
const linked = this.linkAccount(session, request.playerName);
|
||||
this.codes.delete(code);
|
||||
delete session.code;
|
||||
return linked;
|
||||
}
|
||||
|
||||
/** Link a player who proved they own the Factorio account, it moves over from any other Discord account. */
|
||||
linkAccount(discordUser: DiscordUser, playerName: string) {
|
||||
const name = playerName.toLowerCase();
|
||||
const existing = this.accounts.get(discordUser.id);
|
||||
const alreadyLinked = existing?.factorioNames.some(linked => linked.toLowerCase() === name);
|
||||
const maxAccounts = this.controller.config.get("exp_discord_auth.max_accounts");
|
||||
if (!alreadyLinked && (existing?.factorioNames.length ?? 0) >= maxAccounts) {
|
||||
throw new lib.RequestError(
|
||||
`${discordUser.name} already has ${maxAccounts} Factorio accounts linked, unlink one first`
|
||||
);
|
||||
}
|
||||
|
||||
if (!alreadyLinked) {
|
||||
this.removeLink(playerName);
|
||||
const account = this.accounts.getMutable(discordUser.id)
|
||||
?? new messages.DiscordAccountRecord(discordUser.id, discordUser.name, []);
|
||||
account.name = discordUser.name;
|
||||
account.factorioNames = [...account.factorioNames, playerName];
|
||||
this.accounts.set(account);
|
||||
}
|
||||
return { id: discordUser.id, name: discordUser.name };
|
||||
}
|
||||
|
||||
async handleUnlinkRequest(request: messages.DiscordUnlinkRequest) {
|
||||
return { link: this.removeLink(request.playerName), url: this.controller.getControllerUrl() };
|
||||
}
|
||||
|
||||
/** A ticket for /api/exp_discord_auth/authorize?link= which links the Discord account to the user. */
|
||||
async handleLinkStartRequest(request: messages.DiscordLinkStartRequest) {
|
||||
const ticket = crypto.randomBytes(16).toString("base64url");
|
||||
this.linkTickets.set(ticket, { playerName: request.playerName, expiresMs: Date.now() + 60e3 });
|
||||
return ticket;
|
||||
}
|
||||
|
||||
async handleLinkGetRequest(request: messages.DiscordLinkGetRequest) {
|
||||
const account = this.findAccount(request.playerName);
|
||||
return {
|
||||
link: account ? { id: account.id, name: account.name } : null,
|
||||
url: this.controller.getControllerUrl(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default ControllerPlugin.fromContext.bind(ControllerPlugin);
|
||||
@@ -0,0 +1,74 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import * as messages from "./messages.js";
|
||||
|
||||
declare module "@clusterio/lib" {
|
||||
export interface ControllerConfigFields {
|
||||
"exp_discord_auth.client_id": string | null;
|
||||
"exp_discord_auth.client_secret": string | null;
|
||||
"exp_discord_auth.max_accounts": number;
|
||||
}
|
||||
export interface Permissions {
|
||||
"exp_discord_auth.link.get": never;
|
||||
"exp_discord_auth.link.delete": never;
|
||||
}
|
||||
}
|
||||
|
||||
export const plugin: lib.PluginDeclaration = {
|
||||
name: "exp_discord_auth",
|
||||
title: "ExpGaming - Discord Auth",
|
||||
description: "Clusterio plugin linking Factorio accounts to Discord accounts and logging in to the web interface with Discord",
|
||||
|
||||
features: [
|
||||
"SavePatching",
|
||||
"ScriptCommands",
|
||||
],
|
||||
|
||||
messages: [
|
||||
messages.DiscordLinkRequest,
|
||||
messages.DiscordUnlinkRequest,
|
||||
messages.DiscordLinkGetRequest,
|
||||
messages.DiscordLinkStartRequest,
|
||||
],
|
||||
|
||||
permissions: [
|
||||
{
|
||||
name: "exp_discord_auth.link.get",
|
||||
title: "Get Discord Links",
|
||||
description: "See which Discord account any user is linked to, users can always see their own.",
|
||||
grantByDefault: false,
|
||||
},
|
||||
{
|
||||
name: "exp_discord_auth.link.delete",
|
||||
title: "Remove Discord Links",
|
||||
description: "Unlink any user from their Discord account, users can always unlink themselves.",
|
||||
grantByDefault: false,
|
||||
},
|
||||
],
|
||||
|
||||
instanceEntrypoint: "./dist/node/instance.js",
|
||||
|
||||
controllerEntrypoint: "./dist/node/controller.js",
|
||||
controllerConfigFields: {
|
||||
"exp_discord_auth.client_id": {
|
||||
title: "Client ID",
|
||||
description: "Application ID of the Discord application used for logging in.",
|
||||
type: "string",
|
||||
optional: true,
|
||||
},
|
||||
"exp_discord_auth.client_secret": {
|
||||
title: "Client Secret",
|
||||
description: "OAuth2 client secret of the Discord application used for logging in.",
|
||||
type: "string",
|
||||
credential: ["controller"],
|
||||
optional: true,
|
||||
},
|
||||
"exp_discord_auth.max_accounts": {
|
||||
title: "Max Accounts",
|
||||
description: "Number of Factorio accounts which can be linked to one Discord account.",
|
||||
type: "number",
|
||||
initialValue: 3,
|
||||
},
|
||||
},
|
||||
|
||||
webEntrypoint: "./web",
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { InstancePluginContext } from "@clusterio/host";
|
||||
import * as messages from "./messages.js";
|
||||
|
||||
/** Sent by the lua side when a player uses a link code. */
|
||||
export type IpcLink = {
|
||||
player_name: string,
|
||||
code: string,
|
||||
};
|
||||
|
||||
/** Sent by the lua side when a player unlinks their Discord account, or asks which one they are linked to. */
|
||||
export type IpcPlayer = {
|
||||
player_name: string,
|
||||
};
|
||||
|
||||
/** Bridges the lua side and the controller, the answer is printed to the player once it arrives. */
|
||||
export default async function (context: InstancePluginContext) {
|
||||
const { instance } = context;
|
||||
|
||||
/** Hand an answer to lua, unless the instance stopped while it was being fetched. */
|
||||
async function luaSend(receiver: string, json: any) {
|
||||
if (instance.status !== "running") {
|
||||
return;
|
||||
}
|
||||
// Discord names can contain anything, so pick a long bracket level the json does not close
|
||||
const text = JSON.stringify(json);
|
||||
let level = "=";
|
||||
while (text.includes(`]${level}]`)) {
|
||||
level += "=";
|
||||
}
|
||||
await instance.sendRcon(
|
||||
`/sc exp_discord_auth.${receiver}(helpers.json_to_table[${level}[${text}]${level}])`, true
|
||||
);
|
||||
}
|
||||
|
||||
instance.server.handle("exp_discord_auth:link", async (event: IpcLink) => {
|
||||
try {
|
||||
const discord = await instance.sendTo("controller", new messages.DiscordLinkRequest(
|
||||
event.player_name, event.code,
|
||||
));
|
||||
await luaSend("receive_linked", { player_name: event.player_name, discord_name: discord!.name });
|
||||
} catch (err: any) {
|
||||
await luaSend("receive_error", { player_name: event.player_name, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
instance.server.handle("exp_discord_auth:unlink", async (event: IpcPlayer) => {
|
||||
try {
|
||||
const status = await instance.sendTo("controller", new messages.DiscordUnlinkRequest(event.player_name));
|
||||
await luaSend("receive_unlinked", {
|
||||
player_name: event.player_name, discord_name: status.link?.name, url: status.url,
|
||||
});
|
||||
} catch (err: any) {
|
||||
await luaSend("receive_error", { player_name: event.player_name, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
instance.server.handle("exp_discord_auth:get", async (event: IpcPlayer) => {
|
||||
try {
|
||||
const status = await instance.sendTo("controller", new messages.DiscordLinkGetRequest(event.player_name));
|
||||
await luaSend("receive_get", {
|
||||
player_name: event.player_name, discord_name: status.link?.name, url: status.url,
|
||||
});
|
||||
} catch (err: any) {
|
||||
await luaSend("receive_error", { player_name: event.player_name, message: err.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
|
||||
/*
|
||||
Data records
|
||||
*/
|
||||
|
||||
/** A Discord account and the Factorio accounts linked to it. */
|
||||
export class DiscordAccountRecord {
|
||||
constructor(
|
||||
/** Discord user id */
|
||||
public id: string,
|
||||
/** Discord display name at the time of the last login */
|
||||
public name: string,
|
||||
public factorioNames: string[],
|
||||
public updatedAtMs: number = 0,
|
||||
public isDeleted: boolean = false,
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
id: Type.String(),
|
||||
name: Type.String(),
|
||||
factorio_names: Type.Array(Type.String()),
|
||||
updated_at_ms: Type.Optional(Type.Number()),
|
||||
is_deleted: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
toJSON() {
|
||||
const json: Static<typeof DiscordAccountRecord.jsonSchema> = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
factorio_names: this.factorioNames,
|
||||
};
|
||||
|
||||
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.factorio_names,
|
||||
json.updated_at_ms ?? 0,
|
||||
json.is_deleted ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Users may always act on their own link, otherwise the permission is needed. */
|
||||
function selfOrPermission(permission: lib.PermissionName) {
|
||||
return (user: lib.IUser, message: lib.MessageRequest) => {
|
||||
const playerName = (message.data as { player_name: string }).player_name;
|
||||
if (user.name.toLowerCase() !== playerName.toLowerCase()) {
|
||||
user.checkPermission(permission);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Only the user themselves, no permission lets someone link another user to their Discord. */
|
||||
function selfOnly(user: lib.IUser, message: lib.MessageRequest) {
|
||||
const playerName = (message.data as { player_name: string }).player_name;
|
||||
if (user.name.toLowerCase() !== playerName.toLowerCase()) {
|
||||
throw new lib.PermissionError("You can only link your own account");
|
||||
}
|
||||
}
|
||||
|
||||
const Link = Type.Union([
|
||||
Type.Null(),
|
||||
Type.Object({
|
||||
id: Type.String(),
|
||||
name: Type.String(),
|
||||
}),
|
||||
]);
|
||||
const LinkResponse = lib.plainJson(Link);
|
||||
/** The website is included so the player can be told where to link their account. */
|
||||
const LinkStatusResponse = lib.plainJson(Type.Object({
|
||||
link: Link,
|
||||
url: Type.String(),
|
||||
}));
|
||||
|
||||
/*
|
||||
Link requests
|
||||
*/
|
||||
|
||||
/** Link a player to the Discord account which generated the code, sent when the player uses the code in game. */
|
||||
export class DiscordLinkRequest {
|
||||
declare ["constructor"]: typeof DiscordLinkRequest;
|
||||
static plugin = "exp_discord_auth" as const;
|
||||
static type = "request" as const;
|
||||
static src = "instance" as const;
|
||||
static dst = "controller" as const;
|
||||
static Response = LinkResponse;
|
||||
|
||||
constructor(
|
||||
public playerName: string,
|
||||
public code: string,
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
player_name: Type.String(),
|
||||
code: Type.String(),
|
||||
});
|
||||
|
||||
toJSON() {
|
||||
return { player_name: this.playerName, code: this.code };
|
||||
}
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.player_name, json.code);
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove the link of a player, responds with the account it was linked to and the website url. */
|
||||
export class DiscordUnlinkRequest {
|
||||
declare ["constructor"]: typeof DiscordUnlinkRequest;
|
||||
static plugin = "exp_discord_auth" as const;
|
||||
static type = "request" as const;
|
||||
static src = ["control", "instance"] as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = selfOrPermission("exp_discord_auth.link.delete");
|
||||
static Response = LinkStatusResponse;
|
||||
|
||||
constructor(
|
||||
public playerName: string,
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
player_name: Type.String(),
|
||||
});
|
||||
|
||||
toJSON() {
|
||||
return { player_name: this.playerName };
|
||||
}
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.player_name);
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the Discord account a player is linked to and the website url. */
|
||||
export class DiscordLinkGetRequest {
|
||||
declare ["constructor"]: typeof DiscordLinkGetRequest;
|
||||
static plugin = "exp_discord_auth" as const;
|
||||
static type = "request" as const;
|
||||
static src = ["control", "instance"] as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = selfOrPermission("exp_discord_auth.link.get");
|
||||
static Response = LinkStatusResponse;
|
||||
|
||||
constructor(
|
||||
public playerName: string,
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
player_name: Type.String(),
|
||||
});
|
||||
|
||||
toJSON() {
|
||||
return { player_name: this.playerName };
|
||||
}
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.player_name);
|
||||
}
|
||||
}
|
||||
|
||||
/** Start linking the Discord account from the user page, responds with a ticket for the authorize route. */
|
||||
export class DiscordLinkStartRequest {
|
||||
declare ["constructor"]: typeof DiscordLinkStartRequest;
|
||||
static plugin = "exp_discord_auth" as const;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = selfOnly;
|
||||
static Response = lib.plainJson(Type.String());
|
||||
|
||||
constructor(
|
||||
public playerName: string,
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
player_name: Type.String(),
|
||||
});
|
||||
|
||||
toJSON() {
|
||||
return { player_name: this.playerName };
|
||||
}
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.player_name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
--[[-- ExpDiscordAuth - Commands
|
||||
Commands which link and unlink the player's Discord account
|
||||
]]
|
||||
|
||||
local Commands = require("modules/exp_commands")
|
||||
|
||||
local Discord = require("modules/exp_discord_auth")
|
||||
|
||||
--- @class ExpDiscordAuth.commands
|
||||
local commands = {}
|
||||
|
||||
--- Links your account to the Discord account which made the code, printed once the controller answers
|
||||
commands.link_discord = Commands.new("link-discord", { "exp-discord-auth_commands.description-link" })
|
||||
:optional("code", { "exp-discord-auth_commands.arg-code" }, Commands.types.string)
|
||||
:add_aliases{ "link" }
|
||||
:register(function(player, code)
|
||||
--- @cast code string?
|
||||
if code then
|
||||
Discord.link(player, code)
|
||||
else
|
||||
Discord.get_linked(player)
|
||||
end
|
||||
end)
|
||||
|
||||
--- Removes the link to your Discord account, printed once the controller answers
|
||||
commands.unlink_discord = Commands.new("unlink-discord", { "exp-discord-auth_commands.description-unlink" })
|
||||
:add_aliases{ "unlink" }
|
||||
:register(function(player)
|
||||
Discord.unlink(player)
|
||||
end)
|
||||
|
||||
return {
|
||||
commands = commands,
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
--[[-- ExpDiscordAuth
|
||||
Links Factorio accounts to Discord accounts, the links are kept on the controller.
|
||||
|
||||
A player logs in with Discord on the web interface, which gives them a code.
|
||||
Typing that code in game links the account they are playing on. Nothing is
|
||||
stored in the lua state, every call goes to the controller and the answer is
|
||||
printed to the player once it arrives, if they are still online.
|
||||
]]
|
||||
|
||||
local clusterio_api = require("modules/clusterio/api")
|
||||
local ExpUtil = require("modules/exp_util")
|
||||
|
||||
--- @class ExpDiscordAuth
|
||||
local ExpDiscordAuth = {}
|
||||
|
||||
local error_settings = { color = ExpUtil.color.orange_red, sound_path = "utility/wire_pickup" }
|
||||
|
||||
--- Link the player to the Discord account which made the code
|
||||
--- @param player LuaPlayer
|
||||
--- @param code string
|
||||
function ExpDiscordAuth.link(player, code)
|
||||
clusterio_api.send_json("exp_discord_auth:link", { player_name = player.name, code = code })
|
||||
end
|
||||
|
||||
--- Remove the link between the player and their Discord account
|
||||
--- @param player LuaPlayer
|
||||
function ExpDiscordAuth.unlink(player)
|
||||
clusterio_api.send_json("exp_discord_auth:unlink", { player_name = player.name })
|
||||
end
|
||||
|
||||
--- Print the Discord account the player is linked to
|
||||
--- @param player LuaPlayer
|
||||
function ExpDiscordAuth.get_linked(player)
|
||||
clusterio_api.send_json("exp_discord_auth:get", { player_name = player.name })
|
||||
end
|
||||
|
||||
--- A player who can still be printed to, nil once they have left
|
||||
--- @param name string
|
||||
--- @return LuaPlayer?
|
||||
local function get_online_player(name)
|
||||
local player = game.get_player(name)
|
||||
if player and player.valid and player.connected then
|
||||
return player
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
--- The controller linked the player
|
||||
--- @param payload { player_name: string, discord_name: string }
|
||||
function ExpDiscordAuth.receive_linked(payload)
|
||||
local player = get_online_player(payload.player_name)
|
||||
if not player then return end
|
||||
player.print{ "exp-discord-auth.linked", payload.discord_name }
|
||||
end
|
||||
|
||||
--- The controller removed the link, discord_name is nil if there was none
|
||||
--- @param payload { player_name: string, discord_name: string?, url: string }
|
||||
function ExpDiscordAuth.receive_unlinked(payload)
|
||||
local player = get_online_player(payload.player_name)
|
||||
if not player then return end
|
||||
if payload.discord_name then
|
||||
player.print{ "exp-discord-auth.unlinked", payload.discord_name }
|
||||
else
|
||||
player.print({ "exp-discord-auth.not-linked", payload.url }, error_settings)
|
||||
end
|
||||
end
|
||||
|
||||
--- The controller answered which account the player is linked to
|
||||
--- @param payload { player_name: string, discord_name: string?, url: string }
|
||||
function ExpDiscordAuth.receive_get(payload)
|
||||
local player = get_online_player(payload.player_name)
|
||||
if not player then return end
|
||||
if payload.discord_name then
|
||||
player.print{ "exp-discord-auth.linked-to", payload.discord_name }
|
||||
else
|
||||
player.print{ "exp-discord-auth.not-linked", payload.url }
|
||||
end
|
||||
end
|
||||
|
||||
--- The controller refused a request
|
||||
--- @param payload { player_name: string, message: string }
|
||||
function ExpDiscordAuth.receive_error(payload)
|
||||
local player = get_online_player(payload.player_name)
|
||||
if not player then return end
|
||||
player.print(payload.message, error_settings)
|
||||
end
|
||||
|
||||
return ExpDiscordAuth
|
||||
@@ -0,0 +1,10 @@
|
||||
--[[
|
||||
It is best practice to not expose any globals because all modules share a global environment
|
||||
However, sometimes you need globals, for example to access functions within rcon commands
|
||||
Therefore, we advise that this should be the only file in your module to expose globals
|
||||
]]
|
||||
|
||||
--- @diagnostic disable: global-in-non-module
|
||||
|
||||
-- Access using `/sc exp_discord_auth.foo()`
|
||||
exp_discord_auth = require("modules/exp_discord_auth/control")
|
||||
@@ -0,0 +1,10 @@
|
||||
[exp-discord-auth]
|
||||
linked=Your account is now linked to the Discord account __1__.
|
||||
linked-to=Your account is linked to the Discord account __1__. Use /unlink-discord to remove it.
|
||||
unlinked=Your account is no longer linked to the Discord account __1__.
|
||||
not-linked=Your account is not linked to a Discord account. Log in with Discord at __1__ to get a code, then use /link-discord <code>.
|
||||
|
||||
[exp-discord-auth_commands]
|
||||
description-link=Links your account to Discord using the code from the website, or shows the account you are linked to.
|
||||
description-unlink=Removes the link between your account and Discord.
|
||||
arg-code=Code shown on the website after logging in with Discord.
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "exp_discord_auth",
|
||||
"load": [
|
||||
],
|
||||
"require": [
|
||||
"globals.lua",
|
||||
"commands.lua"
|
||||
],
|
||||
"dependencies": {
|
||||
"clusterio": "*",
|
||||
"exp_util": "*",
|
||||
"exp_commands": "*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
|
||||
-- Access the exports from other modules using require("modules/exp_discord_auth")
|
||||
return require("modules/exp_discord_auth/control")
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "@expcluster/discord-auth",
|
||||
"version": "7.0.0",
|
||||
"description": "Clusterio plugin linking Factorio accounts to Discord accounts and logging in to the web interface with Discord",
|
||||
"author": "Cooldude2606 <https://github.com/Cooldude2606>",
|
||||
"license": "MIT",
|
||||
"repository": "explosivegaming/ExpCluster",
|
||||
"type": "module",
|
||||
"main": "dist/node/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build && webpack-cli --env production",
|
||||
"test": "tap --type-strip-only --disable-coverage --allow-empty-coverage test/*.test.js",
|
||||
"coverage": "tap --coverage-report=text test/*.test.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@clusterio/controller": "workspace:^",
|
||||
"@clusterio/host": "workspace:^",
|
||||
"@clusterio/lib": "workspace:^"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@clusterio/controller": {
|
||||
"optional": true
|
||||
},
|
||||
"@clusterio/host": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/controller": "workspace:^",
|
||||
"@clusterio/host": "workspace:^",
|
||||
"@clusterio/lib": "workspace:^",
|
||||
"@clusterio/web_ui": "workspace:^",
|
||||
"@types/express": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"antd": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-router-dom": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"webpack": "catalog:",
|
||||
"tap": "^21.1.0",
|
||||
"webpack-cli": "catalog:",
|
||||
"webpack-merge": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "catalog:",
|
||||
"@expcluster/lib_commands": "workspace:^",
|
||||
"@expcluster/lib_util": "workspace:^"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"keywords": [
|
||||
"clusterio",
|
||||
"clusterio-plugin",
|
||||
"factorio"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import t from "tap";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { Controller } from "@clusterio/controller";
|
||||
import entrypoint, { ControllerPlugin } from "../dist/node/controller.js";
|
||||
import * as messages from "../dist/node/messages.js";
|
||||
import { plugin as pluginDeclaration } from "../dist/node/index.js";
|
||||
|
||||
for (const Message of pluginDeclaration.messages) {
|
||||
lib.Link.register(Message);
|
||||
}
|
||||
lib.addPluginConfigFields([pluginDeclaration]);
|
||||
|
||||
const logger = { child: () => logger, info: () => {}, warn: () => {}, error: () => {}, verbose: () => {} };
|
||||
|
||||
lib.logger.silent = true;
|
||||
t.after(() => {
|
||||
lib.logger.silent = false;
|
||||
});
|
||||
|
||||
const alice = { id: "100", name: "Alice" };
|
||||
const bob = { id: "200", name: "Bob" };
|
||||
|
||||
/** Build the plugin around a real controller, which is side effect free while not started. */
|
||||
async function startPlugin(t2, { config = {} } = {}) {
|
||||
const controllerConfig = new lib.ControllerConfig("controller", {
|
||||
// testdir() empties the folder, so a reload passes the directory in
|
||||
"controller.database_directory": config["controller.database_directory"] ?? t2.testdir(),
|
||||
"controller.public_url": "https://example.com/cluster/",
|
||||
"exp_discord_auth.client_id": "123",
|
||||
"exp_discord_auth.client_secret": "secret",
|
||||
"controller.auth_secret": Buffer.from("test secret").toString("base64"),
|
||||
...config,
|
||||
});
|
||||
const controller = new Controller(logger, [], controllerConfig);
|
||||
for (const name of ["Player1", "Player2", "Player3", "Player4"]) {
|
||||
controller.users.createUser(name);
|
||||
}
|
||||
|
||||
const plugin = await ControllerPlugin.fromContext({ plugin: { name: "exp_discord_auth" }, controller, logger });
|
||||
t2.teardown(() => clearInterval(plugin.cleanupInterval));
|
||||
return { plugin, controller };
|
||||
}
|
||||
|
||||
/** Go through the Discord callback as the given Discord user, returning the session id. */
|
||||
async function discordLogin(plugin, discordUser) {
|
||||
plugin.fetchDiscordUser = async () => discordUser;
|
||||
const result = await plugin.callback({ code: "c", state: "s" }, "s");
|
||||
return decodeURIComponent(new URL(result.redirect).hash.split("=")[1]);
|
||||
}
|
||||
|
||||
/** Get a code on the web and use it in game as playerName. */
|
||||
async function link(plugin, session, playerName) {
|
||||
const { json } = await plugin.linkCode(session);
|
||||
return plugin.handleLinkRequest(new messages.DiscordLinkRequest(playerName, json.code.toLowerCase()));
|
||||
}
|
||||
|
||||
t.test("entrypoint", async t2 => {
|
||||
const controllerConfig = new lib.ControllerConfig("controller", { "controller.database_directory": t2.testdir() });
|
||||
const controller = new Controller(logger, [], controllerConfig);
|
||||
await entrypoint({ controller, logger, plugin: { name: "exp_discord_auth" }, metrics: undefined });
|
||||
for (const hook of ["save", "shutdown"]) {
|
||||
t2.ok([...controller.hooks[hook].attached].includes("exp_discord_auth"), `attached to ${hook}`);
|
||||
}
|
||||
await controller.hooks.shutdown.invoke();
|
||||
});
|
||||
|
||||
t.test("oauth", t2 => {
|
||||
t2.test(".authorize() redirects to Discord with a state cookie scoped to the callback", async t3 => {
|
||||
const { plugin } = await startPlugin(t3);
|
||||
const result = await plugin.authorize();
|
||||
const url = new URL(result.redirect);
|
||||
t3.equal(url.origin + url.pathname, "https://discord.com/oauth2/authorize");
|
||||
t3.equal(url.searchParams.get("client_id"), "123");
|
||||
t3.equal(url.searchParams.get("redirect_uri"), "https://example.com/cluster/api/exp_discord_auth/callback");
|
||||
const state = url.searchParams.get("state");
|
||||
t3.match(result.cookie, `exp_discord_auth_state=${state}; Path=/cluster/api/exp_discord_auth/callback`);
|
||||
t3.match(result.cookie, "Secure");
|
||||
});
|
||||
|
||||
t2.test(".status() tells the login form whether Discord is configured", async t3 => {
|
||||
const { plugin } = await startPlugin(t3);
|
||||
t3.same(await plugin.status(), { status: 200, json: { configured: true } });
|
||||
const { plugin: unconfigured } = await startPlugin(t3, { config: { "exp_discord_auth.client_id": null } });
|
||||
t3.same(await unconfigured.status(), { status: 200, json: { configured: false } });
|
||||
});
|
||||
|
||||
t2.test(".authorize() without credentials goes back with an error", async t3 => {
|
||||
const { plugin } = await startPlugin(t3, { config: { "exp_discord_auth.client_secret": null } });
|
||||
const result = await plugin.authorize();
|
||||
t3.match(result.redirect, /^https:\/\/example\.com\/cluster\/#exp_discord_auth_error=/);
|
||||
});
|
||||
|
||||
t2.test(".callback() rejects a state which does not match the cookie", async t3 => {
|
||||
const { plugin } = await startPlugin(t3);
|
||||
plugin.fetchDiscordUser = async () => t3.fail("should not fetch");
|
||||
for (const cookie of [undefined, "other"]) {
|
||||
const result = await plugin.callback({ code: "c", state: "s" }, cookie);
|
||||
t3.match(result.redirect, "#exp_discord_auth_error=");
|
||||
}
|
||||
t3.equal(plugin.sessions.size, 0);
|
||||
});
|
||||
|
||||
t2.test(".callback() reports a failed token exchange as an error", async t3 => {
|
||||
const { plugin } = await startPlugin(t3);
|
||||
plugin.fetchDiscordUser = async () => { throw new Error("401"); };
|
||||
const result = await plugin.callback({ code: "c", state: "s" }, "s");
|
||||
t3.match(result.redirect, "#exp_discord_auth_error=");
|
||||
});
|
||||
|
||||
t2.test(".callback() starts a session", async t3 => {
|
||||
const { plugin } = await startPlugin(t3);
|
||||
const session = await discordLogin(plugin, alice);
|
||||
const result = await plugin.listAccounts(session);
|
||||
t3.same(result, { status: 200, json: { discord: alice, accounts: [], max_accounts: 3 } });
|
||||
});
|
||||
|
||||
t2.test(".listAccounts() rejects unknown and expired sessions", async t3 => {
|
||||
const { plugin } = await startPlugin(t3);
|
||||
t3.equal((await plugin.listAccounts("nope")).status, 401);
|
||||
const session = await discordLogin(plugin, alice);
|
||||
plugin.sessions.get(session).expiresMs = Date.now() - 1;
|
||||
t3.equal((await plugin.listAccounts(session)).status, 401);
|
||||
});
|
||||
t2.end();
|
||||
});
|
||||
|
||||
t.test("linking", t2 => {
|
||||
t2.test("a code links the player who uses it, and they can then log in", async t3 => {
|
||||
const { plugin, controller } = await startPlugin(t3);
|
||||
const session = await discordLogin(plugin, alice);
|
||||
t3.same(await link(plugin, session, "Player1"), alice);
|
||||
t3.same((await plugin.listAccounts(session)).json.accounts, ["Player1"]);
|
||||
|
||||
const result = await plugin.login(session, "player1");
|
||||
t3.equal(result.status, 200);
|
||||
const payload = JSON.parse(Buffer.from(result.json.token.split(".")[1], "base64url").toString());
|
||||
t3.equal(payload.user, controller.users.getByName("Player1").id);
|
||||
t3.notOk(plugin.sessions.has(session), "session is used up");
|
||||
});
|
||||
|
||||
t2.test("a code can only be used once", async t3 => {
|
||||
const { plugin } = await startPlugin(t3);
|
||||
const session = await discordLogin(plugin, alice);
|
||||
const { json } = await plugin.linkCode(session);
|
||||
await plugin.handleLinkRequest(new messages.DiscordLinkRequest("Player1", json.code));
|
||||
await t3.rejects(
|
||||
plugin.handleLinkRequest(new messages.DiscordLinkRequest("Player2", json.code)),
|
||||
/invalid or has expired, get a new one from https:\/\/example\.com\/cluster\//,
|
||||
);
|
||||
});
|
||||
|
||||
t2.test("asking twice gives the same code", async t3 => {
|
||||
const { plugin } = await startPlugin(t3);
|
||||
const session = await discordLogin(plugin, alice);
|
||||
t3.equal((await plugin.linkCode(session)).json.code, (await plugin.linkCode(session)).json.code);
|
||||
});
|
||||
|
||||
t2.test("expired codes are refused", async t3 => {
|
||||
const { plugin } = await startPlugin(t3);
|
||||
const session = await discordLogin(plugin, alice);
|
||||
const { json } = await plugin.linkCode(session);
|
||||
plugin.codes.get(json.code).expiresMs = Date.now() - 1;
|
||||
await t3.rejects(plugin.handleLinkRequest(new messages.DiscordLinkRequest("Player1", json.code)));
|
||||
});
|
||||
|
||||
t2.test("several accounts up to the limit", async t3 => {
|
||||
const { plugin } = await startPlugin(t3, { config: { "exp_discord_auth.max_accounts": 2 } });
|
||||
const session = await discordLogin(plugin, alice);
|
||||
await link(plugin, session, "Player1");
|
||||
await link(plugin, session, "Player2");
|
||||
await t3.rejects(link(plugin, session, "Player3"), /already has 2 Factorio accounts/);
|
||||
t3.same(await link(plugin, session, "PLAYER1"), alice, "relinking a linked account is fine at the limit");
|
||||
t3.same(plugin.accounts.get(alice.id).factorioNames, ["Player1", "Player2"]);
|
||||
});
|
||||
|
||||
t2.test("linking moves the account away from another Discord account", async t3 => {
|
||||
const { plugin } = await startPlugin(t3);
|
||||
await link(plugin, await discordLogin(plugin, alice), "Player1");
|
||||
await link(plugin, await discordLogin(plugin, bob), "Player1");
|
||||
t3.notOk(plugin.accounts.has(alice.id), "empty account is removed");
|
||||
t3.same(plugin.accounts.get(bob.id).factorioNames, ["Player1"]);
|
||||
});
|
||||
|
||||
t2.test(".login() refuses accounts not linked, missing users and banned users", async t3 => {
|
||||
const { plugin, controller } = await startPlugin(t3);
|
||||
const session = await discordLogin(plugin, alice);
|
||||
t3.equal((await plugin.login(session, "Player2")).status, 403);
|
||||
|
||||
await link(plugin, session, "Player1");
|
||||
controller.users.getByNameMutable("Player1").set("isBanned", true);
|
||||
t3.equal((await plugin.login(session, "Player1")).status, 403);
|
||||
|
||||
await link(plugin, session, "Ghost");
|
||||
t3.equal((await plugin.login(session, "Ghost")).status, 404);
|
||||
});
|
||||
|
||||
t2.test("unlink and get", async t3 => {
|
||||
const { plugin } = await startPlugin(t3);
|
||||
await link(plugin, await discordLogin(plugin, alice), "Player1");
|
||||
const url = "https://example.com/cluster/";
|
||||
const get = name => plugin.handleLinkGetRequest(new messages.DiscordLinkGetRequest(name));
|
||||
const unlink = name => plugin.handleUnlinkRequest(new messages.DiscordUnlinkRequest(name));
|
||||
t3.same(await get("player1"), { link: alice, url });
|
||||
t3.same(await unlink("Player1"), { link: alice, url });
|
||||
t3.same(await get("Player1"), { link: null, url });
|
||||
t3.same(await unlink("Player1"), { link: null, url });
|
||||
});
|
||||
|
||||
t2.test("links are saved and loaded", async t3 => {
|
||||
const dir = t3.testdir();
|
||||
const config = { "controller.database_directory": dir };
|
||||
const { plugin } = await startPlugin(t3, { config });
|
||||
await link(plugin, await discordLogin(plugin, alice), "Player1");
|
||||
await plugin.onShutdown();
|
||||
|
||||
const { plugin: reloaded } = await startPlugin(t3, { config });
|
||||
t3.same(reloaded.accounts.get(alice.id).factorioNames, ["Player1"]);
|
||||
});
|
||||
|
||||
t2.test(".removeExpired() drops old sessions and codes", async t3 => {
|
||||
const { plugin } = await startPlugin(t3);
|
||||
const session = await discordLogin(plugin, alice);
|
||||
await plugin.linkCode(session);
|
||||
plugin.removeExpired(Date.now() + 60 * 60e3);
|
||||
t3.equal(plugin.sessions.size, 0);
|
||||
t3.equal(plugin.codes.size, 0);
|
||||
});
|
||||
t2.end();
|
||||
});
|
||||
|
||||
t.test("linking from the user page", t2 => {
|
||||
/** Start a link for playerName and follow authorize, returning the OAuth state. */
|
||||
async function start(plugin, playerName) {
|
||||
const ticket = await plugin.handleLinkStartRequest(new messages.DiscordLinkStartRequest(playerName));
|
||||
const result = await plugin.authorize(ticket);
|
||||
return new URL(result.redirect).searchParams;
|
||||
}
|
||||
|
||||
t2.test("links the Discord account and goes back to the user page", async t3 => {
|
||||
const { plugin } = await startPlugin(t3);
|
||||
const params = await start(plugin, "Player1");
|
||||
t3.equal(params.get("prompt"), null, "consent screen is shown");
|
||||
plugin.fetchDiscordUser = async () => alice;
|
||||
const result = await plugin.callback({ code: "c", state: params.get("state") }, params.get("state"));
|
||||
t3.equal(result.redirect, "https://example.com/cluster/users/Player1/view");
|
||||
t3.same(plugin.accounts.get(alice.id).factorioNames, ["Player1"]);
|
||||
t3.equal(plugin.sessions.size, 0, "no login session is started");
|
||||
t3.equal(plugin.pendingLinks.size, 0);
|
||||
});
|
||||
|
||||
t2.test("a ticket can only be used once", async t3 => {
|
||||
const { plugin } = await startPlugin(t3);
|
||||
const ticket = await plugin.handleLinkStartRequest(new messages.DiscordLinkStartRequest("Player1"));
|
||||
await plugin.authorize(ticket);
|
||||
const result = await plugin.authorize(ticket);
|
||||
t3.match(result.redirect, /^https:\/\/example\.com\/cluster\/users#exp_discord_auth_error=Discord%20link/);
|
||||
});
|
||||
|
||||
t2.test("errors go back to the user page", async t3 => {
|
||||
const { plugin } = await startPlugin(t3, { config: { "exp_discord_auth.max_accounts": 1 } });
|
||||
await link(plugin, await discordLogin(plugin, alice), "Player2");
|
||||
|
||||
let params = await start(plugin, "Player1");
|
||||
let result = await plugin.callback({ error: "access_denied", state: params.get("state") }, params.get("state"));
|
||||
t3.match(result.redirect, /\/users\/Player1\/view#exp_discord_auth_error=Discord%20login%20was%20cancelled$/);
|
||||
|
||||
params = await start(plugin, "Player1");
|
||||
plugin.fetchDiscordUser = async () => alice;
|
||||
result = await plugin.callback({ code: "c", state: params.get("state") }, params.get("state"));
|
||||
t3.match(result.redirect, /\/users\/Player1\/view#exp_discord_auth_error=.*already%20has%201/);
|
||||
t3.same(plugin.accounts.get(alice.id).factorioNames, ["Player2"]);
|
||||
});
|
||||
|
||||
t2.test("a state without the cookie does not link", async t3 => {
|
||||
const { plugin } = await startPlugin(t3);
|
||||
const params = await start(plugin, "Player1");
|
||||
plugin.fetchDiscordUser = async () => t3.fail("should not fetch");
|
||||
const result = await plugin.callback({ code: "c", state: params.get("state") }, undefined);
|
||||
t3.match(result.redirect, "/users/Player1/view#exp_discord_auth_error=");
|
||||
t3.notOk(plugin.accounts.has(alice.id));
|
||||
});
|
||||
t2.end();
|
||||
});
|
||||
|
||||
t.test("permissions", t2 => {
|
||||
const user = (name, granted = []) => ({
|
||||
name,
|
||||
checkPermission(permission) {
|
||||
if (!granted.includes(permission)) {
|
||||
throw new lib.PermissionError("denied");
|
||||
}
|
||||
},
|
||||
});
|
||||
const request = name => ({ data: { player_name: name } });
|
||||
|
||||
t2.test("DiscordLinkStartRequest", async t3 => {
|
||||
const permission = messages.DiscordLinkStartRequest.permission;
|
||||
t3.doesNotThrow(() => permission(user("Alice"), request("alice")), "own account");
|
||||
t3.throws(
|
||||
() => permission(
|
||||
user("Alice", ["exp_discord_auth.link.get", "exp_discord_auth.link.delete"]), request("Bob"),
|
||||
),
|
||||
"never someone else's",
|
||||
);
|
||||
});
|
||||
|
||||
for (const Request of [messages.DiscordLinkGetRequest, messages.DiscordUnlinkRequest]) {
|
||||
t2.test(Request.name, async t3 => {
|
||||
const permission = Request === messages.DiscordUnlinkRequest
|
||||
? "exp_discord_auth.link.delete"
|
||||
: "exp_discord_auth.link.get";
|
||||
t3.doesNotThrow(() => Request.permission(user("Alice"), request("alice")), "own link");
|
||||
t3.throws(() => Request.permission(user("Alice"), request("Bob")), "someone else's");
|
||||
t3.doesNotThrow(() => Request.permission(user("Alice", [permission]), request("Bob")), "with permission");
|
||||
});
|
||||
}
|
||||
t2.end();
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../tsconfig.browser.json",
|
||||
"include": [ "web/**/*.tsx", "web/**/*.ts", "index.ts", "messages.ts", "package.json" ],
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
{ "path": "./tsconfig.browser.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../tsconfig.node.json",
|
||||
"include": ["./**/*.ts"],
|
||||
"exclude": ["test/*", "./dist/*"],
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Alert, Button, Space, Spin, Typography } from "antd";
|
||||
|
||||
declare const webRoot: string;
|
||||
|
||||
const { Link, Paragraph, Text } = Typography;
|
||||
const sessionKey = "exp_discord_auth_session";
|
||||
|
||||
type Accounts = {
|
||||
discord: { id: string, name: string },
|
||||
accounts: string[],
|
||||
max_accounts: number,
|
||||
};
|
||||
|
||||
async function api(method: "GET" | "POST", route: string, session: string) {
|
||||
const response = await fetch(`${webRoot}api/exp_discord_auth/${route}`, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${session}` },
|
||||
});
|
||||
const json = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(json.error ?? `Bad server response: ${response.status}`);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/** Take the outcome of the Discord redirect out of the url, so a reload does not reuse it. */
|
||||
function readRedirect() {
|
||||
const params = new URLSearchParams(window.location.hash.slice(1));
|
||||
const session = params.get("exp_discord_auth_session");
|
||||
const error = params.get("exp_discord_auth_error");
|
||||
if (session || error) {
|
||||
window.history.replaceState(null, "", window.location.pathname + window.location.search);
|
||||
}
|
||||
if (session) {
|
||||
sessionStorage.setItem(sessionKey, session);
|
||||
}
|
||||
return { session: session ?? sessionStorage.getItem(sessionKey), error };
|
||||
}
|
||||
|
||||
export default function DiscordLoginForm(props: { setToken(token: string): void }) {
|
||||
const [redirect] = useState(readRedirect);
|
||||
const [session, setSession] = useState<string | null>(redirect.session);
|
||||
const [error, setError] = useState<string | null>(redirect.error);
|
||||
const [accounts, setAccounts] = useState<Accounts | null>(null);
|
||||
const [code, setCode] = useState<string | null>(null);
|
||||
const [configured, setConfigured] = useState<boolean | null>(null);
|
||||
const linkedCount = useRef<number | null>(null);
|
||||
|
||||
function forget(message: string | null) {
|
||||
sessionStorage.removeItem(sessionKey);
|
||||
setSession(null);
|
||||
setAccounts(null);
|
||||
setCode(null);
|
||||
linkedCount.current = null;
|
||||
setError(message);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (session || configured !== null) {
|
||||
return;
|
||||
}
|
||||
fetch(`${webRoot}api/exp_discord_auth/status`)
|
||||
.then(response => response.json())
|
||||
.then(json => setConfigured(json.configured))
|
||||
.catch(err => setError(`Failed to load Discord login: ${err.message}`));
|
||||
}, [session, configured]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
try {
|
||||
const json = await api("GET", "accounts", session!) as Accounts;
|
||||
if (!cancelled) {
|
||||
// The code was used once a new account shows up
|
||||
if (linkedCount.current !== null && json.accounts.length > linkedCount.current) {
|
||||
setCode(null);
|
||||
}
|
||||
linkedCount.current = json.accounts.length;
|
||||
setAccounts(json);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (!cancelled) {
|
||||
forget(err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
// While a code is shown, poll until it is used in game
|
||||
const interval = code ? setInterval(load, 3e3) : undefined;
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [session, code]);
|
||||
|
||||
async function login(name: string) {
|
||||
try {
|
||||
const json = await api("POST", `login?name=${encodeURIComponent(name)}`, session!);
|
||||
sessionStorage.removeItem(sessionKey);
|
||||
props.setToken(json.token);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function getCode() {
|
||||
try {
|
||||
const json = await api("POST", "link_code", session!);
|
||||
setCode(json.code);
|
||||
} catch (err: any) {
|
||||
forget(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
const errorAlert = error && <Alert type="error" message={error} showIcon style={{ marginBottom: 16 }} />;
|
||||
|
||||
if (!session) {
|
||||
if (configured === false) {
|
||||
return <Alert type="error" message="Discord login is not configured on this controller" showIcon />;
|
||||
}
|
||||
return <>
|
||||
{errorAlert}
|
||||
<Paragraph>Log in with a linked Discord account. New accounts can be linked after logging in.</Paragraph>
|
||||
<Button type="primary" block disabled={!configured} href={`${webRoot}api/exp_discord_auth/authorize`}>
|
||||
Log in with Discord
|
||||
</Button>
|
||||
</>;
|
||||
}
|
||||
|
||||
if (!accounts) {
|
||||
return <Spin size="large" />;
|
||||
}
|
||||
|
||||
const canLinkMore = accounts.accounts.length < accounts.max_accounts;
|
||||
return <>
|
||||
{errorAlert}
|
||||
<Paragraph>
|
||||
Logged in to Discord as <Text strong>{accounts.discord.name}</Text>. <Link onClick={() => forget(null)}>Not you?</Link>
|
||||
</Paragraph>
|
||||
|
||||
{accounts.accounts.length
|
||||
? <Space direction="vertical" style={{ width: "100%", marginBottom: 16 }}>
|
||||
{accounts.accounts.map(name => <Button
|
||||
key={name}
|
||||
type="primary"
|
||||
block
|
||||
onClick={() => { login(name); }}
|
||||
>Log in as {name}</Button>)}
|
||||
</Space>
|
||||
: <Paragraph>No Factorio accounts are linked to this Discord account yet.</Paragraph>}
|
||||
|
||||
{code
|
||||
? <Alert
|
||||
type="info"
|
||||
style={{ marginBottom: 16 }}
|
||||
message="Link your Factorio account"
|
||||
description={<>
|
||||
Join any of our servers and type this in the chat:
|
||||
<Paragraph copyable={{ text: `/link-discord ${code}` }} style={{ margin: "8px 0" }}>
|
||||
<Text code style={{ fontSize: 18 }}>/link-discord {code}</Text>
|
||||
</Paragraph>
|
||||
The code is valid for 10 minutes. This page continues once it has been used.
|
||||
</>}
|
||||
/>
|
||||
: canLinkMore && <Paragraph>
|
||||
<Button block onClick={() => { getCode(); }}>
|
||||
{accounts.accounts.length ? "Link another Factorio account" : "Link a Factorio account"}
|
||||
</Button>
|
||||
</Paragraph>}
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { Alert, Button, Descriptions, Popconfirm } from "antd";
|
||||
|
||||
import {
|
||||
ControlContext, PluginExtensionProps, SectionHeader, notifyErrorHandler, useAccount,
|
||||
} from "@clusterio/web_ui";
|
||||
|
||||
import { DiscordLinkGetRequest, DiscordLinkStartRequest, DiscordUnlinkRequest } from "../../messages.js";
|
||||
|
||||
declare const webRoot: string;
|
||||
|
||||
type Link = { id: string, name: string } | null;
|
||||
|
||||
/** An error from linking on Discord, taken out of the url so a reload does not show it again. */
|
||||
function readError() {
|
||||
const error = new URLSearchParams(window.location.hash.slice(1)).get("exp_discord_auth_error");
|
||||
if (error) {
|
||||
window.history.replaceState(null, "", window.location.pathname + window.location.search);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
/** The Discord account a user is linked to, shown on their user page. */
|
||||
export default function UserDiscordLink(props: PluginExtensionProps<"UserViewPage">) {
|
||||
const control = useContext(ControlContext);
|
||||
const account = useAccount();
|
||||
const own = account.name.toLowerCase() === props.user.name.toLowerCase();
|
||||
const canGet = own || account.hasPermission("exp_discord_auth.link.get");
|
||||
const canDelete = own || account.hasPermission("exp_discord_auth.link.delete");
|
||||
const [link, setLink] = useState<Link | undefined>(undefined);
|
||||
const [error, setError] = useState(readError);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canGet) {
|
||||
return;
|
||||
}
|
||||
control.send(new DiscordLinkGetRequest(props.user.name))
|
||||
.then(response => setLink(response.link))
|
||||
.catch(notifyErrorHandler("Error loading Discord link"));
|
||||
}, [props.user.name, canGet]);
|
||||
|
||||
if (!canGet || link === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function unlink() {
|
||||
await control.send(new DiscordUnlinkRequest(props.user.name));
|
||||
setLink(null);
|
||||
}
|
||||
|
||||
async function startLink() {
|
||||
const ticket = await control.send(new DiscordLinkStartRequest(props.user.name));
|
||||
window.location.href = `${webRoot}api/exp_discord_auth/authorize?link=${encodeURIComponent(ticket)}`;
|
||||
}
|
||||
|
||||
return <>
|
||||
<SectionHeader title="Discord" />
|
||||
{error && <Alert
|
||||
type="error"
|
||||
message={error}
|
||||
showIcon
|
||||
closable
|
||||
onClose={() => setError(null)}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>}
|
||||
<Descriptions bordered size="small" column={1}>
|
||||
<Descriptions.Item label="Linked account">
|
||||
{link ? <>
|
||||
{link.name}
|
||||
{canDelete && <Popconfirm
|
||||
title={`Unlink ${props.user.name} from ${link.name}?`}
|
||||
onConfirm={() => { unlink().catch(notifyErrorHandler("Error unlinking Discord")); }}
|
||||
>
|
||||
<Button danger size="small" style={{ marginLeft: 8 }}>Unlink</Button>
|
||||
</Popconfirm>}
|
||||
</> : <>
|
||||
Not linked
|
||||
{own && <Button
|
||||
size="small"
|
||||
style={{ marginLeft: 8 }}
|
||||
onClick={() => { startLink().catch(notifyErrorHandler("Error linking Discord")); }}
|
||||
>Link Discord</Button>}
|
||||
</>}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
import { WebPluginContext } from "@clusterio/web_ui";
|
||||
|
||||
import DiscordLoginForm from "./components/DiscordLoginForm";
|
||||
import UserDiscordLink from "./components/UserDiscordLink";
|
||||
|
||||
export default function registerPlugin(context: WebPluginContext) {
|
||||
context.control.hooks.loginForms.attach("exp_discord_auth", () => [
|
||||
{
|
||||
name: "exp_discord_auth",
|
||||
title: "Discord",
|
||||
Component: DiscordLoginForm,
|
||||
},
|
||||
]);
|
||||
|
||||
context.control.hooks.extensionComponents.attach("exp_discord_auth", () => ({
|
||||
UserViewPage: UserDiscordLink,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use strict";
|
||||
const path = require("path");
|
||||
const webpack = require("webpack");
|
||||
const { merge } = require("webpack-merge");
|
||||
|
||||
const common = require("@clusterio/web_ui/webpack.common");
|
||||
|
||||
module.exports = (env = {}) => merge(common(env), {
|
||||
context: __dirname,
|
||||
entry: "./web/index.tsx",
|
||||
output: {
|
||||
path: path.resolve(__dirname, "dist", "web"),
|
||||
},
|
||||
plugins: [
|
||||
new webpack.container.ModuleFederationPlugin({
|
||||
name: "exp_discord_auth",
|
||||
library: { type: "window", name: "plugin_exp_discord_auth" },
|
||||
exposes: {
|
||||
"./": "./index.ts",
|
||||
"./package.json": "./package.json",
|
||||
"./web": "./web/index.tsx",
|
||||
},
|
||||
shared: {
|
||||
"@clusterio/lib": { import: false },
|
||||
"@clusterio/web_ui": { import: false },
|
||||
"antd": { import: false },
|
||||
"react": { import: false },
|
||||
"react-dom": { import: false },
|
||||
"react-router": { import: false },
|
||||
"react-router-dom": { import: false },
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
# Without an .npmignore pnpm packs using the repository .gitignore, which leaves out dist/
|
||||
+76
-124
@@ -1,4 +1,4 @@
|
||||
import type { Instance, InstancePluginContext } from "@clusterio/host";
|
||||
import type { InstancePluginContext } from "@clusterio/host";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import * as messages from "./messages.js";
|
||||
|
||||
@@ -17,174 +17,126 @@ export type IpcPlayerAssignments = {
|
||||
assignments: Record<string, number>,
|
||||
};
|
||||
|
||||
export class InstancePlugin {
|
||||
export default async function (context: InstancePluginContext) {
|
||||
const { instance, plugin } = context;
|
||||
const syncMode = () => instance.config.get("exp_groups.sync_mode");
|
||||
// Once only, don't send permissions for these groups
|
||||
// This is used for groups created on this instance that only need the controller generated id
|
||||
skipSendingPermissions = new Set<string>();
|
||||
const skipSendingPermissions = new Set<string>();
|
||||
// This is used for groups updated / deleted on this instance to stop cycles
|
||||
skipSendingUpdate = new Set<number>();
|
||||
const skipSendingUpdate = new Set<number>();
|
||||
// Track known online players so that we only apply assignment updates for them
|
||||
onlinePlayers = new Set<string>();
|
||||
const onlinePlayers = new Set<string>();
|
||||
|
||||
private constructor(
|
||||
public instance: Instance,
|
||||
public logger: lib.Logger,
|
||||
public name: string,
|
||||
) {}
|
||||
|
||||
static async fromContext(context: InstancePluginContext) {
|
||||
const instance = context.instance;
|
||||
const plugin = new InstancePlugin(instance, context.logger, context.plugin.name);
|
||||
|
||||
instance.handle(messages.GroupUpdatedEvent, plugin.handleGroupUpdatedEvent.bind(plugin));
|
||||
instance.handle(messages.ResolvedAssignmentUpdatedEvent, plugin.handleResolvedAssignmentUpdatedEvent.bind(plugin));
|
||||
instance.server.handle(`exp_group:group_updated`, plugin.handleGroupUpdatedIPC.bind(plugin))
|
||||
instance.server.handle(`exp_group:group_deleted`, plugin.handleGroupDeletedIPC.bind(plugin))
|
||||
instance.server.handle(`exp_group:player_assignments`, plugin.handlePlayerAssignmentsIPC.bind(plugin))
|
||||
|
||||
const hooks = instance.hooks;
|
||||
hooks.instanceConfigFieldChanged.attach(plugin.name, plugin.onInstanceConfigFieldChanged.bind(plugin));
|
||||
hooks.start.attach(plugin.name, plugin.onStart.bind(plugin));
|
||||
hooks.playerEvent.attach(plugin.name, plugin.onPlayerEvent.bind(plugin));
|
||||
return plugin;
|
||||
async function luaSend(receiver: string, json: any) {
|
||||
await instance.sendRcon(`/sc exp_groups.${receiver}(helpers.json_to_table[=[${JSON.stringify(json)}]=])`, true)
|
||||
}
|
||||
|
||||
async onInstanceConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
|
||||
switch(field) {
|
||||
case "exp_groups.sync_mode":
|
||||
await this.luaSetEmitEvents(curr == "bidirectional")
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async onStart() {
|
||||
// We use Date.now() because we need to manually initialise the groups on the lua side
|
||||
await this.instance.sendTo("controller", new lib.SubscriptionRequest(
|
||||
`exp_groups:${messages.GroupUpdatedEvent.name}`, "subscribe", Date.now()
|
||||
async function updatePlayerAssignmentSubscription(playerName: string, action: "subscribe" | "unsubscribe") {
|
||||
await instance.sendTo("controller", new lib.SubscriptionRequest(
|
||||
`exp_groups:${messages.ResolvedAssignmentUpdatedEvent.name}`, action, 0, lib.SubscriptionFilters.fromShorthand(playerName)
|
||||
));
|
||||
const groups = await this.instance.sendTo("controller", new messages.GroupListRequest())
|
||||
await this.luaSendInitialGroups(groups);
|
||||
await this.luaSetEmitEvents(this.instance.config.get("exp_groups.sync_mode") == "bidirectional")
|
||||
}
|
||||
|
||||
async onPlayerEvent(event: lib.PlayerEvent) {
|
||||
switch(event.type) {
|
||||
case "join":
|
||||
this.onlinePlayers.add(event.name);
|
||||
await this.subscribePlayerAssignment(event.name);
|
||||
break;
|
||||
case "leave":
|
||||
this.onlinePlayers.delete(event.name);
|
||||
await this.unsubscribePlayerAssignment(event.name);
|
||||
break;
|
||||
instance.handle(messages.GroupUpdatedEvent, async event => {
|
||||
if (syncMode() === "disabled") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async handleGroupUpdatedEvent(event: messages.GroupUpdatedEvent) {
|
||||
for (const group of event.updates) {
|
||||
await this.luaSendGroupUpdate(group);
|
||||
if (skipSendingUpdate.has(group.id)) {
|
||||
skipSendingUpdate.delete(group.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const json = group.toJSON();
|
||||
if (skipSendingPermissions.has(group.name)) {
|
||||
skipSendingPermissions.delete(group.name);
|
||||
delete (json as any).permissions;
|
||||
}
|
||||
|
||||
await luaSend("receive_group_update", json);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
instance.handle(messages.ResolvedAssignmentUpdatedEvent, async event => {
|
||||
if (syncMode() === "disabled") {
|
||||
return;
|
||||
}
|
||||
|
||||
async handleResolvedAssignmentUpdatedEvent(event: messages.ManualAssignmentUpdatedEvent) {
|
||||
for (const assignment of event.updates) {
|
||||
if (this.onlinePlayers.has(assignment.name)) {
|
||||
await this.luaSendAssignmentUpdate(assignment);
|
||||
if (onlinePlayers.has(assignment.name)) {
|
||||
await luaSend("receive_assignment_update", assignment);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async handleGroupUpdatedIPC(event: IpcGroupUpdated) {
|
||||
instance.server.handle("exp_group:group_updated", async (event: IpcGroupUpdated) => {
|
||||
const permissions = new messages.GroupPermissions(
|
||||
event.permissions.is_blacklist,
|
||||
event.permissions.permissions ?? [],
|
||||
)
|
||||
|
||||
if (event.group_id === undefined) {
|
||||
this.skipSendingPermissions.add(event.group_name);
|
||||
await this.instance.sendTo("controller",
|
||||
skipSendingPermissions.add(event.group_name);
|
||||
await instance.sendTo("controller",
|
||||
new messages.GroupCreateRequest(event.group_name, permissions),
|
||||
);
|
||||
} else {
|
||||
this.skipSendingUpdate.add(event.group_id);
|
||||
await this.instance.sendTo("controller", new messages.GroupUpdateRequest(
|
||||
skipSendingUpdate.add(event.group_id);
|
||||
await instance.sendTo("controller", new messages.GroupUpdateRequest(
|
||||
new messages.GroupRecord(event.group_id, event.group_name, permissions),
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async handleGroupDeletedIPC(event: IpcGroupDeleted) {
|
||||
instance.server.handle("exp_group:group_deleted", async (event: IpcGroupDeleted) => {
|
||||
if (event.group_id === undefined) {
|
||||
return;
|
||||
}
|
||||
this.skipSendingUpdate.add(event.group_id);
|
||||
await this.instance.sendTo("controller", new messages.GroupDeleteRequest(event.group_id));
|
||||
}
|
||||
skipSendingUpdate.add(event.group_id);
|
||||
await instance.sendTo("controller", new messages.GroupDeleteRequest(event.group_id));
|
||||
});
|
||||
|
||||
async handlePlayerAssignmentsIPC(event: IpcPlayerAssignments) {
|
||||
instance.server.handle("exp_group:player_assignments", async (event: IpcPlayerAssignments) => {
|
||||
await Promise.all(
|
||||
Object.entries(event.assignments).map(([playerName, groupId]) =>
|
||||
this.instance.sendTo("controller",
|
||||
instance.sendTo("controller",
|
||||
new messages.AssignmentUpdateRequest(new messages.AssignmentRecord(playerName, groupId)),
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
async subscribePlayerAssignment(playerName: string) {
|
||||
await this.instance.sendTo("controller", new lib.SubscriptionRequest(
|
||||
`exp_groups:${messages.ResolvedAssignmentUpdatedEvent.name}`, "subscribe", 0, lib.SubscriptionFilters.fromShorthand(playerName)
|
||||
const hooks = instance.hooks;
|
||||
hooks.instanceConfigFieldChanged.attach(plugin.name, async (field, curr) => {
|
||||
if (field === "exp_groups.sync_mode") {
|
||||
await luaSend("set_emit_events", curr == "bidirectional")
|
||||
}
|
||||
});
|
||||
|
||||
hooks.start.attach(plugin.name, async () => {
|
||||
// We use Date.now() because we need to manually initialise the groups on the lua side
|
||||
await instance.sendTo("controller", new lib.SubscriptionRequest(
|
||||
`exp_groups:${messages.GroupUpdatedEvent.name}`, "subscribe", Date.now()
|
||||
));
|
||||
}
|
||||
|
||||
async unsubscribePlayerAssignment(playerName: string) {
|
||||
await this.instance.sendTo("controller", new lib.SubscriptionRequest(
|
||||
`exp_groups:${messages.ResolvedAssignmentUpdatedEvent.name}`, "unsubscribe", 0, lib.SubscriptionFilters.fromShorthand(playerName)
|
||||
));
|
||||
}
|
||||
|
||||
async luaSendInitialGroups(groups: messages.GroupRecord[]) {
|
||||
if (this.instance.config.get("exp_groups.sync_mode") === "disabled") {
|
||||
return;
|
||||
const groups = await instance.sendTo("controller", new messages.GroupListRequest())
|
||||
if (syncMode() !== "disabled") {
|
||||
await luaSend("initialise_groups", groups);
|
||||
}
|
||||
await this.luaSend("initialise_groups", groups);
|
||||
}
|
||||
await luaSend("set_emit_events", syncMode() == "bidirectional")
|
||||
});
|
||||
|
||||
async luaSendGroupUpdate(group: messages.GroupRecord) {
|
||||
if (this.instance.config.get("exp_groups.sync_mode") === "disabled") {
|
||||
return;
|
||||
hooks.playerEvent.attach(plugin.name, async (event: lib.PlayerEvent) => {
|
||||
switch(event.type) {
|
||||
case "join":
|
||||
onlinePlayers.add(event.name);
|
||||
await updatePlayerAssignmentSubscription(event.name, "subscribe");
|
||||
break;
|
||||
case "leave":
|
||||
onlinePlayers.delete(event.name);
|
||||
await updatePlayerAssignmentSubscription(event.name, "unsubscribe");
|
||||
break;
|
||||
}
|
||||
|
||||
if (this.skipSendingUpdate.has(group.id)) {
|
||||
this.skipSendingUpdate.delete(group.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const json = group.toJSON();
|
||||
if (this.skipSendingPermissions.has(group.name)) {
|
||||
this.skipSendingPermissions.delete(group.name);
|
||||
delete (json as any).permissions;
|
||||
}
|
||||
|
||||
await this.luaSend("receive_group_update", json);
|
||||
}
|
||||
|
||||
async luaSendAssignmentUpdate(assignment: messages.AssignmentRecord) {
|
||||
if (this.instance.config.get("exp_groups.sync_mode") === "disabled") {
|
||||
return;
|
||||
}
|
||||
await this.luaSend("receive_assignment_update", assignment);
|
||||
}
|
||||
|
||||
async luaSetEmitEvents(emitEvents: boolean) {
|
||||
await this.luaSend("set_emit_events", emitEvents);
|
||||
}
|
||||
|
||||
async luaSend(receiver: string, json: any) {
|
||||
await this.instance.sendRcon(`/sc exp_groups.${receiver}(helpers.json_to_table[=[${JSON.stringify(json)}]=])`, true)
|
||||
}
|
||||
}
|
||||
|
||||
export default async function (context: InstancePluginContext) {
|
||||
await InstancePlugin.fromContext(context);
|
||||
});
|
||||
}
|
||||
+10
-3
@@ -11,13 +11,20 @@
|
||||
"prepare": "tsc --build && webpack-cli --env production"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@clusterio/controller": "workspace:^",
|
||||
"@clusterio/host": "workspace:^",
|
||||
"@clusterio/lib": "workspace:^",
|
||||
"@clusterio/web_ui": "workspace:^"
|
||||
"@clusterio/lib": "workspace:^"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@clusterio/controller": {
|
||||
"optional": true
|
||||
},
|
||||
"@clusterio/host": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ant-design/icons": "^6.2.3",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Without an .npmignore pnpm packs using the repository .gitignore, which leaves out dist/
|
||||
+11
-1
@@ -11,11 +11,21 @@
|
||||
"prepare": "tsc --build && webpack-cli --env production"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@clusterio/controller": "workspace:^",
|
||||
"@clusterio/host": "workspace:^",
|
||||
"@clusterio/lib": "workspace:^"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@clusterio/controller": {
|
||||
"optional": true
|
||||
},
|
||||
"@clusterio/host": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/lib": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Without an .npmignore pnpm packs using the repository .gitignore, which leaves out dist/
|
||||
@@ -8,7 +8,4 @@ return {
|
||||
|
||||
--- Data
|
||||
"modules.data.statistics",
|
||||
"modules.data.player-colours",
|
||||
"modules.data.greetings",
|
||||
"modules.data.language",
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
return {
|
||||
Cooldude2606 = "Lua lets you set metatables on numbers, did you know that? Cooldude2606 knows this.",
|
||||
samy115 = "Tremble in fear as the banhammer is now here, its owner: samy115",
|
||||
XenoCyber = '"Fire Fire Fire" oops wrong game, have no fear XenoCyber is here',
|
||||
HunterOfGames = "Unable to support HunterOfGames. You must construct additional miners.",
|
||||
ookl = 'ookl says: "Pineapples are amazing, hello everyone!"',
|
||||
arty714 = "Arty\'s Potato made it!",
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
--- Preset colours that players get when they join the server, if not in the list then will be given a random colour (which isnt disallowed)
|
||||
-- @config Preset-Player-Colours
|
||||
|
||||
return {
|
||||
players = { --- @setting players list of all players and the colour in rgb256 that they will recive upon joining
|
||||
PHIDIAS0303 = { r = 255, g = 255, b = 255 },
|
||||
BADgamerNL = { r = 255, g = 20, b = 147 },
|
||||
arty714 = { r = 150, g = 68, b = 161 },
|
||||
Cooldude2606 = { r = 57, g = 192, b = 207 },
|
||||
mark9064 = { r = 99, g = 0, b = 255 },
|
||||
eissturm = { r = 25, g = 25, b = 112 },
|
||||
Sakama = { r = 20, g = 213, b = 80 },
|
||||
freek18 = { r = 50, g = 0, b = 255 },
|
||||
aldldl = { r = 0, g = 131, b = 255 },
|
||||
NAD4X4 = { r = 135, g = 206, b = 250 },
|
||||
cydes = { r = 82, g = 249, b = 155 },
|
||||
UUBlueFire = { r = 0, g = 204, b = 255 },
|
||||
CmonMate497 = { r = 103, g = 224, b = 194 },
|
||||
s4sh = { r = 255, g = 120, b = 0 },
|
||||
ArPiiX = { r = 0, g = 255, b = 0 },
|
||||
NextIdea = { r = 255, g = 255, b = 255 },
|
||||
hamsterbryan = { r = 0, g = 255, b = 0 },
|
||||
XenoCyber = { r = 0, g = 128, b = 255 },
|
||||
},
|
||||
disallow = { --- @setting disallow colours which will not given to players; the value does not matter it is only the key which is checked
|
||||
black = { r = 0, g = 0, b = 0 },
|
||||
white = { r = 255, g = 255, b = 255 },
|
||||
success = { r = 0, g = 255, b = 0 },
|
||||
warning = { r = 255, g = 255, b = 0 },
|
||||
fail = { r = 255, g = 0, b = 0 },
|
||||
info = { r = 255, g = 255, b = 255 },
|
||||
},
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
--- Config file for the server detail
|
||||
-- @config Server
|
||||
|
||||
return {
|
||||
community_name = "Explosive Gaming",
|
||||
discord = "https://discord.explosivegaming.nl",
|
||||
website = "https://www.explosivegaming.nl",
|
||||
status = "https://status.explosivegaming.nl",
|
||||
github = "https://github.com/explosivegaming/ExpCluster",
|
||||
patreon = "https://www.patreon.com/ExpGaming"
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
--[[-- Core Module - External
|
||||
- A module used to make accessing externally set data easier.
|
||||
@core External
|
||||
@alias External
|
||||
|
||||
@usage-- Printing all server to chat
|
||||
local External = require("modules.exp_legacy.expcore.external") --- @dep expcore.external
|
||||
|
||||
local message = 'id: %s name: %s version: %s status: %s'
|
||||
for server_id, server in pairs(External.get_servers()) do
|
||||
local status = External.get_server_status(server_id)
|
||||
game.print(message:format(server_id, server.name, server.version, status))
|
||||
end
|
||||
|
||||
]]
|
||||
|
||||
local ext --- @type table<string, any>
|
||||
local var --- @type table<string, any>
|
||||
local concat = table.concat
|
||||
|
||||
local External = {}
|
||||
|
||||
--[[-- Checks that local links are valid, will try to add the links if invalid
|
||||
@treturn boolean If the external data is valid, if false you should not call any other methods from External
|
||||
|
||||
@usage-- Check that external data is valid
|
||||
if not External.valid() then
|
||||
-- error code here
|
||||
end
|
||||
|
||||
]]
|
||||
function External.valid()
|
||||
local stored = storage.ext
|
||||
if stored == nil then return false end
|
||||
if ext == stored and var == ext.var then
|
||||
return var ~= nil
|
||||
else
|
||||
ext = stored
|
||||
var = stored.var
|
||||
return var ~= nil
|
||||
end
|
||||
end
|
||||
|
||||
--[[-- Gets a table of all the servers, key is the server id, value is the server details
|
||||
@treturn table A table containing all the servers, key is the server id, value is the server details
|
||||
|
||||
@usage-- Get all servers
|
||||
local servers = External.get_servers()
|
||||
|
||||
]]
|
||||
function External.get_servers()
|
||||
assert(ext, "No external data was found, use External.valid() to ensure external data exists.")
|
||||
return (assert(ext.servers, "No server list was found, please ensure that the external service is running"))
|
||||
end
|
||||
|
||||
--[[-- Gets a table of all the servers filtered by name, key is the server id, value is the server details
|
||||
@tparam string search The string to search for, names, short_names and ids are checked for this string.
|
||||
@treturn table A table containing all the servers filtered by name, key is the server id, value is the server details
|
||||
|
||||
@usage-- Get all servers with public in the name
|
||||
local servers = External.get_servers_filtered(public)
|
||||
|
||||
]]
|
||||
function External.get_servers_filtered(search)
|
||||
assert(ext, "No external data was found, use External.valid() to ensure external data exists.")
|
||||
local servers = assert(ext.servers, "No server list was found, please ensure that the external service is running")
|
||||
local found_servers = {}
|
||||
search = search:lower()
|
||||
for server_id, server in pairs(servers) do
|
||||
local str = concat{ server.name, server.short_name, server.id }
|
||||
if str:lower():find(search, 1, true) then found_servers[server_id] = server end
|
||||
end
|
||||
|
||||
return found_servers
|
||||
end
|
||||
|
||||
--[[-- Gets the details of the current server
|
||||
@treturn table The details of the current server
|
||||
|
||||
@usage-- Get the details of the current server
|
||||
local server = External.get_current_server()
|
||||
|
||||
]]
|
||||
function External.get_current_server()
|
||||
assert(ext, "No external data was found, use External.valid() to ensure external data exists.")
|
||||
local servers = assert(ext.servers, "No server list was found, please ensure that the external service is running")
|
||||
local server_id = assert(ext.current, "No current id was found, please ensure that the external service is running")
|
||||
return (assert(servers[server_id], "No details found for server with id: " .. tostring(server_id)))
|
||||
end
|
||||
|
||||
--[[-- Gets the details of the given server
|
||||
@tparam string server_id The internal server if for the server you want the details of
|
||||
@treturn table The details of the given server
|
||||
|
||||
@usage-- Get the details of the given server
|
||||
local server = External.get_server_details('eu-01')
|
||||
|
||||
]]
|
||||
function External.get_server_details(server_id)
|
||||
assert(ext, "No external data was found, use External.valid() to ensure external data exists.")
|
||||
local servers = assert(ext.servers, "No server list was found, please ensure that the external service is running")
|
||||
return (assert(servers[server_id], "No details found for server with id: " .. tostring(server_id)))
|
||||
end
|
||||
|
||||
--[[-- Gets the status of the given server
|
||||
@tparam string server_id The internal server if for the server you want the status of
|
||||
@tparam boolean raw When true Current will not be returned as status but rather the raw status for the server
|
||||
@treturn string The status of the given server, one of: Online, Modded, Protected, Current, Offline
|
||||
|
||||
@usage-- Get the status of the given server
|
||||
local status = External.get_server_status('eu-01')
|
||||
|
||||
]]
|
||||
function External.get_server_status(server_id, raw)
|
||||
assert(var, "No external data was found, use External.valid() to ensure external data exists.")
|
||||
local servers = assert(var.status, "No server status was found, please ensure that the external service is running")
|
||||
local current = assert(ext.current, "No current id was found, please ensure that the external service is running")
|
||||
return not raw and server_id == current and "Current" or assert(servers[server_id], "No status found for server with id: " .. tostring(server_id))
|
||||
end
|
||||
|
||||
--[[-- Connect a player to the given server
|
||||
@tparam LuaPlayer player The player that you want to request to join a different server
|
||||
@tparam string server_id The internal id of the server to connect to, can also be any address but this will show Unknown Server
|
||||
@tparam[opt=false] boolean self_requested If the player requested the join themself, this will hide the message about being asked to switch
|
||||
|
||||
@usage-- Request that a player joins a different server
|
||||
External.request_connection(player, 'eu-01')
|
||||
|
||||
@usage-- Request that a player joins a different server, by own request
|
||||
External.request_connection(player, 'eu-01', true)
|
||||
|
||||
]]
|
||||
function External.request_connection(player, server_id, self_requested)
|
||||
local server = { address = server_id, name = "Unknown Server", description = "This server is not ran by us, please check the address of the server." }
|
||||
if ext and ext.servers and ext.servers[server_id] then server = ext.servers[server_id] end
|
||||
local message = "Please press the connect button below to join."
|
||||
if not self_requested then message = "You have been asked to switch to a different server.\n" .. message end
|
||||
player.connect_to_server{
|
||||
address = server.address,
|
||||
name = "\n[color=orange][font=heading-1]" .. server.name .. "[/font][/color]\n",
|
||||
description = server.description .. "\n" .. message,
|
||||
}
|
||||
end
|
||||
|
||||
--- Module return
|
||||
return External
|
||||
@@ -14,29 +14,10 @@ redmew=We don't talk about redmew here; they beat us to 1000 members ;-;
|
||||
lhd=All trains must be LHD! This is a long standing rule on our servers, please respect this.
|
||||
|
||||
# data
|
||||
[join-message]
|
||||
description-add=Sets / Gets your custom join message.
|
||||
description-remove=Removes you custom join message.
|
||||
arg-message=Your custom join message.
|
||||
greet=[color=0,1,0] Welcome to __1__ community server! If you like the server join our discord: __2__ [/color]
|
||||
message-set=Your join message has been updated.
|
||||
message-get=Your join message is: __1__
|
||||
message-cleared=Your join message has been cleared.
|
||||
|
||||
[exp-settings]
|
||||
JoinMessage=Join Message
|
||||
JoinMessage-tooltip=The message displayed when you join
|
||||
JoinMessage-value-tooltip=Change by using /join-message
|
||||
UsesServerUps=Server UPS
|
||||
UsesServerUps-tooltip=Whether the current server UPS is shown
|
||||
UsesServerUps-value-tooltip=Change by using /server-ups
|
||||
Bonus=Player Bonus
|
||||
Bonus-tooltip=The bonus given to your character
|
||||
Bonus-value-tooltip=Change by using /bonus
|
||||
HasEnabledDecon=Quick Tree Decon
|
||||
ToolbarState=Toolbox
|
||||
ToolbarState-tooltip=The order and favourites in your toolbox
|
||||
ToolbarState-value-tooltip=This value is calculated automatically when you leave the game
|
||||
|
||||
[exp-statistics]
|
||||
MapsPlayed=Maps Played
|
||||
@@ -103,8 +84,6 @@ NetPlayTime=Net Play Time
|
||||
NetPlayTime-tooltip=Net Play Time
|
||||
AFKTimeRatio=AFK Time Ratio
|
||||
AFKTimeRatio-tooltip=AFK Time over Play Time
|
||||
Locale=Locale
|
||||
Locale-tooltip=The language of player interface
|
||||
|
||||
# expcore
|
||||
[expcore-data]
|
||||
|
||||
@@ -14,29 +14,10 @@ redmew=We don't talk about redmew here; they beat us to 1000 members ;-;
|
||||
lhd=列車必須是左則通行。這是本服務器長久以來的規則。
|
||||
|
||||
# data
|
||||
[join-message]
|
||||
description-add=設定 / 取得你的加入信息。
|
||||
description-remove=移除你的加入信息。
|
||||
arg-message=你的加入信息。
|
||||
greet=[color=0,1,0] 歡迎來到 __1__ 的服務器! 若果你喜歡本服務器可加入 Discord: __2__ [/color]
|
||||
message-set=你的加入信息已更新。
|
||||
message-get=你的加入信息為: __1__
|
||||
message-cleared=你的加入信息已清除。
|
||||
|
||||
[exp-settings]
|
||||
JoinMessage=加入信息
|
||||
JoinMessage-tooltip=你加入時所顯示的信息
|
||||
JoinMessage-value-tooltip=使用 /join-message 來更換加入信息
|
||||
UsesServerUps=服務器更新數 (SUPS)
|
||||
UsesServerUps-tooltip=現在是否顯示服務器更新數 (SUPS)
|
||||
UsesServerUps-value-tooltip=使用 /server-ups 來更換顯示
|
||||
Bonus=人物附加屬性
|
||||
Bonus-tooltip=你的人物附加屬性
|
||||
Bonus-value-tooltip=使用 /bonus 來更改
|
||||
HasEnabledDecon=快速拆除樹木
|
||||
ToolbarState=工具箱
|
||||
ToolbarState-tooltip=次序和喜愛項目
|
||||
ToolbarState-value-tooltip=此值在離開時自動計算
|
||||
|
||||
[exp-statistics]
|
||||
MapsPlayed=地圖遊玩次數
|
||||
@@ -103,8 +84,6 @@ NetPlayTime=淨遊玩時間
|
||||
NetPlayTime-tooltip=遊玩時間減掛機時間
|
||||
AFKTimeRatio=掛機時間比
|
||||
AFKTimeRatio-tooltip=掛機時間除以遊玩時間
|
||||
Locale=語言
|
||||
Locale-tooltip=用戶介面語言
|
||||
|
||||
# expcore
|
||||
[expcore-data]
|
||||
|
||||
@@ -14,29 +14,10 @@ redmew=We don't talk about redmew here; they beat us to 1000 members ;-;
|
||||
lhd=列車必須是左則通行。這是本服務器長久以來的規則。
|
||||
|
||||
# data
|
||||
[join-message]
|
||||
description-add=設定 / 取得你的加入信息。
|
||||
description-remove=移除你的加入信息。
|
||||
arg-message=你的加入信息。
|
||||
greet=[color=0,1,0] 歡迎來到 __1__ 的服務器! 若果你喜歡本服務器可加入 Discord: __2__ [/color]
|
||||
message-set=你的加入信息已更新。
|
||||
message-get=你的加入信息為: __1__
|
||||
message-cleared=你的加入信息已清除。
|
||||
|
||||
[exp-settings]
|
||||
JoinMessage=加入信息
|
||||
JoinMessage-tooltip=你加入時所顯示的信息
|
||||
JoinMessage-value-tooltip=使用 /join-message 來更換加入信息
|
||||
UsesServerUps=服務器更新數 (SUPS)
|
||||
UsesServerUps-tooltip=現在是否顯示服務器更新數 (SUPS)
|
||||
UsesServerUps-value-tooltip=使用 /server-ups 來更換顯示
|
||||
Bonus=人物附加屬性
|
||||
Bonus-tooltip=你的人物附加屬性
|
||||
Bonus-value-tooltip=使用 /bonus 來更改
|
||||
HasEnabledDecon=快速拆除樹木
|
||||
ToolbarState=工具箱
|
||||
ToolbarState-tooltip=次序和喜愛項目
|
||||
ToolbarState-value-tooltip=此值在離開時自動計算
|
||||
|
||||
[exp-statistics]
|
||||
MapsPlayed=地圖遊玩次數
|
||||
@@ -103,8 +84,6 @@ NetPlayTime=淨遊玩時間
|
||||
NetPlayTime-tooltip=遊玩時間減掛機時間
|
||||
AFKTimeRatio=掛機時間比
|
||||
AFKTimeRatio-tooltip=掛機時間除以遊玩時間
|
||||
Locale=語言
|
||||
Locale-tooltip=用戶介面語言
|
||||
|
||||
# expcore
|
||||
[expcore-data]
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
--- Greets players on join
|
||||
-- @data Greetings
|
||||
|
||||
local config = require("modules.exp_legacy.config.join_messages") --- @dep config.join_messages
|
||||
local config_server_detail = require("modules.exp_legacy.config.server_detail") --- @dep config.server_detail
|
||||
local Commands = require("modules/exp_commands")
|
||||
|
||||
--- Stores the join message that the player have
|
||||
local PlayerData = require("modules.exp_legacy.expcore.player_data") --- @dep expcore.player_data
|
||||
local CustomMessages = PlayerData.Settings:combine("JoinMessage")
|
||||
CustomMessages:set_metadata{
|
||||
permission = "exp_scenario.command.set_join_message",
|
||||
}
|
||||
|
||||
--- When a players data loads show their message
|
||||
CustomMessages:on_load(function(player_name, player_message)
|
||||
local player = game.players[player_name]
|
||||
local custom_message = player_message or config[player_name]
|
||||
if custom_message then
|
||||
game.print(custom_message, { color = player.color })
|
||||
else
|
||||
player.print{
|
||||
"join-message.greet",
|
||||
config_server_detail["community_name"],
|
||||
config_server_detail["discord"]
|
||||
}
|
||||
end
|
||||
end)
|
||||
|
||||
--- Set your custom join message
|
||||
Commands.new("set-join-message", { "join-message.description-add" })
|
||||
:optional("message", false, Commands.types.string_max_length(255))
|
||||
:enable_auto_concatenation()
|
||||
:add_aliases{ "join-message" }
|
||||
:register(function(player, message)
|
||||
--- @cast message string?
|
||||
if message then
|
||||
CustomMessages:set(player, message)
|
||||
return Commands.status.success{ "join-message.message-set" }
|
||||
else
|
||||
return Commands.status.success{ "join-message.message-get", CustomMessages:get(player) }
|
||||
end
|
||||
end)
|
||||
|
||||
--- Removes your custom join message
|
||||
Commands.new("remove-join-message", { "join-message.description-remove" })
|
||||
:register(function(player)
|
||||
CustomMessages:remove(player)
|
||||
return Commands.status.success{ "join-message.message-cleared" }
|
||||
end)
|
||||
@@ -1,21 +0,0 @@
|
||||
--- Stores the language used to join the server
|
||||
-- @data Language
|
||||
|
||||
local Event = require("modules/exp_legacy/utils/event") --- @dep utils.event
|
||||
local PlayerData = require("modules.exp_legacy.expcore.player_data") --- @dep expcore.player_data
|
||||
local LocalLanguage = PlayerData.Statistics:combine("LocalLanguage")
|
||||
LocalLanguage:set_default("Unknown")
|
||||
LocalLanguage:set_metadata{
|
||||
name = { "exp-statistics.Locale" },
|
||||
tooltip = { "exp-statistics.Locale-tooltip" },
|
||||
}
|
||||
|
||||
local function set_locale(event)
|
||||
local player = game.players[event.player_index]
|
||||
LocalLanguage:set(player, player.locale)
|
||||
end
|
||||
|
||||
--- Set the players language when they join and change language
|
||||
Event.add(defines.events.on_player_created, set_locale)
|
||||
Event.add(defines.events.on_player_joined_game, set_locale)
|
||||
Event.add(defines.events.on_player_locale_changed, set_locale)
|
||||
@@ -1,28 +0,0 @@
|
||||
--- Gives players random colours when they are created, also applies preset colours to those who have them
|
||||
--- Inventory sync restores the colour of players who have played before
|
||||
-- @data Player-Colours
|
||||
|
||||
local Event = require("modules/exp_legacy/utils/event") --- @dep utils.event
|
||||
local Colours = require("modules/exp_util/include/color")
|
||||
local config = require("modules.exp_legacy.config.preset_player_colours") --- @dep config.preset_player_colours
|
||||
|
||||
--- Returns a colour that is a bit lighter than the one given
|
||||
local function lighten(c)
|
||||
return { r = 255 - (255 - c.r) * 0.5, g = 255 - (255 - c.g) * 0.5, b = 255 - (255 - c.b) * 0.5, a = 255 }
|
||||
end
|
||||
|
||||
--- Apply the preset colour of the player, or a random one if there is none
|
||||
Event.add(defines.events.on_player_created, function(event)
|
||||
local player = game.players[event.player_index]
|
||||
local colour = config.players[player.name]
|
||||
if not colour then
|
||||
local colour_name = "white"
|
||||
while config.disallow[colour_name] do
|
||||
colour_name = table.get_random(Colours, true)
|
||||
end
|
||||
colour = Colours[colour_name]
|
||||
end
|
||||
|
||||
player.color = colour
|
||||
player.chat_color = lighten(colour)
|
||||
end)
|
||||
@@ -1,31 +0,0 @@
|
||||
local Gui = require("modules/exp_gui")
|
||||
local PlayerData = require("modules.exp_legacy.expcore.player_data")
|
||||
|
||||
-- Used to store the state of the toolbar when a player leaves
|
||||
local ToolbarState = PlayerData.Settings:combine("ToolbarState")
|
||||
ToolbarState:set_metadata{
|
||||
stringify = function()
|
||||
return "Toolbar is saved on exit"
|
||||
end,
|
||||
}
|
||||
|
||||
--- Uncompress the data to be more useable
|
||||
ToolbarState:on_load(function(player_name, value)
|
||||
-- If there is no value, do nothing
|
||||
if value == nil then return end
|
||||
-- Old format, we discard it [ string[], string[], string[], boolean ]
|
||||
if type(value) ~= "string" then return end
|
||||
|
||||
local decompressed = helpers.json_to_table(assert(helpers.decode_string(value), "Failed String Decode"))
|
||||
local player = assert(game.get_player(player_name))
|
||||
Gui.toolbar.set_state(player, decompressed --[[@as Gui.ToolbarState]])
|
||||
|
||||
return nil -- We don't save the state, use Gui.toolbar.get_state
|
||||
end)
|
||||
|
||||
--- Save the current state of the players toolbar menu
|
||||
ToolbarState:on_save(function(player_name, _)
|
||||
local player = assert(game.get_player(player_name))
|
||||
local value = Gui.toolbar.get_state(player)
|
||||
return helpers.encode_string(helpers.table_to_json(value))
|
||||
end)
|
||||
+11
-1
@@ -11,11 +11,21 @@
|
||||
"prepare": "tsc --build && webpack-cli --env production"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@clusterio/controller": "workspace:^",
|
||||
"@clusterio/host": "workspace:^",
|
||||
"@clusterio/lib": "workspace:^"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@clusterio/controller": {
|
||||
"optional": true
|
||||
},
|
||||
"@clusterio/host": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/lib": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Without an .npmignore pnpm packs using the repository .gitignore, which leaves out dist/
|
||||
+89
-117
@@ -1,122 +1,29 @@
|
||||
import type { Controller, ControllerPluginContext } from "@clusterio/controller";
|
||||
import type { ControllerPluginContext } from "@clusterio/controller";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import * as messages from "./messages.js";
|
||||
import * as path from "node:path";
|
||||
|
||||
export class ControllerPlugin {
|
||||
private constructor(
|
||||
public controller: Controller,
|
||||
public logger: lib.Logger,
|
||||
public name: string,
|
||||
public reports: lib.SubscribableDatastore<messages.ReportRecord>,
|
||||
) {}
|
||||
|
||||
static async fromContext(context: ControllerPluginContext) {
|
||||
const controller = context.controller;
|
||||
const databaseDirectory = controller.config.get("controller.database_directory");
|
||||
|
||||
const reports = new lib.SubscribableDatastore(
|
||||
...await new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "exp_reports", "reports.json"),
|
||||
messages.ReportRecord.fromJSON.bind(messages.ReportRecord),
|
||||
).bootstrap()
|
||||
);
|
||||
|
||||
const plugin = new ControllerPlugin(controller, context.logger, context.plugin.name, reports);
|
||||
|
||||
controller.subscriptions.handle(messages.ReportUpdatedEvent, plugin.handleReportSubscription.bind(plugin));
|
||||
reports.on("update", plugin.reportsUpdated.bind(plugin));
|
||||
|
||||
controller.handle(messages.ReportListRequest, plugin.handleReportListRequest.bind(plugin));
|
||||
controller.handle(messages.ReportGetRequest, plugin.handleReportGetRequest.bind(plugin));
|
||||
controller.handle(messages.ReportCreateRequest, plugin.handleReportCreateRequest.bind(plugin));
|
||||
controller.handle(messages.ReportDeleteRequest, plugin.handleReportDeleteRequest.bind(plugin));
|
||||
|
||||
controller.hooks.shutdown.attach(plugin.name, plugin.onShutdown.bind(plugin));
|
||||
return plugin;
|
||||
}
|
||||
|
||||
async onShutdown() {
|
||||
await this.reports.save();
|
||||
}
|
||||
|
||||
/** Reports are immutable, so any update which is not a deletion is a new report. */
|
||||
reportsUpdated(reports: messages.ReportRecord[]) {
|
||||
this.controller.subscriptions.broadcast(new messages.ReportUpdatedEvent(reports));
|
||||
|
||||
for (const report of reports) {
|
||||
if (!report.isDeleted) {
|
||||
this.sendWebhooks(report);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleReportSubscription(request: lib.SubscriptionRequest) {
|
||||
const reports = [...this.reports.values()].filter(report => report.updatedAtMs > request.lastRequestTimeMs);
|
||||
return reports.length ? new messages.ReportUpdatedEvent(reports) : null;
|
||||
}
|
||||
export default async function (context: ControllerPluginContext) {
|
||||
const { controller, logger, plugin } = context;
|
||||
const databaseDirectory = controller.config.get("controller.database_directory");
|
||||
const reports = new lib.SubscribableDatastore(
|
||||
...await new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "exp_reports", "reports.json"),
|
||||
messages.ReportRecord.fromJSON.bind(messages.ReportRecord),
|
||||
).bootstrap()
|
||||
);
|
||||
|
||||
/** Every report, or only those against one player. */
|
||||
listReports(playerName?: string) {
|
||||
const reports = [...this.reports.values()];
|
||||
return playerName === undefined ? reports : reports.filter(report => report.playerName === playerName);
|
||||
}
|
||||
|
||||
async handleReportListRequest(request: messages.ReportListRequest) {
|
||||
return this.listReports(request.playerName);
|
||||
}
|
||||
|
||||
async handleReportGetRequest(request: messages.ReportGetRequest) {
|
||||
const report = this.reports.get(request.id);
|
||||
if (!report) {
|
||||
throw new lib.RequestError(`Report with ID ${request.id} does not exist`);
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
async handleReportCreateRequest(request: messages.ReportCreateRequest, src: lib.Address) {
|
||||
let byPlayerName = request.byPlayerName;
|
||||
let instanceName = "";
|
||||
if (src.type === lib.Address.control) {
|
||||
byPlayerName = this.controller.wsServer.controlConnections.get(src.id)!.user.name;
|
||||
} else {
|
||||
const instance = this.controller.instances.get(src.id);
|
||||
instanceName = instance ? instance.config.get("instance.name") : String(src.id);
|
||||
}
|
||||
|
||||
if (!byPlayerName) {
|
||||
throw new lib.RequestError("A report needs the name of the player making it");
|
||||
}
|
||||
if (!request.reason.trim()) {
|
||||
throw new lib.RequestError("A report needs a reason");
|
||||
}
|
||||
if (this.listReports(request.playerName).some(report => report.byPlayerName === byPlayerName)) {
|
||||
throw new lib.RequestError(`${byPlayerName} has already reported ${request.playerName}`);
|
||||
}
|
||||
|
||||
let id = Math.random() * 2 ** 31 | 0;
|
||||
while (this.reports.has(id)) {
|
||||
id = Math.random() * 2 ** 31 | 0;
|
||||
}
|
||||
|
||||
const report = new messages.ReportRecord(id, request.playerName, byPlayerName, request.reason.trim(), instanceName);
|
||||
this.reports.set(report);
|
||||
return report;
|
||||
}
|
||||
|
||||
async handleReportDeleteRequest(request: messages.ReportDeleteRequest) {
|
||||
const report = this.reports.getMutable(request.id);
|
||||
if (!report) {
|
||||
throw new lib.RequestError(`Report with ID ${request.id} does not exist`);
|
||||
}
|
||||
this.reports.delete(report);
|
||||
function listReports(playerName?: string) {
|
||||
const all = [...reports.values()];
|
||||
return playerName === undefined ? all : all.filter(report => report.playerName === playerName);
|
||||
}
|
||||
|
||||
/** Post a new report to the configured webhooks, failures are logged rather than failing the report. */
|
||||
sendWebhooks(report: messages.ReportRecord) {
|
||||
const discordUrl = this.controller.config.get("exp_reports.discord_webhook_url");
|
||||
function sendWebhooks(report: messages.ReportRecord) {
|
||||
const discordUrl = controller.config.get("exp_reports.discord_webhook_url");
|
||||
if (discordUrl) {
|
||||
this.postWebhook(discordUrl, {
|
||||
postWebhook(discordUrl, {
|
||||
embeds: [{
|
||||
title: "Player reported",
|
||||
color: 0xffcc00,
|
||||
@@ -131,13 +38,13 @@ export class ControllerPlugin {
|
||||
});
|
||||
}
|
||||
|
||||
const jsonUrl = this.controller.config.get("exp_reports.json_webhook_url");
|
||||
const jsonUrl = controller.config.get("exp_reports.json_webhook_url");
|
||||
if (jsonUrl) {
|
||||
this.postWebhook(jsonUrl, { type: "report_created", report: report.toJSON() });
|
||||
postWebhook(jsonUrl, { type: "report_created", report: report.toJSON() });
|
||||
}
|
||||
}
|
||||
|
||||
async postWebhook(url: string, body: unknown) {
|
||||
async function postWebhook(url: string, body: unknown) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
@@ -145,14 +52,79 @@ export class ControllerPlugin {
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
this.logger.warn(`Webhook ${url} responded with ${response.status}`);
|
||||
logger.warn(`Webhook ${url} responded with ${response.status}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`Webhook ${url} failed: ${err.message}`);
|
||||
logger.warn(`Webhook ${url} failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default async function (context: ControllerPluginContext) {
|
||||
await ControllerPlugin.fromContext(context);
|
||||
controller.subscriptions.handle(messages.ReportUpdatedEvent, async request => {
|
||||
const updates = [...reports.values()].filter(report => report.updatedAtMs > request.lastRequestTimeMs);
|
||||
return updates.length ? new messages.ReportUpdatedEvent(updates) : null;
|
||||
});
|
||||
|
||||
// Reports are immutable, so any update which is not a deletion is a new report
|
||||
reports.on("update", updates => {
|
||||
controller.subscriptions.broadcast(new messages.ReportUpdatedEvent(updates));
|
||||
for (const report of updates) {
|
||||
if (!report.isDeleted) {
|
||||
sendWebhooks(report);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
controller.handle(messages.ReportListRequest, async (request: messages.ReportListRequest) => (
|
||||
listReports(request.playerName)
|
||||
));
|
||||
|
||||
controller.handle(messages.ReportGetRequest, async (request: messages.ReportGetRequest) => {
|
||||
const report = reports.get(request.id);
|
||||
if (!report) {
|
||||
throw new lib.RequestError(`Report with ID ${request.id} does not exist`);
|
||||
}
|
||||
return report;
|
||||
});
|
||||
|
||||
controller.handle(messages.ReportCreateRequest, async (request: messages.ReportCreateRequest, src: lib.Address) => {
|
||||
let byPlayerName = request.byPlayerName;
|
||||
let instanceName = "";
|
||||
if (src.type === lib.Address.control) {
|
||||
byPlayerName = controller.wsServer.controlConnections.get(src.id)!.user.name;
|
||||
} else {
|
||||
const instance = controller.instances.get(src.id);
|
||||
instanceName = instance ? instance.config.get("instance.name") : String(src.id);
|
||||
}
|
||||
|
||||
if (!byPlayerName) {
|
||||
throw new lib.RequestError("A report needs the name of the player making it");
|
||||
}
|
||||
if (!request.reason.trim()) {
|
||||
throw new lib.RequestError("A report needs a reason");
|
||||
}
|
||||
if (listReports(request.playerName).some(report => report.byPlayerName === byPlayerName)) {
|
||||
throw new lib.RequestError(`${byPlayerName} has already reported ${request.playerName}`);
|
||||
}
|
||||
|
||||
let id = Math.random() * 2 ** 31 | 0;
|
||||
while (reports.has(id)) {
|
||||
id = Math.random() * 2 ** 31 | 0;
|
||||
}
|
||||
|
||||
const report = new messages.ReportRecord(id, request.playerName, byPlayerName, request.reason.trim(), instanceName);
|
||||
reports.set(report);
|
||||
return report;
|
||||
});
|
||||
|
||||
controller.handle(messages.ReportDeleteRequest, async (request: messages.ReportDeleteRequest) => {
|
||||
const report = reports.getMutable(request.id);
|
||||
if (!report) {
|
||||
throw new lib.RequestError(`Report with ID ${request.id} does not exist`);
|
||||
}
|
||||
reports.delete(report);
|
||||
});
|
||||
|
||||
controller.hooks.shutdown.attach(plugin.name, async () => {
|
||||
await reports.save();
|
||||
});
|
||||
}
|
||||
+28
-47
@@ -1,5 +1,4 @@
|
||||
import type { Instance, InstancePluginContext } from "@clusterio/host";
|
||||
import type * as lib from "@clusterio/lib";
|
||||
import type { InstancePluginContext } from "@clusterio/host";
|
||||
import * as messages from "./messages.js";
|
||||
|
||||
/** Sent by the lua side when a player reports another. */
|
||||
@@ -29,82 +28,64 @@ export type IpcReportDelete = {
|
||||
* the answer is handed back to lua once it arrives. The instance may have
|
||||
* stopped by then, in which case the answer is dropped.
|
||||
*/
|
||||
export class InstancePlugin {
|
||||
private constructor(
|
||||
public instance: Instance,
|
||||
public logger: lib.Logger,
|
||||
public name: string,
|
||||
) {}
|
||||
export default async function (context: InstancePluginContext) {
|
||||
const { instance } = context;
|
||||
|
||||
static async fromContext(context: InstancePluginContext) {
|
||||
const instance = context.instance;
|
||||
const plugin = new InstancePlugin(instance, context.logger, context.plugin.name);
|
||||
|
||||
instance.server.handle("exp_reports:create", plugin.handleCreateIPC.bind(plugin));
|
||||
instance.server.handle("exp_reports:list", plugin.handleListIPC.bind(plugin));
|
||||
instance.server.handle("exp_reports:delete", plugin.handleDeleteIPC.bind(plugin));
|
||||
return plugin;
|
||||
/** Hand an answer to lua, unless the instance stopped while it was being fetched. */
|
||||
async function luaSend(receiver: string, json: any) {
|
||||
if (instance.status !== "running") {
|
||||
return;
|
||||
}
|
||||
await instance.sendRcon(
|
||||
`/sc exp_reports.${receiver}(helpers.json_to_table[=[${JSON.stringify(json)}]=])`, true
|
||||
);
|
||||
}
|
||||
|
||||
async handleCreateIPC(event: IpcReportCreate) {
|
||||
instance.server.handle("exp_reports:create", async (event: IpcReportCreate) => {
|
||||
try {
|
||||
const report = await this.instance.sendTo("controller", new messages.ReportCreateRequest(
|
||||
const report = await instance.sendTo("controller", new messages.ReportCreateRequest(
|
||||
event.player_name, event.reason, event.by_player_name,
|
||||
));
|
||||
const reports = await this.instance.sendTo("controller", new messages.ReportListRequest(event.player_name));
|
||||
await this.luaSend("receive_created", {
|
||||
const reports = await instance.sendTo("controller", new messages.ReportListRequest(event.player_name));
|
||||
await luaSend("receive_created", {
|
||||
report: report.toJSON(),
|
||||
reports: reports.map(other => other.toJSON()),
|
||||
});
|
||||
} catch (err: any) {
|
||||
await this.luaSend("receive_error", { caller: event.by_player_name, message: err.message });
|
||||
await luaSend("receive_error", { caller: event.by_player_name, message: err.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async handleListIPC(event: IpcReportList) {
|
||||
instance.server.handle("exp_reports:list", async (event: IpcReportList) => {
|
||||
try {
|
||||
const reports = await this.instance.sendTo("controller", new messages.ReportListRequest(event.player_name));
|
||||
await this.luaSend("receive_list", {
|
||||
const reports = await instance.sendTo("controller", new messages.ReportListRequest(event.player_name));
|
||||
await luaSend("receive_list", {
|
||||
caller: event.caller,
|
||||
player_name: event.player_name,
|
||||
reports: reports.map(report => report.toJSON()),
|
||||
});
|
||||
} catch (err: any) {
|
||||
await this.luaSend("receive_error", { caller: event.caller, message: err.message });
|
||||
await luaSend("receive_error", { caller: event.caller, message: err.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async handleDeleteIPC(event: IpcReportDelete) {
|
||||
instance.server.handle("exp_reports:delete", async (event: IpcReportDelete) => {
|
||||
try {
|
||||
let reports = await this.instance.sendTo("controller", new messages.ReportListRequest(event.player_name));
|
||||
let reports = await instance.sendTo("controller", new messages.ReportListRequest(event.player_name));
|
||||
if (event.by_player_name !== undefined) {
|
||||
reports = reports.filter(report => report.byPlayerName === event.by_player_name);
|
||||
}
|
||||
for (const report of reports) {
|
||||
await this.instance.sendTo("controller", new messages.ReportDeleteRequest(report.id));
|
||||
await instance.sendTo("controller", new messages.ReportDeleteRequest(report.id));
|
||||
}
|
||||
await this.luaSend("receive_deleted", {
|
||||
await luaSend("receive_deleted", {
|
||||
caller: event.caller,
|
||||
player_name: event.player_name,
|
||||
by_player_name: event.by_player_name,
|
||||
count: reports.length,
|
||||
});
|
||||
} catch (err: any) {
|
||||
await this.luaSend("receive_error", { caller: event.caller, message: err.message });
|
||||
await luaSend("receive_error", { caller: event.caller, message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
/** Hand an answer to lua, unless the instance stopped while it was being fetched. */
|
||||
async luaSend(receiver: string, json: any) {
|
||||
if (this.instance.status !== "running") {
|
||||
return;
|
||||
}
|
||||
await this.instance.sendRcon(
|
||||
`/sc exp_reports.${receiver}(helpers.json_to_table[=[${JSON.stringify(json)}]=])`, true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default async function (context: InstancePluginContext) {
|
||||
await InstancePlugin.fromContext(context);
|
||||
});
|
||||
}
|
||||
@@ -13,13 +13,20 @@
|
||||
"coverage": "tap --coverage-report=text test/*.test.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@clusterio/controller": "workspace:^",
|
||||
"@clusterio/host": "workspace:^",
|
||||
"@clusterio/lib": "workspace:^",
|
||||
"@clusterio/web_ui": "workspace:^"
|
||||
"@clusterio/lib": "workspace:^"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@clusterio/controller": {
|
||||
"optional": true
|
||||
},
|
||||
"@clusterio/host": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ant-design/icons": "^6.2.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import t from "tap";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { Controller, InstanceRecord } from "@clusterio/controller";
|
||||
import entrypoint, { ControllerPlugin } from "../dist/node/controller.js";
|
||||
import entrypoint from "../dist/node/controller.js";
|
||||
import * as messages from "../dist/node/messages.js";
|
||||
import { plugin as pluginDeclaration } from "../dist/node/index.js";
|
||||
|
||||
@@ -24,7 +24,7 @@ const fromInstance = lib.Address.fromShorthand({ instanceId: 1 });
|
||||
const fromUnknownInstance = lib.Address.fromShorthand({ instanceId: 9 });
|
||||
const fromControl = lib.Address.fromShorthand({ controlId: 7 });
|
||||
|
||||
/** Build the plugin around a real controller, which is side effect free while not started. */
|
||||
/** Run the plugin on a real controller, which is side effect free while not started, returns the handlers it registered. */
|
||||
async function startPlugin(t2, { config = {} } = {}) {
|
||||
const controllerConfig = new lib.ControllerConfig("controller", {
|
||||
"controller.database_directory": t2.testdir(),
|
||||
@@ -46,12 +46,45 @@ async function startPlugin(t2, { config = {} } = {}) {
|
||||
broadcast(event);
|
||||
};
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url, init) => {
|
||||
state.posts.push({ url, body: JSON.parse(init.body) });
|
||||
return state.fetchResult();
|
||||
};
|
||||
state.fetchResult = () => ({ ok: true, status: 200 });
|
||||
t2.teardown(() => { globalThis.fetch = realFetch; });
|
||||
|
||||
const handlers = new Map();
|
||||
const handle = controller.handle.bind(controller);
|
||||
controller.handle = (Class, handler) => {
|
||||
handlers.set(Class, handler);
|
||||
handle(Class, handler);
|
||||
};
|
||||
const subscriptionHandle = controller.subscriptions.handle.bind(controller.subscriptions);
|
||||
controller.subscriptions.handle = (Class, handler) => {
|
||||
handlers.set(Class, handler);
|
||||
subscriptionHandle(Class, handler);
|
||||
};
|
||||
|
||||
const pluginLogger = { ...logger, warn: message => state.warnings.push(message) };
|
||||
const plugin = await ControllerPlugin.fromContext({ plugin: { name: "exp_reports" }, controller, logger: pluginLogger });
|
||||
plugin.postWebhook = async (url, body) => { state.posts.push({ url, body }); };
|
||||
return { plugin, controller, state };
|
||||
await entrypoint({ plugin: { name: "exp_reports" }, controller, logger: pluginLogger });
|
||||
const request = (Request, ...args) => handlers.get(Request)(new Request(...args), fromInstance);
|
||||
return {
|
||||
controller,
|
||||
state,
|
||||
create: (playerName, reason, byPlayerName, src = fromInstance) => handlers.get(messages.ReportCreateRequest)(
|
||||
new messages.ReportCreateRequest(playerName, reason, byPlayerName), src,
|
||||
),
|
||||
list: playerName => request(messages.ReportListRequest, playerName),
|
||||
get: id => request(messages.ReportGetRequest, id),
|
||||
remove: id => request(messages.ReportDeleteRequest, id),
|
||||
subscription: lastRequestTimeMs => handlers.get(messages.ReportUpdatedEvent)({ lastRequestTimeMs }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Let the webhook posts which are not awaited finish. */
|
||||
const settle = () => new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
t.test("entrypoint", async t2 => {
|
||||
const controllerConfig = new lib.ControllerConfig("controller", { "controller.database_directory": t2.testdir() });
|
||||
const controller = new Controller(logger, [], controllerConfig);
|
||||
@@ -62,108 +95,102 @@ t.test("entrypoint", async t2 => {
|
||||
await controller.hooks.shutdown.invoke();
|
||||
});
|
||||
|
||||
t.test("class ControllerPlugin", t2 => {
|
||||
t2.test(".handleReportCreateRequest() from an instance records the instance and the reporter", async t3 => {
|
||||
const { plugin } = await startPlugin(t3);
|
||||
const report = await plugin.handleReportCreateRequest(
|
||||
new messages.ReportCreateRequest("bob", " griefing ", "alice"), fromInstance,
|
||||
);
|
||||
t.test("controller plugin", t2 => {
|
||||
t2.test("ReportCreateRequest from an instance records the instance and the reporter", async t3 => {
|
||||
const { create, list } = await startPlugin(t3);
|
||||
const report = await create("bob", " griefing ", "alice", fromInstance);
|
||||
|
||||
t3.strictSame(report.playerName, "bob");
|
||||
t3.strictSame(report.byPlayerName, "alice", "the reporter comes from the request");
|
||||
t3.strictSame(report.reason, "griefing", "the reason is trimmed");
|
||||
t3.strictSame(report.instanceName, "EXP", "the instance is named");
|
||||
t3.ok(report.updatedAtMs > 0, "the report is timestamped");
|
||||
t3.strictSame(plugin.listReports(), [report], "the report is stored");
|
||||
t3.strictSame(await list(), [report], "the report is stored");
|
||||
|
||||
const unknown = await plugin.handleReportCreateRequest(
|
||||
new messages.ReportCreateRequest("carol", "spam", "alice"), fromUnknownInstance,
|
||||
);
|
||||
const unknown = await create("carol", "spam", "alice", fromUnknownInstance);
|
||||
t3.strictSame(unknown.instanceName, "9", "an unknown instance is named by its id");
|
||||
});
|
||||
|
||||
t2.test(".handleReportCreateRequest() from the web ui uses the user of the connection", async t3 => {
|
||||
const { plugin } = await startPlugin(t3);
|
||||
const report = await plugin.handleReportCreateRequest(
|
||||
new messages.ReportCreateRequest("bob", "griefing", "someone-else"), fromControl,
|
||||
);
|
||||
t2.test("ReportCreateRequest from the web ui uses the user of the connection", async t3 => {
|
||||
const { create } = await startPlugin(t3);
|
||||
const report = await create("bob", "griefing", "someone-else", fromControl);
|
||||
|
||||
t3.strictSame(report.byPlayerName, "admin", "the reporter is the connected user");
|
||||
t3.strictSame(report.instanceName, "", "there is no instance");
|
||||
});
|
||||
|
||||
t2.test(".handleReportCreateRequest() rejects reports which can not be stored", async t3 => {
|
||||
const { plugin } = await startPlugin(t3);
|
||||
await plugin.handleReportCreateRequest(new messages.ReportCreateRequest("bob", "griefing", "alice"), fromInstance);
|
||||
t2.test("ReportCreateRequest rejects reports which can not be stored", async t3 => {
|
||||
const { create, list } = await startPlugin(t3);
|
||||
await create("bob", "griefing", "alice", fromInstance);
|
||||
|
||||
await t3.rejects(
|
||||
plugin.handleReportCreateRequest(new messages.ReportCreateRequest("bob", "again", "alice"), fromInstance),
|
||||
create("bob", "again", "alice", fromInstance),
|
||||
{ message: "alice has already reported bob" },
|
||||
"a player can only report another once",
|
||||
);
|
||||
await t3.rejects(
|
||||
plugin.handleReportCreateRequest(new messages.ReportCreateRequest("bob", " ", "carol"), fromInstance),
|
||||
create("bob", " ", "carol", fromInstance),
|
||||
{ message: "A report needs a reason" },
|
||||
"a reason is required",
|
||||
);
|
||||
await t3.rejects(
|
||||
plugin.handleReportCreateRequest(new messages.ReportCreateRequest("bob", "spam", ""), fromInstance),
|
||||
create("bob", "spam", "", fromInstance),
|
||||
{ message: "A report needs the name of the player making it" },
|
||||
"an instance must name the reporter",
|
||||
);
|
||||
t3.strictSame(plugin.listReports().length, 1, "nothing else was stored");
|
||||
t3.strictSame((await list()).length, 1, "nothing else was stored");
|
||||
});
|
||||
|
||||
t2.test(".handleReportListRequest() and .handleReportGetRequest() find reports", async t3 => {
|
||||
const { plugin } = await startPlugin(t3);
|
||||
const first = await plugin.handleReportCreateRequest(new messages.ReportCreateRequest("bob", "griefing", "alice"), fromInstance);
|
||||
await plugin.handleReportCreateRequest(new messages.ReportCreateRequest("carol", "spam", "alice"), fromInstance);
|
||||
t2.test("ReportListRequest and ReportGetRequest find reports", async t3 => {
|
||||
const { create, list, get } = await startPlugin(t3);
|
||||
const first = await create("bob", "griefing", "alice", fromInstance);
|
||||
await create("carol", "spam", "alice", fromInstance);
|
||||
|
||||
t3.strictSame((await plugin.handleReportListRequest(new messages.ReportListRequest())).length, 2, "everything is listed");
|
||||
t3.strictSame((await list()).length, 2, "everything is listed");
|
||||
t3.strictSame(
|
||||
await plugin.handleReportListRequest(new messages.ReportListRequest("bob")), [first],
|
||||
await list("bob"), [first],
|
||||
"a player's reports are listed",
|
||||
);
|
||||
t3.strictSame(await plugin.handleReportGetRequest(new messages.ReportGetRequest(first.id)), first);
|
||||
t3.strictSame(await get(first.id), first);
|
||||
await t3.rejects(
|
||||
plugin.handleReportGetRequest(new messages.ReportGetRequest(123)),
|
||||
get(123),
|
||||
{ message: "Report with ID 123 does not exist" },
|
||||
);
|
||||
});
|
||||
|
||||
t2.test(".handleReportDeleteRequest() removes the report and broadcasts the deletion", async t3 => {
|
||||
const { plugin, state } = await startPlugin(t3);
|
||||
const report = await plugin.handleReportCreateRequest(new messages.ReportCreateRequest("bob", "griefing", "alice"), fromInstance);
|
||||
t2.test("ReportDeleteRequest removes the report and broadcasts the deletion", async t3 => {
|
||||
const { create, list, remove, state } = await startPlugin(t3);
|
||||
const report = await create("bob", "griefing", "alice", fromInstance);
|
||||
|
||||
state.broadcasts.length = 0;
|
||||
await plugin.handleReportDeleteRequest(new messages.ReportDeleteRequest(report.id));
|
||||
t3.strictSame(plugin.listReports(), [], "the report is gone");
|
||||
await remove(report.id);
|
||||
t3.strictSame(await list(), [], "the report is gone");
|
||||
t3.ok(
|
||||
state.broadcasts.some(event => event instanceof messages.ReportUpdatedEvent && event.updates[0].isDeleted),
|
||||
"subscribers are told it was deleted",
|
||||
);
|
||||
await t3.rejects(
|
||||
plugin.handleReportDeleteRequest(new messages.ReportDeleteRequest(report.id)),
|
||||
remove(report.id),
|
||||
{ message: `Report with ID ${report.id} does not exist` },
|
||||
);
|
||||
});
|
||||
|
||||
t2.test(".handleReportSubscription() replays only newer records", async t3 => {
|
||||
const { plugin } = await startPlugin(t3);
|
||||
const report = await plugin.handleReportCreateRequest(new messages.ReportCreateRequest("bob", "griefing", "alice"), fromInstance);
|
||||
t2.test("subscribing replays only newer records", async t3 => {
|
||||
const { create, subscription } = await startPlugin(t3);
|
||||
const report = await create("bob", "griefing", "alice", fromInstance);
|
||||
|
||||
const all = await plugin.handleReportSubscription({ lastRequestTimeMs: 0 });
|
||||
const all = await subscription(0);
|
||||
t3.strictSame(all.updates, [report], "everything is replayed from the start");
|
||||
const none = await plugin.handleReportSubscription({ lastRequestTimeMs: report.updatedAtMs });
|
||||
const none = await subscription(report.updatedAtMs);
|
||||
t3.strictSame(none, null, "nothing is replayed when up to date");
|
||||
});
|
||||
|
||||
t2.test(".sendWebhooks() posts new reports to the configured hooks", async t3 => {
|
||||
const { plugin, state } = await startPlugin(t3, { config: {
|
||||
t2.test("new reports are posted to the configured hooks", async t3 => {
|
||||
const { create, remove, state } = await startPlugin(t3, { config: {
|
||||
"exp_reports.discord_webhook_url": "https://discord.example/hook",
|
||||
"exp_reports.json_webhook_url": "https://json.example/hook",
|
||||
} });
|
||||
const report = await plugin.handleReportCreateRequest(new messages.ReportCreateRequest("bob", "griefing", "alice"), fromInstance);
|
||||
const report = await create("bob", "griefing", "alice", fromInstance);
|
||||
|
||||
t3.strictSame(state.posts.map(post => post.url), ["https://discord.example/hook", "https://json.example/hook"]);
|
||||
const embed = state.posts[0].body.embeds[0];
|
||||
@@ -171,29 +198,29 @@ t.test("class ControllerPlugin", t2 => {
|
||||
t3.strictSame(state.posts[1].body, { type: "report_created", report: report.toJSON() }, "the json hook gets the record");
|
||||
|
||||
state.posts.length = 0;
|
||||
await plugin.handleReportDeleteRequest(new messages.ReportDeleteRequest(report.id));
|
||||
await remove(report.id);
|
||||
t3.strictSame(state.posts, [], "deletions are not posted");
|
||||
});
|
||||
|
||||
t2.test(".sendWebhooks() does nothing without hooks", async t3 => {
|
||||
const { plugin, state } = await startPlugin(t3);
|
||||
await plugin.handleReportCreateRequest(new messages.ReportCreateRequest("bob", "griefing", "alice"), fromInstance);
|
||||
t2.test("nothing is posted without hooks", async t3 => {
|
||||
const { create, state } = await startPlugin(t3);
|
||||
await create("bob", "griefing", "alice", fromInstance);
|
||||
t3.strictSame(state.posts, []);
|
||||
});
|
||||
|
||||
t2.test(".postWebhook() logs rather than throws when the hook fails", async t3 => {
|
||||
const { plugin, state } = await startPlugin(t3);
|
||||
plugin.postWebhook = ControllerPlugin.prototype.postWebhook;
|
||||
t2.test("webhooks log rather than throw when the hook fails", async t3 => {
|
||||
const { create, state } = await startPlugin(t3, { config: {
|
||||
"exp_reports.json_webhook_url": "https://down.example/hook",
|
||||
} });
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => { throw new Error("connection refused"); };
|
||||
t3.teardown(() => { globalThis.fetch = realFetch; });
|
||||
|
||||
await plugin.postWebhook("https://down.example/hook", {});
|
||||
state.fetchResult = () => { throw new Error("connection refused"); };
|
||||
await create("bob", "griefing", "alice");
|
||||
await settle();
|
||||
t3.strictSame(state.warnings, ["Webhook https://down.example/hook failed: connection refused"]);
|
||||
|
||||
globalThis.fetch = async () => ({ ok: false, status: 404 });
|
||||
await plugin.postWebhook("https://down.example/hook", {});
|
||||
state.fetchResult = () => ({ ok: false, status: 404 });
|
||||
await create("bob", "griefing", "carol");
|
||||
await settle();
|
||||
t3.strictSame(state.warnings[1], "Webhook https://down.example/hook responded with 404");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import t from "tap";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { Instance } from "@clusterio/host";
|
||||
import { InstancePlugin } from "../dist/node/instance.js";
|
||||
import entrypoint from "../dist/node/instance.js";
|
||||
import { plugin as pluginDeclaration } from "../dist/node/index.js";
|
||||
import * as messages from "../dist/node/messages.js";
|
||||
|
||||
@@ -25,7 +25,7 @@ class TestConnector extends lib.BaseConnector {
|
||||
|
||||
const report = (id, playerName, byPlayerName) => new messages.ReportRecord(id, playerName, byPlayerName, "griefing", "EXP", 1000);
|
||||
|
||||
/** Build a plugin around a real running instance with spies on what leaves it. */
|
||||
/** Run the plugin on a real running instance with spies on what leaves it, returns the IPC handlers it registered. */
|
||||
async function startPlugin(t2, { reports = [report(1, "bob", "alice"), report(2, "bob", "carol")] } = {}) {
|
||||
const instanceConfig = new lib.InstanceConfig("host");
|
||||
instanceConfig.set("instance.id", 1);
|
||||
@@ -37,8 +37,9 @@ async function startPlugin(t2, { reports = [report(1, "bob", "alice"), report(2,
|
||||
|
||||
// Spies which record the messages and commands leaving the instance
|
||||
const state = { sent: [], rcons: [], failNext: null };
|
||||
const ipc = new Map();
|
||||
instance.server = {
|
||||
handle: () => {},
|
||||
handle: (name, handler) => ipc.set(name, handler),
|
||||
sendRcon: async command => {
|
||||
state.rcons.push(command);
|
||||
return "";
|
||||
@@ -63,8 +64,14 @@ async function startPlugin(t2, { reports = [report(1, "bob", "alice"), report(2,
|
||||
instance.notifyStatus("running");
|
||||
state.sent.length = 0;
|
||||
|
||||
const plugin = await InstancePlugin.fromContext({ plugin: { name: "exp_reports" }, instance, host: {}, logger });
|
||||
return { plugin, instance, state };
|
||||
await entrypoint({ plugin: { name: "exp_reports" }, instance, host: {}, logger });
|
||||
return {
|
||||
instance,
|
||||
state,
|
||||
createIPC: event => ipc.get("exp_reports:create")(event),
|
||||
listIPC: event => ipc.get("exp_reports:list")(event),
|
||||
deleteIPC: event => ipc.get("exp_reports:delete")(event),
|
||||
};
|
||||
}
|
||||
|
||||
/** The lua receiver and payload of a recorded rcon command. */
|
||||
@@ -73,10 +80,10 @@ function decodeRcon(command) {
|
||||
return { receiver: match[1], payload: JSON.parse(match[2]) };
|
||||
}
|
||||
|
||||
t.test("class InstancePlugin", t2 => {
|
||||
t2.test(".handleCreateIPC() creates the report and hands it to lua with the others against the player", async t3 => {
|
||||
const { plugin, state } = await startPlugin(t3);
|
||||
await plugin.handleCreateIPC({ player_name: "bob", by_player_name: "dave", reason: "griefing" });
|
||||
t.test("instance plugin", t2 => {
|
||||
t2.test("exp_reports:create creates the report and hands it to lua with the others against the player", async t3 => {
|
||||
const { state, createIPC } = await startPlugin(t3);
|
||||
await createIPC({ player_name: "bob", by_player_name: "dave", reason: "griefing" });
|
||||
|
||||
const create = state.sent.find(request => request instanceof messages.ReportCreateRequest);
|
||||
t3.strictSame([create.playerName, create.byPlayerName, create.reason], ["bob", "dave", "griefing"]);
|
||||
@@ -89,20 +96,20 @@ t.test("class InstancePlugin", t2 => {
|
||||
t3.strictSame(payload.reports.map(other => other.by_player_name), ["alice", "carol"], "along with the existing ones");
|
||||
});
|
||||
|
||||
t2.test(".handleCreateIPC() prints a refusal to the reporter", async t3 => {
|
||||
const { plugin, state } = await startPlugin(t3);
|
||||
t2.test("exp_reports:create prints a refusal to the reporter", async t3 => {
|
||||
const { state, createIPC } = await startPlugin(t3);
|
||||
state.failNext = new lib.RequestError("dave has already reported bob");
|
||||
await plugin.handleCreateIPC({ player_name: "bob", by_player_name: "dave", reason: "griefing" });
|
||||
await createIPC({ player_name: "bob", by_player_name: "dave", reason: "griefing" });
|
||||
|
||||
const { receiver, payload } = decodeRcon(state.rcons[0]);
|
||||
t3.strictSame(receiver, "receive_error");
|
||||
t3.strictSame(payload, { caller: "dave", message: "dave has already reported bob" });
|
||||
});
|
||||
|
||||
t2.test(".handleListIPC() lists the reports for the caller", async t3 => {
|
||||
const { plugin, state } = await startPlugin(t3);
|
||||
await plugin.handleListIPC({ caller: "admin", player_name: "bob" });
|
||||
await plugin.handleListIPC({ caller: "admin", player_name: undefined });
|
||||
t2.test("exp_reports:list lists the reports for the caller", async t3 => {
|
||||
const { state, listIPC } = await startPlugin(t3);
|
||||
await listIPC({ caller: "admin", player_name: "bob" });
|
||||
await listIPC({ caller: "admin", player_name: undefined });
|
||||
|
||||
const forPlayer = decodeRcon(state.rcons[0]);
|
||||
t3.strictSame(forPlayer.receiver, "receive_list");
|
||||
@@ -113,9 +120,9 @@ t.test("class InstancePlugin", t2 => {
|
||||
t3.strictSame(forAll.payload.player_name, undefined, "no player when listing everyone");
|
||||
});
|
||||
|
||||
t2.test(".handleDeleteIPC() deletes only the matching reports", async t3 => {
|
||||
const { plugin, state } = await startPlugin(t3);
|
||||
await plugin.handleDeleteIPC({ caller: "admin", player_name: "bob", by_player_name: "carol" });
|
||||
t2.test("exp_reports:delete deletes only the matching reports", async t3 => {
|
||||
const { state, deleteIPC } = await startPlugin(t3);
|
||||
await deleteIPC({ caller: "admin", player_name: "bob", by_player_name: "carol" });
|
||||
|
||||
const deletes = state.sent.filter(request => request instanceof messages.ReportDeleteRequest);
|
||||
t3.strictSame(deletes.map(request => request.id), [2], "only carol's report is deleted");
|
||||
@@ -124,17 +131,17 @@ t.test("class InstancePlugin", t2 => {
|
||||
t3.strictSame(payload, { caller: "admin", player_name: "bob", by_player_name: "carol", count: 1 });
|
||||
|
||||
state.sent.length = 0;
|
||||
await plugin.handleDeleteIPC({ caller: "admin", player_name: "bob", by_player_name: undefined });
|
||||
await deleteIPC({ caller: "admin", player_name: "bob", by_player_name: undefined });
|
||||
t3.strictSame(
|
||||
state.sent.filter(request => request instanceof messages.ReportDeleteRequest).length, 2,
|
||||
"every report is deleted without a reporter",
|
||||
);
|
||||
});
|
||||
|
||||
t2.test(".luaSend() drops answers once the instance has stopped", async t3 => {
|
||||
const { plugin, instance, state } = await startPlugin(t3);
|
||||
t2.test("answers are dropped once the instance has stopped", async t3 => {
|
||||
const { instance, state, listIPC } = await startPlugin(t3);
|
||||
instance.notifyStatus("stopped");
|
||||
await plugin.handleListIPC({ caller: "admin", player_name: undefined });
|
||||
await listIPC({ caller: "admin", player_name: undefined });
|
||||
t3.strictSame(state.rcons, [], "nothing is sent to a stopped instance");
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Without an .npmignore pnpm packs using the repository .gitignore, which leaves out dist/
|
||||
+54
-90
@@ -1,4 +1,4 @@
|
||||
import type { Instance, InstancePluginContext } from "@clusterio/host";
|
||||
import type { InstancePluginContext } from "@clusterio/host";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import * as messages from "./messages.js";
|
||||
|
||||
@@ -9,118 +9,82 @@ export type IpcAssignmentUpdate = {
|
||||
unassign: number[] | undefined,
|
||||
};
|
||||
|
||||
export class InstancePlugin {
|
||||
private constructor(
|
||||
public instance: Instance,
|
||||
public logger: lib.Logger,
|
||||
public name: string,
|
||||
) {}
|
||||
export default async function (context: InstancePluginContext) {
|
||||
const { instance, logger, plugin } = context;
|
||||
const syncMode = () => instance.config.get("exp_roles.sync_mode");
|
||||
|
||||
static async fromContext(context: InstancePluginContext) {
|
||||
const instance = context.instance;
|
||||
const plugin = new InstancePlugin(instance, context.logger, context.plugin.name);
|
||||
|
||||
instance.handle(messages.RoleUpdatedEvent, plugin.handleRoleUpdatedEvent.bind(plugin));
|
||||
instance.handle(messages.AssignmentUpdatedEvent, plugin.handleAssignmentUpdatedEvent.bind(plugin));
|
||||
instance.server.handle("exp_roles:assignment_update", plugin.handleAssignmentUpdateIPC.bind(plugin));
|
||||
|
||||
const hooks = instance.hooks;
|
||||
hooks.instanceConfigFieldChanged.attach(plugin.name, plugin.onInstanceConfigFieldChanged.bind(plugin));
|
||||
hooks.start.attach(plugin.name, plugin.onStart.bind(plugin));
|
||||
return plugin;
|
||||
async function luaSend(receiver: string, json: any) {
|
||||
await instance.sendRcon(
|
||||
`/sc exp_roles.${receiver}(helpers.json_to_table[=[${JSON.stringify(json)}]=])`, true
|
||||
);
|
||||
}
|
||||
|
||||
get syncMode() {
|
||||
return this.instance.config.get("exp_roles.sync_mode");
|
||||
}
|
||||
|
||||
async onInstanceConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
|
||||
switch (field) {
|
||||
case "exp_roles.sync_mode":
|
||||
await this.luaSetEmitEvents(curr === "bidirectional");
|
||||
break;
|
||||
instance.handle(messages.RoleUpdatedEvent, async event => {
|
||||
if (syncMode() !== "disabled") {
|
||||
await luaSend("receive_role_updates", event.updates.map(role => role.toJSON()));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async onStart() {
|
||||
if (this.syncMode === "disabled") {
|
||||
instance.handle(messages.AssignmentUpdatedEvent, async event => {
|
||||
if (syncMode() !== "disabled") {
|
||||
await luaSend("receive_assignment_updates", event.updates.map(a => a.toJSON()));
|
||||
}
|
||||
});
|
||||
|
||||
instance.server.handle("exp_roles:assignment_update", async (event: IpcAssignmentUpdate) => {
|
||||
if (syncMode() !== "bidirectional") {
|
||||
return;
|
||||
}
|
||||
|
||||
const assign = event.assign ?? [];
|
||||
try {
|
||||
await instance.sendTo("controller", new messages.AssignmentUpdateRequest(
|
||||
event.name, assign, event.unassign ?? [],
|
||||
));
|
||||
} catch (err: any) {
|
||||
// The roles were already applied in game, so they have to be taken
|
||||
// back off again now that the controller has refused them
|
||||
logger.warn(`Role change for ${event.name} was rejected: ${err.message}`);
|
||||
if (assign.length) {
|
||||
await luaSend("reject_assignment", { name: event.name, role_ids: assign });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
instance.hooks.instanceConfigFieldChanged.attach(plugin.name, async (field, curr) => {
|
||||
if (field === "exp_roles.sync_mode") {
|
||||
await luaSend("set_emit_events", curr === "bidirectional");
|
||||
}
|
||||
});
|
||||
|
||||
instance.hooks.start.attach(plugin.name, async () => {
|
||||
if (syncMode() === "disabled") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Date.now() is used because the lua state is initialised from the full
|
||||
// list below, so only updates made after this point are of interest
|
||||
const subscribedAtMs = Date.now();
|
||||
await this.instance.sendTo("controller", new lib.SubscriptionRequest(
|
||||
await instance.sendTo("controller", new lib.SubscriptionRequest(
|
||||
`exp_roles:${messages.RoleUpdatedEvent.name}`, true, subscribedAtMs
|
||||
));
|
||||
await this.instance.sendTo("controller", new lib.SubscriptionRequest(
|
||||
await instance.sendTo("controller", new lib.SubscriptionRequest(
|
||||
`exp_roles:${messages.AssignmentUpdatedEvent.name}`, true, subscribedAtMs
|
||||
));
|
||||
|
||||
const [roles, assignments] = await Promise.all([
|
||||
this.instance.sendTo("controller", new messages.RoleListRequest()),
|
||||
this.instance.sendTo("controller", new messages.AssignmentListRequest()),
|
||||
instance.sendTo("controller", new messages.RoleListRequest()),
|
||||
instance.sendTo("controller", new messages.AssignmentListRequest()),
|
||||
]);
|
||||
|
||||
if (!roles.some(role => role.isDefault)) {
|
||||
this.logger.warn("No default role is set on the controller, players will hold no roles in game");
|
||||
logger.warn("No default role is set on the controller, players will hold no roles in game");
|
||||
}
|
||||
|
||||
await this.luaSend("initialise", {
|
||||
await luaSend("initialise", {
|
||||
...messages.encodeRolesForLua(roles),
|
||||
assignments: assignments.map(assignment => assignment.toJSON()),
|
||||
});
|
||||
await this.luaSetEmitEvents(this.syncMode === "bidirectional");
|
||||
}
|
||||
|
||||
async handleRoleUpdatedEvent(event: messages.RoleUpdatedEvent) {
|
||||
if (this.syncMode === "disabled") {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.luaSend("receive_role_updates", event.updates.map(role => role.toJSON()));
|
||||
}
|
||||
|
||||
async handleAssignmentUpdatedEvent(event: messages.AssignmentUpdatedEvent) {
|
||||
if (this.syncMode === "disabled") {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.luaSend("receive_assignment_updates", event.updates.map(a => a.toJSON()));
|
||||
}
|
||||
|
||||
async handleAssignmentUpdateIPC(event: IpcAssignmentUpdate) {
|
||||
if (this.syncMode !== "bidirectional") {
|
||||
return;
|
||||
}
|
||||
|
||||
const assign = event.assign ?? [];
|
||||
try {
|
||||
await this.instance.sendTo("controller", new messages.AssignmentUpdateRequest(
|
||||
event.name, assign, event.unassign ?? [],
|
||||
));
|
||||
} catch (err: any) {
|
||||
// The roles were already applied in game, so they have to be taken
|
||||
// back off again now that the controller has refused them
|
||||
this.logger.warn(`Role change for ${event.name} was rejected: ${err.message}`);
|
||||
if (assign.length) {
|
||||
await this.luaSend("reject_assignment", { name: event.name, role_ids: assign });
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async luaSetEmitEvents(emitEvents: boolean) {
|
||||
await this.luaSend("set_emit_events", emitEvents);
|
||||
}
|
||||
|
||||
async luaSend(receiver: string, json: any) {
|
||||
await this.instance.sendRcon(
|
||||
`/sc exp_roles.${receiver}(helpers.json_to_table[=[${JSON.stringify(json)}]=])`, true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default async function (context: InstancePluginContext) {
|
||||
await InstancePlugin.fromContext(context);
|
||||
await luaSend("set_emit_events", syncMode() === "bidirectional");
|
||||
});
|
||||
}
|
||||
+10
-3
@@ -13,13 +13,20 @@
|
||||
"coverage": "tap --coverage-report=text test/*.test.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@clusterio/controller": "workspace:^",
|
||||
"@clusterio/host": "workspace:^",
|
||||
"@clusterio/lib": "workspace:^",
|
||||
"@clusterio/web_ui": "workspace:^"
|
||||
"@clusterio/lib": "workspace:^"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@clusterio/controller": {
|
||||
"optional": true
|
||||
},
|
||||
"@clusterio/host": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ant-design/icons": "^6.2.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import t from "tap";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { Instance } from "@clusterio/host";
|
||||
import entrypoint, { InstancePlugin } from "../dist/node/instance.js";
|
||||
import entrypoint from "../dist/node/instance.js";
|
||||
import { plugin as pluginDeclaration } from "../dist/node/index.js";
|
||||
import * as messages from "../dist/node/messages.js";
|
||||
|
||||
@@ -33,7 +33,7 @@ const sampleRoles = () => [
|
||||
];
|
||||
const sampleAssignments = () => [new messages.AssignmentRecord("alice", new Set([5]))];
|
||||
|
||||
/** Build a plugin around a real instance with spies on what leaves it, started on first use by each test. */
|
||||
/** Run the plugin on a real instance with spies on what leaves it, returns its handlers and hooks. */
|
||||
async function startPlugin(t2, { syncMode = "bidirectional", roles = sampleRoles() } = {}) {
|
||||
const instanceConfig = new lib.InstanceConfig("host");
|
||||
instanceConfig.set("instance.id", 1);
|
||||
@@ -46,8 +46,10 @@ async function startPlugin(t2, { syncMode = "bidirectional", roles = sampleRoles
|
||||
|
||||
// Spies which record the messages and commands leaving the instance
|
||||
const state = { sent: [], rcons: [], warnings: [], failNext: null };
|
||||
const handlers = new Map();
|
||||
instance.handle = (Event, handler) => handlers.set(Event, handler);
|
||||
instance.server = {
|
||||
handle: () => {},
|
||||
handle: (name, handler) => handlers.set(name, handler),
|
||||
sendRcon: async command => {
|
||||
state.rcons.push(command);
|
||||
return "";
|
||||
@@ -70,8 +72,15 @@ async function startPlugin(t2, { syncMode = "bidirectional", roles = sampleRoles
|
||||
};
|
||||
|
||||
const pluginLogger = { ...logger, warn: message => state.warnings.push(message) };
|
||||
const plugin = await InstancePlugin.fromContext({ plugin: { name: "exp_roles" }, instance, host: {}, logger: pluginLogger });
|
||||
return { plugin, state };
|
||||
await entrypoint({ plugin: { name: "exp_roles" }, instance, host: {}, logger: pluginLogger });
|
||||
return {
|
||||
state,
|
||||
start: () => instance.hooks.start.invoke(),
|
||||
configChanged: (...args) => instance.hooks.instanceConfigFieldChanged.invoke(...args),
|
||||
roleUpdated: event => handlers.get(messages.RoleUpdatedEvent)(event),
|
||||
assignmentUpdated: event => handlers.get(messages.AssignmentUpdatedEvent)(event),
|
||||
assignmentIPC: event => handlers.get("exp_roles:assignment_update")(event),
|
||||
};
|
||||
}
|
||||
|
||||
/** The lua receiver and payload of a recorded rcon command. */
|
||||
@@ -94,10 +103,10 @@ t.test("entrypoint", async t2 => {
|
||||
}
|
||||
});
|
||||
|
||||
t.test("class InstancePlugin", t2 => {
|
||||
t2.test(".onStart() subscribes and initialises the lua module", async t3 => {
|
||||
const { plugin, state } = await startPlugin(t3, { syncMode: "enabled" });
|
||||
await plugin.onStart();
|
||||
t.test("instance plugin", t2 => {
|
||||
t2.test("start subscribes and initialises the lua module", async t3 => {
|
||||
const { state, start } = await startPlugin(t3, { syncMode: "enabled" });
|
||||
await start();
|
||||
|
||||
const subscribed = state.sent.filter(request => request instanceof lib.SubscriptionRequest);
|
||||
t3.strictSame(subscribed.length, 2, "roles and assignments are subscribed to");
|
||||
@@ -119,76 +128,76 @@ t.test("class InstancePlugin", t2 => {
|
||||
t3.strictSame(state.warnings.length, 0, "no warnings with a default role");
|
||||
});
|
||||
|
||||
t2.test(".onStart() with bidirectional emits changes back", async t3 => {
|
||||
const { plugin, state } = await startPlugin(t3);
|
||||
await plugin.onStart();
|
||||
t2.test("start with bidirectional emits changes back", async t3 => {
|
||||
const { state, start } = await startPlugin(t3);
|
||||
await start();
|
||||
const emit = decodeRcon(state.rcons[state.rcons.length - 1]);
|
||||
t3.strictSame([emit.receiver, emit.payload], ["set_emit_events", true]);
|
||||
});
|
||||
|
||||
t2.test(".onStart() with disabled does nothing", async t3 => {
|
||||
const { plugin, state } = await startPlugin(t3, { syncMode: "disabled" });
|
||||
await plugin.onStart();
|
||||
t2.test("start with disabled does nothing", async t3 => {
|
||||
const { state, start } = await startPlugin(t3, { syncMode: "disabled" });
|
||||
await start();
|
||||
t3.strictSame(state.sent.length, 0, "nothing is sent");
|
||||
t3.strictSame(state.rcons.length, 0, "no commands are run");
|
||||
});
|
||||
|
||||
t2.test(".onStart() warns when no default role is set", async t3 => {
|
||||
t2.test("start warns when no default role is set", async t3 => {
|
||||
const roles = sampleRoles().map(record => { record.isDefault = false; return record; });
|
||||
const { plugin, state } = await startPlugin(t3, { roles });
|
||||
await plugin.onStart();
|
||||
const { state, start } = await startPlugin(t3, { roles });
|
||||
await start();
|
||||
t3.ok(state.warnings.some(message => /default role/.test(message)), "the warning names the default role");
|
||||
});
|
||||
|
||||
t2.test(".handleRoleUpdatedEvent() and .handleAssignmentUpdatedEvent() forward to lua", async t3 => {
|
||||
const { plugin, state } = await startPlugin(t3);
|
||||
await plugin.handleRoleUpdatedEvent(new messages.RoleUpdatedEvent(sampleRoles()));
|
||||
t2.test("role and assignment updates forward to lua", async t3 => {
|
||||
const { state, roleUpdated, assignmentUpdated } = await startPlugin(t3);
|
||||
await roleUpdated(new messages.RoleUpdatedEvent(sampleRoles()));
|
||||
const roles = decodeRcon(state.rcons[0]);
|
||||
t3.strictSame(roles.receiver, "receive_role_updates", "role updates are forwarded");
|
||||
t3.strictSame(roles.payload[0].name, "Player", "the records are sent in plain form");
|
||||
|
||||
await plugin.handleAssignmentUpdatedEvent(new messages.AssignmentUpdatedEvent(sampleAssignments()));
|
||||
await assignmentUpdated(new messages.AssignmentUpdatedEvent(sampleAssignments()));
|
||||
const assignments = decodeRcon(state.rcons[1]);
|
||||
t3.strictSame(assignments.receiver, "receive_assignment_updates", "assignment updates are forwarded");
|
||||
});
|
||||
|
||||
t2.test(".handleRoleUpdatedEvent() and .handleAssignmentUpdatedEvent() are gated while disabled", async t3 => {
|
||||
const { plugin, state } = await startPlugin(t3, { syncMode: "disabled" });
|
||||
await plugin.handleRoleUpdatedEvent(new messages.RoleUpdatedEvent(sampleRoles()));
|
||||
await plugin.handleAssignmentUpdatedEvent(new messages.AssignmentUpdatedEvent(sampleAssignments()));
|
||||
t2.test("role and assignment updates are gated while disabled", async t3 => {
|
||||
const { state, roleUpdated, assignmentUpdated } = await startPlugin(t3, { syncMode: "disabled" });
|
||||
await roleUpdated(new messages.RoleUpdatedEvent(sampleRoles()));
|
||||
await assignmentUpdated(new messages.AssignmentUpdatedEvent(sampleAssignments()));
|
||||
t3.strictSame(state.rcons.length, 0, "no commands are run");
|
||||
});
|
||||
|
||||
t2.test(".handleAssignmentUpdateIPC() sends the change and rolls back a refusal", async t3 => {
|
||||
const { plugin, state } = await startPlugin(t3);
|
||||
await plugin.handleAssignmentUpdateIPC({ name: "alice", assign: [5], unassign: undefined });
|
||||
t2.test("exp_roles:assignment_update sends the change and rolls back a refusal", async t3 => {
|
||||
const { state, assignmentIPC } = await startPlugin(t3);
|
||||
await assignmentIPC({ name: "alice", assign: [5], unassign: undefined });
|
||||
const request = state.sent[0];
|
||||
t3.ok(request instanceof messages.AssignmentUpdateRequest, "the change is sent as a request");
|
||||
t3.strictSame([request.name, request.assign, request.unassign], ["alice", [5], []], "the request names the change");
|
||||
|
||||
await plugin.handleAssignmentUpdateIPC({ name: "alice", assign: undefined, unassign: [5] });
|
||||
await assignmentIPC({ name: "alice", assign: undefined, unassign: [5] });
|
||||
t3.strictSame(state.sent[1].unassign, [5], "removals are sent the same way");
|
||||
|
||||
state.failNext = new Error("User 'alice' does not exist");
|
||||
await plugin.handleAssignmentUpdateIPC({ name: "alice", assign: [5], unassign: undefined });
|
||||
await assignmentIPC({ name: "alice", assign: [5], unassign: undefined });
|
||||
const rollback = decodeRcon(state.rcons[0]);
|
||||
t3.strictSame(rollback.receiver, "reject_assignment", "the refusal is rolled back in lua");
|
||||
t3.strictSame(rollback.payload, { name: "alice", role_ids: [5] }, "the rollback names the refused roles");
|
||||
|
||||
state.failNext = new Error("User 'alice' does not exist");
|
||||
await plugin.handleAssignmentUpdateIPC({ name: "alice", assign: undefined, unassign: [5] });
|
||||
await assignmentIPC({ name: "alice", assign: undefined, unassign: [5] });
|
||||
t3.strictSame(state.rcons.length, 1, "a refused removal has nothing to roll back");
|
||||
});
|
||||
|
||||
t2.test(".handleAssignmentUpdateIPC() is gated unless bidirectional", async t3 => {
|
||||
const { plugin, state } = await startPlugin(t3, { syncMode: "enabled" });
|
||||
await plugin.handleAssignmentUpdateIPC({ name: "alice", assign: [5], unassign: undefined });
|
||||
t2.test("exp_roles:assignment_update is gated unless bidirectional", async t3 => {
|
||||
const { state, assignmentIPC } = await startPlugin(t3, { syncMode: "enabled" });
|
||||
await assignmentIPC({ name: "alice", assign: [5], unassign: undefined });
|
||||
t3.strictSame(state.sent.length, 0, "nothing is sent");
|
||||
});
|
||||
|
||||
t2.test(".onInstanceConfigFieldChanged() toggles emit with the sync mode", async t3 => {
|
||||
const { plugin, state } = await startPlugin(t3);
|
||||
await plugin.onInstanceConfigFieldChanged("exp_roles.sync_mode", "enabled", "bidirectional");
|
||||
t2.test("changing the sync mode toggles emit with the sync mode", async t3 => {
|
||||
const { state, configChanged } = await startPlugin(t3);
|
||||
await configChanged("exp_roles.sync_mode", "enabled", "bidirectional");
|
||||
const emit = decodeRcon(state.rcons[0]);
|
||||
t3.strictSame([emit.receiver, emit.payload], ["set_emit_events", false], "leaving bidirectional stops emitting");
|
||||
});
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
webpack.config.js
|
||||
dist/web/build
|
||||
dist/browser
|
||||
# Without an .npmignore pnpm packs using the repository .gitignore, which leaves out dist/
|
||||
+138
-121
@@ -1,6 +1,6 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import * as path from "node:path";
|
||||
import type { Controller, ControllerPluginContext } from "@clusterio/controller";
|
||||
import type { ControllerPluginContext } from "@clusterio/controller";
|
||||
import { RoleMetaRecord } from "@expcluster/roles";
|
||||
import { GroupRecord, GroupPermissions, RoleMappingRecord } from "@expcluster/permission-groups";
|
||||
import { ControllerPlugin as RolesPlugin } from "@expcluster/roles/dist/node/controller.js";
|
||||
@@ -9,149 +9,79 @@ import * as messages from "./messages.js";
|
||||
import { features, pruneFeatureValues, validateFeatureValues } from "./features.js";
|
||||
import { SeedRole, SeedGroup, seedRoles, seedGroups, flattenSeedPermissions } from "./seed.js";
|
||||
|
||||
export class ControllerPlugin {
|
||||
private constructor(
|
||||
public controller: Controller,
|
||||
public logger: lib.Logger,
|
||||
public name: string,
|
||||
public features: lib.SubscribableDatastore<messages.FeatureRecord>,
|
||||
) {}
|
||||
export default async function (context: ControllerPluginContext) {
|
||||
const { controller, logger, plugin } = context;
|
||||
const databaseDirectory = controller.config.get("controller.database_directory");
|
||||
const featureStore = new lib.SubscribableDatastore(
|
||||
...await new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "exp_scenario", "features.json"),
|
||||
messages.FeatureRecord.fromJSON.bind(messages.FeatureRecord),
|
||||
).bootstrap()
|
||||
);
|
||||
const playerStore = new lib.SubscribableDatastore(
|
||||
...await new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "exp_scenario", "players.json"),
|
||||
messages.PlayerRecord.fromJSON.bind(messages.PlayerRecord),
|
||||
).bootstrap()
|
||||
);
|
||||
|
||||
static async fromContext(context: ControllerPluginContext) {
|
||||
const controller = context.controller;
|
||||
const databaseDirectory = controller.config.get("controller.database_directory");
|
||||
const features = new lib.SubscribableDatastore(
|
||||
...await new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "exp_scenario", "features.json"),
|
||||
messages.FeatureRecord.fromJSON.bind(messages.FeatureRecord),
|
||||
).bootstrap()
|
||||
type PlayerSettings = Pick<messages.PlayerRecord, "joinMessage" | "locale" | "toolbar">;
|
||||
|
||||
/** Change some settings of a player, empty values are removed and so is a record left empty */
|
||||
function updatePlayer(name: string, changes: PlayerSettings) {
|
||||
const old = playerStore.get(name);
|
||||
const settings: PlayerSettings = {
|
||||
joinMessage: old?.joinMessage, locale: old?.locale, toolbar: old?.toolbar, ...changes,
|
||||
};
|
||||
const record = new messages.PlayerRecord(
|
||||
name, settings.joinMessage || undefined, settings.locale || undefined, settings.toolbar || undefined,
|
||||
);
|
||||
|
||||
const plugin = new ControllerPlugin(controller, context.logger, context.plugin.name, features);
|
||||
plugin.reconcileFeatures();
|
||||
|
||||
controller.subscriptions.handle(messages.FeatureUpdatedEvent, plugin.handleFeatureSubscription.bind(plugin));
|
||||
features.on("update", plugin.featuresUpdated.bind(plugin));
|
||||
|
||||
controller.handle(messages.SeedRequest, plugin.handleSeedRequest.bind(plugin));
|
||||
controller.handle(messages.FeatureListRequest, plugin.handleFeatureListRequest.bind(plugin));
|
||||
controller.handle(messages.FeatureUpdateRequest, plugin.handleFeatureUpdateRequest.bind(plugin));
|
||||
|
||||
controller.hooks.shutdown.attach(plugin.name, plugin.onShutdown.bind(plugin));
|
||||
return plugin;
|
||||
}
|
||||
|
||||
async onShutdown() {
|
||||
await this.features.save();
|
||||
if (record.joinMessage || record.locale || record.toolbar) {
|
||||
playerStore.set(record);
|
||||
} else if (old) {
|
||||
playerStore.delete(old);
|
||||
}
|
||||
}
|
||||
|
||||
/** Add new features, delete removed ones, and drop stored values which no longer fit a field */
|
||||
reconcileFeatures() {
|
||||
function reconcileFeatures() {
|
||||
const declared = new Map(features.map(feature => [feature.name, feature]));
|
||||
for (const record of this.features.values()) {
|
||||
for (const record of featureStore.values()) {
|
||||
const feature = declared.get(record.id);
|
||||
if (!feature) {
|
||||
this.logger.warn(`Dropping stored config of removed feature ${record.id}`);
|
||||
this.features.delete(record);
|
||||
logger.warn(`Dropping stored config of removed feature ${record.id}`);
|
||||
featureStore.delete(record);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { kept, dropped } = pruneFeatureValues(feature, record.values);
|
||||
if (dropped.length) {
|
||||
this.logger.warn(`Dropping stored values of ${record.id} which no longer match a setting: ${dropped.join(", ")}`);
|
||||
this.features.set(new messages.FeatureRecord(record.id, record.enabled, kept));
|
||||
logger.warn(`Dropping stored values of ${record.id} which no longer match a setting: ${dropped.join(", ")}`);
|
||||
featureStore.set(new messages.FeatureRecord(record.id, record.enabled, kept));
|
||||
}
|
||||
}
|
||||
|
||||
const missing = features.filter(feature => !this.features.has(feature.name));
|
||||
const missing = features.filter(feature => !featureStore.has(feature.name));
|
||||
if (missing.length) {
|
||||
this.features.setMany(missing.map(feature => new messages.FeatureRecord(feature.name, true)));
|
||||
featureStore.setMany(missing.map(feature => new messages.FeatureRecord(feature.name, true)));
|
||||
}
|
||||
}
|
||||
|
||||
featuresUpdated(updates: messages.FeatureRecord[]) {
|
||||
this.controller.subscriptions.broadcast(new messages.FeatureUpdatedEvent(updates));
|
||||
}
|
||||
|
||||
async handleFeatureSubscription(request: lib.SubscriptionRequest) {
|
||||
const updates = [...this.features.values()].filter(feature => feature.updatedAtMs > request.lastRequestTimeMs);
|
||||
return updates.length ? new messages.FeatureUpdatedEvent(updates) : null;
|
||||
}
|
||||
|
||||
async handleFeatureListRequest() {
|
||||
return [...this.features.values()];
|
||||
}
|
||||
|
||||
async handleFeatureUpdateRequest(request: messages.FeatureUpdateRequest) {
|
||||
let values;
|
||||
try {
|
||||
values = validateFeatureValues(request.id, request.values);
|
||||
} catch (err: any) {
|
||||
throw new lib.RequestError(err.message);
|
||||
}
|
||||
|
||||
const feature = new messages.FeatureRecord(request.id, request.enabled, values);
|
||||
this.features.set(feature);
|
||||
return feature;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the roles and permission groups the scenario shipped with.
|
||||
*
|
||||
* Roles which already exist by name are reused and only gain the seed
|
||||
* permissions, groups which already exist by name are reset to the seed.
|
||||
*/
|
||||
async handleSeedRequest() {
|
||||
const rolesPlugin = RolesPlugin.get(this.controller);
|
||||
const groupsPlugin = GroupsPlugin.get(this.controller);
|
||||
if (!rolesPlugin || !groupsPlugin) {
|
||||
throw new lib.RequestError("Seeding requires the exp_roles and exp_groups plugins");
|
||||
}
|
||||
|
||||
const roleIds = new Map<string, number>();
|
||||
for (const [index, seedRole] of seedRoles.entries()) {
|
||||
const role = this.seedRole(seedRole);
|
||||
if (!role) {
|
||||
continue;
|
||||
}
|
||||
|
||||
roleIds.set(seedRole.name, role.id);
|
||||
rolesPlugin.roleMeta.set(new RoleMetaRecord(
|
||||
role.id,
|
||||
index + 1,
|
||||
seedRole.priority ?? 0,
|
||||
seedRole.shortHand,
|
||||
"",
|
||||
seedRole.color,
|
||||
seedRole.autoAssignHours === undefined ? null : seedRole.autoAssignHours * 3600000,
|
||||
seedRole.blockAutoAssign ?? false,
|
||||
));
|
||||
}
|
||||
|
||||
const groupIds = new Map<string, number>();
|
||||
for (const seedGroup of seedGroups) {
|
||||
groupIds.set(seedGroup.name, this.seedGroup(groupsPlugin, seedGroup).id);
|
||||
}
|
||||
|
||||
this.seedRoleMappings(groupsPlugin, roleIds, groupIds);
|
||||
this.logger.info(`Seeded ${roleIds.size} roles and ${groupIds.size} permission groups`);
|
||||
}
|
||||
|
||||
/** Find or create the clusterio role for a seed role, returns undefined if it has no role to use. */
|
||||
seedRole(seedRole: SeedRole) {
|
||||
const roles = this.controller.roles;
|
||||
function ensureRole(seedRole: SeedRole) {
|
||||
const roles = controller.roles;
|
||||
if (seedRole.isAdmin) {
|
||||
return roles.get(lib.Role.DefaultAdminRoleId);
|
||||
}
|
||||
if (seedRole.isDefault) {
|
||||
const defaultRoleId = this.controller.config.get("controller.default_role_id");
|
||||
const defaultRoleId = controller.config.get("controller.default_role_id");
|
||||
return defaultRoleId !== null ? roles.get(defaultRoleId) : undefined;
|
||||
}
|
||||
|
||||
const permissions = flattenSeedPermissions(seedRole);
|
||||
for (const permission of permissions) {
|
||||
if (!lib.permissions.has(permission)) {
|
||||
this.logger.warn(`Seed role ${seedRole.name} grants unknown permission ${permission}`);
|
||||
logger.warn(`Seed role ${seedRole.name} grants unknown permission ${permission}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,19 +93,19 @@ export class ControllerPlugin {
|
||||
} else {
|
||||
const id = Math.max(5, ...[...roles.keys()].map(other => other + 1));
|
||||
role = new lib.Role(id, seedRole.name, "", permissions);
|
||||
this.logger.info(`Created role ${seedRole.name}`);
|
||||
logger.info(`Created role ${seedRole.name}`);
|
||||
}
|
||||
roles.set(role);
|
||||
return role;
|
||||
}
|
||||
|
||||
/** Find or create the permission group for a seed group. */
|
||||
seedGroup(groupsPlugin: GroupsPlugin, seedGroup: SeedGroup) {
|
||||
function ensureGroup(groupsPlugin: GroupsPlugin, seedGroup: SeedGroup) {
|
||||
const permissions = new GroupPermissions(seedGroup.isBlacklist, [...seedGroup.inputActions]);
|
||||
const existing = [...groupsPlugin.groups.values()].find(other => other.name === seedGroup.name);
|
||||
const group = new GroupRecord(existing?.id ?? newId(groupsPlugin.groups), seedGroup.name, permissions);
|
||||
if (!existing) {
|
||||
this.logger.info(`Created permission group ${seedGroup.name}`);
|
||||
logger.info(`Created permission group ${seedGroup.name}`);
|
||||
}
|
||||
groupsPlugin.groups.set(group);
|
||||
return group;
|
||||
@@ -188,7 +118,7 @@ export class ControllerPlugin {
|
||||
* priorities follow how exp_roles picks a player's highest role: role
|
||||
* priority first, then the role order.
|
||||
*/
|
||||
seedRoleMappings(groupsPlugin: GroupsPlugin, roleIds: Map<string, number>, groupIds: Map<string, number>) {
|
||||
function seedRoleMappings(groupsPlugin: GroupsPlugin, roleIds: Map<string, number>, groupIds: Map<string, number>) {
|
||||
// Lowest role first, so the mapping priority rises with the role
|
||||
const ranked = seedRoles
|
||||
.filter(seedRole => seedRole.group !== undefined && roleIds.has(seedRole.name))
|
||||
@@ -227,12 +157,99 @@ export class ControllerPlugin {
|
||||
}
|
||||
|
||||
groupsPlugin.roleMappings.setMany(mappings);
|
||||
return mappings;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function (context: ControllerPluginContext) {
|
||||
await ControllerPlugin.fromContext(context);
|
||||
reconcileFeatures();
|
||||
|
||||
controller.subscriptions.handle(messages.FeatureUpdatedEvent, async request => {
|
||||
const updates = [...featureStore.values()].filter(feature => feature.updatedAtMs > request.lastRequestTimeMs);
|
||||
return updates.length ? new messages.FeatureUpdatedEvent(updates) : null;
|
||||
});
|
||||
featureStore.on("update", updates => {
|
||||
controller.subscriptions.broadcast(new messages.FeatureUpdatedEvent(updates));
|
||||
});
|
||||
|
||||
/**
|
||||
* Create the roles and permission groups the scenario shipped with.
|
||||
*
|
||||
* Roles which already exist by name are reused and only gain the seed
|
||||
* permissions, groups which already exist by name are reset to the seed.
|
||||
*/
|
||||
controller.handle(messages.SeedRequest, async () => {
|
||||
const rolesPlugin = RolesPlugin.get(controller);
|
||||
const groupsPlugin = GroupsPlugin.get(controller);
|
||||
if (!rolesPlugin || !groupsPlugin) {
|
||||
throw new lib.RequestError("Seeding requires the exp_roles and exp_groups plugins");
|
||||
}
|
||||
|
||||
const roleIds = new Map<string, number>();
|
||||
for (const [index, seed] of seedRoles.entries()) {
|
||||
const role = ensureRole(seed);
|
||||
if (!role) {
|
||||
continue;
|
||||
}
|
||||
|
||||
roleIds.set(seed.name, role.id);
|
||||
rolesPlugin.roleMeta.set(new RoleMetaRecord(
|
||||
role.id,
|
||||
index + 1,
|
||||
seed.priority ?? 0,
|
||||
seed.shortHand,
|
||||
"",
|
||||
seed.color,
|
||||
seed.autoAssignHours === undefined ? null : seed.autoAssignHours * 3600000,
|
||||
seed.blockAutoAssign ?? false,
|
||||
));
|
||||
}
|
||||
|
||||
const groupIds = new Map<string, number>();
|
||||
for (const seed of seedGroups) {
|
||||
groupIds.set(seed.name, ensureGroup(groupsPlugin, seed).id);
|
||||
}
|
||||
|
||||
seedRoleMappings(groupsPlugin, roleIds, groupIds);
|
||||
logger.info(`Seeded ${roleIds.size} roles and ${groupIds.size} permission groups`);
|
||||
});
|
||||
|
||||
controller.handle(messages.FeatureListRequest, async () => [...featureStore.values()]);
|
||||
|
||||
controller.handle(messages.FeatureUpdateRequest, async (request: messages.FeatureUpdateRequest) => {
|
||||
let values;
|
||||
try {
|
||||
values = validateFeatureValues(request.id, request.values);
|
||||
} catch (err: any) {
|
||||
throw new lib.RequestError(err.message);
|
||||
}
|
||||
|
||||
const feature = new messages.FeatureRecord(request.id, request.enabled, values);
|
||||
featureStore.set(feature);
|
||||
return feature;
|
||||
});
|
||||
|
||||
controller.handle(messages.PlayerGetRequest, async (request: messages.PlayerGetRequest) => (
|
||||
playerStore.get(request.playerName) ?? new messages.PlayerRecord(request.playerName)
|
||||
));
|
||||
|
||||
controller.handle(messages.JoinMessageSetRequest, async (request: messages.JoinMessageSetRequest) => {
|
||||
updatePlayer(request.playerName, { joinMessage: request.message?.trim() });
|
||||
});
|
||||
|
||||
controller.handle(messages.PlayerLocaleEvent, async (event: messages.PlayerLocaleEvent) => {
|
||||
updatePlayer(event.playerName, { locale: event.locale });
|
||||
});
|
||||
|
||||
controller.handle(messages.PlayerToolbarEvent, async (event: messages.PlayerToolbarEvent) => {
|
||||
updatePlayer(event.playerName, { toolbar: event.toolbar });
|
||||
});
|
||||
|
||||
controller.hooks.save.attach(plugin.name, async () => {
|
||||
await playerStore.save();
|
||||
});
|
||||
|
||||
controller.hooks.shutdown.attach(plugin.name, async () => {
|
||||
await featureStore.save();
|
||||
await playerStore.save();
|
||||
});
|
||||
}
|
||||
|
||||
function newId(datastore: { has(id: number): boolean }) {
|
||||
|
||||
@@ -81,6 +81,19 @@ export const features: Feature[] = [
|
||||
str("command_prefix", "Command prefix", "Prefix for chat commands, and for key words to reply to everyone", "!"),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "community_details",
|
||||
title: "Community Details",
|
||||
description: "The community name and links shown in chat, the readme, help bubbles, and the greeting. These are always used, the switch has no effect",
|
||||
fields: [
|
||||
str("community_name", "Community name", "Name of the community", "Explosive Gaming"),
|
||||
str("discord", "Discord", "Invite link to the Discord server", "https://discord.explosivegaming.nl"),
|
||||
str("website", "Website", "Link to the website", "https://www.explosivegaming.nl"),
|
||||
str("status", "Status page", "Link to the server status page", "https://status.explosivegaming.nl"),
|
||||
str("github", "GitHub", "Link to the source code", "https://github.com/explosivegaming/ExpCluster"),
|
||||
str("patreon", "Patreon", "Link to donate", "https://www.patreon.com/ExpGaming"),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "custom_start",
|
||||
title: "Custom Start",
|
||||
@@ -161,6 +174,12 @@ export const features: Feature[] = [
|
||||
num("rocket_launch_display_rate", "Rocket log interval", "After the first few launches, log every this many rockets", 500, 1, "rockets"),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "greetings",
|
||||
title: "Greetings",
|
||||
description: "Greet players when they join and show the join message they set, the messages are edited on the user page",
|
||||
fields: [],
|
||||
},
|
||||
{
|
||||
name: "help_bubbles",
|
||||
title: "Help Bubbles",
|
||||
|
||||
@@ -8,6 +8,8 @@ declare module "@clusterio/lib" {
|
||||
"exp_scenario.config.view": never;
|
||||
"exp_scenario.config.edit": never;
|
||||
"exp_scenario.seed": never;
|
||||
"exp_scenario.join_message.set": never;
|
||||
"exp_scenario.join_message.set_other": never;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +25,10 @@ export const plugin: lib.PluginDeclaration = {
|
||||
messages.FeatureUpdatedEvent,
|
||||
messages.FeatureListRequest,
|
||||
messages.FeatureUpdateRequest,
|
||||
messages.PlayerGetRequest,
|
||||
messages.JoinMessageSetRequest,
|
||||
messages.PlayerLocaleEvent,
|
||||
messages.PlayerToolbarEvent,
|
||||
],
|
||||
|
||||
permissions: [
|
||||
@@ -42,6 +48,16 @@ export const plugin: lib.PluginDeclaration = {
|
||||
title: "Seed ExpScenario roles and groups",
|
||||
description: "Create the roles and permission groups the scenario shipped with",
|
||||
},
|
||||
{
|
||||
name: "exp_scenario.join_message.set",
|
||||
title: "Set own join message",
|
||||
description: "Set the message shown to everyone when you join, in game this is /set-join-message",
|
||||
},
|
||||
{
|
||||
name: "exp_scenario.join_message.set_other",
|
||||
title: "Set join messages of others",
|
||||
description: "Set the message shown to everyone when another user joins",
|
||||
},
|
||||
],
|
||||
|
||||
webEntrypoint: "./web",
|
||||
|
||||
+104
-58
@@ -1,86 +1,132 @@
|
||||
import type { Instance, InstancePluginContext } from "@clusterio/host";
|
||||
import type { InstancePluginContext } from "@clusterio/host";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import * as messages from "./messages.js";
|
||||
import { features, FeatureValue } from "./features.js";
|
||||
|
||||
/** Sends feature config to the lua side, see module/features.lua. */
|
||||
export class InstancePlugin {
|
||||
/** Sent by the lua side when a player sets or removes their join message, see module/control/greetings.lua */
|
||||
export type IpcJoinMessageSet = { player_name: string, message?: string };
|
||||
/** Sent by the lua side when a player asks for their join message */
|
||||
export type IpcJoinMessageGet = { player_name: string };
|
||||
/** Sent by the lua side when a player joins or changes language, see module/control/player_settings.lua */
|
||||
export type IpcLocale = { player_name: string, locale: string };
|
||||
/** Sent by the lua side when a player leaves */
|
||||
export type IpcToolbar = { player_name: string, toolbar: string };
|
||||
|
||||
/** Json as a lua expression for a table, inside a long bracket the json does not contain */
|
||||
export function luaJson(value: unknown) {
|
||||
const json = JSON.stringify(value);
|
||||
let level = "=";
|
||||
while (json.includes(`]${level}]`)) {
|
||||
level += "=";
|
||||
}
|
||||
return `helpers.json_to_table[${level}[${json}]${level}]`;
|
||||
}
|
||||
|
||||
/** Sends feature config and player settings to the lua side, see module/features.lua. */
|
||||
export default async function (context: InstancePluginContext) {
|
||||
const { instance, logger, plugin } = context;
|
||||
/** Rcon is up and the lua side can take updates */
|
||||
started = false;
|
||||
|
||||
private constructor(
|
||||
public instance: Instance,
|
||||
public logger: lib.Logger,
|
||||
public name: string,
|
||||
) {}
|
||||
|
||||
static async fromContext(context: InstancePluginContext) {
|
||||
const instance = context.instance;
|
||||
const plugin = new InstancePlugin(instance, context.logger, context.plugin.name);
|
||||
|
||||
instance.handle(messages.FeatureUpdatedEvent, plugin.handleFeatureUpdatedEvent.bind(plugin));
|
||||
instance.hooks.start.attach(plugin.name, plugin.onStart.bind(plugin));
|
||||
instance.hooks.exit.attach(plugin.name, plugin.onExit.bind(plugin));
|
||||
instance.hooks.controllerConnectionEvent.attach(plugin.name, plugin.onControllerConnectionEvent.bind(plugin));
|
||||
return plugin;
|
||||
}
|
||||
|
||||
async onStart() {
|
||||
this.started = true;
|
||||
await this.syncFeatures();
|
||||
}
|
||||
|
||||
async onExit() {
|
||||
this.started = false;
|
||||
}
|
||||
|
||||
/** A new session means the controller restarted and forgot the subscription. */
|
||||
async onControllerConnectionEvent(event: "connect" | "drop" | "resume" | "close") {
|
||||
if (event === "connect" && this.started) {
|
||||
await this.syncFeatures();
|
||||
}
|
||||
}
|
||||
let started = false;
|
||||
/** Enabled state of features which need it on this side, by name */
|
||||
const featureEnabled = new Map<string, boolean>();
|
||||
|
||||
/** Subscribe, then send every feature */
|
||||
async syncFeatures() {
|
||||
await this.instance.sendTo("controller", new lib.SubscriptionRequest(
|
||||
async function syncFeatures() {
|
||||
await instance.sendTo("controller", new lib.SubscriptionRequest(
|
||||
`exp_scenario:${messages.FeatureUpdatedEvent.name}`, "subscribe", Date.now(),
|
||||
));
|
||||
const features = await this.instance.sendTo("controller", new messages.FeatureListRequest());
|
||||
await this.luaSendFeatures(features);
|
||||
}
|
||||
|
||||
async handleFeatureUpdatedEvent(event: messages.FeatureUpdatedEvent) {
|
||||
if (this.started) {
|
||||
await this.luaSendFeatures(event.updates.filter(feature => !feature.isDeleted));
|
||||
}
|
||||
const records = await instance.sendTo("controller", new messages.FeatureListRequest());
|
||||
await luaSendFeatures(records);
|
||||
}
|
||||
|
||||
/** Every value of each feature is sent, so the lua side sets them all */
|
||||
async luaSendFeatures(records: messages.FeatureRecord[]) {
|
||||
async function luaSendFeatures(records: messages.FeatureRecord[]) {
|
||||
if (!records.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updates = records.map(record => {
|
||||
featureEnabled.set(record.id, record.enabled);
|
||||
const values: Record<string, FeatureValue> = { enabled: record.enabled };
|
||||
for (const field of features.find(feature => feature.name === record.id)?.fields ?? []) {
|
||||
values[field.name] = field.name in record.values ? record.values[field.name] : field.default;
|
||||
}
|
||||
return { name: record.id, values };
|
||||
});
|
||||
const json = JSON.stringify(updates);
|
||||
// Pick a long bracket the json does not contain
|
||||
let level = "=";
|
||||
while (json.includes(`]${level}]`)) {
|
||||
level += "=";
|
||||
await instance.sendRcon(`/sc exp_scenario.features.receive_update(${luaJson(updates)})`, true);
|
||||
}
|
||||
|
||||
/** Run lua with the player as `p` if they are still in the game, the data is available as `d` */
|
||||
async function luaWithPlayer(name: string, data: Record<string, unknown>, code: string) {
|
||||
if (instance.status !== "running") {
|
||||
return;
|
||||
}
|
||||
await this.instance.sendRcon(
|
||||
`/sc exp_scenario.features.receive_update(helpers.json_to_table[${level}[${json}]${level}])`, true,
|
||||
await instance.sendRcon(
|
||||
`/sc local d = ${luaJson({ ...data, name })} local p = game.get_player(d.name) if p then ${code} end`, true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default async function (context: InstancePluginContext) {
|
||||
await InstancePlugin.fromContext(context);
|
||||
async function onPlayerJoin(name: string) {
|
||||
const record = await instance.sendTo("controller", new messages.PlayerGetRequest(name));
|
||||
if (record.joinMessage && featureEnabled.get("greetings") !== false) {
|
||||
await luaWithPlayer(name, { message: record.joinMessage }, "game.print(d.message, { color = p.color })");
|
||||
}
|
||||
if (record.toolbar) {
|
||||
await luaWithPlayer(
|
||||
name, { toolbar: record.toolbar }, "exp_scenario.player_settings.receive_toolbar(d.name, d.toolbar)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
instance.handle(messages.FeatureUpdatedEvent, async event => {
|
||||
if (started) {
|
||||
await luaSendFeatures(event.updates.filter(feature => !feature.isDeleted));
|
||||
}
|
||||
});
|
||||
|
||||
instance.server.handle("exp_scenario:join_message_set", async (event: IpcJoinMessageSet) => {
|
||||
const request = new messages.JoinMessageSetRequest(event.player_name, event.message ?? null);
|
||||
await instance.sendTo("controller", request);
|
||||
});
|
||||
|
||||
instance.server.handle("exp_scenario:join_message_get", async (event: IpcJoinMessageGet) => {
|
||||
const record = await instance.sendTo("controller", new messages.PlayerGetRequest(event.player_name));
|
||||
await luaWithPlayer(
|
||||
event.player_name,
|
||||
record.joinMessage ? { message: record.joinMessage } : {},
|
||||
"p.print(d.message and { \"exp_greetings.message-get\", d.message } or { \"exp_greetings.message-none\" })",
|
||||
);
|
||||
});
|
||||
|
||||
instance.server.handle("exp_scenario:locale", async (event: IpcLocale) => {
|
||||
instance.sendTo("controller", new messages.PlayerLocaleEvent(event.player_name, event.locale));
|
||||
});
|
||||
|
||||
instance.server.handle("exp_scenario:toolbar", async (event: IpcToolbar) => {
|
||||
instance.sendTo("controller", new messages.PlayerToolbarEvent(event.player_name, event.toolbar));
|
||||
});
|
||||
|
||||
instance.hooks.playerEvent.attach(plugin.name, async event => {
|
||||
if (event.type === "join") {
|
||||
await onPlayerJoin(event.name).catch(err => logger.error(
|
||||
`Error sending the settings of ${event.name}:\n${err.stack ?? err.message}`,
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
instance.hooks.start.attach(plugin.name, async () => {
|
||||
started = true;
|
||||
await syncFeatures();
|
||||
});
|
||||
|
||||
instance.hooks.exit.attach(plugin.name, async () => {
|
||||
started = false;
|
||||
});
|
||||
|
||||
// A new session means the controller restarted and forgot the subscription
|
||||
instance.hooks.controllerConnectionEvent.attach(plugin.name, async event => {
|
||||
if (event === "connect" && started) {
|
||||
await syncFeatures();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Country of each Factorio locale without a region in its code, used for the
|
||||
* flag. Languages spoken across many countries or without one, such as ar,
|
||||
* ca, eo, and eu, are left out and get no flag.
|
||||
*/
|
||||
const localeCountries: Record<string, string> = {
|
||||
af: "ZA", be: "BY", bg: "BG", cs: "CZ", da: "DK", de: "DE", el: "GR", en: "GB", et: "EE",
|
||||
fa: "IR", fi: "FI", fil: "PH", fr: "FR", he: "IL", hr: "HR", hu: "HU", id: "ID", is: "IS",
|
||||
it: "IT", ja: "JP", ka: "GE", kk: "KZ", ko: "KR", lt: "LT", lv: "LV", nl: "NL", no: "NO",
|
||||
pl: "PL", ro: "RO", ru: "RU", sk: "SK", sl: "SI", sq: "AL", sr: "RS", th: "TH", tr: "TR",
|
||||
uk: "UA", vi: "VN",
|
||||
};
|
||||
|
||||
/** Flag emoji for a Factorio locale such as "de" or "pt-BR", empty when it has no obvious country. */
|
||||
export function localeFlag(locale: string) {
|
||||
const [language, region] = locale.split("-");
|
||||
const country = region ?? localeCountries[language];
|
||||
if (!country || !/^[A-Z]{2}$/.test(country)) {
|
||||
return "";
|
||||
}
|
||||
// Regional indicator symbols start at U+1F1E6 for A
|
||||
return String.fromCodePoint(...[...country].map(letter => 0x1F1E6 + letter.charCodeAt(0) - 65));
|
||||
}
|
||||
|
||||
/** English name of a locale, the code itself when it is not known. */
|
||||
export function localeName(locale: string) {
|
||||
try {
|
||||
return new Intl.DisplayNames(["en"], { type: "language" }).of(locale) ?? locale;
|
||||
} catch {
|
||||
return locale;
|
||||
}
|
||||
}
|
||||
@@ -126,3 +126,187 @@ export class FeatureUpdateRequest {
|
||||
return new this(json.id, json.enabled, json.values);
|
||||
}
|
||||
}
|
||||
|
||||
/** Settings of a player kept by the controller, by player name. Only set values are stored. */
|
||||
export class PlayerRecord {
|
||||
constructor(
|
||||
/** Name of the player */
|
||||
public id: string,
|
||||
/** Shown to everyone when the player joins */
|
||||
public joinMessage?: string,
|
||||
/** The language of their game, such as "en" or "pt-BR" */
|
||||
public locale?: string,
|
||||
/** Toolbar state from Gui.toolbar.get_state, json compressed with helpers.encode_string */
|
||||
public toolbar?: string,
|
||||
public updatedAtMs: number = 0,
|
||||
public isDeleted: boolean = false,
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
id: Type.String(),
|
||||
join_message: Type.Optional(Type.String()),
|
||||
locale: Type.Optional(Type.String()),
|
||||
toolbar: Type.Optional(Type.String()),
|
||||
updated_at_ms: Type.Optional(Type.Number()),
|
||||
is_deleted: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
toJSON() {
|
||||
const json: Static<typeof PlayerRecord.jsonSchema> = { id: this.id };
|
||||
if (this.joinMessage) {
|
||||
json.join_message = this.joinMessage;
|
||||
}
|
||||
if (this.locale) {
|
||||
json.locale = this.locale;
|
||||
}
|
||||
if (this.toolbar) {
|
||||
json.toolbar = this.toolbar;
|
||||
}
|
||||
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.join_message,
|
||||
json.locale,
|
||||
json.toolbar,
|
||||
json.updated_at_ms ?? 0,
|
||||
json.is_deleted ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Users may always read their own settings, otherwise they need to be able to see users. */
|
||||
function selfOrPermission(permission: lib.PermissionName) {
|
||||
return (user: lib.IUser, message: lib.MessageRequest) => {
|
||||
const playerName = (message.data as { player_name: string }).player_name;
|
||||
if (user.name.toLowerCase() !== playerName.toLowerCase()) {
|
||||
user.checkPermission(permission);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Get the settings of a player, an empty record when nothing is stored. */
|
||||
export class PlayerGetRequest {
|
||||
declare ["constructor"]: typeof PlayerGetRequest;
|
||||
static plugin = "exp_scenario" as const;
|
||||
static type = "request" as const;
|
||||
static src = ["control", "instance"] as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = selfOrPermission("core.user.get");
|
||||
static Response = PlayerRecord;
|
||||
|
||||
constructor(
|
||||
public playerName: string,
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
player_name: Type.String(),
|
||||
});
|
||||
|
||||
toJSON() {
|
||||
return { player_name: this.playerName };
|
||||
}
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.player_name);
|
||||
}
|
||||
}
|
||||
|
||||
export const joinMessageMaxLength = 255;
|
||||
|
||||
/** Set or clear (null or empty) the join message of a player. */
|
||||
export class JoinMessageSetRequest {
|
||||
declare ["constructor"]: typeof JoinMessageSetRequest;
|
||||
static plugin = "exp_scenario" as const;
|
||||
static type = "request" as const;
|
||||
static src = ["control", "instance"] as const;
|
||||
static dst = "controller" as const;
|
||||
static permission(user: lib.IUser, message: lib.MessageRequest) {
|
||||
const playerName = (message.data as { player_name: string }).player_name;
|
||||
if (user.name.toLowerCase() === playerName.toLowerCase()) {
|
||||
user.checkPermission("exp_scenario.join_message.set");
|
||||
} else {
|
||||
user.checkPermission("exp_scenario.join_message.set_other");
|
||||
}
|
||||
}
|
||||
|
||||
constructor(
|
||||
public playerName: string,
|
||||
public message: string | null,
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
player_name: Type.String(),
|
||||
message: Type.Union([Type.String({ maxLength: joinMessageMaxLength }), Type.Null()]),
|
||||
});
|
||||
|
||||
toJSON() {
|
||||
return { player_name: this.playerName, message: this.message };
|
||||
}
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.player_name, json.message);
|
||||
}
|
||||
}
|
||||
|
||||
/** The language a player's game uses, sent when they join and when they change it. */
|
||||
export class PlayerLocaleEvent {
|
||||
declare ["constructor"]: typeof PlayerLocaleEvent;
|
||||
static plugin = "exp_scenario" as const;
|
||||
static type = "event" as const;
|
||||
static src = "instance" as const;
|
||||
static dst = "controller" as const;
|
||||
|
||||
constructor(
|
||||
public playerName: string,
|
||||
public locale: string,
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
player_name: Type.String(),
|
||||
locale: Type.String(),
|
||||
});
|
||||
|
||||
toJSON() {
|
||||
return { player_name: this.playerName, locale: this.locale };
|
||||
}
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.player_name, json.locale);
|
||||
}
|
||||
}
|
||||
|
||||
/** The toolbar of a player, sent when they leave. */
|
||||
export class PlayerToolbarEvent {
|
||||
declare ["constructor"]: typeof PlayerToolbarEvent;
|
||||
static plugin = "exp_scenario" as const;
|
||||
static type = "event" as const;
|
||||
static src = "instance" as const;
|
||||
static dst = "controller" as const;
|
||||
|
||||
constructor(
|
||||
public playerName: string,
|
||||
public toolbar: string,
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
player_name: Type.String(),
|
||||
toolbar: Type.String(),
|
||||
});
|
||||
|
||||
toJSON() {
|
||||
return { player_name: this.playerName, toolbar: this.toolbar };
|
||||
}
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.player_name, json.toolbar);
|
||||
}
|
||||
}
|
||||
@@ -9,4 +9,4 @@ add_static("Gui", require("modules/exp_gui"))
|
||||
|
||||
add_static("Roles", require("modules/exp_roles"))
|
||||
add_static("Datastore", require("modules.exp_legacy.expcore.datastore"))
|
||||
add_static("External", require("modules.exp_legacy.expcore.external"))
|
||||
add_static("ExpServerList", require("modules/exp_server_list"))
|
||||
@@ -5,50 +5,56 @@ Adds a commands that allows you to request a player move to another server
|
||||
local Async = require("modules/exp_util/async")
|
||||
local Commands = require("modules/exp_commands")
|
||||
|
||||
local External = require("modules.exp_legacy.expcore.external") --- @dep expcore.external
|
||||
local request_connection_async = Async.register(External.request_connection)
|
||||
local ExpServerList = require("modules/exp_server_list")
|
||||
local request_connection_async = Async.register(ExpServerList.request_connection)
|
||||
|
||||
local concat = table.concat
|
||||
|
||||
--- Convert a server name into a server id, is not an Commands.InputParser because it does not accept addresses
|
||||
--- @param server string
|
||||
--- @return boolean, LocalisedString # True for success
|
||||
local function get_server_id(server)
|
||||
local servers = External.get_servers_filtered(server)
|
||||
local current_server = External.get_current_server()
|
||||
local current_version = current_server.version
|
||||
--- Find the one server matching a name, is not an Commands.InputParser because it does not accept addresses
|
||||
--- @param search string
|
||||
--- @return boolean, LocalisedString | ExpServerList.Server # True for success
|
||||
local function find_server(search)
|
||||
local current_server = ExpServerList.get_current() --- @cast current_server -nil
|
||||
local found = ExpServerList.find_servers(search)
|
||||
|
||||
local server_names_before, server_names = {}, {}
|
||||
local server_count_before, server_count = 0, 0
|
||||
for next_server_id, server_details in pairs(servers) do
|
||||
server_count_before = server_count_before + 1
|
||||
server_names_before[server_count_before] = server_details.name
|
||||
if server_details.version == current_version then
|
||||
server_count = server_count + 1
|
||||
server_names[server_count] = server_details.name
|
||||
else
|
||||
servers[next_server_id] = nil
|
||||
local names_all, names = {}, {}
|
||||
local matching = {} --- @type ExpServerList.Server[]
|
||||
for index, server in ipairs(found) do
|
||||
names_all[index] = server.name
|
||||
if server.factorio_version == current_server.factorio_version then
|
||||
matching[#matching + 1] = server
|
||||
names[#names + 1] = server.name
|
||||
end
|
||||
end
|
||||
|
||||
if server_count > 1 then
|
||||
return false, { "exp-commands_connect.too-many-matching", concat(server_names, ", ") }
|
||||
elseif server_count == 1 then
|
||||
local server_id, server_details = next(servers) --- @cast server_details -nil
|
||||
local status = External.get_server_status(server_id)
|
||||
if server_id == current_server.id then
|
||||
return false, { "exp-commands_connect.same-server", server_details.name }
|
||||
elseif status == "Offline" then
|
||||
return false, { "exp-commands_connect.offline", server_details.name }
|
||||
if #matching > 1 then
|
||||
return false, { "exp-commands_connect.too-many-matching", concat(names, ", ") }
|
||||
elseif #matching == 1 then
|
||||
local server = matching[1]
|
||||
if server.id == current_server.id then
|
||||
return false, { "exp-commands_connect.same-server", server.name }
|
||||
elseif not ExpServerList.is_online(server) then
|
||||
return false, { "exp-commands_connect.offline", server.name }
|
||||
end
|
||||
return true, server_id --[[@as string]]
|
||||
elseif server_count_before > 0 then
|
||||
return false, { "exp-commands_connect.wrong-version", concat(server_names_before, ", ") }
|
||||
return true, server
|
||||
elseif #found > 0 then
|
||||
return false, { "exp-commands_connect.wrong-version", concat(names_all, ", ") }
|
||||
else
|
||||
return false, { "exp-commands_connect.none-matching" }
|
||||
end
|
||||
end
|
||||
|
||||
--- Resolve the server argument to a server, or leave it as an address
|
||||
--- @param server string
|
||||
--- @param is_address boolean?
|
||||
--- @return boolean, LocalisedString | ExpServerList.Server | string # True for success
|
||||
local function resolve_server(server, is_address)
|
||||
if is_address or not ExpServerList.is_synced() then
|
||||
return true, server
|
||||
end
|
||||
return find_server(server)
|
||||
end
|
||||
|
||||
--- Connect to a different server
|
||||
Commands.new("connect", { "exp-commands_connect.description" })
|
||||
:argument("server", { "exp-commands_connect.arg-server" }, Commands.types.string)
|
||||
@@ -57,15 +63,12 @@ Commands.new("connect", { "exp-commands_connect.description" })
|
||||
:register(function(player, server, is_address)
|
||||
--- @cast server string
|
||||
--- @cast is_address boolean?
|
||||
if not is_address and External.valid() then
|
||||
local success, result = get_server_id(server)
|
||||
if not success then
|
||||
return Commands.status.invalid_input(result)
|
||||
end
|
||||
server = result
|
||||
local success, result = resolve_server(server, is_address)
|
||||
if not success then
|
||||
return Commands.status.invalid_input(result)
|
||||
end
|
||||
|
||||
request_connection_async(player, server, true)
|
||||
request_connection_async(player, result, true)
|
||||
end)
|
||||
|
||||
--- Connect another player to a different server
|
||||
@@ -78,15 +81,12 @@ Commands.new("connect-player", { "exp-commands_connect.description-player" })
|
||||
--- @cast other_player LuaPlayer
|
||||
--- @cast server string
|
||||
--- @cast is_address boolean?
|
||||
if not is_address and External.valid() then
|
||||
local success, result = get_server_id(server)
|
||||
if not success then
|
||||
return Commands.status.invalid_input(result)
|
||||
end
|
||||
server = result
|
||||
local success, result = resolve_server(server, is_address)
|
||||
if not success then
|
||||
return Commands.status.invalid_input(result)
|
||||
end
|
||||
|
||||
request_connection_async(other_player, server)
|
||||
request_connection_async(other_player, result)
|
||||
end)
|
||||
|
||||
--- Connect all players to a different server
|
||||
@@ -97,15 +97,12 @@ Commands.new("connect-all", { "exp-commands_connect.description-all" })
|
||||
:register(function(player, server, is_address)
|
||||
--- @cast server string
|
||||
--- @cast is_address boolean?
|
||||
if not is_address and External.valid() then
|
||||
local success, result = get_server_id(server)
|
||||
if not success then
|
||||
return Commands.status.invalid_input(result)
|
||||
end
|
||||
server = result
|
||||
local success, result = resolve_server(server, is_address)
|
||||
if not success then
|
||||
return Commands.status.invalid_input(result)
|
||||
end
|
||||
|
||||
for _, next_player in pairs(game.connected_players) do
|
||||
request_connection_async(next_player, server)
|
||||
request_connection_async(next_player, result)
|
||||
end
|
||||
end)
|
||||
@@ -4,7 +4,7 @@
|
||||
local ExpUtil = require("modules/exp_util")
|
||||
local Feature = require("modules/exp_scenario/features")
|
||||
local Async = require("modules/exp_util/async")
|
||||
local config_server_detail = require("modules.exp_legacy.config.server_detail") --- @dep config.server_detail
|
||||
local community_details = require("modules/exp_scenario/config/community_details").config
|
||||
|
||||
local floor = math.floor
|
||||
local random = math.random
|
||||
@@ -20,6 +20,15 @@ local send_message_async =
|
||||
end
|
||||
end)
|
||||
|
||||
--- Reply with a link from community_details, read when the reply is sent
|
||||
--- @param key string
|
||||
--- @return fun(): LocalisedString
|
||||
local function server_link(key)
|
||||
return function()
|
||||
return { "info." .. key, community_details[key] }
|
||||
end
|
||||
end
|
||||
|
||||
local afk_time_units = {
|
||||
minutes = true,
|
||||
seconds = true,
|
||||
@@ -28,13 +37,13 @@ local afk_time_units = {
|
||||
return Feature.register("chat_auto_reply", {
|
||||
--- @type table<string, LocalisedString | fun(player: LuaPlayer, is_command: boolean): LocalisedString>
|
||||
messages = { -- will trigger when ever the word is said
|
||||
["discord"] = { "info.discord", config_server_detail["discord"] },
|
||||
["expgaming"] = { "info.website", config_server_detail["website"] },
|
||||
["website"] = { "info.website", config_server_detail["website"] },
|
||||
["status"] = { "info.status", config_server_detail["status"] },
|
||||
["github"] = { "info.github", config_server_detail["github"] },
|
||||
["patreon"] = { "info.patreon", config_server_detail["patreon"] },
|
||||
["donate"] = { "info.patreon", config_server_detail["patreon"] },
|
||||
["discord"] = server_link("discord"),
|
||||
["expgaming"] = server_link("website"),
|
||||
["website"] = server_link("website"),
|
||||
["status"] = server_link("status"),
|
||||
["github"] = server_link("github"),
|
||||
["patreon"] = server_link("patreon"),
|
||||
["donate"] = server_link("patreon"),
|
||||
["command"] = { "info.custom-commands" },
|
||||
["commands"] = { "info.custom-commands" },
|
||||
["softmod"] = { "info.softmod" },
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
--- Names and links of the community, shown in chat, the readme, and the greeting
|
||||
-- @config Community-Details
|
||||
|
||||
local Feature = require("modules/exp_scenario/features")
|
||||
|
||||
--- The values are always used, disabling the feature changes nothing
|
||||
return Feature.register("community_details", {
|
||||
community_name = "Explosive Gaming",
|
||||
discord = "https://discord.explosivegaming.nl",
|
||||
website = "https://www.explosivegaming.nl",
|
||||
status = "https://status.explosivegaming.nl",
|
||||
github = "https://github.com/explosivegaming/ExpCluster",
|
||||
patreon = "https://www.patreon.com/ExpGaming",
|
||||
})
|
||||
@@ -52,11 +52,13 @@ add(require("modules/exp_scenario/control/discord_alerts"))
|
||||
add(require("modules/exp_scenario/control/features"))
|
||||
add(require("modules/exp_scenario/control/extra_logging"))
|
||||
add(require("modules/exp_scenario/control/fast_deconstruction"))
|
||||
add(require("modules/exp_scenario/control/greetings"))
|
||||
add(require("modules/exp_scenario/control/help_bubbles"))
|
||||
add(require("modules/exp_scenario/control/inserter_pickup"))
|
||||
add(require("modules/exp_scenario/control/inventory_clear"))
|
||||
add(require("modules/exp_scenario/control/mine_depletion"))
|
||||
add(require("modules/exp_scenario/control/nuke_protection"))
|
||||
add(require("modules/exp_scenario/control/player_settings"))
|
||||
add(require("modules/exp_scenario/control/pollution_grading"))
|
||||
add(require("modules/exp_scenario/control/popups"))
|
||||
add(require("modules/exp_scenario/control/protection"))
|
||||
|
||||
@@ -5,10 +5,14 @@ Makes trees which are marked for decon "decay" quickly to allow faster building
|
||||
local Gui = require("modules/exp_gui")
|
||||
local Async = require("modules/exp_util/async")
|
||||
local Roles = require("modules/exp_roles")
|
||||
local Storage = require("modules/exp_util/storage")
|
||||
|
||||
local PlayerData = require("modules.exp_legacy.expcore.player_data")
|
||||
local HasEnabledDecon = PlayerData.Settings:combine("HasEnabledDecon")
|
||||
HasEnabledDecon:set_default(false)
|
||||
--- Players who toggled fast decon on, by player index
|
||||
--- @type table<number, true>
|
||||
local enabled_players = {}
|
||||
Storage.register(enabled_players, function(tbl)
|
||||
enabled_players = tbl
|
||||
end)
|
||||
|
||||
local random = math.random
|
||||
local floor = math.floor
|
||||
@@ -58,7 +62,7 @@ local remove_trees_async =
|
||||
--- @return "fast" | "allow" | "disallow"
|
||||
local function get_permission(player)
|
||||
if Roles.player_has_permission(player, "exp_scenario.decon.fast_trees") then
|
||||
return HasEnabledDecon:get(player) and "fast" or "allow"
|
||||
return enabled_players[player.index] and "fast" or "allow"
|
||||
elseif Roles.player_has_permission(player, "exp_scenario.decon.standard") then
|
||||
return "allow"
|
||||
else
|
||||
@@ -105,7 +109,7 @@ Gui.toolbar.create_button{
|
||||
end
|
||||
}:on_click(function(def, player, element)
|
||||
local state = Gui.toolbar.get_button_toggled_state(def, player)
|
||||
HasEnabledDecon:set(player, state)
|
||||
enabled_players[player.index] = state or nil
|
||||
player.print{ "exp_fast-decon.chat-toggle", state and { "exp_fast-decon.chat-enabled" } or { "exp_fast-decon.chat-disabled" } }
|
||||
end)
|
||||
|
||||
@@ -164,8 +168,7 @@ local function on_entity_damaged(event)
|
||||
end
|
||||
|
||||
-- Mark the entity to be removed
|
||||
local allow = get_player_cache(driver.index)
|
||||
if allow == "fast" and HasEnabledDecon:get(driver) then
|
||||
if get_player_cache(driver.index).permission == "fast" then
|
||||
event.entity.destroy()
|
||||
else
|
||||
event.entity.order_deconstruction(event.force, driver)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
--[[-- Control - Greetings
|
||||
Greets players when they join, and lets players set a message shown to everyone when they join
|
||||
|
||||
The join messages are kept by the controller, the instance plugin prints them when a player joins
|
||||
]]
|
||||
|
||||
local Commands = require("modules/exp_commands")
|
||||
local Feature = require("modules/exp_scenario/features")
|
||||
local clusterio_api = require("modules/clusterio/api")
|
||||
local community_details = require("modules/exp_scenario/config/community_details").config
|
||||
|
||||
local feature = Feature.register("greetings")
|
||||
|
||||
--- @param event EventData.on_player_joined_game
|
||||
local function on_player_joined_game(event)
|
||||
local player = assert(game.get_player(event.player_index))
|
||||
player.print{ "exp_greetings.greet", community_details.community_name, community_details.discord }
|
||||
end
|
||||
|
||||
--- Set your custom join message, or show it when no message is given
|
||||
Commands.new("set-join-message", { "exp_greetings.description-set" })
|
||||
:optional("message", { "exp_greetings.arg-message" }, Commands.types.string_max_length(255))
|
||||
:enable_auto_concatenation()
|
||||
:add_aliases{ "join-message" }
|
||||
:add_flags{ feature = feature }
|
||||
:register(function(player, message)
|
||||
--- @cast message string?
|
||||
if message then
|
||||
clusterio_api.send_json("exp_scenario:join_message_set", { player_name = player.name, message = message })
|
||||
return Commands.status.success{ "exp_greetings.message-set" }
|
||||
end
|
||||
-- The instance plugin prints the message
|
||||
clusterio_api.send_json("exp_scenario:join_message_get", { player_name = player.name })
|
||||
end)
|
||||
|
||||
--- Removes your custom join message
|
||||
Commands.new("remove-join-message", { "exp_greetings.description-remove" })
|
||||
:add_flags{ feature = feature }
|
||||
:register(function(player)
|
||||
clusterio_api.send_json("exp_scenario:join_message_set", { player_name = player.name })
|
||||
return Commands.status.success{ "exp_greetings.message-cleared" }
|
||||
end)
|
||||
|
||||
local e = defines.events
|
||||
|
||||
return feature:guard{
|
||||
events = {
|
||||
[e.on_player_joined_game] = on_player_joined_game,
|
||||
},
|
||||
}
|
||||
@@ -5,7 +5,7 @@ Adds friendly biters that walk around and give helpful messages
|
||||
local Async = require("modules/exp_util/async")
|
||||
local Storage = require("modules/exp_util/storage")
|
||||
local Feature = require("modules/exp_scenario/features")
|
||||
local config_server_detail = require("modules.exp_legacy.config.server_detail")
|
||||
local community_details = require("modules/exp_scenario/config/community_details").config
|
||||
|
||||
local feature = Feature.register("help_bubbles")
|
||||
|
||||
@@ -17,18 +17,21 @@ local locations = {
|
||||
spawn_position = { x = 0, y = 0 },
|
||||
spawn_surface = "nauvis",
|
||||
entity_name = "small-biter",
|
||||
messages = {
|
||||
{ "info.website", config_server_detail["website"] },
|
||||
{ "info.read-readme" },
|
||||
{ "info.discord", config_server_detail["discord"] },
|
||||
{ "info.softmod" },
|
||||
{ "info.redmew" },
|
||||
{ "info.custom-commands" },
|
||||
{ "info.status", config_server_detail["status"] },
|
||||
{ "info.lhd" },
|
||||
{ "info.github", config_server_detail["github"] },
|
||||
{ "info.patreon", config_server_detail["patreon"] },
|
||||
},
|
||||
--- Built each cycle so changes to community_details show without a restart
|
||||
messages = function()
|
||||
return {
|
||||
{ "info.website", community_details.website },
|
||||
{ "info.read-readme" },
|
||||
{ "info.discord", community_details.discord },
|
||||
{ "info.softmod" },
|
||||
{ "info.redmew" },
|
||||
{ "info.custom-commands" },
|
||||
{ "info.status", community_details.status },
|
||||
{ "info.lhd" },
|
||||
{ "info.github", community_details.github },
|
||||
{ "info.patreon", community_details.patreon },
|
||||
}
|
||||
end,
|
||||
}
|
||||
|
||||
}
|
||||
@@ -41,7 +44,8 @@ end)
|
||||
|
||||
--- @class speech_bubble_task_param
|
||||
--- @field entity LuaEntity
|
||||
--- @field messages LocalisedString[]
|
||||
--- @field messages LocalisedString[]?
|
||||
--- @field location string? Used when there are no messages, a key of locations
|
||||
--- @field previous_message LuaEntity?
|
||||
--- @field current_message_index number
|
||||
|
||||
@@ -60,8 +64,9 @@ local speech_bubble_task =
|
||||
return Async.status.complete()
|
||||
end
|
||||
|
||||
local messages = task.messages or locations[task.location].messages()
|
||||
local index = task.current_message_index
|
||||
if index > #task.messages then
|
||||
if index > #messages then
|
||||
index = 1
|
||||
end
|
||||
|
||||
@@ -71,7 +76,7 @@ local speech_bubble_task =
|
||||
end
|
||||
|
||||
speech_bubble_param.source = entity
|
||||
speech_bubble_param.text = task.messages[index]
|
||||
speech_bubble_param.text = messages[index]
|
||||
task.previous_message = entity.surface.create_entity(speech_bubble_param)
|
||||
|
||||
task.current_message_index = index + 1
|
||||
@@ -114,7 +119,11 @@ local function check_persistent_locations()
|
||||
goto continue
|
||||
end
|
||||
|
||||
persistent_locations[name] = register_entity(entity, location.messages)
|
||||
persistent_locations[name] = speech_bubble_task{
|
||||
entity = entity,
|
||||
location = name,
|
||||
current_message_index = 1,
|
||||
}
|
||||
::continue::
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
--[[-- Control - Player Settings
|
||||
Sends settings of players to the controller, which keeps them between servers
|
||||
|
||||
The toolbar is sent compressed when a player leaves, the instance plugin
|
||||
calls receive_toolbar with it when they join again
|
||||
]]
|
||||
|
||||
local Gui = require("modules/exp_gui")
|
||||
local clusterio_api = require("modules/clusterio/api")
|
||||
|
||||
--- @param event EventData.on_player_joined_game | EventData.on_player_locale_changed
|
||||
local function send_locale(event)
|
||||
local player = assert(game.get_player(event.player_index))
|
||||
clusterio_api.send_json("exp_scenario:locale", { player_name = player.name, locale = player.locale })
|
||||
end
|
||||
|
||||
--- @param event EventData.on_player_left_game
|
||||
local function send_toolbar(event)
|
||||
local player = assert(game.get_player(event.player_index))
|
||||
local success, state = pcall(Gui.toolbar.get_state, player)
|
||||
if not success then
|
||||
log("[WARNING] Could not read the toolbar of " .. player.name .. ": " .. tostring(state))
|
||||
return
|
||||
end
|
||||
local toolbar = helpers.encode_string(helpers.table_to_json(state))
|
||||
clusterio_api.send_json("exp_scenario:toolbar", { player_name = player.name, toolbar = toolbar })
|
||||
end
|
||||
|
||||
--- @param toolbar string
|
||||
--- @return Gui.ToolbarState?
|
||||
local function decode_toolbar(toolbar)
|
||||
local json = helpers.decode_string(toolbar)
|
||||
local state = json and helpers.json_to_table(json)
|
||||
if type(state) == "table" and type(state.order) == "table" and type(state.open) == "table"
|
||||
and type(state.visible) == "boolean" then
|
||||
return state --[[@as Gui.ToolbarState]]
|
||||
end
|
||||
end
|
||||
|
||||
--- Restore the toolbar of a player, called over rcon by the instance plugin
|
||||
--- @param player_name string
|
||||
--- @param toolbar string From send_toolbar
|
||||
local function receive_toolbar(player_name, toolbar)
|
||||
local player = game.get_player(player_name)
|
||||
if not player or not player.connected then
|
||||
return
|
||||
end
|
||||
|
||||
-- Stored states can name buttons which no longer exist
|
||||
local success, err = pcall(function()
|
||||
local state = assert(decode_toolbar(toolbar), "invalid toolbar state")
|
||||
Gui.toolbar.set_state(player, state)
|
||||
end)
|
||||
if not success then
|
||||
log("[WARNING] Could not restore the toolbar of " .. player_name .. ": " .. tostring(err))
|
||||
end
|
||||
end
|
||||
|
||||
local e = defines.events
|
||||
|
||||
return {
|
||||
events = {
|
||||
[e.on_player_joined_game] = send_locale,
|
||||
[e.on_player_locale_changed] = send_locale,
|
||||
[e.on_player_left_game] = send_toolbar,
|
||||
},
|
||||
receive_toolbar = receive_toolbar,
|
||||
}
|
||||
@@ -3,4 +3,5 @@
|
||||
-- Access using `/sc exp_scenario.features.foo()`
|
||||
exp_scenario = {
|
||||
features = require("modules/exp_scenario/features"),
|
||||
player_settings = require("modules/exp_scenario/control/player_settings"),
|
||||
}
|
||||
@@ -159,12 +159,12 @@ function Elements.player_stats_table.refresh(data_table, player)
|
||||
else
|
||||
value = format_number(value, false)
|
||||
end
|
||||
labels[stat_name].caption = { "readme.data-format", value, metadata.unit or "" }
|
||||
labels[stat_name].caption = { "exp-gui_readme.data-format", value, metadata.unit or "" }
|
||||
end
|
||||
|
||||
-- Update all computed stats
|
||||
for stat_name, data in pairs(computed_stats) do
|
||||
labels[stat_name].caption = { "readme.data-format", data.calculate(player), "" }
|
||||
labels[stat_name].caption = { "exp-gui_readme.data-format", data.calculate(player), "" }
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ local Gui = require("modules/exp_gui")
|
||||
local Roles = require("modules/exp_roles")
|
||||
local Commands = require("modules/exp_commands")
|
||||
local PlayerData = require("modules.exp_legacy.expcore.player_data")
|
||||
local External = require("modules.exp_legacy.expcore.external")
|
||||
local config_server_detail = require("modules.exp_legacy.config.server_detail") --- @dep config.server_detail
|
||||
local ExpServerList = require("modules/exp_server_list")
|
||||
local community = require("modules/exp_scenario/config/community_details").config
|
||||
|
||||
local format_number = require("util").format_number
|
||||
local format_time = ExpUtil.format_time_factory_locale{ format = "long", days = true, hours = true, minutes = true }
|
||||
@@ -16,16 +16,33 @@ local format_time = ExpUtil.format_time_factory_locale{ format = "long", days =
|
||||
local frame_width = 595
|
||||
local title_width = 270
|
||||
local scroll_height = 275
|
||||
local description_length = 120
|
||||
|
||||
--- Cut a string to a number of bytes without splitting a utf8 character
|
||||
--- @param str string
|
||||
--- @param length number
|
||||
--- @return string
|
||||
local function shorten(str, length)
|
||||
if #str <= length then
|
||||
return str
|
||||
end
|
||||
local next_byte = str:byte(length + 1)
|
||||
while length > 0 and next_byte >= 0x80 and next_byte < 0xC0 do
|
||||
length = length - 1
|
||||
next_byte = str:byte(length + 1)
|
||||
end
|
||||
return str:sub(1, length) .. "..."
|
||||
end
|
||||
|
||||
--- @class ExpGui_Readme.elements
|
||||
local Elements = {}
|
||||
|
||||
--- @type table<number, { caption: LocalisedString, tooltip: LocalisedString, element: ExpElement }>
|
||||
--- @type table<number, { caption: LocalisedString, tooltip: LocalisedString | (fun(): LocalisedString), element: ExpElement }>
|
||||
local tabs = {}
|
||||
|
||||
--- Register a readme tab
|
||||
--- @param caption LocalisedString
|
||||
--- @param tooltip LocalisedString
|
||||
--- @param tooltip LocalisedString | fun(): LocalisedString Functions are called when the readme opens
|
||||
--- @param element ExpElement
|
||||
local function define_tab(caption, tooltip, element)
|
||||
tabs[#tabs + 1] = { caption = caption, tooltip = tooltip, element = element }
|
||||
@@ -84,16 +101,17 @@ Elements.sub_content = Gui.define("readme/sub_content")
|
||||
} --[[@as any]]
|
||||
|
||||
--- @class ExpGui_Readme.elements.join_server.elements
|
||||
--- @field server_id string
|
||||
--- @field server_id number
|
||||
|
||||
--- Join server button
|
||||
--- @class ExpGui_Readme.elements.join_server: ExpElement
|
||||
--- @field data table<LuaGuiElement, ExpGui_Readme.elements.join_server.elements>
|
||||
--- @overload fun(parent: LuaGuiElement, server_id: string, wrong_version: string?): LuaGuiElement
|
||||
--- @overload fun(parent: LuaGuiElement, server: ExpServerList.Server): LuaGuiElement
|
||||
Elements.join_server = Gui.define("readme/join_server")
|
||||
:track_all_elements()
|
||||
:draw(function(def, parent, server_id, wrong_version)
|
||||
:draw(function(def, parent, server)
|
||||
--- @cast def ExpGui_Readme.elements.join_server
|
||||
--- @cast server ExpServerList.Server
|
||||
local flow = parent.add{
|
||||
type = "flow",
|
||||
}
|
||||
@@ -106,10 +124,10 @@ Elements.join_server = Gui.define("readme/join_server")
|
||||
}
|
||||
|
||||
def.data[button] = {
|
||||
server_id = server_id,
|
||||
server_id = server.id,
|
||||
}
|
||||
|
||||
Elements.join_server.refresh(button, wrong_version)
|
||||
Elements.join_server.refresh(button, server)
|
||||
return button
|
||||
end)
|
||||
:style{
|
||||
@@ -118,22 +136,33 @@ Elements.join_server = Gui.define("readme/join_server")
|
||||
}
|
||||
:on_click(function(def, player, button)
|
||||
--- @cast def ExpGui_Readme.elements.join_server
|
||||
local server_id = def.data[button].server_id
|
||||
External.request_connection(player, server_id, true)
|
||||
local server = ExpServerList.get_server(def.data[button].server_id)
|
||||
if server and ExpServerList.is_online(server) then
|
||||
ExpServerList.request_connection(player, server, true)
|
||||
end
|
||||
end) --[[@as any]]
|
||||
|
||||
--- Refresh join server button
|
||||
--- @param button LuaGuiElement
|
||||
--- @param wrong_version string?
|
||||
function Elements.join_server.refresh(button, wrong_version)
|
||||
local server_id = Elements.join_server.data[button].server_id
|
||||
local status = External.get_server_status(server_id) or "Offline"
|
||||
|
||||
if wrong_version then
|
||||
status = "Version"
|
||||
--- @param server ExpServerList.Server?
|
||||
function Elements.join_server.refresh(button, server)
|
||||
local current = ExpServerList.get_current()
|
||||
local status, detail = "Online", nil
|
||||
if not server or not ExpServerList.is_online(server) then
|
||||
status = "Offline"
|
||||
elseif current and server.id == current.id then
|
||||
status = "Current"
|
||||
elseif current and server.factorio_version ~= current.factorio_version then
|
||||
status, detail = "Version", server.factorio_version
|
||||
elseif server.has_password then
|
||||
status = "Password"
|
||||
elseif current and server.mod_pack_name ~= current.mod_pack_name then
|
||||
status, detail = "Modded", server.mod_pack_name
|
||||
end
|
||||
|
||||
button.tooltip = { "exp-gui_readme.servers-connect-" .. status, wrong_version }
|
||||
-- Factorio drops nil parameters, which would shift the player count into __1__
|
||||
local player_count = server and server.player_count or 0
|
||||
button.tooltip = { "exp-gui_readme.servers-connect-" .. status, detail or "", player_count }
|
||||
|
||||
if status == "Offline" or status == "Current" then
|
||||
button.enabled = false
|
||||
@@ -162,40 +191,35 @@ function Elements.join_server.refresh(button, wrong_version)
|
||||
end
|
||||
end
|
||||
|
||||
--- Refresh all online join buttons
|
||||
--- Refresh all join buttons
|
||||
function Elements.join_server.refresh_all()
|
||||
if not External.valid() then
|
||||
return
|
||||
end
|
||||
|
||||
local current_version = External.get_current_server().version
|
||||
|
||||
for _, button in Elements.join_server:tracked_elements() do
|
||||
local server_id = Elements.join_server.data[button].server_id
|
||||
local server = External.get_servers()[server_id]
|
||||
|
||||
if server then
|
||||
Elements.join_server.refresh(button, current_version ~= server.version and server.version or nil)
|
||||
end
|
||||
Elements.join_server.refresh(button, ExpServerList.get_server(server_id))
|
||||
end
|
||||
end
|
||||
|
||||
--- Welcome tab
|
||||
define_tab(
|
||||
{ "exp-gui_readme.welcome-tab" },
|
||||
{ "exp-gui_readme.welcome-tooltip", config_server_detail["community_name"] },
|
||||
function() return { "exp-gui_readme.welcome-tooltip", community.community_name } end,
|
||||
Gui.define("readme/welcome")
|
||||
:draw(function(_, parent)
|
||||
local player = Gui.get_player(parent)
|
||||
|
||||
local server_details = {
|
||||
name = config_server_detail["community_name"] .. " S0 - Local",
|
||||
welcome = "Failed to load description: disconnected from external api.",
|
||||
name = community.community_name .. " S0 - Local",
|
||||
welcome = "Failed to load description: disconnected from the controller.",
|
||||
reset_time = "Not Set",
|
||||
}
|
||||
|
||||
if External.valid() then
|
||||
server_details = External.get_current_server()
|
||||
local current = ExpServerList.get_current()
|
||||
if current then
|
||||
server_details = {
|
||||
name = current.name,
|
||||
welcome = current.welcome ~= "" and current.welcome or { "exp-gui_readme.welcome-default" },
|
||||
reset_time = current.reset_time ~= "" and current.reset_time or "Not Set",
|
||||
}
|
||||
end
|
||||
|
||||
local container = parent.add{ type = "flow", direction = "vertical" }
|
||||
@@ -206,7 +230,7 @@ define_tab(
|
||||
local center_flow = top_flow.add{ type = "flow", direction = "vertical" }
|
||||
center_flow.style.horizontal_align = "center"
|
||||
|
||||
Gui.elements.title_label(center_flow, 62, { "exp-gui_readme.welcome-title", server_details.name })
|
||||
Gui.elements.centered_label(center_flow, 380, { "exp-gui_readme.welcome-title", server_details.name })
|
||||
Gui.elements.centered_label(center_flow, 380, server_details.welcome)
|
||||
|
||||
top_flow.add{ type = "sprite", sprite = "file/modules/exp_scenario/gui/logo.png" }
|
||||
@@ -224,7 +248,7 @@ define_tab(
|
||||
frame_width,
|
||||
{
|
||||
"exp-gui_readme.welcome-general",
|
||||
config_server_detail["community_name"],
|
||||
community.community_name,
|
||||
format_time(game.tick),
|
||||
server_details.reset_time,
|
||||
}
|
||||
@@ -317,28 +341,19 @@ define_tab(
|
||||
local scroll_pane = Elements.title_table_scroll(container)
|
||||
scroll_pane.style.maximal_height = scroll_height + 20
|
||||
|
||||
if External.valid() then
|
||||
local current_version = External.get_current_server().version
|
||||
|
||||
local factorio_servers = Elements.title_table(scroll_pane, 225, { "exp-gui_readme.servers-factorio" }, 3)
|
||||
|
||||
for server_id, server in pairs(External.get_servers()) do
|
||||
Gui.elements.centered_label(factorio_servers, 110, server.short_name)
|
||||
Gui.elements.centered_label(factorio_servers, 436, server.description)
|
||||
Elements.join_server(factorio_servers, server_id, current_version ~= server.version and server.version or nil)
|
||||
end
|
||||
else
|
||||
local factorio_servers = Elements.title_table(scroll_pane, 225, { "exp-gui_readme.servers-factorio" }, 2)
|
||||
for i = 1, 8 do
|
||||
Gui.elements.centered_label(factorio_servers, 110, { "exp-gui_readme.servers-" .. i })
|
||||
Gui.elements.centered_label(factorio_servers, 460, { "exp-gui_readme.servers-d" .. i })
|
||||
end
|
||||
local factorio_servers = Elements.title_table(scroll_pane, 225, { "exp-gui_readme.servers-factorio" }, 3)
|
||||
for _, server in ipairs(ExpServerList.get_servers()) do
|
||||
local description = shorten(server.description, description_length)
|
||||
local tooltip = description ~= server.description and server.description or nil
|
||||
Gui.elements.centered_label(factorio_servers, 110, server.display_name)
|
||||
Gui.elements.centered_label(factorio_servers, 436, description, tooltip)
|
||||
Elements.join_server(factorio_servers, server)
|
||||
end
|
||||
|
||||
local external_links = Elements.title_table(scroll_pane, 235, { "exp-gui_readme.servers-external" }, 2)
|
||||
for _, key in ipairs{ "discord", "website", "patreon", "status", "github" } do
|
||||
Gui.elements.centered_label(external_links, 110, key:gsub("^%l", string.upper))
|
||||
Gui.elements.centered_label(external_links, 460, config_server_detail[key], { "exp-gui_readme.servers-open-in-browser" })
|
||||
Gui.elements.centered_label(external_links, 460, community[key], { "exp-gui_readme.servers-open-in-browser" })
|
||||
end
|
||||
|
||||
return container
|
||||
@@ -636,11 +651,16 @@ Elements.container = Gui.define("readme/container")
|
||||
}
|
||||
|
||||
for _, tab in ipairs(tabs) do
|
||||
local tooltip = tab.tooltip
|
||||
if type(tooltip) == "function" then
|
||||
tooltip = tooltip()
|
||||
end
|
||||
|
||||
local gui_tab = pane.add{
|
||||
type = "tab",
|
||||
style = "frame_tab",
|
||||
caption = tab.caption,
|
||||
tooltip = tab.tooltip,
|
||||
tooltip = tooltip,
|
||||
}
|
||||
|
||||
pane.add_tab(gui_tab, tab.element(pane))
|
||||
@@ -707,8 +727,6 @@ return {
|
||||
[e.on_player_created] = open_readme,
|
||||
[e.on_player_joined_game] = clear_readme,
|
||||
[e.on_player_respawned] = clear_readme,
|
||||
[ExpServerList.on_servers_updated] = Elements.join_server.refresh_all,
|
||||
},
|
||||
on_nth_tick = {
|
||||
[60 * 60] = Elements.join_server.refresh_all,
|
||||
}
|
||||
}
|
||||
@@ -320,6 +320,7 @@ main-tooltip=Information
|
||||
welcome-tab=Welcome
|
||||
welcome-tooltip=Welcome to __1__
|
||||
welcome-title=Welcome to __1__
|
||||
welcome-default=Thanks for playing on our server, we hope you enjoy your stay.
|
||||
welcome-general=Welcome to __1__; we host many Factorio servers. While you are here, we ask you to follow our rules. You can find these in the tab above. You can also find our custom commands and links to our other servers. This map has been online for __2__.\nPlease note that our servers reset periodically, the next reset is: __3__
|
||||
welcome-roles=We run a custom role system to help protect the work of others. As a result you may not be able to use your deconstruction planner yet or drop items on the ground. Roles also give you access to some custom features such as adding tasks to our task list or making new warp points.\nYou have been assigned the roles: __1__
|
||||
welcome-chat=Chatting can be difficult for new players because it’s different than other games! It’s very simple, the button you need to press is the “GRAVE/TILDE” key (which is located under the “ESC key”). If you would like to change the key, go to your Controls tab in options.\nThe setting you need to change is “Toggle chat (and Lua console)” you currently have it set to "__CONTROL__toggle-console__"
|
||||
@@ -350,28 +351,12 @@ servers-general=This is only one of our Factorio servers, we host many others as
|
||||
servers-factorio=Factorio Servers
|
||||
servers-external=External Links
|
||||
servers-open-in-browser=Open in your browser
|
||||
servers-1=S1 Public
|
||||
servers-d1=This is our 48 hour reset server.
|
||||
servers-2=S2 Off
|
||||
servers-d2=This server is closed.
|
||||
servers-3=S3 Weekly
|
||||
servers-d3=This is our weekly reset server.
|
||||
servers-4=S4 Monthly
|
||||
servers-d4=This is our monthly reset server.
|
||||
servers-5=S5 Modded
|
||||
servers-d5=This is our modded server, see Discord for details.
|
||||
servers-6=S6 Donator
|
||||
servers-d6=This is our donator only server, online when requested.
|
||||
servers-7=S7 Event
|
||||
servers-d7=This is our event server, we try to run events at least once per week.
|
||||
servers-8=S8 T̷-̶s̶-̴:̷
|
||||
servers-d8=N̵o̴ ̶o̷-̶e̵ ̴k̸n̷-̶w̵s̸ ̴w̷h̷a̶-̶ ̷h̴a̴p̷p̴e̷n̷s̸ ̷o̶n̴ ̷t̶h̴-̶s̶ ̷s̷e̶r̸v̸e̴r̷,̶ ̸i̸t̴ ̷m̶-̸g̴h̶t̷ ̸n̸-̶t̵ ̷e̴v̸e̸n̶t̷ ̵-̷x̴i̵s̶t̸.̸
|
||||
servers-connect-Offline=Server is currently offline
|
||||
servers-connect-Current=This is your current server
|
||||
servers-connect-Version=Server is on a different version: __1__
|
||||
servers-connect-Password=Server requires a password
|
||||
servers-connect-Modded=Server requires mods to be downloaded
|
||||
servers-connect-Online=Server is online
|
||||
servers-connect-Password=Server requires a password\nPlayers online: __2__
|
||||
servers-connect-Modded=Server uses the __1__ mod pack, mods may need to be downloaded\nPlayers online: __2__
|
||||
servers-connect-Online=Server is online\nPlayers online: __2__
|
||||
backers-tab=Backers
|
||||
backers-tooltip=People who have helped make our server
|
||||
backers-general=We would like to thank all our staff and backers who have helped our community grow. Our staff have helped to keep our servers safe from trolls and a fun place to play. Our backers have helped us to cover our running costs and provide a great community for us all to enjoy together.
|
||||
@@ -601,6 +586,16 @@ chat-enabled=enabled
|
||||
chat-disabled=disabled
|
||||
chat-toggle=Fast decon has been __1__
|
||||
|
||||
[exp_greetings]
|
||||
description-set=Sets / Gets your custom join message.
|
||||
description-remove=Removes you custom join message.
|
||||
arg-message=Your custom join message.
|
||||
greet=[color=0,1,0] Welcome to __1__ community server! If you like the server join our discord: __2__ [/color]
|
||||
message-set=Your join message has been updated.
|
||||
message-get=Your join message is: __1__
|
||||
message-cleared=Your join message has been cleared.
|
||||
message-none=You have not set a join message.
|
||||
|
||||
[exp_nuke-protection]
|
||||
chat-found=You cannot have __1__ in your inventory, so it was placed into the chests at spawn.
|
||||
|
||||
|
||||
@@ -339,22 +339,6 @@ servers-tab=伺服器
|
||||
servers-tooltip=到其他伺服器及網站的連結
|
||||
servers-general=這是其中一個伺服器。你可以在下方找到其他同樣是由我們運行的伺服器資料。
|
||||
servers-factorio=異星工廠伺服器
|
||||
servers-1=S1 公共
|
||||
servers-d1=這個服務器每兩日重設。
|
||||
servers-2=S2 -
|
||||
servers-d2=這個服務器沒在營運。
|
||||
servers-3=S3 周
|
||||
servers-d3=這個服務器每週重設。
|
||||
servers-4=S4 月
|
||||
servers-d4=這個服務器每月重設。
|
||||
servers-5=S5 模組
|
||||
servers-d5=這個服務器運行模組,可在 Discord 中得到更多資訊。
|
||||
servers-6=S6 Donator
|
||||
servers-d6=這個服務器為支持者運行,可在 Discord 中得到更多資訊。
|
||||
servers-7=S7 項目
|
||||
servers-d7=這個服務器只在有項目期間運行。
|
||||
servers-8=S8 T̷-̶s̶-̴:̷
|
||||
servers-d8=N̵o̴ ̶o̷-̶e̵ ̴k̸n̷-̶w̵s̸ ̴w̷h̷a̶-̶ ̷h̴a̴p̷p̴e̷n̷s̸ ̷o̶n̴ ̷t̶h̴-̶s̶ ̷s̷e̶r̸v̸e̴r̷,̶ ̸i̸t̴ ̷m̶-̸g̴h̶t̷ ̸n̸-̶t̵ ̷e̴v̸e̸n̶t̷ ̵-̷x̴i̵s̶t̸。̸
|
||||
servers-connect-Offline=該服務器目前沒有在運行
|
||||
servers-connect-Current=你目前所在的伺服器
|
||||
servers-connect-Version=該服務器運行不同的遊戲版本: __1__
|
||||
@@ -594,6 +578,16 @@ chat-enabled=啟用
|
||||
chat-disabled=停用
|
||||
chat-toggle=樹木快速拆除已 __1__
|
||||
|
||||
[exp_greetings]
|
||||
description-set=設定 / 取得你的加入信息。
|
||||
description-remove=移除你的加入信息。
|
||||
arg-message=你的加入信息。
|
||||
greet=[color=0,1,0] 歡迎來到 __1__ 的服務器! 若果你喜歡本服務器可加入 Discord: __2__ [/color]
|
||||
message-set=你的加入信息已更新。
|
||||
message-get=你的加入信息為: __1__
|
||||
message-cleared=你的加入信息已清除。
|
||||
message-none=你沒有設定加入信息。
|
||||
|
||||
[exp_nuke-protection]
|
||||
chat-found=你的用戶組不允許你有 __1__ ,所以該物品已放在出生點的箱子。
|
||||
|
||||
|
||||
@@ -339,22 +339,6 @@ servers-tab=伺服器
|
||||
servers-tooltip=到其他伺服器及網站的連結
|
||||
servers-general=這是其中一個伺服器。你可以在下方找到其他同樣是由我們運行的伺服器資料。
|
||||
servers-factorio=異星工廠伺服器
|
||||
servers-1=S1 公共
|
||||
servers-d1=這個服務器每兩日重設。
|
||||
servers-2=S2 -
|
||||
servers-d2=這個服務器沒在營運。
|
||||
servers-3=S3 周
|
||||
servers-d3=這個服務器每週重設。
|
||||
servers-4=S4 月
|
||||
servers-d4=這個服務器每月重設。
|
||||
servers-5=S5 模組
|
||||
servers-d5=這個服務器運行模組,可在 Discord 中得到更多資訊。
|
||||
servers-6=S6 Donator
|
||||
servers-d6=這個服務器為支持者運行,可在 Discord 中得到更多資訊。
|
||||
servers-7=S7 項目
|
||||
servers-d7=這個服務器只在有項目期間運行。
|
||||
servers-8=S8 T̷-̶s̶-̴:̷
|
||||
servers-d8=N̵o̴ ̶o̷-̶e̵ ̴k̸n̷-̶w̵s̸ ̴w̷h̷a̶-̶ ̷h̴a̴p̷p̴e̷n̷s̸ ̷o̶n̴ ̷t̶h̴-̶s̶ ̷s̷e̶r̸v̸e̴r̷,̶ ̸i̸t̴ ̷m̶-̸g̴h̶t̷ ̸n̸-̶t̵ ̷e̴v̸e̸n̶t̷ ̵-̷x̴i̵s̶t̸。̸
|
||||
servers-connect-Offline=該服務器目前沒有在運行
|
||||
servers-connect-Current=你目前所在的伺服器
|
||||
servers-connect-Version=該服務器運行不同的遊戲版本: __1__
|
||||
@@ -594,6 +578,16 @@ chat-enabled=啟用
|
||||
chat-disabled=停用
|
||||
chat-toggle=樹木快速拆除已 __1__
|
||||
|
||||
[exp_greetings]
|
||||
description-set=設定 / 取得你的加入信息。
|
||||
description-remove=移除你的加入信息。
|
||||
arg-message=你的加入信息。
|
||||
greet=[color=0,1,0] 歡迎來到 __1__ 的服務器! 若果你喜歡本服務器可加入 Discord: __2__ [/color]
|
||||
message-set=你的加入信息已更新。
|
||||
message-get=你的加入信息為: __1__
|
||||
message-cleared=你的加入信息已清除。
|
||||
message-none=你沒有設定加入信息。
|
||||
|
||||
[exp_nuke-protection]
|
||||
chat-found=你的用戶組不允許你有 __1__ ,所以該物品已放在出生點的箱子。
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"exp_util": "*",
|
||||
"exp_roles": "*",
|
||||
"exp_reports": "*",
|
||||
"exp_server_list": "*",
|
||||
"exp_gui": "*",
|
||||
"exp_commands": "*"
|
||||
}
|
||||
|
||||
@@ -13,13 +13,21 @@
|
||||
"coverage": "tap --coverage-report=text test/*.test.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@clusterio/controller": "workspace:^",
|
||||
"@clusterio/host": "workspace:^",
|
||||
"@clusterio/lib": "workspace:^"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@clusterio/controller": {
|
||||
"optional": true
|
||||
},
|
||||
"@clusterio/host": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/controller": "workspace:^",
|
||||
"@clusterio/host": "workspace:^",
|
||||
@@ -43,6 +51,7 @@
|
||||
"@expcluster/lib_gui": "workspace:^",
|
||||
"@expcluster/permission-groups": "workspace:^",
|
||||
"@expcluster/roles": "workspace:^",
|
||||
"@expcluster/server-list": "workspace:^",
|
||||
"@sinclair/typebox": "catalog:"
|
||||
},
|
||||
"publishConfig": {
|
||||
|
||||
@@ -47,6 +47,7 @@ const definitions = [
|
||||
["exp_scenario.command.kill.always", "/kill (any player)", "Kill any player, not just yourself."],
|
||||
["exp_scenario.command.kill_enemies", "/kill-enemies", "Kill all enemy units."],
|
||||
["exp_scenario.command.lawnmower", "/lawnmower", "Clean up biter corpse, decoratives and nuclear hole."],
|
||||
["exp_scenario.command.link_discord", "/link-discord", "Link your account to Discord with the code from the website.", true],
|
||||
["exp_scenario.command.locate", "/locate", "Opens remote view at the location of the player's last location.", true],
|
||||
["exp_scenario.command.me", "/me", "Sends an action message in the chat."],
|
||||
["exp_scenario.command.protect_area", "/protect-area", "Toggles area protection selection, hold shift to remove protection."],
|
||||
@@ -84,6 +85,7 @@ const definitions = [
|
||||
["exp_scenario.command.teleport", "/teleport", "Teleports a player to another player."],
|
||||
["exp_scenario.command.unassign_role", "/unassign-role", "Unassigns a role lower than your own from a player."],
|
||||
["exp_scenario.command.unjail", "/unjail", "Removes a player from jail, restoring their other roles."],
|
||||
["exp_scenario.command.unlink_discord", "/unlink-discord", "Remove the link between your account and Discord.", true],
|
||||
["exp_scenario.command.vlayer_info", "/vlayer-info", "Print all vlayer information."],
|
||||
["exp_scenario.command.waterfill", "/waterfill", "Replace tiles with shallow water."],
|
||||
["exp_scenario.decon.fast_trees", "Fast tree deconstruction", "Trees and rocks marked for deconstruction are removed instantly, when enabled from the toolbar."],
|
||||
|
||||
@@ -87,6 +87,7 @@ export const seedRoles: SeedRole[] = [
|
||||
"exp_scenario.command.tag_clear.always",
|
||||
"exp_scenario.command.spawn.always",
|
||||
"exp_scenario.command.clear_reports",
|
||||
"exp_scenario.join_message.set_other",
|
||||
"exp_scenario.command.clear_inventory",
|
||||
"exp_scenario.command.kill_enemies",
|
||||
"exp_scenario.command.remove_enemies",
|
||||
@@ -193,6 +194,7 @@ export const seedRoles: SeedRole[] = [
|
||||
"exp_scenario.command.unjail",
|
||||
"exp_scenario.command.set_join_message",
|
||||
"exp_scenario.command.remove_join_message",
|
||||
"exp_scenario.join_message.set",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import t from "tap";
|
||||
import path from "node:path";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { Controller } from "@clusterio/controller";
|
||||
import { ControllerPlugin } from "../dist/node/controller.js";
|
||||
import entrypoint from "../dist/node/controller.js";
|
||||
import * as messages from "../dist/node/messages.js";
|
||||
import { seedRoles, seedGroups } from "../dist/node/seed.js";
|
||||
import { features } from "../dist/node/features.js";
|
||||
@@ -29,7 +30,7 @@ t.after(() => {
|
||||
lib.logger.silent = false;
|
||||
});
|
||||
|
||||
/** Build the plugin around a real controller, which is side effect free while not started. */
|
||||
/** Run the plugin on a real controller, which is side effect free while not started, returns the handlers it registered. */
|
||||
async function startPlugin(t2, { withPlugins = true, databaseDirectory = t2.testdir() } = {}) {
|
||||
const controllerConfig = new lib.ControllerConfig("controller", {
|
||||
"controller.database_directory": databaseDirectory,
|
||||
@@ -45,42 +46,77 @@ async function startPlugin(t2, { withPlugins = true, databaseDirectory = t2.test
|
||||
}
|
||||
}
|
||||
|
||||
const plugin = await ControllerPlugin.fromContext({ plugin: { name: "exp_scenario" }, controller, logger });
|
||||
return { plugin, controller };
|
||||
const handlers = new Map();
|
||||
const handle = controller.handle.bind(controller);
|
||||
controller.handle = (Class, handler) => {
|
||||
handlers.set(Class, handler);
|
||||
handle(Class, handler);
|
||||
};
|
||||
const subscriptionHandle = controller.subscriptions.handle.bind(controller.subscriptions);
|
||||
controller.subscriptions.handle = (Class, handler) => {
|
||||
handlers.set(Class, handler);
|
||||
subscriptionHandle(Class, handler);
|
||||
};
|
||||
|
||||
await entrypoint({ plugin: { name: "exp_scenario" }, controller, logger });
|
||||
return {
|
||||
controller,
|
||||
seed: () => handlers.get(messages.SeedRequest)(new messages.SeedRequest()),
|
||||
listFeatures: () => handlers.get(messages.FeatureListRequest)(new messages.FeatureListRequest()),
|
||||
updateFeature: request => handlers.get(messages.FeatureUpdateRequest)(request),
|
||||
featureSubscription: request => handlers.get(messages.FeatureUpdatedEvent)(request),
|
||||
shutdown: () => controller.hooks.shutdown.invoke(),
|
||||
save: () => controller.hooks.save.invoke(),
|
||||
/** Call the handler of a message as it would be called for a request or event */
|
||||
send: message => handlers.get(message.constructor)(message),
|
||||
};
|
||||
}
|
||||
|
||||
/** Write the stored features as a previous run would have left them. */
|
||||
async function storeFeatures(databaseDirectory, records) {
|
||||
await new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "exp_scenario", "features.json"),
|
||||
messages.FeatureRecord.fromJSON.bind(messages.FeatureRecord),
|
||||
).save(new Map(records.map(record => [record.id, record])));
|
||||
}
|
||||
|
||||
/** The feature record with the given id. */
|
||||
async function getFeature(listFeatures, id) {
|
||||
return (await listFeatures()).find(record => record.id === id);
|
||||
}
|
||||
|
||||
const byName = (datastore, name) => [...datastore.values()].find(other => other.name === name);
|
||||
|
||||
t.test("class ControllerPlugin", t2 => {
|
||||
t2.test(".handleSeedRequest() throws when the seeded plugins are missing", async t3 => {
|
||||
const { plugin } = await startPlugin(t3, { withPlugins: false });
|
||||
t.test("controller plugin", t2 => {
|
||||
t2.test("SeedRequest throws when the seeded plugins are missing", async t3 => {
|
||||
const { seed } = await startPlugin(t3, { withPlugins: false });
|
||||
await t3.rejects(
|
||||
plugin.handleSeedRequest(),
|
||||
seed(),
|
||||
{ message: "Seeding requires the exp_roles and exp_groups plugins" },
|
||||
"the request is rejected",
|
||||
);
|
||||
});
|
||||
|
||||
t2.test(".handleSeedRequest() creates the roles and reuses them by name", async t3 => {
|
||||
const { plugin, controller } = await startPlugin(t3);
|
||||
t2.test("SeedRequest creates the roles and reuses them by name", async t3 => {
|
||||
const { seed, controller } = await startPlugin(t3);
|
||||
const rolesPlugin = RolesPlugin.get(controller);
|
||||
|
||||
await plugin.handleSeedRequest();
|
||||
await seed();
|
||||
t3.strictSame(controller.roles.size, seedRoles.length, "every seed role exists");
|
||||
|
||||
const moderator = byName(controller.roles, "Moderator");
|
||||
t3.ok(moderator.permissions.has("exp_scenario.command.jail"), "parent permissions are flattened in");
|
||||
t3.strictSame(rolesPlugin.roleMeta.get(moderator.id).shortHand, "Mod", "the role properties match the seed");
|
||||
|
||||
await plugin.handleSeedRequest();
|
||||
await seed();
|
||||
t3.strictSame(controller.roles.size, seedRoles.length, "seeding again reuses the roles");
|
||||
});
|
||||
|
||||
t2.test(".handleSeedRequest() creates the groups and resets them by name", async t3 => {
|
||||
const { plugin, controller } = await startPlugin(t3);
|
||||
t2.test("SeedRequest creates the groups and resets them by name", async t3 => {
|
||||
const { seed, controller } = await startPlugin(t3);
|
||||
const groupsPlugin = GroupsPlugin.get(controller);
|
||||
|
||||
await plugin.handleSeedRequest();
|
||||
await seed();
|
||||
t3.strictSame(groupsPlugin.groups.size, seedGroups.length, "every seed group exists");
|
||||
|
||||
const restricted = byName(groupsPlugin.groups, "Restricted");
|
||||
@@ -89,7 +125,7 @@ t.test("class ControllerPlugin", t2 => {
|
||||
|
||||
const admin = byName(groupsPlugin.groups, "Admin");
|
||||
groupsPlugin.groups.set(new GroupRecord(admin.id, admin.name, new GroupPermissions(true, [])));
|
||||
await plugin.handleSeedRequest();
|
||||
await seed();
|
||||
t3.strictSame(groupsPlugin.groups.size, seedGroups.length, "seeding again reuses the groups");
|
||||
t3.ok(
|
||||
byName(groupsPlugin.groups, "Admin").permissions.permissions.includes("toggle_map_editor"),
|
||||
@@ -97,11 +133,11 @@ t.test("class ControllerPlugin", t2 => {
|
||||
);
|
||||
});
|
||||
|
||||
t2.test(".handleSeedRequest() maps each role onto its group by rank", async t3 => {
|
||||
const { plugin, controller } = await startPlugin(t3);
|
||||
t2.test("SeedRequest maps each role onto its group by rank", async t3 => {
|
||||
const { seed, controller } = await startPlugin(t3);
|
||||
const groupsPlugin = GroupsPlugin.get(controller);
|
||||
|
||||
await plugin.handleSeedRequest();
|
||||
await seed();
|
||||
const mapped = seedRoles.filter(role => role.group !== undefined);
|
||||
t3.strictSame(groupsPlugin.roleMappings.size, mapped.length, "every role with a group is mapped");
|
||||
|
||||
@@ -119,76 +155,180 @@ t.test("class ControllerPlugin", t2 => {
|
||||
t3.strictSame(priorities.length, new Set(priorities).size, "priorities are unique");
|
||||
});
|
||||
|
||||
t2.test(".handleSeedRequest() reuses mappings and keeps clear of other mappings", async t3 => {
|
||||
const { plugin, controller } = await startPlugin(t3);
|
||||
t2.test("SeedRequest reuses mappings and keeps clear of other mappings", async t3 => {
|
||||
const { seed, controller } = await startPlugin(t3);
|
||||
const groupsPlugin = GroupsPlugin.get(controller);
|
||||
|
||||
groupsPlugin.roleMappings.set(new RoleMappingRecord(99, new Set([0, 1]), 5, 2, true));
|
||||
await plugin.handleSeedRequest();
|
||||
await seed();
|
||||
const first = new Map([...groupsPlugin.roleMappings.values()].map(mapping => [mapping.id, mapping.priority]));
|
||||
|
||||
await plugin.handleSeedRequest();
|
||||
await seed();
|
||||
const second = new Map([...groupsPlugin.roleMappings.values()].map(mapping => [mapping.id, mapping.priority]));
|
||||
t3.strictSame(second, first, "seeding again reuses the mappings");
|
||||
t3.strictSame(groupsPlugin.roleMappings.get(99).priority, 2, "other mappings are left alone");
|
||||
t3.notOk([...first.values()].filter(priority => priority === 2).length > 1, "seed priorities skip taken ones");
|
||||
});
|
||||
|
||||
t2.test(".fromContext() adds every feature with its defaults", async t3 => {
|
||||
const { plugin } = await startPlugin(t3, { withPlugins: false });
|
||||
const records = await plugin.handleFeatureListRequest();
|
||||
t2.test("startup adds every feature with its defaults", async t3 => {
|
||||
const { listFeatures } = await startPlugin(t3, { withPlugins: false });
|
||||
const records = await listFeatures();
|
||||
t3.strictSame(records.map(record => record.id), features.map(feature => feature.name), "one record per feature");
|
||||
t3.ok(records.every(record => record.enabled && !Object.keys(record.values).length), "enabled with no overrides");
|
||||
});
|
||||
|
||||
t2.test(".reconcileFeatures() drops stored values and features which no longer exist", async t3 => {
|
||||
t2.test("startup drops stored values and features which no longer exist", async t3 => {
|
||||
const databaseDirectory = t3.testdir();
|
||||
const { plugin } = await startPlugin(t3, { withPlugins: false, databaseDirectory });
|
||||
plugin.features.set(new messages.FeatureRecord("afk_kick", false, { afk_minutes: 5, trust_time: 600 }));
|
||||
plugin.features.set(new messages.FeatureRecord("gone", true, {}));
|
||||
await plugin.onShutdown();
|
||||
await storeFeatures(databaseDirectory, [
|
||||
new messages.FeatureRecord("afk_kick", false, { afk_minutes: 5, trust_time: 600 }),
|
||||
new messages.FeatureRecord("gone", true, {}),
|
||||
]);
|
||||
|
||||
const { plugin: restarted } = await startPlugin(t3, { withPlugins: false, databaseDirectory });
|
||||
const record = restarted.features.get("afk_kick");
|
||||
const { listFeatures } = await startPlugin(t3, { withPlugins: false, databaseDirectory });
|
||||
const record = await getFeature(listFeatures, "afk_kick");
|
||||
t3.strictSame([record.enabled, record.values], [false, { afk_minutes: 5 }], "the stale value is dropped, the rest kept");
|
||||
t3.notOk(restarted.features.has("gone"), "the removed feature is deleted");
|
||||
t3.ok(features.every(feature => restarted.features.has(feature.name)), "every declared feature still has a record");
|
||||
const ids = new Set((await listFeatures()).map(other => other.id));
|
||||
t3.notOk(ids.has("gone"), "the removed feature is deleted");
|
||||
t3.ok(features.every(feature => ids.has(feature.name)), "every declared feature still has a record");
|
||||
});
|
||||
|
||||
t2.test(".handleFeatureUpdateRequest() stores the values which differ from the default", async t3 => {
|
||||
const { plugin } = await startPlugin(t3, { withPlugins: false });
|
||||
t2.test("FeatureUpdateRequest stores the values which differ from the default", async t3 => {
|
||||
const { controller, listFeatures, updateFeature, featureSubscription } = await startPlugin(t3, { withPlugins: false });
|
||||
const broadcasts = [];
|
||||
plugin.controller.subscriptions.broadcast = event => broadcasts.push(event);
|
||||
controller.subscriptions.broadcast = event => broadcasts.push(event);
|
||||
|
||||
const record = await plugin.handleFeatureUpdateRequest(new messages.FeatureUpdateRequest(
|
||||
const record = await updateFeature(new messages.FeatureUpdateRequest(
|
||||
"death_markers", false, { show_map_markers: false, collect_corpses: true },
|
||||
));
|
||||
t3.strictSame(record.values, { show_map_markers: false }, "the default value is dropped");
|
||||
t3.equal(plugin.features.get("death_markers").enabled, false, "the feature is disabled");
|
||||
t3.equal((await getFeature(listFeatures, "death_markers")).enabled, false, "the feature is disabled");
|
||||
t3.strictSame(broadcasts.map(event => event.updates.map(update => update.id)), [["death_markers"]], "the update is broadcast");
|
||||
|
||||
const subscription = await plugin.handleFeatureSubscription({ lastRequestTimeMs: record.updatedAtMs - 1 });
|
||||
const subscription = await featureSubscription({ lastRequestTimeMs: record.updatedAtMs - 1 });
|
||||
t3.strictSame(subscription.updates.map(update => update.id), ["death_markers"], "subscribers catch up on the change");
|
||||
});
|
||||
|
||||
t2.test(".handleFeatureUpdateRequest() rejects invalid values", async t3 => {
|
||||
const { plugin } = await startPlugin(t3, { withPlugins: false });
|
||||
t2.test("FeatureUpdateRequest rejects invalid values", async t3 => {
|
||||
const { updateFeature } = await startPlugin(t3, { withPlugins: false });
|
||||
await t3.rejects(
|
||||
plugin.handleFeatureUpdateRequest(new messages.FeatureUpdateRequest("death_markers", true, { unknown: 1 })),
|
||||
updateFeature(new messages.FeatureUpdateRequest("death_markers", true, { unknown: 1 })),
|
||||
{ message: "Feature death_markers has no setting unknown" },
|
||||
);
|
||||
});
|
||||
|
||||
t2.test(".fromContext() keeps stored features across restarts", async t3 => {
|
||||
t2.test("stored features are kept across restarts", async t3 => {
|
||||
const databaseDirectory = t3.testdir();
|
||||
const { plugin } = await startPlugin(t3, { withPlugins: false, databaseDirectory });
|
||||
await plugin.handleFeatureUpdateRequest(new messages.FeatureUpdateRequest("afk_kick", false, { afk_minutes: 5 }));
|
||||
await plugin.onShutdown();
|
||||
const { updateFeature, shutdown } = await startPlugin(t3, { withPlugins: false, databaseDirectory });
|
||||
await updateFeature(new messages.FeatureUpdateRequest("afk_kick", false, { afk_minutes: 5 }));
|
||||
await shutdown();
|
||||
|
||||
const { plugin: restarted } = await startPlugin(t3, { withPlugins: false, databaseDirectory });
|
||||
const record = restarted.features.get("afk_kick");
|
||||
const { listFeatures } = await startPlugin(t3, { withPlugins: false, databaseDirectory });
|
||||
const record = await getFeature(listFeatures, "afk_kick");
|
||||
t3.strictSame([record.enabled, record.values], [false, { afk_minutes: 5 }], "the stored record is loaded");
|
||||
});
|
||||
|
||||
t2.end();
|
||||
});
|
||||
|
||||
/** Read players.json as the next run would. */
|
||||
async function loadPlayers(databaseDirectory) {
|
||||
return new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "exp_scenario", "players.json"),
|
||||
messages.PlayerRecord.fromJSON.bind(messages.PlayerRecord),
|
||||
).load();
|
||||
}
|
||||
|
||||
t.test("player settings", t2 => {
|
||||
t2.test("PlayerGetRequest returns an empty record for unknown players", async t3 => {
|
||||
const { send } = await startPlugin(t3, { withPlugins: false });
|
||||
const record = await send(new messages.PlayerGetRequest("Alice"));
|
||||
t3.equal(record.id, "Alice");
|
||||
t3.strictSame(record.toJSON(), { id: "Alice" }, "nothing is set");
|
||||
});
|
||||
|
||||
t2.test("JoinMessageSetRequest sets, trims, and clears the message", async t3 => {
|
||||
const { send } = await startPlugin(t3, { withPlugins: false });
|
||||
await send(new messages.JoinMessageSetRequest("Alice", " Hello there "));
|
||||
t3.equal((await send(new messages.PlayerGetRequest("Alice"))).joinMessage, "Hello there");
|
||||
|
||||
await send(new messages.JoinMessageSetRequest("Alice", null));
|
||||
t3.equal((await send(new messages.PlayerGetRequest("Alice"))).joinMessage, undefined, "null clears it");
|
||||
|
||||
await send(new messages.JoinMessageSetRequest("Alice", "Hi"));
|
||||
await send(new messages.JoinMessageSetRequest("Alice", " "));
|
||||
t3.equal((await send(new messages.PlayerGetRequest("Alice"))).joinMessage, undefined, "blank clears it");
|
||||
});
|
||||
|
||||
t2.test("players are saved on the save hook and empty records are removed", async t3 => {
|
||||
const databaseDirectory = t3.testdir();
|
||||
const { send, save, shutdown } = await startPlugin(t3, { withPlugins: false, databaseDirectory });
|
||||
await send(new messages.JoinMessageSetRequest("Alice", "Hi"));
|
||||
await send(new messages.JoinMessageSetRequest("Bob", "Hey"));
|
||||
await send(new messages.JoinMessageSetRequest("Bob", null));
|
||||
await save();
|
||||
|
||||
const stored = await loadPlayers(databaseDirectory);
|
||||
t3.strictSame([...stored.keys()], ["Alice"], "only players with a setting are stored");
|
||||
t3.equal(stored.get("Alice").joinMessage, "Hi");
|
||||
|
||||
await send(new messages.JoinMessageSetRequest("Alice", "Bye"));
|
||||
await shutdown();
|
||||
const restarted = await startPlugin(t3, { withPlugins: false, databaseDirectory });
|
||||
const record = await restarted.send(new messages.PlayerGetRequest("Alice"));
|
||||
t3.equal(record.joinMessage, "Bye", "kept across restarts");
|
||||
});
|
||||
|
||||
t2.test("PlayerLocaleEvent stores the locale next to the join message", async t3 => {
|
||||
const { send } = await startPlugin(t3, { withPlugins: false });
|
||||
await send(new messages.JoinMessageSetRequest("Alice", "Hi"));
|
||||
await send(new messages.PlayerLocaleEvent("Alice", "pt-BR"));
|
||||
const record = await send(new messages.PlayerGetRequest("Alice"));
|
||||
t3.strictSame([record.joinMessage, record.locale], ["Hi", "pt-BR"]);
|
||||
|
||||
await send(new messages.JoinMessageSetRequest("Alice", null));
|
||||
const cleared = await send(new messages.PlayerGetRequest("Alice"));
|
||||
t3.equal(cleared.locale, "pt-BR", "clearing the message keeps the locale");
|
||||
});
|
||||
|
||||
t2.test("PlayerToolbarEvent stores the toolbar and it is saved", async t3 => {
|
||||
const databaseDirectory = t3.testdir();
|
||||
const { send, save } = await startPlugin(t3, { withPlugins: false, databaseDirectory });
|
||||
await send(new messages.PlayerToolbarEvent("Alice", "abc"));
|
||||
t3.equal((await send(new messages.PlayerGetRequest("Alice"))).toolbar, "abc");
|
||||
|
||||
await save();
|
||||
const stored = await loadPlayers(databaseDirectory);
|
||||
const alice = stored.get("Alice");
|
||||
t3.strictSame(alice.toJSON(), { id: "Alice", toolbar: "abc", updated_at_ms: alice.updatedAtMs });
|
||||
});
|
||||
|
||||
t2.test("permissions", async t3 => {
|
||||
const user = (name, granted = []) => ({
|
||||
name,
|
||||
checkPermission(permission) {
|
||||
if (!granted.includes(permission)) {
|
||||
throw new lib.PermissionError("denied");
|
||||
}
|
||||
},
|
||||
});
|
||||
const request = name => ({ data: { player_name: name } });
|
||||
|
||||
const set = messages.JoinMessageSetRequest.permission;
|
||||
t3.throws(() => set(user("Alice"), request("alice")), "own message needs the set permission");
|
||||
t3.doesNotThrow(() => set(user("Alice", ["exp_scenario.join_message.set"]), request("alice")), "own message");
|
||||
t3.throws(
|
||||
() => set(user("Alice", ["exp_scenario.join_message.set"]), request("Bob")),
|
||||
"someone else's message needs set_other",
|
||||
);
|
||||
t3.doesNotThrow(
|
||||
() => set(user("Alice", ["exp_scenario.join_message.set_other"]), request("Bob")), "with set_other",
|
||||
);
|
||||
|
||||
const get = messages.PlayerGetRequest.permission;
|
||||
t3.doesNotThrow(() => get(user("Alice"), request("alice")), "own settings");
|
||||
t3.throws(() => get(user("Alice"), request("Bob")), "someone else's");
|
||||
t3.doesNotThrow(() => get(user("Alice", ["core.user.get"]), request("Bob")), "with core.user.get");
|
||||
});
|
||||
|
||||
t2.end();
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import t from "tap";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { Instance } from "@clusterio/host";
|
||||
import { InstancePlugin } from "../dist/node/instance.js";
|
||||
import entrypoint from "../dist/node/instance.js";
|
||||
import { plugin as pluginDeclaration } from "../dist/node/index.js";
|
||||
import * as messages from "../dist/node/messages.js";
|
||||
import { features } from "../dist/node/features.js";
|
||||
@@ -24,8 +24,10 @@ class TestConnector extends lib.BaseConnector {
|
||||
send() {}
|
||||
}
|
||||
|
||||
/** Build a plugin around a real instance with spies on what leaves it. */
|
||||
async function startPlugin(t2, features = [new messages.FeatureRecord("death_markers", false, { show_map_markers: false })]) {
|
||||
/** Run the plugin on a real instance with spies on what leaves it, returns the event handler it registered. */
|
||||
async function startPlugin(
|
||||
t2, features = [new messages.FeatureRecord("death_markers", false, { show_map_markers: false })], players = [],
|
||||
) {
|
||||
const instanceConfig = new lib.InstanceConfig("host");
|
||||
instanceConfig.set("instance.id", 1);
|
||||
instanceConfig.set("instance.name", "test");
|
||||
@@ -35,8 +37,9 @@ async function startPlugin(t2, features = [new messages.FeatureRecord("death_mar
|
||||
);
|
||||
|
||||
const state = { sent: [], rcons: [] };
|
||||
const ipcHandlers = new Map();
|
||||
instance.server = {
|
||||
handle: () => {},
|
||||
handle: (channel, handler) => ipcHandlers.set(channel, handler),
|
||||
sendRcon: async command => {
|
||||
state.rcons.push(command);
|
||||
return "";
|
||||
@@ -44,11 +47,28 @@ async function startPlugin(t2, features = [new messages.FeatureRecord("death_mar
|
||||
};
|
||||
instance.sendTo = async (dst, request) => {
|
||||
state.sent.push(request);
|
||||
if (request instanceof messages.PlayerGetRequest) {
|
||||
const found = players.find(record => record.id === request.playerName);
|
||||
return found ?? new messages.PlayerRecord(request.playerName);
|
||||
}
|
||||
return request instanceof messages.FeatureListRequest ? features : undefined;
|
||||
};
|
||||
|
||||
const plugin = await InstancePlugin.fromContext({ plugin: { name: "exp_scenario" }, instance, host: {}, logger });
|
||||
return { plugin, instance, state };
|
||||
const handlers = new Map();
|
||||
instance.handle = (Event, handler) => handlers.set(Event, handler);
|
||||
|
||||
await entrypoint({ plugin: { name: "exp_scenario" }, instance, host: {}, logger });
|
||||
const hooks = instance.hooks;
|
||||
return {
|
||||
state,
|
||||
start: () => hooks.start.invoke(),
|
||||
exit: () => hooks.exit.invoke(),
|
||||
connectionEvent: event => hooks.controllerConnectionEvent.invoke(event),
|
||||
featureUpdated: handlers.get(messages.FeatureUpdatedEvent),
|
||||
ipc: (channel, event) => ipcHandlers.get(channel)(event),
|
||||
playerEvent: event => hooks.playerEvent.invoke(event),
|
||||
setStatus: status => { instance._status = status; },
|
||||
};
|
||||
}
|
||||
|
||||
/** The payload of a recorded rcon command. */
|
||||
@@ -57,10 +77,18 @@ function decodeRcon(command) {
|
||||
return JSON.parse(match[2]);
|
||||
}
|
||||
|
||||
t.test("class InstancePlugin", t2 => {
|
||||
t2.test(".onStart() subscribes and sends every feature to lua", async t3 => {
|
||||
const { plugin, state } = await startPlugin(t3);
|
||||
await plugin.onStart();
|
||||
/** The player data and code of a recorded rcon command made by luaWithPlayer. */
|
||||
function decodePlayerRcon(command) {
|
||||
const match = command.match(
|
||||
/^\/sc local d = helpers\.json_to_table\[(=+)\[(.*)\]\1\] local p = game\.get_player\(d\.name\) if p then (.*) end$/s,
|
||||
);
|
||||
return { data: JSON.parse(match[2]), code: match[3] };
|
||||
}
|
||||
|
||||
t.test("instance plugin", t2 => {
|
||||
t2.test("start subscribes and sends every feature to lua", async t3 => {
|
||||
const { start, state } = await startPlugin(t3);
|
||||
await start();
|
||||
|
||||
t3.ok(state.sent[0] instanceof lib.SubscriptionRequest, "subscribes to updates first");
|
||||
t3.ok(state.sent[1] instanceof messages.FeatureListRequest, "then lists the features");
|
||||
@@ -72,52 +100,139 @@ t.test("class InstancePlugin", t2 => {
|
||||
t3.equal(Object.keys(update.values).length, deathMarkers.fields.length + 1, "every field and enabled");
|
||||
});
|
||||
|
||||
t2.test(".handleFeatureUpdatedEvent() only sends while the game is up", async t3 => {
|
||||
const { plugin, state } = await startPlugin(t3);
|
||||
t2.test("FeatureUpdatedEvent only sends while the game is up", async t3 => {
|
||||
const { start, exit, featureUpdated, state } = await startPlugin(t3);
|
||||
const event = new messages.FeatureUpdatedEvent([
|
||||
new messages.FeatureRecord("afk_kick", true, { afk_minutes: 5 }),
|
||||
new messages.FeatureRecord("removed", true, {}, 0, true),
|
||||
]);
|
||||
|
||||
await plugin.handleFeatureUpdatedEvent(event);
|
||||
await featureUpdated(event);
|
||||
t3.strictSame(state.rcons, [], "nothing is sent before start");
|
||||
|
||||
await plugin.onStart();
|
||||
await start();
|
||||
state.rcons.length = 0;
|
||||
await plugin.handleFeatureUpdatedEvent(event);
|
||||
await featureUpdated(event);
|
||||
const updates = decodeRcon(state.rcons[0]);
|
||||
t3.strictSame(updates.map(update => update.name), ["afk_kick"], "deleted features are left out");
|
||||
t3.strictSame([updates[0].values.enabled, updates[0].values.afk_minutes], [true, 5]);
|
||||
|
||||
await plugin.onExit();
|
||||
await exit();
|
||||
state.rcons.length = 0;
|
||||
await plugin.handleFeatureUpdatedEvent(event);
|
||||
await featureUpdated(event);
|
||||
t3.strictSame(state.rcons, [], "nothing is sent after exit");
|
||||
});
|
||||
|
||||
t2.test(".onControllerConnectionEvent() syncs again after the controller restarts", async t3 => {
|
||||
const { plugin, state } = await startPlugin(t3);
|
||||
await plugin.onControllerConnectionEvent("connect");
|
||||
t2.test("a new controller connection syncs again after the controller restarts", async t3 => {
|
||||
const { start, connectionEvent, state } = await startPlugin(t3);
|
||||
await connectionEvent("connect");
|
||||
t3.strictSame(state.sent, [], "nothing happens before start");
|
||||
|
||||
await plugin.onStart();
|
||||
await start();
|
||||
state.sent.length = 0;
|
||||
state.rcons.length = 0;
|
||||
await plugin.onControllerConnectionEvent("resume");
|
||||
await connectionEvent("resume");
|
||||
t3.strictSame(state.sent, [], "a resumed session keeps its subscription");
|
||||
|
||||
await plugin.onControllerConnectionEvent("connect");
|
||||
await connectionEvent("connect");
|
||||
t3.ok(state.sent[0] instanceof lib.SubscriptionRequest, "subscribes again");
|
||||
t3.equal(state.rcons.length, 1, "and sends every feature");
|
||||
});
|
||||
|
||||
t2.test(".luaSendFeatures() picks a long bracket the values do not close", async t3 => {
|
||||
const { plugin, state } = await startPlugin(t3);
|
||||
t2.test("feature updates pick a long bracket the values do not close", async t3 => {
|
||||
const { start, featureUpdated, state } = await startPlugin(t3, []);
|
||||
await start();
|
||||
const name = "a]=]b]==]c";
|
||||
await plugin.luaSendFeatures([new messages.FeatureRecord("station_auto_name", true, { station_name: name })]);
|
||||
await featureUpdated(new messages.FeatureUpdatedEvent([
|
||||
new messages.FeatureRecord("station_auto_name", true, { station_name: name }),
|
||||
]));
|
||||
t3.match(state.rcons[0], "json_to_table[===[", "uses a level not found in the json");
|
||||
t3.strictSame(decodeRcon(state.rcons[0])[0].values.station_name, name);
|
||||
});
|
||||
|
||||
t2.end();
|
||||
});
|
||||
|
||||
t.test("player settings", t2 => {
|
||||
const alice = new messages.PlayerRecord("Alice", "Alice has arrived ]=] ]==]");
|
||||
|
||||
t2.test("a joining player's message is printed to everyone", async t3 => {
|
||||
const { start, playerEvent, setStatus, state } = await startPlugin(t3, [], [alice]);
|
||||
await start();
|
||||
setStatus("running");
|
||||
state.rcons.length = 0;
|
||||
|
||||
await playerEvent({ type: "join", name: "Alice" });
|
||||
t3.equal(state.rcons.length, 1);
|
||||
const { data, code } = decodePlayerRcon(state.rcons[0]);
|
||||
t3.strictSame(data, { message: alice.joinMessage, name: "Alice" }, "the message survives the long bracket");
|
||||
t3.equal(code, "game.print(d.message, { color = p.color })");
|
||||
|
||||
await playerEvent({ type: "join", name: "Bob" });
|
||||
await playerEvent({ type: "leave", name: "Alice" });
|
||||
t3.equal(state.rcons.length, 1, "players without a message and leaves print nothing");
|
||||
});
|
||||
|
||||
t2.test("nothing is printed while the instance is not running or greetings are disabled", async t3 => {
|
||||
const { start, playerEvent, setStatus, featureUpdated, state } = await startPlugin(t3, [], [alice]);
|
||||
await start();
|
||||
state.rcons.length = 0;
|
||||
await playerEvent({ type: "join", name: "Alice" });
|
||||
t3.strictSame(state.rcons, [], "not running");
|
||||
|
||||
setStatus("running");
|
||||
await featureUpdated(new messages.FeatureUpdatedEvent([new messages.FeatureRecord("greetings", false)]));
|
||||
state.rcons.length = 0;
|
||||
await playerEvent({ type: "join", name: "Alice" });
|
||||
t3.strictSame(state.rcons, [], "greetings disabled");
|
||||
});
|
||||
|
||||
t2.test("ipc from the set and remove commands is forwarded to the controller", async t3 => {
|
||||
const { ipc, state } = await startPlugin(t3);
|
||||
await ipc("exp_scenario:join_message_set", { player_name: "Alice", message: "Hi" });
|
||||
await ipc("exp_scenario:join_message_set", { player_name: "Alice" });
|
||||
const sent = state.sent.filter(request => request instanceof messages.JoinMessageSetRequest);
|
||||
t3.strictSame(sent.map(request => [request.playerName, request.message]), [["Alice", "Hi"], ["Alice", null]]);
|
||||
});
|
||||
|
||||
t2.test("a joining player's stored toolbar is sent to the lua side", async t3 => {
|
||||
const bob = new messages.PlayerRecord("Bob", undefined, undefined, "eNqrVkrLz1eyUlBKSixSqgUAGQwD8g==");
|
||||
const { start, playerEvent, setStatus, state } = await startPlugin(t3, [], [bob]);
|
||||
await start();
|
||||
setStatus("running");
|
||||
state.rcons.length = 0;
|
||||
|
||||
await playerEvent({ type: "join", name: "Bob" });
|
||||
t3.equal(state.rcons.length, 1, "only the toolbar, Bob has no join message");
|
||||
const { data, code } = decodePlayerRcon(state.rcons[0]);
|
||||
t3.strictSame(data, { toolbar: bob.toolbar, name: "Bob" });
|
||||
t3.equal(code, "exp_scenario.player_settings.receive_toolbar(d.name, d.toolbar)");
|
||||
});
|
||||
|
||||
t2.test("ipc with a toolbar is forwarded to the controller", async t3 => {
|
||||
const { ipc, state } = await startPlugin(t3);
|
||||
await ipc("exp_scenario:toolbar", { player_name: "Alice", toolbar: "abc" });
|
||||
const [event] = state.sent.filter(request => request instanceof messages.PlayerToolbarEvent);
|
||||
t3.strictSame([event.playerName, event.toolbar], ["Alice", "abc"]);
|
||||
});
|
||||
|
||||
t2.test("ipc with a locale is forwarded to the controller", async t3 => {
|
||||
const { ipc, state } = await startPlugin(t3);
|
||||
await ipc("exp_scenario:locale", { player_name: "Alice", locale: "de" });
|
||||
const [event] = state.sent.filter(request => request instanceof messages.PlayerLocaleEvent);
|
||||
t3.strictSame([event.playerName, event.locale], ["Alice", "de"]);
|
||||
});
|
||||
|
||||
t2.test("ipc asking for the message prints it to the player", async t3 => {
|
||||
const { ipc, setStatus, state } = await startPlugin(t3, [], [alice]);
|
||||
setStatus("running");
|
||||
await ipc("exp_scenario:join_message_get", { player_name: "Alice" });
|
||||
await ipc("exp_scenario:join_message_get", { player_name: "Bob" });
|
||||
const [found, missing] = state.rcons.map(decodePlayerRcon);
|
||||
t3.equal(found.data.message, alice.joinMessage);
|
||||
t3.equal(missing.data.message, undefined, "no message key, so the lua side prints message-none");
|
||||
t3.match(found.code, "exp_greetings.message-none");
|
||||
});
|
||||
|
||||
t2.end();
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import t from "tap";
|
||||
import { localeFlag, localeName } from "../dist/node/locales.js";
|
||||
|
||||
// The locale folders of Factorio 2.1
|
||||
const factorioLocales = [
|
||||
"af", "ar", "be", "bg", "ca", "cs", "da", "de", "el", "en", "eo", "es-ES", "et", "eu", "fa", "fi", "fil", "fr",
|
||||
"fy-NL", "ga-IE", "he", "hr", "hu", "id", "is", "it", "ja", "ka", "kk", "ko", "lt", "lv", "nl", "no", "pl",
|
||||
"pt-BR", "pt-PT", "ro", "ru", "sk", "sl", "sq", "sr", "sv-SE", "th", "tr", "uk", "vi", "zh-CN", "zh-TW",
|
||||
];
|
||||
const noFlag = ["ar", "ca", "eo", "eu"];
|
||||
|
||||
t.test("localeFlag()", async t2 => {
|
||||
t2.equal(localeFlag("de"), "\u{1F1E9}\u{1F1EA}", "language codes use the table");
|
||||
t2.equal(localeFlag("pt-BR"), "\u{1F1E7}\u{1F1F7}", "regions are used directly");
|
||||
t2.equal(localeFlag("zh-TW"), "\u{1F1F9}\u{1F1FC}");
|
||||
for (const locale of factorioLocales) {
|
||||
t2.equal(localeFlag(locale) === "", noFlag.includes(locale), `${locale} has a flag unless it has no country`);
|
||||
}
|
||||
t2.equal(localeFlag("xx"), "", "unknown languages have no flag");
|
||||
t2.equal(localeFlag(""), "");
|
||||
});
|
||||
|
||||
t.test("localeName()", async t2 => {
|
||||
t2.equal(localeName("de"), "German");
|
||||
t2.equal(localeName("not a locale"), "not a locale", "invalid codes are shown as they are");
|
||||
});
|
||||
@@ -6,3 +6,6 @@ const envFile = path.join(import.meta.dirname, "module", "env.lua");
|
||||
|
||||
// Each file runs in its own lua state, and each test in a fresh environment
|
||||
t.test("features.lua", t2 => reportLuaTests(t2, envFile, path.join(import.meta.dirname, "module", "features.lua")));
|
||||
t.test("player_settings.lua", t2 => reportLuaTests(
|
||||
t2, envFile, path.join(import.meta.dirname, "module", "player_settings.lua"),
|
||||
));
|
||||
@@ -17,6 +17,7 @@ local Framework = assert(loadfile(shared_root .. "/framework.lua"))() --- @type
|
||||
--- @field storage table The table registered with storage, as on_init would store it
|
||||
--- @field on_load fun(tbl: table) Act out on_load with the given storage table
|
||||
--- @field load_config fun(name: string): ExpScenario.Feature Load module/config/<name>.lua
|
||||
--- @field load_module fun(name: string): any Load module/<name>.lua, such as "control/player_settings"
|
||||
--- @field logged string[] Messages passed to log
|
||||
--- @field events table[] Raised events, name and tick filled in
|
||||
|
||||
@@ -51,5 +52,9 @@ return Framework.suite(function(env)
|
||||
return assert(loadfile(plugin_root .. "/module/config/" .. name .. ".lua"))()
|
||||
end
|
||||
|
||||
function env.load_module(name)
|
||||
return assert(loadfile(plugin_root .. "/module/" .. name .. ".lua"))()
|
||||
end
|
||||
|
||||
return env
|
||||
end)
|
||||
@@ -0,0 +1,79 @@
|
||||
local Suite = ... --- @type Suite<ExpScenario.TestEnv>
|
||||
|
||||
local valid_state = { order = { { name = "readme", favourite = true } }, open = {}, visible = true }
|
||||
|
||||
--- Load control/player_settings.lua with a stubbed toolbar, encoding is a "enc:" prefix
|
||||
--- @param env ExpScenario.TestEnv
|
||||
--- @param toolbar table? Replaces the Gui.toolbar stub
|
||||
--- @return table PlayerSettings
|
||||
--- @return table restored Calls to set_state as { player name, state }
|
||||
local function load(env, toolbar)
|
||||
local restored = {}
|
||||
env.extend_defines{ events = { on_player_locale_changed = 3, on_player_left_game = 4 } }
|
||||
env.extend_requires{
|
||||
["modules/exp_gui"] = {
|
||||
toolbar = toolbar or {
|
||||
get_state = function() return valid_state end,
|
||||
set_state = function(player, state) restored[#restored + 1] = { player.name, state } end,
|
||||
},
|
||||
},
|
||||
}
|
||||
local json_values = { ['{"valid"}'] = valid_state, ['{"wrong shape"}'] = { order = "x" } }
|
||||
helpers = {
|
||||
encode_string = function(str) return "enc:" .. str end,
|
||||
decode_string = function(str) return str:match("^enc:(.*)$") end,
|
||||
table_to_json = function(tbl) return tbl == valid_state and '{"valid"}' or "{}" end,
|
||||
json_to_table = function(json) return json_values[json] end,
|
||||
}
|
||||
return env.load_module("control/player_settings"), restored
|
||||
end
|
||||
|
||||
Suite.test("send_locale() sends the locale of the player", function(env)
|
||||
local PlayerSettings = load(env)
|
||||
rawset(env.add_player{ name = "Alice" }, "locale", "pt-BR")
|
||||
PlayerSettings.events[defines.events.on_player_joined_game]{ player_index = 1 }
|
||||
Suite.eq(env.sent, { { channel = "exp_scenario:locale", data = { player_name = "Alice", locale = "pt-BR" } } }, "sent")
|
||||
end)
|
||||
|
||||
Suite.test("send_toolbar() sends the compressed toolbar when a player leaves", function(env)
|
||||
local PlayerSettings = load(env)
|
||||
env.add_player{ name = "Alice" }
|
||||
PlayerSettings.events[defines.events.on_player_left_game]{ player_index = 1 }
|
||||
Suite.eq(env.sent, { { channel = "exp_scenario:toolbar", data = { player_name = "Alice", toolbar = 'enc:{"valid"}' } } }, "sent")
|
||||
end)
|
||||
|
||||
Suite.test("send_toolbar() sends nothing when the toolbar can not be read", function(env)
|
||||
local PlayerSettings = load(env, { get_state = function() error("no toolbar") end })
|
||||
env.add_player{ name = "Alice" }
|
||||
PlayerSettings.events[defines.events.on_player_left_game]{ player_index = 1 }
|
||||
Suite.eq(env.sent, {}, "nothing sent")
|
||||
Suite.eq(#env.logged, 1, "a warning is logged")
|
||||
end)
|
||||
|
||||
Suite.test("receive_toolbar() restores the toolbar of online players", function(env)
|
||||
local PlayerSettings, restored = load(env)
|
||||
env.add_player{ name = "Alice" }
|
||||
env.add_player{ name = "Bob", connected = false }
|
||||
PlayerSettings.receive_toolbar("Alice", 'enc:{"valid"}')
|
||||
PlayerSettings.receive_toolbar("Bob", 'enc:{"valid"}')
|
||||
PlayerSettings.receive_toolbar("Unknown", 'enc:{"valid"}')
|
||||
Suite.eq(restored, { { "Alice", valid_state } }, "only Alice is online")
|
||||
end)
|
||||
|
||||
Suite.test("receive_toolbar() logs malformed toolbars instead of raising", function(env)
|
||||
local PlayerSettings, restored = load(env)
|
||||
env.add_player{ name = "Alice" }
|
||||
for _, toolbar in ipairs{ "not encoded", "enc:not json", 'enc:{"wrong shape"}' } do
|
||||
PlayerSettings.receive_toolbar("Alice", toolbar)
|
||||
end
|
||||
Suite.eq(restored, {}, "nothing is restored")
|
||||
Suite.eq(#env.logged, 3, "each one is logged")
|
||||
end)
|
||||
|
||||
Suite.test("receive_toolbar() logs errors from set_state", function(env)
|
||||
local PlayerSettings = load(env, { set_state = function() error("Missing toolbox item for removed") end })
|
||||
env.add_player{ name = "Alice" }
|
||||
PlayerSettings.receive_toolbar("Alice", 'enc:{"valid"}')
|
||||
Suite.eq(#env.logged, 1, "a warning is logged")
|
||||
end)
|
||||
return Suite.run()
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"extends": "../tsconfig.browser.json",
|
||||
"include": [ "web/**/*.tsx", "web/**/*.ts", "index.ts", "messages.ts", "features.ts", "permissions.ts", "package.json" ],
|
||||
"include": [ "web/**/*.tsx", "web/**/*.ts", "index.ts", "messages.ts", "features.ts", "locales.ts", "permissions.ts", "package.json" ],
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { Button, Descriptions, Input, Space } from "antd";
|
||||
|
||||
import { ControlContext, PluginExtensionProps, SectionHeader, notifyErrorHandler, useAccount } from "@clusterio/web_ui";
|
||||
|
||||
import { JoinMessageSetRequest, PlayerGetRequest, PlayerRecord, joinMessageMaxLength } from "../../messages.js";
|
||||
import { localeFlag, localeName } from "../../locales.js";
|
||||
|
||||
/** The settings the scenario keeps for a player, shown on their user page. */
|
||||
export default function UserPlayerSettings(props: PluginExtensionProps<"UserViewPage">) {
|
||||
const control = useContext(ControlContext);
|
||||
const account = useAccount();
|
||||
const name = props.user.name;
|
||||
const own = account.name.toLowerCase() === name.toLowerCase();
|
||||
const canGet = own || Boolean(account.hasPermission("core.user.get"));
|
||||
const canEdit = Boolean(account.hasPermission(
|
||||
own ? "exp_scenario.join_message.set" : "exp_scenario.join_message.set_other",
|
||||
));
|
||||
const [record, setRecord] = useState<PlayerRecord>();
|
||||
/** The message being typed, undefined while not editing */
|
||||
const [draft, setDraft] = useState<string>();
|
||||
|
||||
function load() {
|
||||
control.send(new PlayerGetRequest(name))
|
||||
.then(setRecord)
|
||||
.catch(notifyErrorHandler("Error loading player settings"));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (canGet) {
|
||||
load();
|
||||
}
|
||||
}, [name, canGet]);
|
||||
|
||||
if (!canGet || !record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function save(message: string) {
|
||||
await control.send(new JoinMessageSetRequest(name, message.trim() || null));
|
||||
setDraft(undefined);
|
||||
load();
|
||||
}
|
||||
|
||||
function onSave() {
|
||||
save(draft ?? "").catch(notifyErrorHandler("Error saving join message"));
|
||||
}
|
||||
|
||||
return <>
|
||||
<SectionHeader title="Scenario" />
|
||||
<Descriptions bordered size="small" column={1}>
|
||||
<Descriptions.Item label="Language">
|
||||
{record.locale
|
||||
? `${localeFlag(record.locale)} ${localeName(record.locale)} (${record.locale})`.trim()
|
||||
: <span style={{ color: "#888" }}>Unknown</span>}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Join message">
|
||||
{draft !== undefined
|
||||
? <Space.Compact style={{ width: "100%", maxWidth: 600 }}>
|
||||
<Input
|
||||
value={draft}
|
||||
maxLength={joinMessageMaxLength}
|
||||
placeholder="Shown to everyone when they join, empty for none"
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onPressEnter={onSave}
|
||||
/>
|
||||
<Button type="primary" onClick={onSave}>Save</Button>
|
||||
<Button onClick={() => setDraft(undefined)}>Cancel</Button>
|
||||
</Space.Compact>
|
||||
: <>
|
||||
{record.joinMessage ?? <span style={{ color: "#888" }}>None</span>}
|
||||
{canEdit && <Button
|
||||
size="small"
|
||||
style={{ marginLeft: 8 }}
|
||||
onClick={() => setDraft(record.joinMessage ?? "")}
|
||||
>Edit</Button>}
|
||||
</>}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</>;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import * as messages from "../messages.js";
|
||||
|
||||
import FeaturesPage from "./components/FeaturesPage";
|
||||
import Seed from "./components/Seed";
|
||||
import UserPlayerSettings from "./components/UserPlayerSettings";
|
||||
|
||||
let features: lib.MapSubscriber<messages.FeatureUpdatedEvent> | undefined;
|
||||
|
||||
@@ -17,6 +18,7 @@ export function useFeatures() {
|
||||
export default function registerPlugin(context: WebPluginContext) {
|
||||
context.control.hooks.extensionComponents.attach("exp_scenario", () => ({
|
||||
RolesPage: Seed,
|
||||
UserViewPage: UserPlayerSettings,
|
||||
}));
|
||||
|
||||
context.control.hooks.pages.attach("exp_scenario", () => [
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# ExpGaming - Server List
|
||||
|
||||
Shares the details of every instance with every other instance. Replaces `expcore/external` from exp_legacy, which read the same data from a file written by an outside service.
|
||||
|
||||
The controller keeps one record per instance with its name, mod pack, Factorio version, players online, status and address. The address is the public address of the host and the game port of the instance, the same one the web UI shows. Whether the server has a game password is sent, the password is not. Nothing is saved, the records are rebuilt from the instances when the controller starts.
|
||||
|
||||
## Config
|
||||
|
||||
On the controller:
|
||||
|
||||
| Field | Default | Description |
|
||||
|---|---|---|
|
||||
| `exp_server_list.welcome` | | Message shown on the welcome tab of the readme for instances without their own. |
|
||||
|
||||
On the instance:
|
||||
|
||||
| Field | Default | Description |
|
||||
|---|---|---|
|
||||
| `exp_server_list.display_name` | | Name shown in the server list, the instance name when empty. |
|
||||
| `exp_server_list.description` | | One line about the server shown in the server list, the description in `factorio.settings` when empty. |
|
||||
| `exp_server_list.welcome` | | Message shown on the welcome tab of the readme, the controller welcome when empty. |
|
||||
| `exp_server_list.reset_time` | | When the map next resets, shown on the welcome tab of the readme. |
|
||||
| `exp_server_list.hidden` | false | Leave this instance out of the server list on other instances. |
|
||||
| `exp_server_list.always_on_top` | false | List this instance before those without it. |
|
||||
|
||||
## Lua
|
||||
|
||||
```lua
|
||||
local ExpServerList = require("modules/exp_server_list")
|
||||
|
||||
ExpServerList.get_servers() -- Every server that is not hidden, always on top first then by name
|
||||
ExpServerList.get_server(instance_id)
|
||||
ExpServerList.is_synced() -- false until the controller has sent the list
|
||||
ExpServerList.get_current() -- nil until the controller has sent the list
|
||||
ExpServerList.find_servers("weekly") -- Matches name, display name or id
|
||||
ExpServerList.is_online(server)
|
||||
ExpServerList.request_connection(player, server_or_address, self_requested)
|
||||
|
||||
[ExpServerList.on_servers_updated] = function(event) end, -- { server_ids }
|
||||
```
|
||||
|
||||
## Node
|
||||
|
||||
`ServerListRequest` returns every record, hidden ones included. Web plugins and control connections can subscribe to `ServerUpdatesEvent` with the `exp_server_list.list` permission.
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { ControllerPluginContext, InstanceRecord } from "@clusterio/controller";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import * as messages from "./messages.js";
|
||||
|
||||
/** Instance config fields which change the record */
|
||||
const recordFields = new Set([
|
||||
"instance.name",
|
||||
"instance.assigned_host",
|
||||
"factorio.mod_pack_id",
|
||||
"factorio.settings",
|
||||
"exp_server_list.display_name",
|
||||
"exp_server_list.description",
|
||||
"exp_server_list.welcome",
|
||||
"exp_server_list.reset_time",
|
||||
"exp_server_list.hidden",
|
||||
"exp_server_list.always_on_top",
|
||||
]);
|
||||
|
||||
/** Controller config fields which change every record */
|
||||
const controllerFields = new Set([
|
||||
"controller.default_mod_pack_id",
|
||||
"exp_server_list.welcome",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Keeps a record for every instance and broadcasts the ones which change.
|
||||
*
|
||||
* Records are rebuilt from controller state on start, nothing is saved.
|
||||
*/
|
||||
export default async function (context: ControllerPluginContext) {
|
||||
const { controller, plugin } = context;
|
||||
const servers = new lib.SubscribableDatastore<messages.ServerRecord>();
|
||||
|
||||
function modPackName(instance: InstanceRecord) {
|
||||
const id = instance.config.get("factorio.mod_pack_id")
|
||||
?? controller.config.get("controller.default_mod_pack_id");
|
||||
return id !== null && id !== undefined ? controller.modPacks.get(id)?.name ?? "" : "";
|
||||
}
|
||||
|
||||
function playerCount(instanceId: number) {
|
||||
let count = 0;
|
||||
for (const user of controller.users.records.values()) {
|
||||
if (user.instances.has(instanceId)) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Same as the address the web ui shows for the instance */
|
||||
function address(instance: InstanceRecord) {
|
||||
const hostId = instance.config.get("instance.assigned_host");
|
||||
const publicAddress = hostId !== null ? controller.hosts.get(hostId)?.publicAddress : undefined;
|
||||
if (!publicAddress) {
|
||||
return "";
|
||||
}
|
||||
return instance.gamePort === undefined ? publicAddress : `${publicAddress}:${instance.gamePort}`;
|
||||
}
|
||||
|
||||
function buildRecord(instance: InstanceRecord) {
|
||||
const config = instance.config;
|
||||
const settings = config.get("factorio.settings");
|
||||
const settingString = (name: string) => (typeof settings[name] === "string" ? settings[name] : "");
|
||||
return new messages.ServerRecord(
|
||||
instance.id,
|
||||
config.get("instance.name"),
|
||||
config.get("exp_server_list.display_name") || config.get("instance.name"),
|
||||
config.get("exp_server_list.description") || settingString("description"),
|
||||
config.get("exp_server_list.welcome") || controller.config.get("exp_server_list.welcome"),
|
||||
config.get("exp_server_list.reset_time"),
|
||||
config.get("exp_server_list.hidden"),
|
||||
config.get("exp_server_list.always_on_top"),
|
||||
settingString("game_password") !== "",
|
||||
modPackName(instance),
|
||||
instance.factorioVersion === "latest" ? "" : instance.factorioVersion ?? "",
|
||||
instance.status === "running" ? playerCount(instance.id) : 0,
|
||||
instance.status,
|
||||
address(instance),
|
||||
);
|
||||
}
|
||||
|
||||
/** Rebuild the records of the instances, and store the ones which changed in one update */
|
||||
function refresh(instances: Iterable<InstanceRecord>) {
|
||||
const changed: messages.ServerRecord[] = [];
|
||||
const deleted: messages.ServerRecord[] = [];
|
||||
for (const instance of instances) {
|
||||
const existing = servers.get(instance.id);
|
||||
if (instance.isDeleted) {
|
||||
if (existing) {
|
||||
deleted.push(existing);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const record = buildRecord(instance);
|
||||
if (!existing || !existing.sameAs(record)) {
|
||||
changed.push(record);
|
||||
}
|
||||
}
|
||||
|
||||
if (changed.length) {
|
||||
servers.setMany(changed);
|
||||
}
|
||||
if (deleted.length) {
|
||||
servers.deleteMany(deleted);
|
||||
}
|
||||
}
|
||||
|
||||
const refreshAll = () => refresh(controller.instances.values());
|
||||
|
||||
refreshAll();
|
||||
servers.on("update", updates => {
|
||||
controller.subscriptions.broadcast(new messages.ServerUpdatesEvent(updates));
|
||||
});
|
||||
|
||||
// The public address of a host arrives after it connects
|
||||
controller.hosts.on("update", hosts => {
|
||||
const hostIds = new Set(hosts.map(host => host.id));
|
||||
refresh([...controller.instances.values()].filter(instance => {
|
||||
const hostId = instance.config.get("instance.assigned_host");
|
||||
return hostId !== null && hostIds.has(hostId);
|
||||
}));
|
||||
});
|
||||
|
||||
controller.subscriptions.handle(messages.ServerUpdatesEvent, async request => {
|
||||
const updates = [...servers.values()].filter(server => server.updatedAtMs > request.lastRequestTimeMs);
|
||||
return updates.length ? new messages.ServerUpdatesEvent(updates) : null;
|
||||
});
|
||||
controller.handle(messages.ServerListRequest, async () => [...servers.values()]);
|
||||
|
||||
const hooks = controller.hooks;
|
||||
hooks.instanceStatusChanged.attach(plugin.name, async instance => {
|
||||
refresh([instance]);
|
||||
});
|
||||
hooks.instanceConfigFieldChanged.attach(plugin.name, async (instance, field) => {
|
||||
if (recordFields.has(field)) {
|
||||
refresh([instance]);
|
||||
}
|
||||
});
|
||||
hooks.controllerConfigFieldChanged.attach(plugin.name, async field => {
|
||||
if (controllerFields.has(field)) {
|
||||
refreshAll();
|
||||
}
|
||||
});
|
||||
hooks.playerEvent.attach(plugin.name, async (instance, event) => {
|
||||
if (event.type === "join" || event.type === "leave") {
|
||||
refresh([instance]);
|
||||
}
|
||||
});
|
||||
hooks.modPacksUpdated.attach(plugin.name, async () => {
|
||||
refreshAll();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import * as messages from "./messages.js";
|
||||
|
||||
declare module "@clusterio/lib" {
|
||||
export interface ControllerConfigFields {
|
||||
"exp_server_list.welcome": string;
|
||||
}
|
||||
|
||||
export interface InstanceConfigFields {
|
||||
"exp_server_list.display_name": string;
|
||||
"exp_server_list.description": string;
|
||||
"exp_server_list.welcome": string;
|
||||
"exp_server_list.reset_time": string;
|
||||
"exp_server_list.hidden": boolean;
|
||||
"exp_server_list.always_on_top": boolean;
|
||||
}
|
||||
|
||||
export interface Permissions {
|
||||
"exp_server_list.list": never;
|
||||
}
|
||||
}
|
||||
|
||||
export const plugin: lib.PluginDeclaration = {
|
||||
name: "exp_server_list",
|
||||
title: "ExpGaming - Server List",
|
||||
description: "Clusterio plugin sharing the details of every instance with the others",
|
||||
|
||||
controllerEntrypoint: "./dist/node/controller.js",
|
||||
instanceEntrypoint: "./dist/node/instance.js",
|
||||
controllerConfigFields: {
|
||||
"exp_server_list.welcome": {
|
||||
title: "Welcome",
|
||||
description: "Message shown on the welcome tab of the readme for instances without their own",
|
||||
type: "string",
|
||||
initialValue: "",
|
||||
},
|
||||
},
|
||||
instanceConfigFields: {
|
||||
"exp_server_list.display_name": {
|
||||
title: "Display Name",
|
||||
description: "Name shown in the server list, the instance name when empty",
|
||||
type: "string",
|
||||
initialValue: "",
|
||||
},
|
||||
"exp_server_list.description": {
|
||||
title: "Description",
|
||||
description: "One line about the server shown in the server list, the server description when empty",
|
||||
type: "string",
|
||||
initialValue: "",
|
||||
},
|
||||
"exp_server_list.welcome": {
|
||||
title: "Welcome",
|
||||
description: "Message shown on the welcome tab of the readme, the controller welcome when empty",
|
||||
type: "string",
|
||||
initialValue: "",
|
||||
},
|
||||
"exp_server_list.reset_time": {
|
||||
title: "Reset Time",
|
||||
description: "When the map next resets, shown on the welcome tab of the readme",
|
||||
type: "string",
|
||||
initialValue: "",
|
||||
},
|
||||
"exp_server_list.hidden": {
|
||||
title: "Hidden",
|
||||
description: "Leave this instance out of the server list on other instances",
|
||||
type: "boolean",
|
||||
initialValue: false,
|
||||
},
|
||||
"exp_server_list.always_on_top": {
|
||||
title: "Always On Top",
|
||||
description: "List this instance before those without it",
|
||||
type: "boolean",
|
||||
initialValue: false,
|
||||
},
|
||||
},
|
||||
|
||||
messages: [
|
||||
messages.ServerUpdatesEvent,
|
||||
messages.ServerListRequest,
|
||||
],
|
||||
|
||||
permissions: [
|
||||
{
|
||||
name: "exp_server_list.list",
|
||||
title: "List servers",
|
||||
description: "View the server list and subscribe to its updates",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { InstancePluginContext } from "@clusterio/host";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import * as messages from "./messages.js";
|
||||
|
||||
/**
|
||||
* Sends the server list to the lua side, see module/control.lua.
|
||||
*
|
||||
* The subscription is made by the start hook once rcon is up, so updates
|
||||
* only arrive while the game can take them.
|
||||
*/
|
||||
export default async function (context: InstancePluginContext) {
|
||||
const { instance, plugin } = context;
|
||||
|
||||
async function luaSendServers(servers: messages.ServerRecord[], replace: boolean) {
|
||||
const json = JSON.stringify({
|
||||
current_id: instance.id,
|
||||
replace,
|
||||
servers: servers.map(server => server.toJSON()),
|
||||
});
|
||||
// Pick a long bracket the json does not contain
|
||||
let level = "=";
|
||||
while (json.includes(`]${level}]`)) {
|
||||
level += "=";
|
||||
}
|
||||
await instance.sendRcon(
|
||||
`/sc exp_server_list.receive_update(helpers.json_to_table[${level}[${json}]${level}])`, true,
|
||||
);
|
||||
}
|
||||
|
||||
/** Subscribe, then replace the whole list */
|
||||
async function syncServers() {
|
||||
await instance.sendTo("controller", new lib.SubscriptionRequest(
|
||||
`exp_server_list:${messages.ServerUpdatesEvent.name}`, "subscribe", Date.now(),
|
||||
));
|
||||
const servers = await instance.sendTo("controller", new messages.ServerListRequest());
|
||||
await luaSendServers(servers, true);
|
||||
}
|
||||
|
||||
instance.handle(messages.ServerUpdatesEvent, async event => {
|
||||
if (instance.status === "starting" || instance.status === "running") {
|
||||
await luaSendServers(event.updates, false);
|
||||
}
|
||||
});
|
||||
|
||||
instance.hooks.start.attach(plugin.name, syncServers);
|
||||
|
||||
// A new session means the controller restarted and forgot the subscription
|
||||
instance.hooks.controllerConnectionEvent.attach(plugin.name, async event => {
|
||||
if (event === "connect" && instance.status === "running") {
|
||||
await syncServers();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
|
||||
/**
|
||||
* What the rest of the cluster knows about one instance.
|
||||
*
|
||||
* Built by the controller from the instance, its host and its mod pack, so
|
||||
* it is never stored. The id is the instance id.
|
||||
*/
|
||||
export class ServerRecord {
|
||||
constructor(
|
||||
public id: number,
|
||||
public name: string,
|
||||
public displayName: string,
|
||||
public description: string,
|
||||
public welcome: string,
|
||||
public resetTime: string,
|
||||
public hidden: boolean,
|
||||
/** Sorted before the servers which are not */
|
||||
public alwaysOnTop: boolean,
|
||||
/** The password itself is never sent */
|
||||
public hasPassword: boolean,
|
||||
/** Empty when the instance has no mod pack */
|
||||
public modPackName: string,
|
||||
/** Version the server last ran, empty if it has never started */
|
||||
public factorioVersion: string,
|
||||
public playerCount: number,
|
||||
public status: lib.InstanceStatus,
|
||||
/** Empty when the host has no public address */
|
||||
public address: string,
|
||||
public updatedAtMs: number = 0,
|
||||
public isDeleted: boolean = false,
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
id: Type.Integer(),
|
||||
name: Type.String(),
|
||||
display_name: Type.String(),
|
||||
description: Type.String(),
|
||||
welcome: Type.String(),
|
||||
reset_time: Type.String(),
|
||||
hidden: Type.Boolean(),
|
||||
always_on_top: Type.Boolean(),
|
||||
has_password: Type.Boolean(),
|
||||
mod_pack_name: Type.String(),
|
||||
factorio_version: Type.String(),
|
||||
player_count: Type.Integer(),
|
||||
status: lib.InstanceStatus,
|
||||
address: Type.String(),
|
||||
updated_at_ms: Type.Optional(Type.Number()),
|
||||
is_deleted: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
toJSON() {
|
||||
const json: Static<typeof ServerRecord.jsonSchema> = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
display_name: this.displayName,
|
||||
description: this.description,
|
||||
welcome: this.welcome,
|
||||
reset_time: this.resetTime,
|
||||
hidden: this.hidden,
|
||||
always_on_top: this.alwaysOnTop,
|
||||
has_password: this.hasPassword,
|
||||
mod_pack_name: this.modPackName,
|
||||
factorio_version: this.factorioVersion,
|
||||
player_count: this.playerCount,
|
||||
status: this.status,
|
||||
address: this.address,
|
||||
};
|
||||
|
||||
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.display_name,
|
||||
json.description,
|
||||
json.welcome,
|
||||
json.reset_time,
|
||||
json.hidden,
|
||||
json.always_on_top,
|
||||
json.has_password,
|
||||
json.mod_pack_name,
|
||||
json.factorio_version,
|
||||
json.player_count,
|
||||
json.status,
|
||||
json.address,
|
||||
json.updated_at_ms ?? 0,
|
||||
json.is_deleted ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
/** True when every field other than updatedAtMs matches */
|
||||
sameAs(other: ServerRecord) {
|
||||
return this.id === other.id
|
||||
&& this.name === other.name
|
||||
&& this.displayName === other.displayName
|
||||
&& this.description === other.description
|
||||
&& this.welcome === other.welcome
|
||||
&& this.resetTime === other.resetTime
|
||||
&& this.hidden === other.hidden
|
||||
&& this.alwaysOnTop === other.alwaysOnTop
|
||||
&& this.hasPassword === other.hasPassword
|
||||
&& this.modPackName === other.modPackName
|
||||
&& this.factorioVersion === other.factorioVersion
|
||||
&& this.playerCount === other.playerCount
|
||||
&& this.status === other.status
|
||||
&& this.address === other.address
|
||||
&& this.isDeleted === other.isDeleted;
|
||||
}
|
||||
}
|
||||
|
||||
export class ServerUpdatesEvent {
|
||||
declare ["constructor"]: typeof ServerUpdatesEvent;
|
||||
static plugin = "exp_server_list" as const;
|
||||
static type = "event" as const;
|
||||
static src = "controller" as const;
|
||||
static dst = ["control", "instance"] as const;
|
||||
static permission = "exp_server_list.list" as const;
|
||||
|
||||
constructor(
|
||||
public updates: ServerRecord[],
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
updates: Type.Array(ServerRecord.jsonSchema),
|
||||
});
|
||||
|
||||
toJSON() {
|
||||
return { updates: this.updates.map(server => server.toJSON()) };
|
||||
}
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.updates.map(server => ServerRecord.fromJSON(server)));
|
||||
}
|
||||
}
|
||||
|
||||
/** List every instance, hidden ones included. */
|
||||
export class ServerListRequest {
|
||||
declare ["constructor"]: typeof ServerListRequest;
|
||||
static plugin = "exp_server_list" as const;
|
||||
static type = "request" as const;
|
||||
static src = ["control", "instance"] as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "exp_server_list.list" as const;
|
||||
static Response = lib.jsonArray(ServerRecord);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
--[[-- ExpServerList
|
||||
The details of every instance in the cluster, kept up to date by the controller.
|
||||
|
||||
The instance plugin sends the whole list when the server starts and each
|
||||
changed server after that. Hidden servers are kept but left out of
|
||||
get_servers and find_servers. Servers which are always on top come first.
|
||||
|
||||
local ExpServerList = require("modules/exp_server_list")
|
||||
|
||||
for _, server in ipairs(ExpServerList.get_servers()) do
|
||||
game.print(server.name .. " " .. server.player_count)
|
||||
end
|
||||
|
||||
[ExpServerList.on_servers_updated] = function(event) end, -- { server_ids }
|
||||
]]
|
||||
|
||||
local Storage = require("modules/exp_util/storage")
|
||||
|
||||
--- @class ExpServerList.Server
|
||||
--- @field id number The instance id
|
||||
--- @field name string
|
||||
--- @field display_name string The name when not set
|
||||
--- @field description string The server description when not set
|
||||
--- @field welcome string The controller welcome when not set, can still be empty
|
||||
--- @field reset_time string
|
||||
--- @field hidden boolean
|
||||
--- @field always_on_top boolean
|
||||
--- @field has_password boolean
|
||||
--- @field mod_pack_name string
|
||||
--- @field factorio_version string Empty if it has never started
|
||||
--- @field player_count number
|
||||
--- @field status string The clusterio instance status, "running" once players can join
|
||||
--- @field address string Empty when the host has no public address
|
||||
|
||||
--- @class ExpServerList.Update
|
||||
--- @field current_id number
|
||||
--- @field replace boolean True when servers is the whole list
|
||||
--- @field servers (ExpServerList.Server | { id: number, is_deleted: true })[]
|
||||
|
||||
--- @class ExpServerList
|
||||
local ExpServerList = {
|
||||
--- Raised after servers were added, changed or removed
|
||||
--- @type EventData.ExpServerList.on_servers_updated
|
||||
on_servers_updated = script.generate_event_name(),
|
||||
}
|
||||
|
||||
--- @class EventData.ExpServerList.on_servers_updated : EventData
|
||||
--- @field server_ids number[] Removed servers included
|
||||
|
||||
local servers = {} --- @type table<number, ExpServerList.Server>
|
||||
local current = { id = nil } --- @type { id: number? }
|
||||
Storage.register({ servers, current }, function(tbl)
|
||||
servers = tbl[1]
|
||||
current = tbl[2]
|
||||
end)
|
||||
|
||||
--- @param a ExpServerList.Server
|
||||
--- @param b ExpServerList.Server
|
||||
local function by_name(a, b)
|
||||
if a.always_on_top ~= b.always_on_top then
|
||||
return a.always_on_top
|
||||
end
|
||||
return a.name < b.name
|
||||
end
|
||||
|
||||
--- Get every server that is not hidden, those always on top first then by name
|
||||
--- @return ExpServerList.Server[]
|
||||
function ExpServerList.get_servers()
|
||||
local rtn, count = {}, 0
|
||||
for _, server in pairs(servers) do
|
||||
if not server.hidden then
|
||||
count = count + 1
|
||||
rtn[count] = server
|
||||
end
|
||||
end
|
||||
table.sort(rtn, by_name)
|
||||
return rtn
|
||||
end
|
||||
|
||||
--- Get a server by instance id, hidden ones included
|
||||
--- @param server_id number
|
||||
--- @return ExpServerList.Server?
|
||||
function ExpServerList.get_server(server_id)
|
||||
return servers[server_id]
|
||||
end
|
||||
|
||||
--- True once the controller has sent the list
|
||||
--- @return boolean
|
||||
function ExpServerList.is_synced()
|
||||
return current.id ~= nil
|
||||
end
|
||||
|
||||
--- Get this server, nil until the controller has sent the list
|
||||
--- @return ExpServerList.Server?
|
||||
function ExpServerList.get_current()
|
||||
return current.id and servers[current.id]
|
||||
end
|
||||
|
||||
--- Get the servers that are not hidden whose name, display name or id contains the search, sorted like get_servers
|
||||
--- @param search string
|
||||
--- @return ExpServerList.Server[]
|
||||
function ExpServerList.find_servers(search)
|
||||
search = search:lower()
|
||||
local rtn, count = {}, 0
|
||||
for _, server in ipairs(ExpServerList.get_servers()) do
|
||||
local str = server.name .. " " .. server.display_name .. " " .. server.id
|
||||
if str:lower():find(search, 1, true) then
|
||||
count = count + 1
|
||||
rtn[count] = server
|
||||
end
|
||||
end
|
||||
return rtn
|
||||
end
|
||||
|
||||
--- True when players can join the server
|
||||
--- @param server ExpServerList.Server
|
||||
--- @return boolean
|
||||
function ExpServerList.is_online(server)
|
||||
return server.status == "running" and server.address ~= ""
|
||||
end
|
||||
|
||||
--- Ask a player to join a different server
|
||||
--- @param player LuaPlayer
|
||||
--- @param server ExpServerList.Server | string A server, or the address of one outside the cluster
|
||||
--- @param self_requested boolean? Hides the message about being asked to switch
|
||||
function ExpServerList.request_connection(player, server, self_requested)
|
||||
local message = self_requested and "exp-server-list.connect-description" or "exp-server-list.connect-description-asked"
|
||||
if type(server) == "string" then
|
||||
player.connect_to_server{
|
||||
address = server,
|
||||
name = { "exp-server-list.connect-name", { "exp-server-list.unknown-name" } },
|
||||
description = { message, { "exp-server-list.unknown-description" } },
|
||||
}
|
||||
else
|
||||
player.connect_to_server{
|
||||
address = server.address,
|
||||
name = { "exp-server-list.connect-name", server.name },
|
||||
description = { message, server.description },
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
--- Called by the instance plugin over rcon
|
||||
--- @param payload ExpServerList.Update
|
||||
function ExpServerList.receive_update(payload)
|
||||
current.id = payload.current_id
|
||||
local changed = {} --- @type table<number, true>
|
||||
if payload.replace then
|
||||
for server_id in pairs(servers) do
|
||||
changed[server_id] = true
|
||||
servers[server_id] = nil
|
||||
end
|
||||
end
|
||||
|
||||
for _, server in ipairs(payload.servers) do
|
||||
changed[server.id] = true
|
||||
if server.is_deleted then
|
||||
servers[server.id] = nil
|
||||
else
|
||||
servers[server.id] = server --[[@as ExpServerList.Server]]
|
||||
end
|
||||
end
|
||||
|
||||
local server_ids, count = {}, 0
|
||||
for server_id in pairs(changed) do
|
||||
count = count + 1
|
||||
server_ids[count] = server_id
|
||||
end
|
||||
script.raise_event(ExpServerList.on_servers_updated, { server_ids = server_ids })
|
||||
end
|
||||
|
||||
return ExpServerList
|
||||
@@ -0,0 +1,4 @@
|
||||
--- @diagnostic disable: global-in-non-module
|
||||
|
||||
-- Access using `/sc exp_server_list.foo()`
|
||||
exp_server_list = require("modules/exp_server_list/control")
|
||||
@@ -0,0 +1,6 @@
|
||||
[exp-server-list]
|
||||
connect-name=\n[color=orange][font=heading-1]__1__[/font][/color]\n
|
||||
connect-description=__1__\nPlease press the connect button below to join.
|
||||
connect-description-asked=__1__\nYou have been asked to switch to a different server.\nPlease press the connect button below to join.
|
||||
unknown-name=Unknown Server
|
||||
unknown-description=This server is not run by us, please check the address of the server.
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "exp_server_list",
|
||||
"load": [
|
||||
],
|
||||
"require": [
|
||||
"globals.lua"
|
||||
],
|
||||
"dependencies": {
|
||||
"clusterio": "*",
|
||||
"exp_util": "*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Access the exports from other modules using require("modules/exp_server_list")
|
||||
return require("modules/exp_server_list/control")
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "@expcluster/server-list",
|
||||
"version": "7.0.1",
|
||||
"description": "Clusterio plugin sharing the details of every instance with the others.",
|
||||
"author": "Cooldude2606 <https://github.com/Cooldude2606>",
|
||||
"license": "MIT",
|
||||
"repository": "explosivegaming/ExpCluster",
|
||||
"type": "module",
|
||||
"main": "dist/node/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build && webpack-cli --env production",
|
||||
"test": "tap --type-strip-only --disable-coverage --allow-empty-coverage test/*.test.js",
|
||||
"coverage": "tap --coverage-report=text test/*.test.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@clusterio/controller": "workspace:^",
|
||||
"@clusterio/host": "workspace:^",
|
||||
"@clusterio/lib": "workspace:^"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@clusterio/controller": {
|
||||
"optional": true
|
||||
},
|
||||
"@clusterio/host": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/controller": "workspace:^",
|
||||
"@clusterio/host": "workspace:^",
|
||||
"@clusterio/lib": "workspace:^",
|
||||
"@clusterio/web_ui": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"fengari": "^0.1.5",
|
||||
"tap": "^21.1.0",
|
||||
"typescript": "catalog:",
|
||||
"webpack": "catalog:",
|
||||
"webpack-cli": "catalog:",
|
||||
"webpack-merge": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "catalog:",
|
||||
"@expcluster/lib_util": "workspace:^"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"keywords": [
|
||||
"clusterio",
|
||||
"clusterio-plugin",
|
||||
"factorio"
|
||||
]
|
||||
}
|
||||
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user