diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 8b07806b74..6dd7c72989 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -702,6 +702,7 @@ export class ClientGameRunner { private lastMessageTime: number = 0; private connectionCheckInterval: NodeJS.Timeout | null = null; + private connectionCheckTimeout: NodeJS.Timeout | null = null; private goToPlayerTimeout: NodeJS.Timeout | null = null; private lastTickReceiveTime: number = 0; @@ -778,7 +779,9 @@ export class ClientGameRunner { this.isActive = true; this.lastMessageTime = Date.now(); - setTimeout(() => { + this.connectionCheckTimeout = setTimeout(() => { + this.connectionCheckTimeout = null; + if (!this.isActive) return; this.connectionCheckInterval = setInterval( () => this.onConnectionCheck(), 1000, @@ -984,6 +987,11 @@ export class ClientGameRunner { this.isActive = false; this.worker.cleanup(); this.transport.leaveGame(); + this.input.destroy(); + if (this.connectionCheckTimeout) { + clearTimeout(this.connectionCheckTimeout); + this.connectionCheckTimeout = null; + } if (this.connectionCheckInterval) { clearInterval(this.connectionCheckInterval); this.connectionCheckInterval = null; @@ -1339,7 +1347,7 @@ export class ClientGameRunner { } private onConnectionCheck() { - if (this.transport.isLocal) { + if (!this.isActive || this.transport.isLocal) { return; } const now = Date.now(); diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index bcd99d26ca..f57af1331c 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -229,6 +229,7 @@ export class InputHandler { private keybinds: Record = {}; private keybindAndEvent: Array<[string, KeybindEntry]> = []; private coordinateGridEnabled = false; + private listenerAbort: AbortController | null = null; private readonly PAN_SPEED = 5; private readonly ZOOM_SPEED = 10; @@ -244,6 +245,11 @@ export class InputHandler { ) {} initialize() { + // Tear down any previous listeners if initialize is called twice. + this.destroy(); + this.listenerAbort = new AbortController(); + const { signal } = this.listenerAbort; + this.keybinds = this.userSettings.keybinds(Platform.isMac); this.addKeybindAndEvent(this.keybinds.boatAttack, () => { @@ -405,27 +411,13 @@ export class InputHandler { ); } // Listen for warship selection to change cursor - this.eventBus.on(UnitSelectionEvent, (e) => { - if (e.isSelected && (e.units ?? []).length > 0) { - // Multi-selection active - this.multiSelectionActive = true; - this.canvas.style.cursor = "crosshair"; - } else if (e.isSelected) { - // Single warship selected — cursor crosshair, but not multi - this.multiSelectionActive = false; - this.canvas.style.cursor = "crosshair"; - } else { - // Deselected - this.multiSelectionActive = false; - if (!this.selectionBoxActive) { - this.canvas.style.cursor = ""; - } - } - }); + this.eventBus.on(UnitSelectionEvent, this.onUnitSelection); - this.canvas.addEventListener("pointerdown", (e) => this.onPointerDown(e)); - window.addEventListener("pointerup", (e) => this.onPointerUp(e)); - window.addEventListener("pointercancel", (e) => this.onPointerUp(e)); + this.canvas.addEventListener("pointerdown", this.onPointerDown, { + signal, + }); + window.addEventListener("pointerup", this.onPointerUp, { signal }); + window.addEventListener("pointercancel", this.onPointerUp, { signal }); this.canvas.addEventListener( "wheel", (e) => { @@ -433,40 +425,50 @@ export class InputHandler { this.onShiftScroll(e); e.preventDefault(); }, - { passive: false }, + { passive: false, signal }, ); - window.addEventListener("pointermove", this.onPointerMove.bind(this)); - this.canvas.addEventListener("contextmenu", (e) => this.onContextMenu(e)); - window.addEventListener("mousemove", (e) => { - if (e.movementX || e.movementY) { - this.eventBus.emit(new MouseMoveEvent(e.clientX, e.clientY)); - } + window.addEventListener("pointermove", this.onPointerMove, { signal }); + this.canvas.addEventListener("contextmenu", this.onContextMenu, { + signal, }); + window.addEventListener( + "mousemove", + (e) => { + if (e.movementX || e.movementY) { + this.eventBus.emit(new MouseMoveEvent(e.clientX, e.clientY)); + } + }, + { signal }, + ); // Clear all tracked keys when the window loses focus so keys that had // their keyup swallowed by the browser (e.g. cmd+zoom) don't stay stuck. // Also release the hold-to-view state and any active pointer/drag state // so the alternate view and drags aren't left latched when focus returns. - window.addEventListener("blur", () => { - this.activeKeys.clear(); - if (this.alternateView) { - this.alternateView = false; - this.eventBus.emit(new AlternateViewEvent(false)); - } - this.pointerDown = false; - this.pointers.clear(); - if (this.longPressTimer !== null) { - clearTimeout(this.longPressTimer); - this.longPressTimer = null; - } - this.longPressActive = false; - this.suppressNextTap = false; - if (this.selectionBoxActive || this.multiSelectionActive) { - this.selectionBoxActive = false; - this.multiSelectionActive = false; - this.eventBus.emit(new WarshipSelectionBoxCancelEvent()); - } - this.canvas.style.cursor = ""; - }); + window.addEventListener( + "blur", + () => { + this.activeKeys.clear(); + if (this.alternateView) { + this.alternateView = false; + this.eventBus.emit(new AlternateViewEvent(false)); + } + this.pointerDown = false; + this.pointers.clear(); + if (this.longPressTimer !== null) { + clearTimeout(this.longPressTimer); + this.longPressTimer = null; + } + this.longPressActive = false; + this.suppressNextTap = false; + if (this.selectionBoxActive || this.multiSelectionActive) { + this.selectionBoxActive = false; + this.multiSelectionActive = false; + this.eventBus.emit(new WarshipSelectionBoxCancelEvent()); + } + this.canvas.style.cursor = ""; + }, + { signal }, + ); this.pointers.clear(); this.moveInterval = setInterval(() => { @@ -522,150 +524,172 @@ export class InputHandler { } }, 1); - window.addEventListener("keydown", (e) => { - const isTextInput = this.isTextInputTarget(e.target); - if (isTextInput && e.code !== "Escape") { - return; - } + window.addEventListener("keydown", this.onKeyDown, { signal }); + window.addEventListener("keyup", this.onKeyUp, { signal }); + } - if (this.keybindMatchesEvent(e, this.keybinds.toggleView)) { - e.preventDefault(); - if (!this.alternateView) { - this.alternateView = true; - this.eventBus.emit(new AlternateViewEvent(true)); - } + private onUnitSelection = (e: UnitSelectionEvent) => { + if (e.isSelected && (e.units ?? []).length > 0) { + // Multi-selection active + this.multiSelectionActive = true; + this.canvas.style.cursor = "crosshair"; + } else if (e.isSelected) { + // Single warship selected — cursor crosshair, but not multi + this.multiSelectionActive = false; + this.canvas.style.cursor = "crosshair"; + } else { + // Deselected + this.multiSelectionActive = false; + if (!this.selectionBoxActive) { + this.canvas.style.cursor = ""; } + } + }; - if ( - this.keybindMatchesEvent(e, this.keybinds.coordinateGrid) && - !e.repeat - ) { - e.preventDefault(); - this.coordinateGridEnabled = !this.coordinateGridEnabled; - this.eventBus.emit( - new ToggleCoordinateGridEvent(this.coordinateGridEnabled), - ); - } + private onKeyDown = (e: KeyboardEvent) => { + const isTextInput = this.isTextInputTarget(e.target); + if (isTextInput && e.code !== "Escape") { + return; + } - if (e.code === "Escape") { - e.preventDefault(); - this.eventBus.emit(new CloseViewEvent()); - this.setGhostStructure(null); - if (this.selectionBoxActive || this.multiSelectionActive) { - this.selectionBoxActive = false; - this.multiSelectionActive = false; - this.eventBus.emit(new WarshipSelectionBoxCancelEvent()); - } + if (this.keybindMatchesEvent(e, this.keybinds.toggleView)) { + e.preventDefault(); + if (!this.alternateView) { + this.alternateView = true; + this.eventBus.emit(new AlternateViewEvent(true)); } + } - if ( - (e.code === "Enter" || e.code === "NumpadEnter") && - this.uiState.ghostStructure !== null - ) { - e.preventDefault(); - this.eventBus.emit(new ConfirmGhostStructureEvent()); + if ( + this.keybindMatchesEvent(e, this.keybinds.coordinateGrid) && + !e.repeat + ) { + e.preventDefault(); + this.coordinateGridEnabled = !this.coordinateGridEnabled; + this.eventBus.emit( + new ToggleCoordinateGridEvent(this.coordinateGridEnabled), + ); + } + + if (e.code === "Escape") { + e.preventDefault(); + this.eventBus.emit(new CloseViewEvent()); + this.setGhostStructure(null); + if (this.selectionBoxActive || this.multiSelectionActive) { + this.selectionBoxActive = false; + this.multiSelectionActive = false; + this.eventBus.emit(new WarshipSelectionBoxCancelEvent()); } + } - // Don't track zoom keys when a meta/ctrl modifier is held — that means - // the browser is handling its own zoom (cmd+/cmd-) and the keyup will - // never fire, which would leave the key stuck in activeKeys forever. - // Also covers numpad zoom shortcuts (Ctrl+NumpadAdd/NumpadSubtract). - const isBrowserZoomCombo = - (e.metaKey || e.ctrlKey) && - (e.code === "Minus" || - e.code === "Equal" || - e.code === "NumpadAdd" || - e.code === "NumpadSubtract"); + if ( + (e.code === "Enter" || e.code === "NumpadEnter") && + this.uiState.ghostStructure !== null + ) { + e.preventDefault(); + this.eventBus.emit(new ConfirmGhostStructureEvent()); + } - if ( - !isBrowserZoomCombo && - [ - this.keybinds.moveUp, - this.keybinds.moveDown, - this.keybinds.moveLeft, - this.keybinds.moveRight, - this.keybinds.zoomOut, - this.keybinds.zoomIn, - "ArrowUp", - "ArrowLeft", - "ArrowDown", - "ArrowRight", - "Minus", - "Equal", - "NumpadAdd", - "NumpadSubtract", - this.keybinds.attackRatioDown, - this.keybinds.attackRatioUp, - this.keybinds.centerCamera, - "ControlLeft", - "ControlRight", - this.keybinds.shiftKey, - this.keybinds.emojiMenuModifier, - this.keybinds.buildMenuModifier, - this.keybinds.altKey, - ].includes(e.code) - ) { - this.activeKeys.add(e.code); - } + // Don't track zoom keys when a meta/ctrl modifier is held — that means + // the browser is handling its own zoom (cmd+/cmd-) and the keyup will + // never fire, which would leave the key stuck in activeKeys forever. + // Also covers numpad zoom shortcuts (Ctrl+NumpadAdd/NumpadSubtract). + const isBrowserZoomCombo = + (e.metaKey || e.ctrlKey) && + (e.code === "Minus" || + e.code === "Equal" || + e.code === "NumpadAdd" || + e.code === "NumpadSubtract"); - // Shift = warship box selection mode. - // If a ghost structure is active, discard it first. - if (e.code === this.keybinds.shiftKey) { - if (this.uiState.ghostStructure !== null) { - this.setGhostStructure(null); - } - this.canvas.style.cursor = "crosshair"; - } - }); - window.addEventListener("keyup", (e) => { - const isTextInput = this.isTextInputTarget(e.target); - if (isTextInput && !this.activeKeys.has(e.code)) { - return; - } + if ( + !isBrowserZoomCombo && + [ + this.keybinds.moveUp, + this.keybinds.moveDown, + this.keybinds.moveLeft, + this.keybinds.moveRight, + this.keybinds.zoomOut, + this.keybinds.zoomIn, + "ArrowUp", + "ArrowLeft", + "ArrowDown", + "ArrowRight", + "Minus", + "Equal", + "NumpadAdd", + "NumpadSubtract", + this.keybinds.attackRatioDown, + this.keybinds.attackRatioUp, + this.keybinds.centerCamera, + "ControlLeft", + "ControlRight", + this.keybinds.shiftKey, + this.keybinds.emojiMenuModifier, + this.keybinds.buildMenuModifier, + this.keybinds.altKey, + ].includes(e.code) + ) { + this.activeKeys.add(e.code); + } - // When the meta (cmd) or ctrl key is released, any keys that were held - // simultaneously will have had their keyup swallowed by the browser - // (e.g. cmd+Plus for browser zoom). Clear zoom-related keys to - // prevent them staying stuck in activeKeys. - if ( - e.code === "MetaLeft" || - e.code === "MetaRight" || - e.code === "ControlLeft" || - e.code === "ControlRight" - ) { - this.activeKeys.delete("Minus"); - this.activeKeys.delete("Equal"); - this.activeKeys.delete("NumpadAdd"); - this.activeKeys.delete("NumpadSubtract"); - this.activeKeys.delete(this.keybinds.zoomIn); - this.activeKeys.delete(this.keybinds.zoomOut); + // Shift = warship box selection mode. + // If a ghost structure is active, discard it first. + if (e.code === this.keybinds.shiftKey) { + if (this.uiState.ghostStructure !== null) { + this.setGhostStructure(null); } + this.canvas.style.cursor = "crosshair"; + } + }; + + private onKeyUp = (e: KeyboardEvent) => { + const isTextInput = this.isTextInputTarget(e.target); + if (isTextInput && !this.activeKeys.has(e.code)) { + return; + } - outerLoop: for (const item of this.keybindAndEvent) { - if (this.keybindMatchesEvent(e, item[0])) { - for (const i of item[1].conditions) { - if (!i(e)) { - continue outerLoop; - } + // When the meta (cmd) or ctrl key is released, any keys that were held + // simultaneously will have had their keyup swallowed by the browser + // (e.g. cmd+Plus for browser zoom). Clear zoom-related keys to + // prevent them staying stuck in activeKeys. + if ( + e.code === "MetaLeft" || + e.code === "MetaRight" || + e.code === "ControlLeft" || + e.code === "ControlRight" + ) { + this.activeKeys.delete("Minus"); + this.activeKeys.delete("Equal"); + this.activeKeys.delete("NumpadAdd"); + this.activeKeys.delete("NumpadSubtract"); + this.activeKeys.delete(this.keybinds.zoomIn); + this.activeKeys.delete(this.keybinds.zoomOut); + } + + outerLoop: for (const item of this.keybindAndEvent) { + if (this.keybindMatchesEvent(e, item[0])) { + for (const i of item[1].conditions) { + if (!i(e)) { + continue outerLoop; } - e.preventDefault(); - item[1].handler(e); } + e.preventDefault(); + item[1].handler(e); } - this.activeKeys.delete(e.code); + } + this.activeKeys.delete(e.code); - // Reset crosshair when Shift is released (unless selection box or multi-selection still active) - if ( - e.code === this.keybinds.shiftKey && - !this.selectionBoxActive && - !this.multiSelectionActive - ) { - this.canvas.style.cursor = ""; - } - }); - } + // Reset crosshair when Shift is released (unless selection box or multi-selection still active) + if ( + e.code === this.keybinds.shiftKey && + !this.selectionBoxActive && + !this.multiSelectionActive + ) { + this.canvas.style.cursor = ""; + } + }; - private onPointerDown(event: PointerEvent) { + private onPointerDown = (event: PointerEvent) => { if (event.button === 1) { event.preventDefault(); this.eventBus.emit(new AutoUpgradeEvent(event.clientX, event.clientY)); @@ -720,9 +744,9 @@ export class InputHandler { } this.lastPinchDistance = this.getPinchDistance(); } - } + }; - onPointerUp(event: PointerEvent) { + private onPointerUp = (event: PointerEvent) => { if (event.button === 1) { event.preventDefault(); return; @@ -806,7 +830,7 @@ export class InputHandler { this.eventBus.emit(new ContextMenuEvent(event.clientX, event.clientY)); } } - } + }; private onScroll(event: WheelEvent) { if (!event.shiftKey) { @@ -846,7 +870,7 @@ export class InputHandler { } } - private onPointerMove(event: PointerEvent) { + private onPointerMove = (event: PointerEvent) => { if (event.button === 1) { event.preventDefault(); return; @@ -912,9 +936,9 @@ export class InputHandler { this.lastPinchDistance = currentPinchDistance; } } - } + }; - private onContextMenu(event: MouseEvent) { + private onContextMenu = (event: MouseEvent) => { event.preventDefault(); if (this.gameView.inSpawnPhase()) { return; @@ -924,7 +948,7 @@ export class InputHandler { return; } this.eventBus.emit(new ContextMenuEvent(event.clientX, event.clientY)); - } + }; private setGhostStructure(ghostStructure: PlayerBuildableUnitType | null) { this.uiState.ghostStructure = ghostStructure; @@ -1077,10 +1101,20 @@ export class InputHandler { } destroy() { + this.listenerAbort?.abort(); + this.listenerAbort = null; + this.eventBus.off(UnitSelectionEvent, this.onUnitSelection); if (this.moveInterval !== null) { clearInterval(this.moveInterval); + this.moveInterval = null; + } + if (this.longPressTimer !== null) { + clearTimeout(this.longPressTimer); + this.longPressTimer = null; } this.activeKeys.clear(); this.keybindAndEvent = []; + this.pointers.clear(); + this.pointerDown = false; } } diff --git a/src/client/hud/layers/ActionableEvents.ts b/src/client/hud/layers/ActionableEvents.ts index 4211ff5ea1..01d9bd1ab3 100644 --- a/src/client/hud/layers/ActionableEvents.ts +++ b/src/client/hud/layers/ActionableEvents.ts @@ -50,6 +50,14 @@ export class ActionableEvents extends LitElement implements Controller { private alliancesCheckedAt = new Map(); @state() private _isVisible = false; + init() { + // HUD elements are page-lifetime singletons reused across games. + this.events = []; + this.alliancesCheckedAt.clear(); + this._isVisible = false; + this.active = false; + } + private updateMap = [ [GameUpdateType.AllianceRequest, this.onAllianceRequestEvent.bind(this)], [ @@ -115,20 +123,29 @@ export class ActionableEvents extends LitElement implements Controller { } } - const remainingEvents = this.events.filter( - (event) => - (event.duration === undefined || - this.game.ticks() - event.createdAt < event.duration) && - (event.type !== MessageType.ALLIANCE_REQUEST || - // We remove Alliance Requests if the requestor is dead. - (( - this.game.playerBySmallID(event.requestorID) as PlayerView - ).isAlive() && - // We remove Alliance Requests if the requestor is no longer requesting an alliance with us. - ( - this.game.playerBySmallID(event.requestorID) as PlayerView - ).isRequestingAllianceWith(this.game.myPlayer() as PlayerView))), - ); + const remainingEvents = this.events.filter((event) => { + if ( + event.duration !== undefined && + this.game.ticks() - event.createdAt >= event.duration + ) { + return false; + } + if (event.type !== MessageType.ALLIANCE_REQUEST) { + return true; + } + // Drop stale cards from a previous game (unknown small IDs throw). + let requestor: PlayerView; + try { + requestor = this.game.playerBySmallID(event.requestorID) as PlayerView; + } catch { + return false; + } + // Remove Alliance Requests if the requestor is dead or no longer requesting. + return ( + requestor.isAlive() && + requestor.isRequestingAllianceWith(this.game.myPlayer() as PlayerView) + ); + }); if (this.events.length !== remainingEvents.length) { this.events = remainingEvents; diff --git a/src/client/hud/layers/EventsDisplay.ts b/src/client/hud/layers/EventsDisplay.ts index 042469ff24..b59837e29a 100644 --- a/src/client/hud/layers/EventsDisplay.ts +++ b/src/client/hud/layers/EventsDisplay.ts @@ -144,10 +144,20 @@ export class EventsDisplay extends LitElement implements Controller { this.events = []; } + private onAllianceRequestSent = (e: SendAllianceRequestIntentEvent) => { + this.onAllianceRequestSentConfirmation(e); + }; + init() { + // Clear carried-over events from a previous game on this page-lifetime singleton. + this.events = []; + this.eventBus.off( + SendAllianceRequestIntentEvent, + this.onAllianceRequestSent, + ); this.eventBus.on( SendAllianceRequestIntentEvent, - this.onAllianceRequestSentConfirmation.bind(this), + this.onAllianceRequestSent, ); } diff --git a/src/client/hud/layers/GameRightSidebar.ts b/src/client/hud/layers/GameRightSidebar.ts index 8889271b0e..b3189a45d4 100644 --- a/src/client/hud/layers/GameRightSidebar.ts +++ b/src/client/hud/layers/GameRightSidebar.ts @@ -63,33 +63,51 @@ export class GameRightSidebar extends LitElement implements Controller { return this; } + private onSpawnBarVisible = (e: SpawnBarVisibleEvent) => { + this.spawnBarVisible = e.visible; + this.updateParentOffset(); + }; + + private onImmunityBarVisible = (e: ImmunityBarVisibleEvent) => { + this.immunityBarVisible = e.visible; + this.updateParentOffset(); + }; + + private onSendWinner = () => { + this.hasWinner = true; + this.requestUpdate(); + }; + + private onTogglePause = () => { + const isReplayOrSingleplayer = + this._isSinglePlayer || this.game?.config()?.isReplay(); + if (isReplayOrSingleplayer || this.isLobbyCreator) { + this.onPauseButtonClick(); + } + }; + init() { this._isSinglePlayer = this.game?.config()?.gameConfig()?.gameType === GameType.Singleplayer || this.game.config().isReplay(); this._isVisible = true; - - this.eventBus.on(SpawnBarVisibleEvent, (e) => { - this.spawnBarVisible = e.visible; - this.updateParentOffset(); - }); - this.eventBus.on(ImmunityBarVisibleEvent, (e) => { - this.immunityBarVisible = e.visible; - this.updateParentOffset(); - }); - - this.eventBus.on(SendWinnerEvent, () => { - this.hasWinner = true; - this.requestUpdate(); - }); - - this.eventBus.on(TogglePauseIntentEvent, () => { - const isReplayOrSingleplayer = - this._isSinglePlayer || this.game?.config()?.isReplay(); - if (isReplayOrSingleplayer || this.isLobbyCreator) { - this.onPauseButtonClick(); - } - }); + // Reset per-game state — this element is a page-lifetime singleton. + this.hasWinner = false; + this.isPaused = false; + this.isLobbyCreator = false; + this.timer = 0; + this.spawnBarVisible = false; + this.immunityBarVisible = false; + + this.eventBus.off(SpawnBarVisibleEvent, this.onSpawnBarVisible); + this.eventBus.off(ImmunityBarVisibleEvent, this.onImmunityBarVisible); + this.eventBus.off(SendWinnerEvent, this.onSendWinner); + this.eventBus.off(TogglePauseIntentEvent, this.onTogglePause); + + this.eventBus.on(SpawnBarVisibleEvent, this.onSpawnBarVisible); + this.eventBus.on(ImmunityBarVisibleEvent, this.onImmunityBarVisible); + this.eventBus.on(SendWinnerEvent, this.onSendWinner); + this.eventBus.on(TogglePauseIntentEvent, this.onTogglePause); this.requestUpdate(); } diff --git a/src/client/hud/layers/Leaderboard.ts b/src/client/hud/layers/Leaderboard.ts index 34588ad3ab..bce0bc2c7a 100644 --- a/src/client/hud/layers/Leaderboard.ts +++ b/src/client/hud/layers/Leaderboard.ts @@ -146,7 +146,7 @@ export class Leaderboard extends LitElement implements Controller { name: myPlayer.displayName(), position: place, score: formatPercentage( - myPlayer.numTilesOwned() / this.game.numLandTiles(), + myPlayer.numTilesOwned() / numTilesWithoutFallout, ), gold: renderNumber(myPlayer.gold()), maxTroops: renderTroops(myPlayerMaxTroops), diff --git a/src/client/hud/layers/PlayerPanel.ts b/src/client/hud/layers/PlayerPanel.ts index 452bcc9a27..c46613ad36 100644 --- a/src/client/hud/layers/PlayerPanel.ts +++ b/src/client/hud/layers/PlayerPanel.ts @@ -7,6 +7,7 @@ import { AllPlayers, GameType, PlayerActions, + PlayerID, PlayerProfile, PlayerType, Relation, @@ -63,7 +64,7 @@ export class PlayerPanel extends LitElement implements Controller { private actions: PlayerActions | null = null; private tile: TileRef | null = null; - private _profileForPlayerId: number | null = null; + private _profileForPlayerId: PlayerID | null = null; private kickedPlayerIDs = new Set(); @state() private sendTarget: PlayerView | null = null; @@ -134,9 +135,9 @@ export class PlayerPanel extends LitElement implements Controller { const pv = owner as PlayerView; const id = pv.id(); // fetch only if we don't have it or the player changed - if (this._profileForPlayerId !== Number(id)) { + if (this._profileForPlayerId !== id) { this.otherProfile = await pv.profile(); - this._profileForPlayerId = Number(id); + this._profileForPlayerId = id; } } diff --git a/src/client/hud/layers/WinModal.ts b/src/client/hud/layers/WinModal.ts index 2728db5463..f824a915af 100644 --- a/src/client/hud/layers/WinModal.ts +++ b/src/client/hud/layers/WinModal.ts @@ -254,7 +254,13 @@ export class WinModal extends LitElement implements Controller { window.location.href = "/?requeue"; } - init() {} + init() { + // Reset per-game state — this element is a page-lifetime singleton. + this.hasShownDeathModal = false; + this.isVisible = false; + this.showButtons = false; + this.isWin = false; + } tick() { const myPlayer = this.game.myPlayer(); diff --git a/src/core/execution/AttackExecution.ts b/src/core/execution/AttackExecution.ts index 6271cfb37d..72a876e261 100644 --- a/src/core/execution/AttackExecution.ts +++ b/src/core/execution/AttackExecution.ts @@ -245,12 +245,16 @@ export class AttackExecution implements Execution { return; } - if (this.attack.retreating()) { + // Check isActive before retreating: an external cancel (e.g. opposing + // attack merge) can delete() the attack while a retreat is in flight. + // Without this, the retreating() early-return spins forever and never + // refunds troops. + if (!this.attack.isActive()) { + this.active = false; return; } - if (!this.attack.isActive()) { - this.active = false; + if (this.attack.retreating()) { return; } @@ -385,6 +389,9 @@ export class AttackExecution implements Execution { private handleDeadDefender() { if (!(this.target.isPlayer() && this.target.numTilesOwned() < 100)) return; const target: Player = this.target; + // Island survivors can sit under the 100-tile threshold for many ticks; + // only conquer once so gold/kills aren't recorded repeatedly. + if (this.mg.hasConqueredPlayer(target.id())) return; this.mg.conquerPlayer(this._owner, target); diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index d2cf39c76e..4de9e66f3f 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -829,6 +829,8 @@ export interface Game extends GameMap { addUpdate(update: GameUpdate): void; railNetwork(): RailNetwork; conquerPlayer(conqueror: Player, conquered: Player): void; + /** True after conquerPlayer has already run for this victim this game. */ + hasConqueredPlayer(id: PlayerID): boolean; miniWaterHPA(): PathFinder | null; miniWaterGraph(): AbstractGraph | null; getWaterComponent(tile: TileRef): number | null; diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index a48335f7dd..bfa6511705 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -116,6 +116,8 @@ export class GameImpl implements Game { private _waterManager: WaterManager; private _sharedWaterCache: SharedWaterCache; private _teamGameSpawnAreas: TeamGameSpawnAreas | undefined; + /** Victims already processed by conquerPlayer — prevents repeat gold/kills. */ + private _conqueredPlayerIDs = new Set(); constructor( private _humans: PlayerInfo[], @@ -1248,7 +1250,16 @@ export class GameImpl implements Game { sharedWaterComponents(player: Player): Set | null { return this._sharedWaterCache.get(player); } + hasConqueredPlayer(id: PlayerID): boolean { + return this._conqueredPlayerIDs.has(id); + } + conquerPlayer(conqueror: Player, conquered: Player) { + if (this._conqueredPlayerIDs.has(conquered.id())) { + return; + } + this._conqueredPlayerIDs.add(conquered.id()); + if (conquered.isDisconnected() && conqueror.isOnSameTeam(conquered)) { const ships = conquered .units() @@ -1289,7 +1300,7 @@ export class GameImpl implements Game { "events_display.received_gold_from_conquest", MessageType.CONQUERED_PLAYER, conqueror.id(), - gold, + goldCaptured, { gold: renderNumber(goldCaptured), name: conquered.displayName(), diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index 1daabb9a3b..ba1540b99b 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -633,7 +633,11 @@ export class UnitImpl implements Unit { decreaseLevel(destroyer?: Player): void { this._level--; if ([UnitType.MissileSilo, UnitType.SAMLauncher].includes(this.type())) { - this._missileTimerQueue.pop(); + // Only drop a cooldown when every remaining slot was on cooldown. + // Otherwise the lost slot was ready and the active cooldown must stay. + if (this._missileTimerQueue.length > this._level) { + this._missileTimerQueue.pop(); + } } if (this._level <= 0) { this.delete(true, destroyer); diff --git a/tests/AttackRetreatCancel.test.ts b/tests/AttackRetreatCancel.test.ts new file mode 100644 index 0000000000..536291f443 --- /dev/null +++ b/tests/AttackRetreatCancel.test.ts @@ -0,0 +1,75 @@ +import { AttackExecution } from "../src/core/execution/AttackExecution"; +import { SpawnExecution } from "../src/core/execution/SpawnExecution"; +import { Game, Player, PlayerInfo, PlayerType } from "../src/core/game/Game"; +import { GameID } from "../src/core/Schemas"; +import { setup } from "./util/Setup"; + +const gameID: GameID = "game_id"; + +describe("Attack retreat cancelled externally", () => { + let game: Game; + let attacker: Player; + let defender: Player; + + beforeEach(async () => { + game = await setup("ocean_and_land", { + infiniteGold: true, + infiniteTroops: true, + }); + const attackerInfo = new PlayerInfo( + "attacker", + PlayerType.Human, + null, + "attacker_id", + ); + const defenderInfo = new PlayerInfo( + "defender", + PlayerType.Human, + null, + "defender_id", + ); + game.addPlayer(attackerInfo); + game.addPlayer(defenderInfo); + + game.addExecution( + new SpawnExecution( + gameID, + game.player(attackerInfo.id).info(), + game.ref(0, 10), + ), + new SpawnExecution( + gameID, + game.player(defenderInfo.id).info(), + game.ref(0, 15), + ), + ); + game.executeNextTick(); + game.executeNextTick(); + + attacker = game.player(attackerInfo.id); + defender = game.player(defenderInfo.id); + }); + + test("deactivates when attack is deleted during retreat delay", () => { + const attackExec = new AttackExecution(100, attacker, defender.id()); + game.addExecution(attackExec); + game.executeNextTick(); + + expect(attacker.outgoingAttacks()).toHaveLength(1); + const attack = attacker.outgoingAttacks()[0]; + expect(attackExec.isActive()).toBe(true); + + // Simulate RetreatExecution.orderRetreat during the cancel delay. + attack.orderRetreat(); + expect(attack.retreating()).toBe(true); + expect(attack.isActive()).toBe(true); + + // External cancel (e.g. opposing attack merge) deletes the attack mid-retreat. + attack.delete(); + expect(attack.isActive()).toBe(false); + expect(attack.retreating()).toBe(true); + + game.executeNextTick(); + expect(attackExec.isActive()).toBe(false); + }); +}); diff --git a/tests/MissileSiloDowngrade.test.ts b/tests/MissileSiloDowngrade.test.ts new file mode 100644 index 0000000000..ec842f19f0 --- /dev/null +++ b/tests/MissileSiloDowngrade.test.ts @@ -0,0 +1,63 @@ +import { SpawnExecution } from "../src/core/execution/SpawnExecution"; +import { UpgradeStructureExecution } from "../src/core/execution/UpgradeStructureExecution"; +import { + Game, + Player, + PlayerInfo, + PlayerType, + UnitType, +} from "../src/core/game/Game"; +import { GameID } from "../src/core/Schemas"; +import { setup } from "./util/Setup"; +import { constructionExecution, executeTicks } from "./util/utils"; + +const gameID: GameID = "game_id"; + +describe("Missile silo downgrade cooldown", () => { + let game: Game; + let player: Player; + + beforeEach(async () => { + game = await setup("plains", { infiniteGold: true, instantBuild: true }); + const info = new PlayerInfo("player", PlayerType.Human, null, "player_id"); + game.addPlayer(info); + game.addExecution( + new SpawnExecution(gameID, game.player(info.id).info(), game.ref(1, 1)), + ); + player = game.player("player_id"); + constructionExecution(game, player, 1, 1, UnitType.MissileSilo); + }); + + test("downgrade with a ready slot keeps the active cooldown", () => { + const silo = player.units(UnitType.MissileSilo)[0]; + // Upgrade puts the new slot on cooldown (queue length 1, level 2). + game.addExecution(new UpgradeStructureExecution(player, silo.id())); + executeTicks(game, 2); + expect(silo.level()).toBe(2); + expect(silo.missileTimerQueue()).toHaveLength(1); + expect(silo.isInCooldown()).toBe(false); + + const cooldownBefore = silo.missileTimerQueue()[0]; + silo.decreaseLevel(); + expect(silo.level()).toBe(1); + // Ready slot was lost; the active cooldown from the upgrade must remain. + expect(silo.missileTimerQueue()).toEqual([cooldownBefore]); + expect(silo.isInCooldown()).toBe(true); + }); + + test("downgrade when fully on cooldown drops one timer", () => { + const silo = player.units(UnitType.MissileSilo)[0]; + game.addExecution(new UpgradeStructureExecution(player, silo.id())); + executeTicks(game, 2); + expect(silo.level()).toBe(2); + // Upgrade cooldown + one launch = fully on cooldown. + silo.launch(); + expect(silo.missileTimerQueue()).toHaveLength(2); + expect(silo.isInCooldown()).toBe(true); + + silo.decreaseLevel(); + expect(silo.level()).toBe(1); + expect(silo.missileTimerQueue()).toHaveLength(1); + expect(silo.isInCooldown()).toBe(true); + }); +}); diff --git a/tests/RepeatConquest.test.ts b/tests/RepeatConquest.test.ts new file mode 100644 index 0000000000..49971eefc0 --- /dev/null +++ b/tests/RepeatConquest.test.ts @@ -0,0 +1,57 @@ +import { SpawnExecution } from "../src/core/execution/SpawnExecution"; +import { Game, Player, PlayerInfo, PlayerType } from "../src/core/game/Game"; +import { GameID } from "../src/core/Schemas"; +import { setup } from "./util/Setup"; + +const gameID: GameID = "game_id"; + +describe("Repeat conquest guard", () => { + let game: Game; + let conqueror: Player; + let victim: Player; + + beforeEach(async () => { + game = await setup("ocean_and_land"); + const conquerorInfo = new PlayerInfo( + "conqueror", + PlayerType.Human, + "conqueror_client", + "conqueror", + ); + const victimInfo = new PlayerInfo( + "victim", + PlayerType.Human, + "victim_client", + "victim", + ); + game.addPlayer(conquerorInfo); + game.addPlayer(victimInfo); + game.addExecution( + new SpawnExecution(gameID, conquerorInfo, game.ref(0, 10)), + ); + conqueror = game.player(conquerorInfo.id); + victim = game.player(victimInfo.id); + victim.addGold(1000n); + // Record an attack so the gold transfer is not skipped for humans. + game.stats().attack(victim, game.terraNullius(), 100); + }); + + test("conquerPlayer only transfers gold and records a kill once", () => { + const goldBefore = conqueror.gold(); + game.conquerPlayer(conqueror, victim); + expect(conqueror.gold()).toBe(goldBefore + 500n); + expect(victim.gold()).toBe(0n); + expect(game.hasConqueredPlayer(victim.id())).toBe(true); + + const killsAfterFirst = + game.stats().getPlayerStats(conqueror)?.kills?.length ?? 0; + expect(killsAfterFirst).toBe(1); + + // Second call must be a no-op (island-survivor / <100-tile path). + victim.addGold(2000n); + game.conquerPlayer(conqueror, victim); + expect(conqueror.gold()).toBe(goldBefore + 500n); + expect(victim.gold()).toBe(2000n); + expect(game.stats().getPlayerStats(conqueror)?.kills?.length ?? 0).toBe(1); + }); +});