Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -545,7 +545,11 @@
"copy_clipboard": "Copy to clipboard",
"copied": "Copied!",
"failed_copy": "Failed to copy",
"desync_notice": "You are desynced from other players. What you see might differ from other players."
"desync_notice": "You are desynced from other players. What you see might differ from other players.",
"kicked_message": "You have been kicked from the game.",
"kicked_reason_admin": "An administrator removed you from the game.",
"kicked_reason_lobby_creator": "The lobby host removed you from the game.",
"kicked_reason_multi_tab": "You may be playing on another tab or browser window."
},
"heads_up_message": {
"choose_spawn": "Choose a starting location"
Expand Down
68 changes: 50 additions & 18 deletions src/client/ClientGameRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,15 +105,31 @@ export function joinLobby(
).then((r) => r.start());
}
if (message.type === "error") {
showErrorModal(
message.error,
message.message,
lobbyConfig.gameID,
lobbyConfig.clientID,
true,
false,
"error_modal.connection_error",
);
if (message.kickReason) {
const kickMessage = translateText("error_modal.kicked_message");
const reasonKey = `error_modal.kicked_reason_${message.kickReason.replace("kick_", "")}`;
const reasonMessage = translateText(reasonKey);

showErrorModal(
kickMessage,
`${reasonMessage}\nError Code: ${message.kickReason.toUpperCase()}`,
lobbyConfig.gameID,
lobbyConfig.clientID,
true,
false,
"error_modal.connection_error",
);
Comment on lines +108 to +121
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix import path to avoid duplicate module instances of Utils

The file imports Utils twice with two different relative paths (“./Utils” and “../client/Utils”). This can cause the bundler/loader to create separate module instances, breaking shared caches in translateText and adding bytes.

Update the import at the top of this file:

// before
import { translateText } from "../client/Utils";

// after
import { translateText } from "./Utils";
🤖 Prompt for AI Agents
In src/client/ClientGameRunner.ts around lines 108 to 121, the file currently
imports translateText from "../client/Utils" which creates a duplicate module
instance alongside "./Utils"; update the import to use "./Utils" (replace the
"../client/Utils" import) so both imports resolve to the same module instance,
remove the duplicate import if present, and run a quick build to confirm shared
caches (e.g., translateText) behave correctly.

🛠️ Refactor suggestion

⚠️ Potential issue

Use the “kicked” heading and add a robust translation fallback

Set the modal heading to the translated “kicked” title, and fall back to a safe reason message if the specific translation key is missing (e.g., mixed versions). Also remove the now-unused kickMessage variable.

Apply this diff:

-      if (message.kickReason) {
-        const kickMessage = translateText("error_modal.kicked_message");
-        const reasonKey = `error_modal.kicked_reason_${message.kickReason.replace("kick_", "")}`;
-        const reasonMessage = translateText(reasonKey);
-
-        showErrorModal(
-          kickMessage,
-          `${reasonMessage}\nError Code: ${message.kickReason.toUpperCase()}`,
-          lobbyConfig.gameID,
-          lobbyConfig.clientID,
-          true,
-          false,
-          "error_modal.connection_error",
-        );
+      if (message.kickReason) {
+        const reasonKey = `error_modal.kicked_reason_${message.kickReason.replace("kick_", "")}`;
+        let reasonMessage = translateText(reasonKey);
+        if (reasonMessage === reasonKey) {
+          // Fallback if key missing (e.g., mixed versions)
+          reasonMessage = translateText("error_modal.kicked_reason_multi_tab");
+        }
+        showErrorModal(
+          reasonMessage,
+          `Error Code: ${message.kickReason.toUpperCase()}`,
+          lobbyConfig.gameID,
+          lobbyConfig.clientID,
+          true,
+          false,
+          "error_modal.kicked_message",
+        );
       } else {
         showErrorModal(
           message.error,
           message.message,
           lobbyConfig.gameID,
           lobbyConfig.clientID,
           true,
           false,
           "error_modal.connection_error",
         );
       }

Also applies to: 123-132

} else {
showErrorModal(
message.error,
message.message,
lobbyConfig.gameID,
lobbyConfig.clientID,
true,
false,
"error_modal.connection_error",
);
}
}
};
transport.connect(onconnect, onmessage);
Expand Down Expand Up @@ -336,15 +352,31 @@ export class ClientGameRunner {
);
}
if (message.type === "error") {
showErrorModal(
message.error,
message.message,
this.lobby.gameID,
this.lobby.clientID,
true,
false,
"error_modal.connection_error",
);
if (message.kickReason) {
const kickMessage = translateText("error_modal.kicked_message");
const reasonKey = `error_modal.kicked_reason_${message.kickReason.replace("kick_", "")}`;
const reasonMessage = translateText(reasonKey);

showErrorModal(
kickMessage,
`${reasonMessage}\nError Code: ${message.kickReason.toUpperCase()}`,
this.lobby.gameID,
this.lobby.clientID,
true,
false,
"error_modal.connection_error",
);
} else {
Comment on lines +355 to +369
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Duplicate logic: apply same heading and fallback in the in-game handler

Mirror the fixes from join flow inside the in-game WebSocket handler to keep UX consistent.

-      if (message.type === "error") {
-        if (message.kickReason) {
-          const kickMessage = translateText("error_modal.kicked_message");
-          const reasonKey = `error_modal.kicked_reason_${message.kickReason.replace("kick_", "")}`;
-          const reasonMessage = translateText(reasonKey);
-
-          showErrorModal(
-            kickMessage,
-            `${reasonMessage}\nError Code: ${message.kickReason.toUpperCase()}`,
-            this.lobby.gameID,
-            this.lobby.clientID,
-            true,
-            false,
-            "error_modal.connection_error",
-          );
-        } else {
+      if (message.type === "error") {
+        if (message.kickReason) {
+          const reasonKey = `error_modal.kicked_reason_${message.kickReason.replace("kick_", "")}`;
+          let reasonMessage = translateText(reasonKey);
+          if (reasonMessage === reasonKey) {
+            reasonMessage = translateText("error_modal.kicked_reason_multi_tab");
+          }
+          showErrorModal(
+            reasonMessage,
+            `Error Code: ${message.kickReason.toUpperCase()}`,
+            this.lobby.gameID,
+            this.lobby.clientID,
+            true,
+            false,
+            "error_modal.kicked_message",
+          );
+        } else {
           showErrorModal(
             message.error,
             message.message,
             this.lobby.gameID,
             this.lobby.clientID,
             true,
             false,
             "error_modal.connection_error",
           );
         }
       }

Also applies to: 370-379

🤖 Prompt for AI Agents
In src/client/ClientGameRunner.ts around lines 355 to 369 (and similarly 370 to
379), the in-game WebSocket handler duplicates old logic for kicked messages
instead of mirroring the join-flow fixes; update the block so the heading and
reason fallback behavior match the join flow: compute kickMessage via
translateText("error_modal.kicked_message"), derive reasonKey by stripping
"kick_" and attempt translateText(reasonKey) with a fallback to
translateText("error_modal.kicked_reason_unknown") (or the same fallback used in
join flow), and call showErrorModal with the same parameters and formatted body
(reasonMessage plus "Error Code: ..." in uppercase) so UX is consistent across
handlers.

showErrorModal(
message.error,
message.message,
this.lobby.gameID,
this.lobby.clientID,
true,
false,
"error_modal.connection_error",
);
}
}
if (message.type === "turn") {
if (!this.hasJoined) {
Expand Down
1 change: 1 addition & 0 deletions src/core/Schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,7 @@ export const ServerDesyncSchema = z.object({

export const ServerErrorSchema = z.object({
error: z.string(),
kickReason: z.enum(["kick_admin", "kick_multi_tab", "kick_lobby_creator"]).optional(),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Extract KickReason into a shared schema and type for reuse

Inline string unions tend to get duplicated across server and client. Centralizing the values improves consistency and reduces drift.

  • Add these definitions near other shared schemas:
export const KickReasonValues = ["kick_admin", "kick_multi_tab", "kick_lobby_creator"] as const;
export type KickReason = typeof KickReasonValues[number];
export const KickReasonSchema = z.enum(KickReasonValues);
  • Then update the field here:
-  kickReason: z.enum(["kick_admin", "kick_multi_tab", "kick_lobby_creator"] as const).optional(),
+  kickReason: KickReasonSchema.optional(),

This also lets you import KickReason as a type for signatures like kickClient(reason?: KickReason).

🤖 Prompt for AI Agents
In src/core/Schemas.ts around line 468 the kickReason field uses an inline
z.enum which should be centralized; add three shared exports near other shared
schemas: define a const KickReasonValues as the readonly array of
["kick_admin","kick_multi_tab","kick_lobby_creator"], export a type KickReason =
typeof KickReasonValues[number], and export KickReasonSchema =
z.enum(KickReasonValues); then replace the inline z.enum at line 468 with
KickReasonSchema.optional() (and update any imports/usages to use the KickReason
type where needed).

⚠️ Potential issue

Add as const to z.enum literal array to keep strict types and avoid TS error

Without as const, TypeScript often widens the array to string[], which is not assignable to the tuple type [string, ...string[]] expected by z.enum. This can break type inference or fail compilation.

Apply this minimal fix:

-  kickReason: z.enum(["kick_admin", "kick_multi_tab", "kick_lobby_creator"]).optional(),
+  kickReason: z.enum(["kick_admin", "kick_multi_tab", "kick_lobby_creator"] as const).optional(),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
kickReason: z.enum(["kick_admin", "kick_multi_tab", "kick_lobby_creator"]).optional(),
kickReason: z.enum(["kick_admin", "kick_multi_tab", "kick_lobby_creator"] as const).optional(),
🤖 Prompt for AI Agents
In src/core/Schemas.ts around line 468, the z.enum call uses a plain string
array which TypeScript widens to string[] and can cause type errors; update the
enum literal to use a readonly tuple by appending "as const" to the array (e.g.
z.enum(["kick_admin", "kick_multi_tab", "kick_lobby_creator"] as
const).optional()) so the types remain strict and satisfy z.enum's expected
tuple type.

message: z.string().optional(),
type: z.literal("error"),
});
Expand Down
9 changes: 6 additions & 3 deletions src/server/GameServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ export class GameServer {
});
// Kick the existing client instead of the new one, because this was causing issues when
// a client wanted to replay the game afterwards.
this.kickClient(conflicting.clientID);
this.kickClient(conflicting.clientID, "kick_multi_tab");
}
}

Expand Down Expand Up @@ -502,7 +502,8 @@ export class GameServer {
return this.gameConfig.gameType === GameType.Public;
}

public kickClient(clientID: ClientID): void {

public kickClient(clientID: ClientID, kickReason?: "kick_admin" | "kick_multi_tab" | "kick_lobby_creator"): void {
if (this.kickedClients.has(clientID)) {
Comment on lines +505 to 507
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Avoid duplicating the KickReason union; import a shared type

The inline union risks drift if a new reason is added elsewhere. Prefer a single source of truth (Schemas).

Option A (preferred, if exported):

-  public kickClient(clientID: ClientID, kickReason?: "kick_admin" | "kick_multi_tab" | "kick_lobby_creator"): void {
+  public kickClient(clientID: ClientID, kickReason?: KickReason): void {

And add at top:

import type { KickReason } from "../core/Schemas";

Option B (if KickReason type isn’t exported yet): export it from Schemas and use it here. I can open a follow-up PR to centralize this type.

🤖 Prompt for AI Agents
In src/server/GameServer.ts around lines 505 to 507, the method kickClient uses
an inline union type for the kick reason which duplicates the KickReason
definition; replace the inline union with the shared KickReason type from the
Schemas module by importing it at the top of the file (import type { KickReason
} from "../core/Schemas") and update the method signature to use that type; if
KickReason is not exported from Schemas, export it there first (or ask to open a
follow-up PR) then import and use it here.

this.log.warn(`cannot kick client, already kicked`, {
clientID,
Expand All @@ -513,11 +514,13 @@ export class GameServer {
if (client) {
this.log.info("Kicking client from game", {
clientID: client.clientID,
kickReason,
persistentID: client.persistentID,
});
client.ws.send(
JSON.stringify({
error: "Kicked from game (you may have been playing on another tab)",
error: "Kicked from game",
kickReason,
type: "error",
} satisfies ServerErrorMessage),
);
Expand Down
2 changes: 1 addition & 1 deletion src/server/Worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ export async function startWorker() {
return;
}

game.kickClient(clientID);
game.kickClient(clientID, "kick_admin");
res.status(200).send("Player kicked successfully");
}),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export async function postJoinMessageHandler(
target: clientMsg.intent.target,
});

gs.kickClient(clientMsg.intent.target);
gs.kickClient(clientMsg.intent.target, "kick_lobby_creator");
return;
}

Expand Down
Loading