Skip to content

fix: reset HUD/input between games and harden conquest/retreat/silo edge cases#4544

Closed
Ahmet-Dedeler wants to merge 1 commit into
openfrontio:mainfrom
Ahmet-Dedeler:fix/game-lifecycle-and-core-bugs
Closed

fix: reset HUD/input between games and harden conquest/retreat/silo edge cases#4544
Ahmet-Dedeler wants to merge 1 commit into
openfrontio:mainfrom
Ahmet-Dedeler:fix/game-lifecycle-and-core-bugs

Conversation

@Ahmet-Dedeler

@Ahmet-Dedeler Ahmet-Dedeler commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Description:

Fixes several bugs that show up when leaving a game and joining another without a full page reload, plus a few related core edge cases.

Client (page-lifetime HUD / input reuse):

  • Call InputHandler.destroy() on game stop and tear down window/canvas listeners via AbortController, so hotkeys and pan don't stack every game.
  • Store and clear the connection-check setTimeout, and guard onConnectionCheck with isActive, so quitting within 20s doesn't leave a zombie reconnect loop.
  • Clear stale actionable/event-log state on init(), and stop unknown previous-game small IDs from throwing (which froze later HUD layers).
  • Reset GameRightSidebar win/pause state and dedupe EventBus subscriptions so the clock and pause hotkey work in the next game.
  • Key PlayerPanel profile cache on string PlayerID instead of Number(id) (always NaN), which was spamming the worker every tick.
  • Reset death-modal state between games; use the same land-tile denominator for the pinned leaderboard row as the top list.

Core:

  • Check !attack.isActive() before the retreating() early-return so an externally cancelled attack during the retreat delay deactivates instead of spinning forever.
  • Guard conquerPlayer so island survivors under 100 tiles aren't conquered (gold/kills) repeatedly.
  • Pass goldCaptured (half for humans) as the conquest display gold amount so it matches the message text.
  • On silo/SAM downgrade, only pop a missile cooldown when every remaining slot was on cooldown.

Added tests for retreat cancel, repeat conquest, and silo downgrade cooldown.

Please complete the following:

  • I have added screenshots for all UI updates
  • I process any text displayed to the user through translateText() and I've added it to the en.json file
  • I have added relevant tests to the test directory

Please put your Discord username so you can be contacted if a bug or regression is found:

ahmetd17

…dge cases

Page-lifetime HUD elements and InputHandler were leaking listeners and state across games; also fix zombie retreat executions, repeat conquest, conquest gold display, and silo downgrade cooldown.

Co-authored-by: Cursor <cursoragent@cursor.com>
@Ahmet-Dedeler Ahmet-Dedeler requested a review from a team as a code owner July 9, 2026 00:37
Copilot AI review requested due to automatic review settings July 9, 2026 00:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR fixes cleanup bugs across client lifecycle code (connection checks, input listeners) and HUD layers (state reset on init, stable event handlers), and hardens game logic with a repeat-conquest guard, an attack-retreat-cancellation fix, and a missile silo cooldown trimming correction, all backed by new tests.

Changes

Client Lifecycle and HUD Reset

Layer / File(s) Summary
Connection check timeout tracking
src/client/ClientGameRunner.ts
A connectionCheckTimeout field is tracked, cleared on fire/stop, scheduling and onConnectionCheck are gated by isActive, and stop() destroys the input handler.
InputHandler abortable listeners
src/client/InputHandler.ts
initialize() calls destroy() first and uses an AbortController signal for all DOM listeners; handlers become class-field arrow functions; destroy() aborts listeners and clears state.
HUD init state reset and stable handlers
src/client/hud/layers/ActionableEvents.ts, .../EventsDisplay.ts, .../GameRightSidebar.ts, .../WinModal.ts
init() resets per-game fields and defensively re-registers event bus listeners via stable handler references; ActionableEvents.tick() validates alliance-request events with a safe playerBySmallID lookup.
Leaderboard and PlayerPanel fixes
src/client/hud/layers/Leaderboard.ts, .../PlayerPanel.ts
Leaderboard score uses tiles excluding fallout; PlayerPanel tracks profile target using raw PlayerID instead of a coerced number.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Repeat Conquest and Execution Fixes

Layer / File(s) Summary
hasConqueredPlayer contract and guard
src/core/game/Game.ts, src/core/game/GameImpl.ts, tests/RepeatConquest.test.ts
hasConqueredPlayer(id) is added to the Game interface and implemented via a _conqueredPlayerIDs set; conquerPlayer skips repeat processing and the gold-receipt message now uses goldCaptured.
Attack retreat cancellation
src/core/execution/AttackExecution.ts, tests/AttackRetreatCancel.test.ts
tick() checks isActive() before retreating() to handle external deletion during retreat; handleDeadDefender() uses hasConqueredPlayer to avoid repeated processing.
Missile silo cooldown trimming
src/core/game/UnitImpl.ts, tests/MissileSiloDowngrade.test.ts
decreaseLevel only pops a cooldown timer when the queue has surplus slots relative to the new level.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Game
  participant AttackExecution
  participant Attack

  Game->>AttackExecution: tick()
  AttackExecution->>Attack: isActive()?
  alt attack deleted externally
    Attack-->>AttackExecution: false
    AttackExecution->>AttackExecution: set active=false, return
  else attack still active
    Attack-->>AttackExecution: true
    AttackExecution->>Attack: retreating()?
    AttackExecution->>AttackExecution: continue normal handling
  end
Loading

Possibly related PRs

Suggested labels: UI/UX, Backend

Suggested reviewers: evanpelle, Celant

Poem

A timer cleared, a listener abort,
Conquest counted just once — no more, no less, in short.
Retreat now holds even when deleted mid-flight,
Silos keep their cooldown slot tight.
🐇 hopping through code so clean,
Tests all green, bugs unseen!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main changes: game reset cleanup plus conquest, retreat, and silo edge-case fixes.
Description check ✅ Passed The description accurately summarizes the client and core fixes, and it matches the added tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the auto-closed-needs-issue PR closed by gate — see comment for next steps label Jul 9, 2026
@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown

Hi @Ahmet-Dedeler, thanks for the contribution.

This PR was automatically closed because it doesn't fit our contribution workflow:

  • You aren't currently assigned to an issue labelled approved, and
  • The change is larger than 50 lines (our cap for unsolicited contributions).

To contribute to OpenFront:

  1. For bugs or small quality-of-life improvements: open an issue. A maintainer will label it approved if it's something we'll work on.
  2. For feature ideas: discuss in the dev Discord first. We don't accept unsolicited feature PRs — even if they're good ideas, every merged feature is a permanent maintenance burden.
  3. Once an issue is labelled approved, comment asking to be assigned. After you're assigned, you can open a PR referencing that issue.

If you believe this was closed in error, please reach out on our Discord or comment below.

See CONTRIBUTING.md for the full contribution process.

Automated PR gate. Source.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 763ceeb5c9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep sweeping already-conquered defenders

When a defender has already been marked conquered, this early return skips the cleanup loop below that transfers the remaining <100 tiles. In island or narrow-territory cases the first 10-pass sweep can leave disconnected or deep interior tiles behind; any later landing/attack on those leftovers will now only skip conquerPlayer and also skip the sweep, so the defeated player can keep remnants that used to be cleaned up. The repeat-gold guard should avoid only the reward/stat call, not the tile redistribution.

Useful? React with 👍 / 👎.

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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Invalidate profiles when same-player relations change

This cache never refreshes while the selected PlayerID stays the same, but PlayerProfile contains dynamic relation/alliance data used by renderRelationPillIfNation. If the user keeps or reopens the panel for the same nation after attacks, decay, or alliance changes, tick() skips pv.profile() and the panel keeps showing the stale relation indefinitely; clear the cached id on show/hide or refresh it periodically/on relevant updates.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/core/execution/AttackExecution.ts (1)

389-421: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Keep the mop-up loop running

GameImpl.conquerPlayer() already no-ops on repeat ids, so this early return only blocks the 10-pass cleanup on later ticks. Move the guard to the conquerPlayer() call, or drop it, so island tiles can still be absorbed after the first conquest.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/execution/AttackExecution.ts` around lines 389 - 421, The mop-up
cleanup in handleDeadDefender is being skipped on later ticks because the early
return prevents the 10-pass island absorption loop from running after the first
conquest. Keep the repeat-id guard tied to the mg.conquerPlayer(this._owner,
target) call in AttackExecution.handleDeadDefender, but allow the rest of the
method to continue so the tile cleanup logic still runs even when the player was
already conquered.
src/client/hud/layers/Leaderboard.ts (1)

103-104: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Guard against a zero denominator.

If fallout ever covers every land tile, numTilesWithoutFallout becomes 0 and the leaderboard percentage becomes Infinity/NaN. The prior numLandTiles() denominator couldn't hit zero, so this is a new edge case introduced by the change.

🛠️ Proposed fix
     const numTilesWithoutFallout =
-      this.game.numLandTiles() - this.game.numTilesWithFallout();
+      Math.max(1, this.game.numLandTiles() - this.game.numTilesWithFallout());

Also applies to: 117-119, 148-150

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/hud/layers/Leaderboard.ts` around lines 103 - 104, Guard the
leaderboard percentage calculations against a zero denominator after switching
to numTilesWithoutFallout in Leaderboard. Update the affected computations in
the Leaderboard logic (including the shared calculations around the referenced
percentage blocks) so that when numTilesWithoutFallout is 0 you return a safe
fallback value instead of dividing, and ensure the same protection is applied
wherever that denominator is used.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/client/ClientGameRunner.ts`:
- Around line 987-994: The stop() cleanup in ClientGameRunner currently tears
down transport/input state but leaves the EventBus listeners registered from
start(), so bound handlers will accumulate across runs. Update ClientGameRunner
to retain references to the callbacks registered for MouseUpEvent,
MouseMoveEvent, AutoUpgradeEvent, and the other start() subscriptions, then
remove them with eventBus.off(...) during stop() before or alongside the
existing cleanup. Use the existing start()/stop() lifecycle in ClientGameRunner
to ensure the same bound handlers are unsubscribed every time.

In `@src/client/hud/layers/EventsDisplay.ts`:
- Around line 151-162: Reset the stale visibility state in EventsDisplay.init()
alongside clearing this.events, because this page-lifetime singleton can carry
_isVisible from a previous game; update init() in EventsDisplay to also clear
_isVisible, matching the reset behavior used by ActionableEvents.init(), and
keep the existing eventBus off/on wiring for SendAllianceRequestIntentEvent and
onAllianceRequestSent.

In `@src/client/hud/layers/GameRightSidebar.ts`:
- Around line 89-113: `GameRightSidebar.init()` resets the bar visibility state
but does not refresh the parent offset, so a stale `marginTop` can carry over
between games. Update `init()` to also invoke the same offset-sync logic used by
`onSpawnBarVisible`/`onImmunityBarVisible` (via `updateParentOffset()` or
equivalent) after resetting `spawnBarVisible` and `immunityBarVisible`, so the
parent style is corrected immediately on re-init.

In `@src/client/InputHandler.ts`:
- Around line 1104-1118: Reset all remaining interaction flags in
InputHandler.destroy() so teardown fully clears state across games. In addition
to the existing listenerAbort, eventBus.off, timer cleanup, activeKeys,
keybindAndEvent, pointers, and pointerDown resets, also clear alternateView,
selectionBoxActive, multiSelectionActive, longPressActive, suppressNextTap, and
restore canvas.style.cursor to its default. Update the destroy() method in
InputHandler to make sure no stale selection or tap-suppression state survives
into the next game.

---

Outside diff comments:
In `@src/client/hud/layers/Leaderboard.ts`:
- Around line 103-104: Guard the leaderboard percentage calculations against a
zero denominator after switching to numTilesWithoutFallout in Leaderboard.
Update the affected computations in the Leaderboard logic (including the shared
calculations around the referenced percentage blocks) so that when
numTilesWithoutFallout is 0 you return a safe fallback value instead of
dividing, and ensure the same protection is applied wherever that denominator is
used.

In `@src/core/execution/AttackExecution.ts`:
- Around line 389-421: The mop-up cleanup in handleDeadDefender is being skipped
on later ticks because the early return prevents the 10-pass island absorption
loop from running after the first conquest. Keep the repeat-id guard tied to the
mg.conquerPlayer(this._owner, target) call in
AttackExecution.handleDeadDefender, but allow the rest of the method to continue
so the tile cleanup logic still runs even when the player was already conquered.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c906c3f6-36e2-4b16-88e0-5231a6dc0f55

📥 Commits

Reviewing files that changed from the base of the PR and between 901b271 and 763ceeb.

📒 Files selected for processing (15)
  • src/client/ClientGameRunner.ts
  • src/client/InputHandler.ts
  • src/client/hud/layers/ActionableEvents.ts
  • src/client/hud/layers/EventsDisplay.ts
  • src/client/hud/layers/GameRightSidebar.ts
  • src/client/hud/layers/Leaderboard.ts
  • src/client/hud/layers/PlayerPanel.ts
  • src/client/hud/layers/WinModal.ts
  • src/core/execution/AttackExecution.ts
  • src/core/game/Game.ts
  • src/core/game/GameImpl.ts
  • src/core/game/UnitImpl.ts
  • tests/AttackRetreatCancel.test.ts
  • tests/MissileSiloDowngrade.test.ts
  • tests/RepeatConquest.test.ts

Comment on lines 987 to +994
this.isActive = false;
this.worker.cleanup();
this.transport.leaveGame();
this.input.destroy();
if (this.connectionCheckTimeout) {
clearTimeout(this.connectionCheckTimeout);
this.connectionCheckTimeout = null;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check if ClientGameRunner and EventBus are reused or recreated per game.

# Search for where ClientGameRunner is instantiated and whether EventBus is passed in.
rg -nC5 'new ClientGameRunner' --type=ts src/

# Check if EventBus is created once or per game.
rg -nC3 'new EventBus' --type=ts src/

Repository: openfrontio/OpenFrontIO

Length of output: 1174


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the client lifecycle and game runner event subscriptions.
sed -n '240,320p' src/client/Main.ts
printf '\n--- ClientGameRunner start/stop area ---\n'
sed -n '820,1025p' src/client/ClientGameRunner.ts
printf '\n--- EventBus implementation ---\n'
fd -a 'EventBus.ts' src

Repository: openfrontio/OpenFrontIO

Length of output: 9230


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the EventBus API and the listener setup/teardown in ClientGameRunner.
sed -n '1,220p' src/core/EventBus.ts
printf '\n--- start() listener registration ---\n'
rg -nC3 'eventBus\.(on|off)|\.bind\(this\)' src/client/ClientGameRunner.ts
printf '\n--- input destroy path ---\n'
rg -nC4 'class InputHandler|destroy\(\)' src/client -g '*.ts'

Repository: openfrontio/OpenFrontIO

Length of output: 6327


Unsubscribe the game runner event handlers in stop()
The client keeps one EventBus for the whole session, so the .bind(this) listeners added in start() will stack across games unless they are removed. Store the bound callbacks (or use arrow fields) and call eventBus.off(...) for MouseUpEvent, MouseMoveEvent, AutoUpgradeEvent, etc.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/ClientGameRunner.ts` around lines 987 - 994, The stop() cleanup in
ClientGameRunner currently tears down transport/input state but leaves the
EventBus listeners registered from start(), so bound handlers will accumulate
across runs. Update ClientGameRunner to retain references to the callbacks
registered for MouseUpEvent, MouseMoveEvent, AutoUpgradeEvent, and the other
start() subscriptions, then remove them with eventBus.off(...) during stop()
before or alongside the existing cleanup. Use the existing start()/stop()
lifecycle in ClientGameRunner to ensure the same bound handlers are unsubscribed
every time.

Comment on lines 151 to 162
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,
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reset _isVisible in init() too.

_isVisible isn't cleared here, unlike ActionableEvents.init() in this same cohort. Since this component is a page-lifetime singleton, if the previous game ended while the local player was still alive (team/nation win, spectating), _isVisible stays true and the events containers can render stale-visible during the next game's spawn phase.

🛠️ Proposed fix
   init() {
     // Clear carried-over events from a previous game on this page-lifetime singleton.
     this.events = [];
+    this._isVisible = false;
     this.eventBus.off(
       SendAllianceRequestIntentEvent,
       this.onAllianceRequestSent,
     );
📝 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
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,
);
}
init() {
// Clear carried-over events from a previous game on this page-lifetime singleton.
this.events = [];
this._isVisible = false;
this.eventBus.off(
SendAllianceRequestIntentEvent,
this.onAllianceRequestSent,
);
this.eventBus.on(
SendAllianceRequestIntentEvent,
this.onAllianceRequestSent,
);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/hud/layers/EventsDisplay.ts` around lines 151 - 162, Reset the
stale visibility state in EventsDisplay.init() alongside clearing this.events,
because this page-lifetime singleton can carry _isVisible from a previous game;
update init() in EventsDisplay to also clear _isVisible, matching the reset
behavior used by ActionableEvents.init(), and keep the existing eventBus off/on
wiring for SendAllianceRequestIntentEvent and onAllianceRequestSent.

Comment on lines 89 to 113
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();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Sync marginTop on init() too.

spawnBarVisible/immunityBarVisible are reset here, but updateParentOffset() (which writes parent.style.marginTop) is only invoked from the bar-visibility handlers, not from init(). If the previous game ended with a bar visible, the stale margin persists into the new game until the next visibility event fires.

🛠️ Proposed fix
     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.updateParentOffset();

     this.requestUpdate();
📝 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
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();
}
init() {
this._isSinglePlayer =
this.game?.config()?.gameConfig()?.gameType === GameType.Singleplayer ||
this.game.config().isReplay();
this._isVisible = true;
// 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.updateParentOffset();
this.requestUpdate();
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/hud/layers/GameRightSidebar.ts` around lines 89 - 113,
`GameRightSidebar.init()` resets the bar visibility state but does not refresh
the parent offset, so a stale `marginTop` can carry over between games. Update
`init()` to also invoke the same offset-sync logic used by
`onSpawnBarVisible`/`onImmunityBarVisible` (via `updateParentOffset()` or
equivalent) after resetting `spawnBarVisible` and `immunityBarVisible`, so the
parent style is corrected immediately on re-init.

Comment on lines +1104 to +1118
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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reset remaining interactive state in destroy() to prevent leaks across games.

destroy() aborts the blur listener, so the blur handler's state resets (lines 447-471) won't fire after teardown. However, destroy() only clears pointers and pointerDown — it leaves alternateView, selectionBoxActive, multiSelectionActive, longPressActive, suppressNextTap, and canvas.style.cursor unset. If the game ends mid-selection or mid-long-press, these stale values persist into the next game. Most critically, suppressNextTap = true would silently swallow the first tap in a new game.

🛡️ Proposed fix: add missing state resets to destroy()
 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;
+   this.alternateView = false;
+   this.selectionBoxActive = false;
+   this.multiSelectionActive = false;
+   this.longPressActive = false;
+   this.suppressNextTap = false;
+   this.canvas.style.cursor = "";
  }
📝 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
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;
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;
this.alternateView = false;
this.selectionBoxActive = false;
this.multiSelectionActive = false;
this.longPressActive = false;
this.suppressNextTap = false;
this.canvas.style.cursor = "";
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/InputHandler.ts` around lines 1104 - 1118, Reset all remaining
interaction flags in InputHandler.destroy() so teardown fully clears state
across games. In addition to the existing listenerAbort, eventBus.off, timer
cleanup, activeKeys, keybindAndEvent, pointers, and pointerDown resets, also
clear alternateView, selectionBoxActive, multiSelectionActive, longPressActive,
suppressNextTap, and restore canvas.style.cursor to its default. Update the
destroy() method in InputHandler to make sure no stale selection or
tap-suppression state survives into the next game.

@github-project-automation github-project-automation Bot moved this from Complete to Development in OpenFront Release Management Jul 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto-closed-needs-issue PR closed by gate — see comment for next steps

Projects

Status: Development

Development

Successfully merging this pull request may close these issues.

2 participants