fix(servers): 主人可放弃服务器、管理员可删除服务器,由 reaper 归档世界后释放或移除

This commit is contained in:
Lemon-miaow committed 2026-09-27 03:49:49 +08:00
1 parent 4a26bf4ca0
commit 0e08fa7ced
55 files changed
+2774 -119

No files matched your search

+52 -2
View File
@@ -761,7 +761,7 @@ function fleetView(state: MockState, accountInfo: MockAccount): FleetServer[] {
playersOnline: s.ready ? s.playersOnline : 0,
owner: owner ? state.accounts[owner].email : "",
owned: owner === accountInfo.id,
claimable: owner === null,
claimable: owner === null && !s.retiring,
};
});
}
@@ -775,7 +775,7 @@ function myServerView(serverInfo: MockServer, accountInfo: MockAccount): MyServe
name: serverInfo.name,
subdomain: serverInfo.subdomain,
owned,
claimable: serverInfo.owner === null && accountInfo.linked && !owned,
claimable: serverInfo.owner === null && !serverInfo.retiring && accountInfo.linked && !owned,
phase: serverInfo.phase,
playersOnline: serverInfo.playersOnline,
playersMax: serverInfo.playersMax,
@@ -784,6 +784,7 @@ function myServerView(serverInfo: MockServer, accountInfo: MockAccount): MyServe
desiredState: serverInfo.desiredState,
autostartPolicy: serverInfo.autostartPolicy,
playerCountUnknown: serverInfo.playerCountUnknown,
retiring: serverInfo.retiring,
}),
};
}
@@ -2234,6 +2235,10 @@ async function handleServerRoute(ctx: SessionContext): Promise<boolean> {
sendError(ctx.res, 403, "forbidden", "server is not owned by this account");
return true;
}
if (serverInfo.retiring) {
sendError(ctx.res, 409, "server_retiring", "this server is being given up or deleted; cancel that first");
return true;
}
setPhase(serverInfo, "Starting");
sendJSON(ctx.res, 200, { name: serverInfo.name, desiredState: "Running" });
return true;
@@ -2255,6 +2260,10 @@ async function handleServerRoute(ctx: SessionContext): Promise<boolean> {
handleCommandMock(ctx, serverInfo);
return true;
}
if (ctx.parts[4] === "retirement" && (is("PUT", ctx) || is("DELETE", ctx))) {
await handleRetirementMock(ctx, serverInfo);
return true;
}
if (is("POST", ctx) && ctx.parts[4] === "claim") {
claimServer(ctx, serverInfo);
return true;
@@ -2695,6 +2704,47 @@ function mockCommandReply(cmd: string): string {
return `[mock] command "${cmd}" executed`;
}
// handleRetirementMock mirrors handlers_retire.go: owner-or-admin, delete is an
// admin's, the typed name must match, a system server is refused. The mock has no
// reaper, so the request just sits until cancelled; the cancel of a deletion is an
// admin's too.
async function handleRetirementMock(ctx: SessionContext, serverInfo: MockServer): Promise<void> {
if (!canManage(ctx.account, serverInfo)) {
sendError(ctx.res, 403, "forbidden", "server is not owned by this account");
return;
}
const admin = isAdmin(ctx.account.role);
if (is("DELETE", ctx)) {
if (serverInfo.retiring?.delete && !admin) {
sendError(ctx.res, 403, "forbidden", "only an administrator can cancel the deletion of a server");
return;
}
serverInfo.retiring = undefined;
ctx.res.statusCode = 204;
ctx.res.end();
return;
}
const body = await readJSON<{ confirm?: string; delete?: boolean }>(ctx.req);
if (body.delete && !admin) {
sendError(ctx.res, 403, "forbidden", "only an administrator can delete a server");
return;
}
if (body.confirm !== serverInfo.name) {
sendError(ctx.res, 400, "confirm_mismatch", "type the server's name to confirm");
return;
}
if (serverInfo.reaperExempt) {
sendError(ctx.res, 409, "system_server", "a system server cannot be given up or deleted");
return;
}
setPhase(serverInfo, "Stopped");
serverInfo.retiring = {
requested_at: serverInfo.retiring?.requested_at ?? new Date().toISOString(),
delete: (serverInfo.retiring?.delete ?? false) || body.delete === true,
};
sendJSON(ctx.res, 202, { name: serverInfo.name, retiring: serverInfo.retiring });
}
function claimServer(ctx: SessionContext, serverInfo: MockServer): void {
if (serverInfo.owner !== null) {
sendError(ctx.res, 409, "already_claimed", "server is already claimed");