From d3ad1f51bd879064bb0336a9c681ceccd640ddda Mon Sep 17 00:00:00 2001 From: Evan Date: Wed, 8 Jul 2026 12:02:37 -0700 Subject: [PATCH] fix(crazygames): guest username on logout, hide fullscreen, in-game pop-ups (#4538) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses four requests from the CrazyGames platform team. Supersedes #4520 (closed when its branch was renamed to `crazygames`). ## 1. Username reverts to guest on CrazyGames logout When a player logged into their CrazyGames account and then logged out, the username stayed the CrazyGames name. It now reverts to a fresh guest (`AnonXXX`) name. A `crazyGamesLoggedIn` flag guards this so it only fires on a real login→logout transition (not on the initial "not logged in" state, which would otherwise wipe a stored name). ## 2. Fullscreen button hidden on CrazyGames CrazyGames provides its own fullscreen control in the game frame, so our in-game fullscreen button is now hidden when running on CrazyGames (and unchanged everywhere else). ## 3. Native browser prompts → in-game pop-ups The `showInGameConfirm()` / `showInGameAlert()` helpers (promise-based, callable from non-Lit contexts like WebSocket/popstate handlers) drive the existing shared `` component, so styling stays consistent with the rest of the app. Every native `confirm()`/`alert()` shown **during gameplay** now uses it: - Exit-game confirmation (right sidebar + browser-back path) - Kick-player confirmation - Host-left notice (dispatches leave-lobby only after dismiss, preserving the old blocking UX) - Connection-refused notice (also removes a stale `// TODO: make this a modal`) ## 4. `gameplayStop` when Settings menu / pop-up is open - The pop-up helpers report `gameplayStop()` while shown and `gameplayStart()` on dismiss. - Settings + Graphics Settings modals now report `gameplayStop` whenever the menu is open — previously it only fired when the modal *also* paused the sim (singleplayer / lobby-creator), so regular multiplayer players never triggered it. Also fixes graphics-settings so gameplay resumes correctly on close for those players. ## Scope Per request, this covers **in-game dialogs only**. The main-menu native alerts (store/checkout, account, subscriptions, friends, login) were intentionally left as-is. ## Testing - `tsc --noEmit`, `eslint`, and `prettier --check` all pass. - Verified the confirm and alert pop-ups render correctly in the running app (shared ``, danger/warning variants), resolve their promises, and tear down their portal. - CrazyGames-iframe-only behaviors (username revert, `gameplayStop`, fullscreen hiding) are verified by code inspection — they require running inside the actual CrazyGames frame. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- resources/lang/en.json | 1 + src/client/ClientGameRunner.ts | 18 +++---- src/client/InGameModal.ts | 47 +++++++++++++++++++ src/client/Main.ts | 29 +++++++----- src/client/Transport.ts | 9 +++- src/client/UsernameInput.ts | 20 ++++++-- src/client/hud/layers/GameRightSidebar.ts | 7 ++- .../hud/layers/GraphicsSettingsModal.ts | 15 ++++-- .../hud/layers/PlayerModerationModal.ts | 5 +- src/client/hud/layers/SettingsModal.ts | 15 ++++-- .../graphics/layers/PlayerPanelKick.test.ts | 21 +++++---- 11 files changed, 137 insertions(+), 50 deletions(-) create mode 100644 src/client/InGameModal.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index 51f499c34..5ccd4825c 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -437,6 +437,7 @@ }, "error_modal": { "connection_error": "Connection error!", + "connection_refused": "Connection refused: {reason}", "copied": "Copied!", "copy_clipboard": "Copy to clipboard", "crashed": "Game crashed!", diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 474ddc811..8b07806b7 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -35,6 +35,7 @@ import { } from "../core/game/UserSettings"; import { WorkerClient } from "../core/worker/WorkerClient"; import { getPersistentID } from "./Auth"; +import { showInGameAlert } from "./InGameModal"; import { AutoUpgradeEvent, DoBoatAttackEvent, @@ -218,14 +219,15 @@ export function joinLobby( }), ); } else if (message.error === "kick_reason.host_left") { - alert(translateText("kick_reason.host_left")); - document.dispatchEvent( - new CustomEvent("leave-lobby", { - detail: { lobby: lobbyConfig.gameID, cause: "host-left" }, - bubbles: true, - composed: true, - }), - ); + showInGameAlert(translateText("kick_reason.host_left")).then(() => { + document.dispatchEvent( + new CustomEvent("leave-lobby", { + detail: { lobby: lobbyConfig.gameID, cause: "host-left" }, + bubbles: true, + composed: true, + }), + ); + }); } else { showErrorModal( message.error, diff --git a/src/client/InGameModal.ts b/src/client/InGameModal.ts new file mode 100644 index 000000000..0271cad0d --- /dev/null +++ b/src/client/InGameModal.ts @@ -0,0 +1,47 @@ +import "./components/ConfirmDialog"; +import { ConfirmDialog } from "./components/ConfirmDialog"; +import { crazyGamesSDK } from "./CrazyGamesSDK"; +import { translateText } from "./Utils"; + +/** + * Promise-based, imperative replacements for the native `confirm()` / `alert()` + * browser dialogs, for use during gameplay (CrazyGames disallows native prompts + * inside the game frame). These wrap the shared component so + * the styling stays consistent with the rest of the app, and are callable from + * non-Lit contexts (WebSocket handlers, popstate, etc.). + * + * While a pop-up is displayed we report gameplay as stopped to CrazyGames, and + * resume it once dismissed. + */ +function showDialog(props: Partial): Promise { + const dialog = document.createElement("confirm-dialog") as ConfirmDialog; + Object.assign(dialog, props); + + crazyGamesSDK.gameplayStop(); + document.body.appendChild(dialog); + + return new Promise((resolve) => { + const done = (result: boolean) => { + dialog.remove(); + crazyGamesSDK.gameplayStart(); + resolve(result); + }; + dialog.addEventListener("confirm", () => done(true)); + dialog.addEventListener("cancel", () => done(false)); + }); +} + +/** In-game replacement for `confirm()`. Resolves true when confirmed. */ +export function showInGameConfirm(message: string): Promise { + return showDialog({ message, variant: "danger", buttons: "confirmCancel" }); +} + +/** In-game replacement for `alert()`. Resolves once dismissed. */ +export function showInGameAlert(message: string): Promise { + return showDialog({ + message, + variant: "warning", + buttons: "confirmOnly", + confirmText: translateText("common.close"), + }); +} diff --git a/src/client/Main.ts b/src/client/Main.ts index c951be8a9..4ff95ed64 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -35,6 +35,7 @@ import "./GoogleAdElement"; import { HelpModal } from "./HelpModal"; import "./HomepagePromos"; import { HostLobbyModal as HostPrivateLobbyModal } from "./HostLobbyModal"; +import { showInGameConfirm } from "./InGameModal"; import { JoinLobbyModal } from "./JoinLobbyModal"; import "./LangSelector"; import { LangSelector } from "./LangSelector"; @@ -586,6 +587,13 @@ class Client { onJoinChanged(); }; + const leaveGame = () => { + crazyGamesSDK.gameplayStop().then(() => { + // redirect to the home page + window.location.href = "/"; + }); + }; + const onPopState = () => { if (this.currentUrl !== null && this.lobbyHandle !== null) { console.info("Game is active"); @@ -593,23 +601,20 @@ class Client { if (!this.lobbyHandle.stop()) { console.info("Player is active, ask before leaving game"); - const isConfirmed = confirm( - translateText("help_modal.exit_confirmation"), + // We can't block navigation on an async confirmation, so restore the + // history entry immediately and only leave once the player confirms. + history.pushState(null, "", this.currentUrl); + showInGameConfirm(translateText("help_modal.exit_confirmation")).then( + (isConfirmed) => { + if (isConfirmed) leaveGame(); + }, ); - - if (!isConfirmed) { - // Rollback navigator history - history.pushState(null, "", this.currentUrl); - return; - } + return; } console.info("Player is not active, leave the game immediately"); - crazyGamesSDK.gameplayStop().then(() => { - // redirect to the home page - window.location.href = "/"; - }); + leaveGame(); } else { console.info("Game not active, handle hash update"); diff --git a/src/client/Transport.ts b/src/client/Transport.ts index a35099c11..b4790bcf2 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -30,7 +30,9 @@ import { import { replacer } from "../core/Util"; import { getPlayToken } from "./Auth"; import { LobbyConfig } from "./ClientGameRunner"; +import { showInGameAlert } from "./InGameModal"; import { LocalServer } from "./LocalServer"; +import { translateText } from "./Utils"; import { PlayerView } from "./view"; export class PauseGameIntentEvent implements GameEvent { @@ -387,8 +389,11 @@ export class Transport { `WebSocket closed. Code: ${event.code}, Reason: ${event.reason}`, ); if (event.code === 1002) { - // TODO: make this a modal - alert(`connection refused: ${event.reason}`); + showInGameAlert( + translateText("error_modal.connection_refused", { + reason: event.reason, + }), + ); } else if (event.code !== 1000) { console.log(`received error code ${event.code}, reconnecting`); this.reconnect(); diff --git a/src/client/UsernameInput.ts b/src/client/UsernameInput.ts index 3867c51ae..be157e8c1 100644 --- a/src/client/UsernameInput.ts +++ b/src/client/UsernameInput.ts @@ -93,6 +93,11 @@ export class UsernameInput extends LitElement { connectedCallback() { super.connectedCallback(); this.loadStoredUsername(); + // On CrazyGames the account username is applied here but never persisted + // (see loadStoredUsername / validateAndStore), so logging out — which + // reloads the whole page — falls back to a fresh guest username instead of + // keeping the account name. addAuthListener only fires on login; CrazyGames + // refreshes the page on logout, so there is no logout event to handle. crazyGamesSDK.getUsername().then((username) => { if (username) { this.baseUsername = username; @@ -125,11 +130,14 @@ export class UsernameInput extends LitElement { } private loadStoredUsername() { - const storedUsername = localStorage.getItem(usernameKey); + // On CrazyGames the username is never persisted, so ignore any stored value + // and start from a fresh guest name; the account name (if signed in) is + // applied afterwards in connectedCallback. + const storedUsername = this.onCrazyGames + ? null + : localStorage.getItem(usernameKey); if (storedUsername) { - if (!this.onCrazyGames) { - this.clanTag = localStorage.getItem(clanTagKey) ?? ""; - } + this.clanTag = localStorage.getItem(clanTagKey) ?? ""; this.baseUsername = storedUsername; this.validateAndStore(); this.startClanCheck(); @@ -280,8 +288,10 @@ export class UsernameInput extends LitElement { const result = validateUsername(trimmedBase); this._isValid = result.isValid; if (result.isValid) { - localStorage.setItem(usernameKey, trimmedBase); + // Never persist on CrazyGames: keeping localStorage empty means a logout + // (page reload) restores a guest username instead of the account name. if (!this.onCrazyGames) { + localStorage.setItem(usernameKey, trimmedBase); localStorage.setItem(clanTagKey, this.getClanTag() ?? ""); } this.validationError = ""; diff --git a/src/client/hud/layers/GameRightSidebar.ts b/src/client/hud/layers/GameRightSidebar.ts index e5d535dd9..8889271b0 100644 --- a/src/client/hud/layers/GameRightSidebar.ts +++ b/src/client/hud/layers/GameRightSidebar.ts @@ -6,6 +6,7 @@ import { GameType } from "../../../core/game/Game"; import "../../components/DoomsdayClockPanel"; import { Controller } from "../../Controller"; import { crazyGamesSDK } from "../../CrazyGamesSDK"; +import { showInGameConfirm } from "../../InGameModal"; import { TogglePauseIntentEvent } from "../../InputHandler"; import { PauseGameIntentEvent, SendWinnerEvent } from "../../Transport"; import { translateText } from "../../Utils"; @@ -45,6 +46,8 @@ export class GameRightSidebar extends LitElement implements Controller { @state() private timer: number = 0; + // CrazyGames provides its own fullscreen control in the game frame, so hide ours. + private readonly onCrazyGames = crazyGamesSDK.isOnCrazyGames(); private hasWinner = false; private isLobbyCreator = false; private spawnBarVisible = false; @@ -194,7 +197,7 @@ export class GameRightSidebar extends LitElement implements Controller { private async onExitButtonClick() { const isAlive = this.game.myPlayer()?.isAlive(); if (isAlive) { - const isConfirmed = confirm( + const isConfirmed = await showInGameConfirm( translateText("help_modal.exit_confirmation"), ); if (!isConfirmed) return; @@ -250,7 +253,7 @@ export class GameRightSidebar extends LitElement implements Controller { settings - ${document.fullscreenEnabled + ${document.fullscreenEnabled && !this.onCrazyGames ? html`
{ + private handleKickClick = async (e: MouseEvent) => { e.stopPropagation(); const my = this.myPlayer; @@ -66,7 +67,7 @@ export class PlayerModerationModal extends LitElement { const targetClientID = other.clientID(); if (!targetClientID || targetClientID.length === 0) return; - const confirmed = confirm( + const confirmed = await showInGameConfirm( translateText("player_panel.kick_confirm", { name: other.displayName() }), ); if (!confirmed) return; diff --git a/src/client/hud/layers/SettingsModal.ts b/src/client/hud/layers/SettingsModal.ts index f1095cabe..14c40e79a 100644 --- a/src/client/hud/layers/SettingsModal.ts +++ b/src/client/hud/layers/SettingsModal.ts @@ -108,12 +108,17 @@ export class SettingsModal extends LitElement implements Controller { } private pauseGame(pause: boolean) { + // CrazyGames: report gameplay as stopped whenever the settings menu is open, + // and resumed when it closes — unless the game was already paused when opened. + if (pause) { + crazyGamesSDK.gameplayStop(); + } else if (!this.wasPausedWhenOpened) { + crazyGamesSDK.gameplayStart(); + } + + // Only pause the simulation itself when we own the pause (singleplayer or + // lobby creator). if (this.shouldPause && !this.wasPausedWhenOpened) { - if (pause) { - crazyGamesSDK.gameplayStop(); - } else { - crazyGamesSDK.gameplayStart(); - } this.eventBus.emit(new PauseGameIntentEvent(pause)); } } diff --git a/tests/client/graphics/layers/PlayerPanelKick.test.ts b/tests/client/graphics/layers/PlayerPanelKick.test.ts index ca19c9b89..856951b2f 100644 --- a/tests/client/graphics/layers/PlayerPanelKick.test.ts +++ b/tests/client/graphics/layers/PlayerPanelKick.test.ts @@ -26,9 +26,15 @@ vi.mock("../../../../src/client/components/ui/ActionButton", () => ({ actionButton: vi.fn((props: unknown) => props), })); +vi.mock("../../../../src/client/InGameModal", () => ({ + showInGameConfirm: vi.fn(), + showInGameAlert: vi.fn(), +})); + import { actionButton } from "../../../../src/client/components/ui/ActionButton"; import { PlayerModerationModal } from "../../../../src/client/hud/layers/PlayerModerationModal"; import { PlayerPanel } from "../../../../src/client/hud/layers/PlayerPanel"; +import { showInGameConfirm } from "../../../../src/client/InGameModal"; import { SendKickPlayerIntentEvent } from "../../../../src/client/Transport"; import { PlayerView } from "../../../../src/client/view"; import { PlayerType } from "../../../../src/core/game/Game"; @@ -120,15 +126,12 @@ describe("PlayerPanel - kick player moderation", () => { }); describe("PlayerModerationModal - kick confirmation", () => { - const originalConfirm = globalThis.confirm; - afterEach(() => { vi.clearAllMocks(); - globalThis.confirm = originalConfirm; }); - test("emits SendKickPlayerIntentEvent and dispatches kicked when confirmed", () => { - (globalThis as any).confirm = vi.fn(() => true); + test("emits SendKickPlayerIntentEvent and dispatches kicked when confirmed", async () => { + (showInGameConfirm as ReturnType).mockResolvedValue(true); const modal = new PlayerModerationModal(); const eventBus = { emit: vi.fn() }; @@ -148,7 +151,7 @@ describe("PlayerModerationModal - kick confirmation", () => { const kickedListener = vi.fn(); modal.addEventListener("kicked", kickedListener as any); - (modal as any).handleKickClick({ stopPropagation: vi.fn() }); + await (modal as any).handleKickClick({ stopPropagation: vi.fn() }); expect(eventBus.emit).toHaveBeenCalledTimes(1); const event = eventBus.emit.mock.calls[0][0] as SendKickPlayerIntentEvent; @@ -160,8 +163,8 @@ describe("PlayerModerationModal - kick confirmation", () => { expect(kickedEvent.detail).toEqual({ playerId: "2" }); }); - test("does not emit when confirmation is cancelled", () => { - (globalThis as any).confirm = vi.fn(() => false); + test("does not emit when confirmation is cancelled", async () => { + (showInGameConfirm as ReturnType).mockResolvedValue(false); const modal = new PlayerModerationModal(); const eventBus = { emit: vi.fn() }; @@ -181,7 +184,7 @@ describe("PlayerModerationModal - kick confirmation", () => { const kickedListener = vi.fn(); modal.addEventListener("kicked", kickedListener as any); - (modal as any).handleKickClick({ stopPropagation: vi.fn() }); + await (modal as any).handleKickClick({ stopPropagation: vi.fn() }); expect(eventBus.emit).not.toHaveBeenCalled(); expect(kickedListener).not.toHaveBeenCalled();