Skip to content

Conversation

@kkissdev
Copy link

@kkissdev kkissdev commented Nov 1, 2025

Description:

This adds a layer which makes the mouse act as a flashlight. It is implemented into user settings.

Updates to do:

  • Add Lights to cities
  • Add Lights to Nukes and SAMs (depending on height)
  • Create an Icon for UserSettings

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
  • I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced

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

sysrex

Screenshots

Night Mode, No Dark Mode
image

Night Mode, Dark Mode
image

In-Game Settings
image

Main Menu Settings
image

Summary by CodeRabbit

  • New Features

    • Introduced Night Mode with a Settings toggle and quick button; applies a persistent "night" theme and displays a dark overlay with a cursor-following spotlight and subtle city lights.
  • Localization

    • Added Night Mode labels and descriptions to English translations.
  • Tests

    • Added comprehensive tests for Night Mode rendering, interaction, event dispatching, and settings persistence.
  • Chores

    • Added project configuration file.

@kkissdev kkissdev requested a review from a team as a code owner November 1, 2025 23:24
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 1, 2025

Walkthrough

Adds Night Mode: new translations, user setting and toggle, document night CSS class applied at startup and on toggle, Settings UI integration and event, a new exported NightModeLayer wired into the renderer to draw a dark overlay with a cursor spotlight, and tests plus a CI review file.

Changes

Cohort / File(s) Summary
Translations
resources/lang/en.json
Adds user_setting.night_mode_label and user_setting.night_mode_desc.
User settings core
src/core/game/UserSettings.ts
Adds nightMode() getter and toggleNightMode() to persist settings.nightMode and update document night class.
App initialization
src/client/Main.ts
Applies/removes the night CSS class on documentElement at startup based on userSettings.nightMode().
Settings UI / modal
src/client/UserSettingModal.ts, src/client/graphics/layers/SettingsModal.ts
Adds Night Mode toggle and handlers (toggleNightMode / onToggleNightModeButtonClick), dispatches night-mode-changed, and exposes a Settings button bound to userSettings.nightMode().
Renderer integration
src/client/graphics/GameRenderer.ts
Imports and instantiates NightModeLayer in the renderer layer stack and calls NightModeLayer.setGame(this.game) during init.
Night mode rendering
src/client/graphics/layers/NightModeLayer.ts
New exported NightModeLayer implementing Layer; tracks mouse and renders a full-screen dark overlay with city lights and a flashlight-like illuminated region computed per tile and scaled by transform; exposes renderLayer and setGame.
Tests
tests/NightModeLayer.test.ts, tests/NightModeUserSettingModal.test.ts, tests/NightModeUserSettings.test.ts
New test suites covering settings persistence, DOM class toggling, event dispatch, rendering behavior, edge cases, and performance.
Tooling
.coderabbit.yaml
Adds CI/review profile configuration file.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant SettingsModal
    participant UserSettings
    participant Main
    participant Renderer
    participant NightModeLayer
    participant Canvas

    User->>SettingsModal: Toggle Night Mode (UI)
    SettingsModal->>UserSettings: toggleNightMode()
    UserSettings->>UserSettings: update localStorage `settings.nightMode`
    UserSettings->>Main: add/remove 'night' class on documentElement
    SettingsModal->>Renderer: dispatch 'night-mode-changed'
    Renderer->>NightModeLayer: request redraw / RefreshGraphicsEvent

    User->>Canvas: Move mouse
    Canvas->>NightModeLayer: mousemove -> update cursor pos
    NightModeLayer->>Canvas: renderLayer()
    Note right of NightModeLayer: if nightMode enabled\n draw dark overlay + city lights + spotlight
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

  • Focus review on:
    • src/client/graphics/layers/NightModeLayer.ts (render math, tile/grid iteration, scale and zero-scale handling, event listeners, performance).
    • Consistency of localStorage key settings.nightMode and the night-mode-changed event payload across UserSettings, UserSettingModal, SettingsModal, and tests.
    • Startup ordering in src/client/Main.ts relative to dark-mode initialization.

Possibly related issues

Possibly related PRs

Suggested labels

Feature - New, Feature - Frontend

Suggested reviewers

  • evanpelle
  • scottanderson

Poem

🐇
I hop through code where night descends,
I carve a beam where mouselight bends,
A tiny glow on pixel seam,
Toggle night and guard your dream.

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The PR title "Night Mode" directly and accurately reflects the primary change in the changeset. The entire pull request is focused on implementing the night mode feature, including new translation keys, UI toggles in both the user settings and main settings modals, core layer rendering logic with a flashlight-style spotlight effect, persistent storage via user settings, and comprehensive test coverage. The title is concise, clear, and specific enough that a developer reviewing the commit history would immediately understand that this PR adds night mode functionality. The title avoids vague terminology and noise while capturing the essence of the work.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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 and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 935ff7a and dfb8d17.

📒 Files selected for processing (7)
  • resources/lang/en.json (1 hunks)
  • src/client/Main.ts (1 hunks)
  • src/client/UserSettingModal.ts (2 hunks)
  • src/client/graphics/GameRenderer.ts (2 hunks)
  • src/client/graphics/layers/NightModeLayer.ts (1 hunks)
  • src/client/graphics/layers/SettingsModal.ts (2 hunks)
  • src/core/game/UserSettings.ts (2 hunks)
🧰 Additional context used
🧠 Learnings (4)
📚 Learning: 2025-10-18T11:00:57.142Z
Learnt from: NewYearNewPhil
Repo: openfrontio/OpenFrontIO PR: 2230
File: src/client/graphics/GameRenderer.ts:269-277
Timestamp: 2025-10-18T11:00:57.142Z
Learning: In src/client/graphics/GameRenderer.ts, the GameRecapCapture implementation does not use setCaptureRenderEnabled on layers. Instead, it uses RecapCaptureSurface.capture() to render capture layers (TerrainLayer, TerritoryLayer, RailroadLayer, StructureIconsLayer, UnitLayer) directly to an off-screen canvas without requiring layer-level capture mode methods.

Applied to files:

  • src/client/graphics/GameRenderer.ts
  • src/client/graphics/layers/NightModeLayer.ts
📚 Learning: 2025-06-09T02:20:43.637Z
Learnt from: VariableVince
Repo: openfrontio/OpenFrontIO PR: 1110
File: src/client/Main.ts:293-295
Timestamp: 2025-06-09T02:20:43.637Z
Learning: In src/client/Main.ts, during game start in the handleJoinLobby callback, UI elements are hidden using direct DOM manipulation with classList.add("hidden") for consistency. This includes modals, buttons, and error divs. The codebase follows this pattern rather than using component APIs for hiding elements during game transitions.

Applied to files:

  • src/client/graphics/GameRenderer.ts
  • src/client/Main.ts
📚 Learning: 2025-06-02T14:27:37.609Z
Learnt from: andrewNiziolek
Repo: openfrontio/OpenFrontIO PR: 1007
File: resources/lang/de.json:115-115
Timestamp: 2025-06-02T14:27:37.609Z
Learning: For OpenFrontIO project: When localization keys are renamed in language JSON files, the maintainers separate technical changes from translation content updates. They wait for community translators to update the actual translation values rather than attempting to translate in the same PR. This allows technical changes to proceed while ensuring accurate translations from native speakers.

Applied to files:

  • resources/lang/en.json
📚 Learning: 2025-08-16T10:52:08.292Z
Learnt from: TheGiraffe3
Repo: openfrontio/OpenFrontIO PR: 884
File: resources/lang/en.json:456-461
Timestamp: 2025-08-16T10:52:08.292Z
Learning: In OpenFrontIO, translation files in resources/lang/*.json (except en.json) should not be updated in regular PRs. Only dedicated translation PRs titled "mls" and made by Aotumori should update non-English locale files. Regular PRs should only update en.json when adding or modifying translation keys.

Applied to files:

  • resources/lang/en.json
🧬 Code graph analysis (4)
src/client/UserSettingModal.ts (1)
src/client/LangSelector.ts (1)
  • translateText (254-274)
src/client/graphics/GameRenderer.ts (1)
src/client/graphics/layers/NightModeLayer.ts (1)
  • NightModeLayer (6-83)
src/client/graphics/layers/SettingsModal.ts (2)
src/client/InputHandler.ts (1)
  • RefreshGraphicsEvent (74-74)
src/client/LangSelector.ts (1)
  • translateText (254-274)
src/client/graphics/layers/NightModeLayer.ts (3)
src/client/graphics/layers/Layer.ts (1)
  • Layer (1-7)
src/core/game/UserSettings.ts (1)
  • UserSettings (6-214)
src/client/graphics/TransformHandler.ts (1)
  • TransformHandler (15-295)

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 1, 2025

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

CodeRabbit commands

These commands are invoked using PR/Issue comments.

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai evaluate custom pre-merge check --instructions <custom-checks-instructions> --name <custom-checks-title> [--mode <error|warning>] to validate the custom pre-merge checks instructions. Defaults to error when --mode is omitted.
    • @coderabbitai ignore pre-merge checks to override pre-merge checks and get an approval on PR.
    • @coderabbitai run pre-merge checks to run pre-merge checks on the pull request.
  • @coderabbitai generate sequence diagram to generate a sequence diagram of the changes in this PR.
  • @coderabbitai resolve to resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore or @coderabbit ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit configuration file (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, support, documentation and community

  • Visit our status page to check the current availability of CodeRabbit.
  • Create a ticket on our support page for assistance with any issues or questions.
  • Visit our documentation site for detailed information on how to use CodeRabbit.
  • Join our Discord community to connect with other users and get help from the community.
  • Follow us on X/Twitter for updates and announcements.

coderabbitai[bot]
coderabbitai bot previously approved these changes Nov 1, 2025
@kkissdev
Copy link
Author

kkissdev commented Nov 2, 2025

@coderabbitai generate docstrings

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 2, 2025

Warning

Docstrings generation is disabled for your repository or organization.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 2, 2025

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Read the key files that need docstrings

echo "=== NightModeLayer.ts ==="
cat src/client/graphics/layers/NightModeLayer.ts

echo -e "\n=== UserSettings.ts (relevant portion) ==="
cat src/core/game/UserSettings.ts

echo -e "\n=== UserSettingModal.ts (relevant portion) ==="
cat src/client/UserSettingModal.ts

echo -e "\n=== SettingsModal.ts (relevant portion) ==="
cat src/client/graphics/layers/SettingsModal.ts

echo -e "\n=== Main.ts (relevant portion) ==="
cat src/client/Main.ts

Length of output: 70903


@kkissdev I'll generate comprehensive docstrings for all the Night Mode feature code. Here's the documented version:

NightModeLayer.ts

import { UserSettings } from "../../../core/game/UserSettings";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";

/**
 * Rendering layer that creates a night mode effect with a flashlight-style spotlight
 * following the mouse cursor. When enabled, darkens the entire screen except for
 * an illuminated circular area around the cursor position.
 * 
 * The flashlight effect uses a tile-based rendering approach where brightness
 * decreases with distance from the cursor, creating a smooth gradient falloff.
 */
export class NightModeLayer implements Layer {
  /**
   * RGB color values for the darkness overlay.
   * @private
   */
  private darkenColor: [number, number, number] = [0, 0, 0];
  
  /**
   * Alpha transparency value for the darkness overlay (0.0 - 1.0).
   * Separated from darkenColor for more readable code.
   * @private
   */
  private darkenAlpha: number = 0.8;

  /**
   * Radius of the flashlight effect in game tiles.
   * Determines how large the illuminated area appears around the cursor.
   * @private
   */
  private flashlightRadius: number = 50;

  /**
   * Instance of UserSettings for reading night mode preferences.
   * @private
   */
  private userSettingsInstance = new UserSettings();

  /**
   * Current mouse X coordinate relative to the canvas.
   * @private
   */
  private mouseX: number = 0;
  
  /**
   * Current mouse Y coordinate relative to the canvas.
   * @private
   */
  private mouseY: number = 0;
  
  /**
   * Handles mouse movement events to update the flashlight position.
   * Calculates cursor position relative to the canvas bounding rectangle.
   * 
   * @param event - The mouse movement event
   * @private
   */
  private handleMouseMove(event: MouseEvent) {
    const rect = this.transformHandler.boundingRect();
    this.mouseX = event.clientX - rect.left;
    this.mouseY = event.clientY - rect.top;
  }

  /**
   * Lifecycle method called during layer initialization.
   * Currently unused for this layer.
   */
  init(): void {}
  
  /**
   * Lifecycle method called on each game tick.
   * Currently unused for this layer.
   */
  tick(): void {}
  
  /**
   * Lifecycle method called when a redraw is needed.
   * Currently unused for this layer.
   */
  redraw(): void {}

  /**
   * Creates a new NightModeLayer instance.
   * Applies or removes the "night" CSS class based on user settings
   * and registers a mousemove listener to track cursor position.
   * 
   * @param transformHandler - Handler for canvas transformations and dimensions
   */
  constructor(private transformHandler: TransformHandler) {
    if (this.userSettingsInstance.nightMode()) {
      document.documentElement.classList.add("night");
    } else {
      document.documentElement.classList.remove("night");
    }
    document.addEventListener("mousemove", (e) => this.handleMouseMove(e));
  }

  /**
   * Renders the night mode overlay with flashlight effect.
   * 
   * When night mode is enabled:
   * 1. Fills the entire screen with a dark overlay
   * 2. Iterates over tiles in a grid around the cursor position
   * 3. Calculates distance from each tile to the cursor
   * 4. Renders illuminated tiles with brightness based on distance
   * 
   * The brightness factor creates a gradient falloff, with tiles closer
   * to the cursor appearing brighter than those further away.
   * 
   * @param context - The 2D rendering context to draw on
   */
  renderLayer(context: CanvasRenderingContext2D): void {
    if (!this.userSettingsInstance.nightMode()) return;

    const width = this.transformHandler.width();
    const height = this.transformHandler.boundingRect().height;
    const cellSize = this.transformHandler.scale;

    // Fill the entire screen with dark
    context.fillStyle = `rgba(${this.darkenColor[0]}, ${this.darkenColor[1]}, ${this.darkenColor[2]}, ${this.darkenAlpha})`;
    context.fillRect(0, 0, width, height);

    // Calculate the bounding box for tiles within flashlight radius
    const startX =
      Math.floor(
        Math.max(this.mouseX - this.flashlightRadius * cellSize, 0) / cellSize,
      ) * cellSize;
    const endX =
      Math.ceil(
        Math.min(this.mouseX + this.flashlightRadius * cellSize, width) /
          cellSize,
      ) * cellSize;

    const startY =
      Math.floor(
        Math.max(this.mouseY - this.flashlightRadius * cellSize, 0) / cellSize,
      ) * cellSize;
    const endY =
      Math.ceil(
        Math.min(this.mouseY + this.flashlightRadius * cellSize, height) /
          cellSize,
      ) * cellSize;

    // Render illuminated tiles in a grid pattern
    for (let y = startY; y < endY; y += cellSize) {
      for (let x = startX; x < endX; x += cellSize) {
        // Calculate distance from mouse in tile units
        const dist = Math.hypot(
          (this.mouseX - (x + cellSize / 2)) / cellSize,
          (this.mouseY - (y + cellSize / 2)) / cellSize,
        );

        // Determine brightness factor based on distance (0 at edge, 1 at center)
        const brightness = Math.max(0, 1 - dist / this.flashlightRadius);

        if (brightness > 0) {
          // Render illuminated tile with warm yellowish tint
          context.fillStyle = `rgba(200,200,130,${(this.darkenAlpha / 2) * brightness})`;
          context.fillRect(x, y, cellSize, cellSize);
        }
      }
    }
  }
}

UserSettings.ts (new methods only)

/**
 * Gets the current night mode setting from localStorage.
 * Night mode creates a flashlight effect that darkens the map except
 * for an illuminated area around the cursor.
 * 
 * @returns {boolean} True if night mode is enabled, false otherwise. Defaults to false.
 */
nightMode() {
  return this.get("settings.nightMode", false);
}

/**
 * Toggles the night mode setting and updates the document root CSS class.
 * When enabled, adds the "night" class to enable night mode styling.
 * When disabled, removes the "night" class to restore normal appearance.
 * 
 * The setting is persisted to localStorage for future sessions.
 */
toggleNightMode() {
  this.set("settings.nightMode", !this.nightMode());
  if (this.nightMode()) {
    document.documentElement.classList.add("night");
  } else {
    document.documentElement.classList.remove("night");
  }
}

UserSettingModal.ts (new method only)

/**
 * Handles the toggle event for the Night Mode setting.
 * 
 * Validates the toggle payload, updates the localStorage setting,
 * applies or removes the "night" CSS class on the document root,
 * and dispatches a custom "night-mode-changed" event to notify
 * other components of the state change.
 * 
 * @param e - Custom event containing the checked state in e.detail.checked
 */
toggleNightMode(e: CustomEvent<{ checked: boolean }>) {
  const enabled = e.detail?.checked;

  if (typeof enabled !== "boolean") {
    console.warn("Unexpected toggle event payload", e);
    return;
  }
  this.userSettings.set("settings.nightMode", enabled);

  if (enabled) {
    document.documentElement.classList.add("night");
  } else {
    document.documentElement.classList.remove("night");
  }

  this.dispatchEvent(
    new CustomEvent("night-mode-changed", {
      detail: { nightMode: enabled },
      bubbles: true,
      composed: true,
    }),
  );
}

SettingsModal.ts (new method only)

/**
 * Handles the Night Mode toggle button click in the in-game settings modal.
 * 
 * Toggles the night mode setting via UserSettings, emits a RefreshGraphicsEvent
 * to update the rendering layers, and requests a UI update to reflect the new state.
 * 
 * @private
 */
private onToggleNightModeButtonClick() {
  this.userSettings.toggleNightMode();
  this.eventBus.emit(new RefreshGraphicsEvent());
  this.requestUpdate();
}

GameRenderer.ts (import section comment)

/**
 * Night mode rendering layer that creates a flashlight effect
 * with darkness overlay and illuminated cursor area.
 */
import { NightModeLayer } from "./layers/NightModeLayer";

These docstrings follow TypeScript/JSDoc conventions and provide clear explanations of:

  • What each component does
  • Parameter types and purposes
  • Return values
  • Side effects (DOM manipulation, events)
  • Implementation details for complex logic

The documentation makes it easy for future developers to understand the Night Mode feature architecture and behavior.

@kkissdev
Copy link
Author

kkissdev commented Nov 2, 2025

@coderabbitai What tests could I add to make sure this functions optimally?

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 2, 2025

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Examine the test directory structure and existing test patterns
echo "=== Test directory structure ==="
fd -t f -e test.ts -e spec.ts . test/ 2>/dev/null || fd -t f . test/ | head -20

echo -e "\n=== Sample test file to understand patterns ==="
# Find a test file that might be similar (settings or layer tests)
test_file=$(fd -t f -e test.ts -e spec.ts . test/ | grep -i "setting\|layer\|render" | head -1)
if [ -n "$test_file" ]; then
  echo "Found: $test_file"
  cat "$test_file"
else
  # Just grab any test file to see the pattern
  test_file=$(fd -t f -e test.ts -e spec.ts . test/ | head -1)
  if [ -n "$test_file" ]; then
    echo "Found: $test_file"
    cat "$test_file"
  fi
fi

echo -e "\n=== Check for testing framework and configuration ==="
cat package.json | jq -r '.devDependencies | to_entries[] | select(.key | test("test|jest|mocha|vitest|jasmine")) | "\(.key): \(.value)"'

echo -e "\n=== Check test configuration files ==="
ls -la | grep -E "jest|vitest|karma|mocha"

Length of output: 679


🏁 Script executed:

#!/bin/bash
# Find test files in the repository
echo "=== Finding test files in the repository ==="
fd -t f -e test.ts -e spec.ts . | head -20

echo -e "\n=== Jest configuration ==="
cat jest.config.ts

echo -e "\n=== Check for __tests__ directories ==="
fd -t d __tests__

echo -e "\n=== Sample existing test file ==="
test_file=$(fd -t f -e test.ts -e spec.ts . | head -1)
if [ -n "$test_file" ]; then
  echo "Found: $test_file"
  cat "$test_file"
else
  echo "No test files found in repository"
fi

echo -e "\n=== Check UserSettings class for testing context ==="
cat src/core/game/UserSettings.ts | head -50

Length of output: 7227


@kkissdev Great question! Here are comprehensive test recommendations for the Night Mode feature, organized by category and following your project's Jest patterns:

1. Unit Tests for UserSettings

Create tests/UserSettings.test.ts:

import { UserSettings } from "../src/core/game/UserSettings";

describe("UserSettings - Night Mode", () => {
  let userSettings: UserSettings;
  
  beforeEach(() => {
    // Mock localStorage
    const localStorageMock = (() => {
      let store: { [key: string]: string } = {};
      return {
        getItem: (key: string) => store[key] || null,
        setItem: (key: string, value: string) => { store[key] = value; },
        clear: () => { store = {}; },
      };
    })();
    Object.defineProperty(global, 'localStorage', { value: localStorageMock, writable: true });
    
    // Mock document.documentElement.classList
    const classListMock = {
      add: jest.fn(),
      remove: jest.fn(),
    };
    Object.defineProperty(document.documentElement, 'classList', {
      value: classListMock,
      writable: true,
    });
    
    userSettings = new UserSettings();
    localStorage.clear();
  });

  test("nightMode returns false by default", () => {
    expect(userSettings.nightMode()).toBe(false);
  });

  test("nightMode returns stored value from localStorage", () => {
    localStorage.setItem("settings.nightMode", "true");
    expect(userSettings.nightMode()).toBe(true);
    
    localStorage.setItem("settings.nightMode", "false");
    expect(userSettings.nightMode()).toBe(false);
  });

  test("toggleNightMode enables night mode when disabled", () => {
    expect(userSettings.nightMode()).toBe(false);
    
    userSettings.toggleNightMode();
    
    expect(userSettings.nightMode()).toBe(true);
    expect(document.documentElement.classList.add).toHaveBeenCalledWith("night");
  });

  test("toggleNightMode disables night mode when enabled", () => {
    localStorage.setItem("settings.nightMode", "true");
    
    userSettings.toggleNightMode();
    
    expect(userSettings.nightMode()).toBe(false);
    expect(document.documentElement.classList.remove).toHaveBeenCalledWith("night");
  });

  test("toggleNightMode persists state to localStorage", () => {
    userSettings.toggleNightMode();
    expect(localStorage.getItem("settings.nightMode")).toBe("true");
    
    userSettings.toggleNightMode();
    expect(localStorage.getItem("settings.nightMode")).toBe("false");
  });
});

2. Unit Tests for NightModeLayer

Create tests/NightModeLayer.test.ts:

import { NightModeLayer } from "../src/client/graphics/layers/NightModeLayer";
import { TransformHandler } from "../src/client/graphics/TransformHandler";

describe("NightModeLayer", () => {
  let layer: NightModeLayer;
  let mockTransformHandler: jest.Mocked<TransformHandler>;
  let mockContext: jest.Mocked<CanvasRenderingContext2D>;
  
  beforeEach(() => {
    // Mock localStorage
    const localStorageMock = (() => {
      let store: { [key: string]: string } = {};
      return {
        getItem: (key: string) => store[key] || null,
        setItem: (key: string, value: string) => { store[key] = value; },
        clear: () => { store = {}; },
      };
    })();
    Object.defineProperty(global, 'localStorage', { value: localStorageMock, writable: true });
    
    // Mock document
    const classListMock = {
      add: jest.fn(),
      remove: jest.fn(),
    };
    Object.defineProperty(document.documentElement, 'classList', {
      value: classListMock,
      writable: true,
    });
    
    // Mock addEventListener
    document.addEventListener = jest.fn();
    
    // Mock TransformHandler
    mockTransformHandler = {
      width: jest.fn().mockReturnValue(1920),
      boundingRect: jest.fn().mockReturnValue({ 
        left: 0, 
        top: 0, 
        height: 1080 
      }),
      scale: 20,
    } as any;
    
    // Mock Canvas Context
    mockContext = {
      fillStyle: "",
      fillRect: jest.fn(),
    } as any;
    
    localStorage.clear();
  });

  test("adds 'night' class to document when night mode enabled", () => {
    localStorage.setItem("settings.nightMode", "true");
    
    layer = new NightModeLayer(mockTransformHandler);
    
    expect(document.documentElement.classList.add).toHaveBeenCalledWith("night");
  });

  test("removes 'night' class from document when night mode disabled", () => {
    localStorage.setItem("settings.nightMode", "false");
    
    layer = new NightModeLayer(mockTransformHandler);
    
    expect(document.documentElement.classList.remove).toHaveBeenCalledWith("night");
  });

  test("registers mousemove event listener on construction", () => {
    layer = new NightModeLayer(mockTransformHandler);
    
    expect(document.addEventListener).toHaveBeenCalledWith(
      "mousemove",
      expect.any(Function)
    );
  });

  test("does not render when night mode disabled", () => {
    localStorage.setItem("settings.nightMode", "false");
    layer = new NightModeLayer(mockTransformHandler);
    
    layer.renderLayer(mockContext);
    
    expect(mockContext.fillRect).not.toHaveBeenCalled();
  });

  test("renders dark overlay when night mode enabled", () => {
    localStorage.setItem("settings.nightMode", "true");
    layer = new NightModeLayer(mockTransformHandler);
    
    layer.renderLayer(mockContext);
    
    // Should fill entire screen with dark overlay
    expect(mockContext.fillRect).toHaveBeenCalledWith(0, 0, 1920, 1080);
    expect(mockContext.fillStyle).toContain("rgba(0, 0, 0, 0.8)");
  });

  test("renders flashlight effect around mouse position", () => {
    localStorage.setItem("settings.nightMode", "true");
    layer = new NightModeLayer(mockTransformHandler);
    
    // Simulate mouse move to center
    const mouseEvent = new MouseEvent("mousemove", {
      clientX: 960,
      clientY: 540,
    });
    document.dispatchEvent(mouseEvent);
    
    layer.renderLayer(mockContext);
    
    // Should render multiple tiles (dark overlay + illuminated tiles)
    expect(mockContext.fillRect).toHaveBeenCalled();
    expect(mockContext.fillRect.mock.calls.length).toBeGreaterThan(1);
  });
});

3. Integration Tests for UserSettingModal

Create tests/UserSettingModal.test.ts:

import { UserSettingModal } from "../src/client/UserSettingModal";

describe("UserSettingModal - Night Mode", () => {
  let modal: UserSettingModal;
  
  beforeEach(() => {
    // Mock localStorage
    const localStorageMock = (() => {
      let store: { [key: string]: string } = {};
      return {
        getItem: (key: string) => store[key] || null,
        setItem: (key: string, value: string) => { store[key] = value; },
        clear: () => { store = {}; },
      };
    })();
    Object.defineProperty(global, 'localStorage', { value: localStorageMock, writable: true });
    
    // Mock classList
    const classListMock = {
      add: jest.fn(),
      remove: jest.fn(),
    };
    Object.defineProperty(document.documentElement, 'classList', {
      value: classListMock,
      writable: true,
    });
    
    modal = new UserSettingModal();
    localStorage.clear();
  });

  test("toggleNightMode enables night mode on valid toggle event", () => {
    const event = new CustomEvent("toggle", {
      detail: { checked: true },
    });
    
    modal.toggleNightMode(event);
    
    expect(localStorage.getItem("settings.nightMode")).toBe("true");
    expect(document.documentElement.classList.add).toHaveBeenCalledWith("night");
  });

  test("toggleNightMode disables night mode on valid toggle event", () => {
    localStorage.setItem("settings.nightMode", "true");
    const event = new CustomEvent("toggle", {
      detail: { checked: false },
    });
    
    modal.toggleNightMode(event);
    
    expect(localStorage.getItem("settings.nightMode")).toBe("false");
    expect(document.documentElement.classList.remove).toHaveBeenCalledWith("night");
  });

  test("toggleNightMode dispatches night-mode-changed event", () => {
    const dispatchSpy = jest.spyOn(modal, "dispatchEvent");
    const event = new CustomEvent("toggle", {
      detail: { checked: true },
    });
    
    modal.toggleNightMode(event);
    
    expect(dispatchSpy).toHaveBeenCalledWith(
      expect.objectContaining({
        type: "night-mode-changed",
        detail: { nightMode: true },
      })
    );
  });

  test("toggleNightMode handles invalid event payload gracefully", () => {
    const consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation();
    const event = new CustomEvent("toggle", {
      detail: { checked: "invalid" }, // Wrong type
    }) as any;
    
    modal.toggleNightMode(event);
    
    expect(consoleWarnSpy).toHaveBeenCalledWith(
      "Unexpected toggle event payload",
      event
    );
    // Should not modify localStorage
    expect(localStorage.getItem("settings.nightMode")).toBeNull();
    
    consoleWarnSpy.mockRestore();
  });
});

4. Performance Tests

Add to tests/NightModeLayer.test.ts:

describe("NightModeLayer - Performance", () => {
  test("rendering completes within reasonable time for large canvas", () => {
    localStorage.setItem("settings.nightMode", "true");
    
    mockTransformHandler.width.mockReturnValue(3840); // 4K width
    mockTransformHandler.boundingRect.mockReturnValue({ 
      left: 0, 
      top: 0, 
      height: 2160 // 4K height
    });
    
    layer = new NightModeLayer(mockTransformHandler);
    
    const startTime = performance.now();
    layer.renderLayer(mockContext);
    const endTime = performance.now();
    
    // Rendering should complete in under 16ms (60fps)
    expect(endTime - startTime).toBeLessThan(16);
  });

  test("flashlight radius limits tile iteration count", () => {
    localStorage.setItem("settings.nightMode", "true");
    layer = new NightModeLayer(mockTransformHandler);
    
    // Set mouse at edge of screen
    const mouseEvent = new MouseEvent("mousemove", {
      clientX: 0,
      clientY: 0,
    });
    document.dispatchEvent(mouseEvent);
    
    mockContext.fillRect.mockClear();
    layer.renderLayer(mockContext);
    
    const callCount = mockContext.fillRect.mock.calls.length;
    
    // Should only render tiles within flashlight radius, not entire screen
    const maxTilesInRadius = Math.PI * 50 * 50; // π * r²
    expect(callCount).toBeLessThan(maxTilesInRadius * 1.5); // Add buffer for grid cells
  });
});

5. Edge Case Tests

describe("NightModeLayer - Edge Cases", () => {
  test("handles mouse position at canvas boundaries", () => {
    localStorage.setItem("settings.nightMode", "true");
    layer = new NightModeLayer(mockTransformHandler);
    
    // Mouse at far right edge
    const mouseEvent = new MouseEvent("mousemove", {
      clientX: 1920,
      clientY: 1080,
    });
    document.dispatchEvent(mouseEvent);
    
    expect(() => layer.renderLayer(mockContext)).not.toThrow();
  });

  test("handles negative mouse coordinates", () => {
    localStorage.setItem("settings.nightMode", "true");
    layer = new NightModeLayer(mockTransformHandler);
    
    mockTransformHandler.boundingRect.mockReturnValue({
      left: 100,
      top: 100,
      height: 1080,
    });
    
    // Mouse outside canvas (negative relative position)
    const mouseEvent = new MouseEvent("mousemove", {
      clientX: 50,
      clientY: 50,
    });
    document.dispatchEvent(mouseEvent);
    
    expect(() => layer.renderLayer(mockContext)).not.toThrow();
  });

  test("handles zero scale in TransformHandler", () => {
    localStorage.setItem("settings.nightMode", "true");
    mockTransformHandler.scale = 0;
    
    layer = new NightModeLayer(mockTransformHandler);
    
    // Should not cause division by zero
    expect(() => layer.renderLayer(mockContext)).not.toThrow();
  });
});

Test Execution

Run the tests with:

npm test                           # Run all tests
npm test UserSettings              # Run specific test file
npm test -- --coverage             # Generate coverage report
npm test -- --watch                # Watch mode for development

Key Testing Benefits

These tests ensure:

  1. Correctness - Settings persist correctly, CSS classes applied properly
  2. Performance - Rendering stays under 16ms for 60fps
  3. Robustness - Handles edge cases and invalid inputs gracefully
  4. Maintainability - Catches regressions when refactoring
  5. Documentation - Tests serve as usage examples

The tile-based rendering and mouse tracking are the most critical areas for performance testing, as they execute every frame. Focus on ensuring the flashlight radius effectively limits computation.

@kkissdev
Copy link
Author

kkissdev commented Nov 2, 2025

@coderabbitai I want each city in the game to be illuminated (with a pattern I will tinker with) like the satellite images of Earth with all the lights. Where can i find the location of each city?

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 2, 2025

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for City class or interface definitions
echo "=== Searching for City class/interface definitions ==="
ast-grep --pattern 'class City {
  $$$
}'

ast-grep --pattern 'interface City {
  $$$
}'

ast-grep --pattern 'type City = $$$'

echo -e "\n=== Searching for files with 'city' or 'cities' in the name ==="
fd -i city

echo -e "\n=== Searching for city-related data structures with coordinates ==="
rg -nP --type=ts -C3 '\bcity\b.*\b(x|y|lat|lon|latitude|longitude|position|coordinate)\b' -i

echo -e "\n=== Searching for city rendering or drawing code ==="
rg -nP --type=ts -C3 'render.*city|draw.*city|city.*render|city.*draw' -i

echo -e "\n=== Searching for city data in JSON files ==="
fd -e json | xargs rg -l 'city|cities' -i | head -10

Length of output: 2273


🏁 Script executed:

#!/bin/bash
# Get more details on promising files
echo "=== Examining City entity files ==="
fd -i city --type f -e ts | head -10 | while read -r file; do
  echo "--- File: $file ---"
  cat "$file" | head -100
  echo ""
done

echo -e "\n=== Searching for collections/arrays of cities ==="
rg -nP --type=ts -C5 'cities\s*[:=]|CITIES\s*[:=]|City\[\]' -i

echo -e "\n=== Looking for game entities that might include cities ==="
rg -nP --type=ts -C3 'class.*Entity|interface.*Entity' | grep -A10 -i city

Length of output: 8174


🏁 Script executed:

#!/bin/bash
# Find Unit class definition and its properties
echo "=== Searching for Unit class definition ==="
ast-grep --pattern 'class Unit {
  $$$
}'

echo -e "\n=== Searching for Unit interface/type ==="
ast-grep --pattern 'interface Unit {
  $$$
}'

echo -e "\n=== Looking for methods to get units from game/player ==="
rg -nP --type=ts -C3 'units\(\)|getUnits|allUnits|getUnit' | head -100

echo -e "\n=== Checking Game.ts for unit-related methods ==="
cat src/core/game/Game.ts | grep -A5 -B5 'units\|Unit'

Length of output: 17744


🏁 Script executed:

#!/bin/bash
# Find tile/position properties on Unit
echo "=== Looking for tile/position methods on Unit ==="
rg -nP --type=ts -C3 'tile\(\)|position\(\)|getPosition|x\(\)|y\(\)' src/core/game/Game.ts

echo -e "\n=== Examining TileRef structure ==="
rg -nP --type=ts -C5 'type TileRef|interface TileRef|class TileRef'

echo -e "\n=== Check how cities are iterated in existing code ==="
rg -nP --type=ts -C5 'UnitType\.City' | head -150

Length of output: 12295


🏁 Script executed:

#!/bin/bash
# Look at GameView and Player classes to understand the API
echo "=== Examining Player class API ==="
ast-grep --pattern 'class Player {
  $$$
}' | head -200

echo -e "\n=== Looking for methods that return all units ==="
rg -nP --type=ts 'units\s*\(\s*\).*\{' -A10

echo -e "\n=== Check GameMap for tile coordinates ==="
rg -nP --type=ts -C5 'class GameMap|interface GameMap' | head -100

Length of output: 7568


@kkissdev Perfect! I can help you find where cities are located so you can add illumination to them. Here's what you need to know:

City Location Information

Cities are represented as Unit objects in the game. Here's how to access them:

Key Methods:

  • game.units(UnitType.City) - Returns all city units in the game
  • city.tile() - Returns the TileRef (tile position) for a city
  • game.x(tileRef) and game.y(tileRef) - Convert TileRef to x,y coordinates

Implementation in NightModeLayer

You'll need to add game access to your NightModeLayer. Here's the updated implementation:

import { UserSettings } from "../../../core/game/UserSettings";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
import { UnitType } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";

export class NightModeLayer implements Layer {
  private darkenColor: [number, number, number] = [0, 0, 0];
  private darkenAlpha: number = 0.8;
  private flashlightRadius: number = 50;
  private userSettingsInstance = new UserSettings();
  private mouseX: number = 0;
  private mouseY: number = 0;
  
  // Add game reference
  private game: GameView | null = null;

  private handleMouseMove(event: MouseEvent) {
    const rect = this.transformHandler.boundingRect();
    this.mouseX = event.clientX - rect.left;
    this.mouseY = event.clientY - rect.top;
  }

  init(): void {}
  tick(): void {}
  redraw(): void {}

  constructor(
    private transformHandler: TransformHandler,
    game?: GameView  // Add game parameter
  ) {
    this.game = game || null;
    if (this.userSettingsInstance.nightMode()) {
      document.documentElement.classList.add("night");
    } else {
      document.documentElement.classList.remove("night");
    }
    document.addEventListener("mousemove", (e) => this.handleMouseMove(e));
  }
  
  // New method to set game reference after construction
  setGame(game: GameView) {
    this.game = game;
  }

  renderLayer(context: CanvasRenderingContext2D): void {
    if (!this.userSettingsInstance.nightMode()) return;

    const width = this.transformHandler.width();
    const height = this.transformHandler.boundingRect().height;
    const cellSize = this.transformHandler.scale;

    // Fill the entire screen with dark
    context.fillStyle = `rgba(${this.darkenColor[0]}, ${this.darkenColor[1]}, ${this.darkenColor[2]}, ${this.darkenAlpha})`;
    context.fillRect(0, 0, width, height);

    // ===== NEW: Render city lights =====
    if (this.game) {
      this.renderCityLights(context, cellSize);
    }

    // Render flashlight effect around mouse
    this.renderFlashlight(context, width, height, cellSize);
  }

  /**
   * Renders illumination for all cities on the map.
   * Creates a glow effect similar to satellite images of Earth at night.
   */
  private renderCityLights(
    context: CanvasRenderingContext2D,
    cellSize: number
  ): void {
    // Get all cities in the game
    const cities = this.game!.units(UnitType.City);

    for (const city of cities) {
      // Get city position
      const tileRef = city.tile();
      const cityX = this.game!.x(tileRef);
      const cityY = this.game!.y(tileRef);

      // Convert tile coordinates to screen coordinates
      const screenX = cityX * cellSize;
      const screenY = cityY * cellSize;

      // Get city level for scaling the light effect
      const cityLevel = city.level();

      // Render city glow - you can customize this pattern
      this.renderCityGlow(context, screenX, screenY, cellSize, cityLevel);
    }
  }

  /**
   * Renders a glow effect for a single city.
   * Customize this method to achieve your desired lighting pattern.
   */
  private renderCityGlow(
    context: CanvasRenderingContext2D,
    x: number,
    y: number,
    cellSize: number,
    level: number
  ): void {
    // Example 1: Simple bright square (like a satellite image)
    const lightRadius = 2 + level; // Larger cities have bigger glow
    
    for (let dy = -lightRadius; dy <= lightRadius; dy++) {
      for (let dx = -lightRadius; dx <= lightRadius; dx++) {
        const distance = Math.sqrt(dx * dx + dy * dy);
        if (distance <= lightRadius) {
          // Brightness decreases with distance
          const brightness = 1 - (distance / lightRadius);
          const alpha = (this.darkenAlpha * 0.8) * brightness;
          
          context.fillStyle = `rgba(255, 220, 150, ${alpha})`;
          context.fillRect(
            x + dx * cellSize,
            y + dy * cellSize,
            cellSize,
            cellSize
          );
        }
      }
    }

    // Example 2: Add a brighter core
    context.fillStyle = `rgba(255, 255, 200, ${this.darkenAlpha * 0.9})`;
    context.fillRect(x, y, cellSize, cellSize);
  }

  /**
   * Renders the flashlight effect around the mouse cursor.
   * Extracted from original renderLayer for better organization.
   */
  private renderFlashlight(
    context: CanvasRenderingContext2D,
    width: number,
    height: number,
    cellSize: number
  ): void {
    const startX =
      Math.floor(
        Math.max(this.mouseX - this.flashlightRadius * cellSize, 0) / cellSize,
      ) * cellSize;
    const endX =
      Math.ceil(
        Math.min(this.mouseX + this.flashlightRadius * cellSize, width) /
          cellSize,
      ) * cellSize;

    const startY =
      Math.floor(
        Math.max(this.mouseY - this.flashlightRadius * cellSize, 0) / cellSize,
      ) * cellSize;
    const endY =
      Math.ceil(
        Math.min(this.mouseY + this.flashlightRadius * cellSize, height) /
          cellSize,
      ) * cellSize;

    for (let y = startY; y < endY; y += cellSize) {
      for (let x = startX; x < endX; x += cellSize) {
        const dist = Math.hypot(
          (this.mouseX - (x + cellSize / 2)) / cellSize,
          (this.mouseY - (y + cellSize / 2)) / cellSize,
        );

        const brightness = Math.max(0, 1 - dist / this.flashlightRadius);

        if (brightness > 0) {
          context.fillStyle = `rgba(200,200,130,${(this.darkenAlpha / 2) * brightness})`;
          context.fillRect(x, y, cellSize, cellSize);
        }
      }
    }
  }
}

Update GameRenderer.ts

You'll need to pass the game instance when creating the layer:

// In src/client/graphics/GameRenderer.ts
const nightModeLayer = new NightModeLayer(transformHandler);

// Later, when game is available (in a method that has access to game):
nightModeLayer.setGame(game);

Alternative Lighting Patterns

Here are some pattern ideas you can experiment with:

Pattern 1: Radial Gradient (Soft Glow)

private renderCityGlow(context: CanvasRenderingContext2D, x: number, y: number, cellSize: number, level: number): void {
  const gradient = context.createRadialGradient(
    x + cellSize / 2, y + cellSize / 2, 0,
    x + cellSize / 2, y + cellSize / 2, cellSize * (2 + level)
  );
  gradient.addColorStop(0, 'rgba(255, 230, 150, 0.9)');
  gradient.addColorStop(0.5, 'rgba(255, 200, 100, 0.5)');
  gradient.addColorStop(1, 'rgba(255, 180, 80, 0)');
  
  context.fillStyle = gradient;
  context.fillRect(x - cellSize * 2, y - cellSize * 2, cellSize * 5, cellSize * 5);
}

Pattern 2: Grid Pattern (Street Lights)

private renderCityGlow(context: CanvasRenderingContext2D, x: number, y: number, cellSize: number, level: number): void {
  const gridSize = 1 + level;
  for (let i = -gridSize; i <= gridSize; i++) {
    for (let j = -gridSize; j <= gridSize; j++) {
      if ((i + j) % 2 === 0) { // Checkerboard pattern
        context.fillStyle = 'rgba(255, 220, 120, 0.7)';
        context.fillRect(x + i * cellSize, y + j * cellSize, cellSize, cellSize);
      }
    }
  }
}

Pattern 3: Pulsing Effect (Animated)

private renderCityGlow(context: CanvasRenderingContext2D, x: number, y: number, cellSize: number, level: number): void {
  // Use game tick or timestamp for animation
  const pulse = (Math.sin(Date.now() / 1000) + 1) / 2; // 0 to 1
  const alpha = 0.5 + pulse * 0.4;
  
  context.fillStyle = `rgba(255, 220, 150, ${alpha})`;
  context.fillRect(x, y, cellSize * (1 + level), cellSize * (1 + level));
}

Adding Lights to SAMs and Nukes

Similarly, you can add lights to other structures based on their height in trajectory:

private renderStructureLights(context: CanvasRenderingContext2D, cellSize: number): void {
  // SAM Launchers
  const sams = this.game!.units(UnitType.SAMLauncher);
  for (const sam of sams) {
    const tileRef = sam.tile();
    const x = this.game!.x(tileRef) * cellSize;
    const y = this.game!.y(tileRef) * cellSize;
    
    // Red warning lights
    context.fillStyle = 'rgba(255, 50, 50, 0.6)';
    context.fillRect(x, y, cellSize, cellSize);
  }
  
  // Nukes in flight (if above certain height)
  const nukes = this.game!.units(
    UnitType.AtomBomb,
    UnitType.HydrogenBomb,
    UnitType.MIRV
  );
  
  for (const nuke of nukes) {
    const trajectoryIndex = nuke.trajectoryIndex();
    const trajectory = nuke.trajectory();
    
    if (trajectoryIndex < trajectory.length) {
      const currentTile = trajectory[trajectoryIndex].tile;
      const x = this.game!.x(currentTile) * cellSize;
      const y = this.game!.y(currentTile) * cellSize;
      
      // Bright white glow for nukes
      context.fillStyle = 'rgba(255, 255, 255, 0.9)';
      context.fillRect(x - cellSize, y - cellSize, cellSize * 3, cellSize * 3);
    }
  }
}

This gives you complete access to all city locations and their coordinates. You can now tinker with the lighting patterns to achieve the satellite image effect you're looking for!

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 7

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ee8bb56 and d688bb0.

⛔ Files ignored due to path filters (1)
  • resources/images/NightModeIconWhite.svg is excluded by !**/*.svg
📒 Files selected for processing (7)
  • .coderabbit.yaml (1 hunks)
  • resources/lang/en.json (1 hunks)
  • src/client/graphics/GameRenderer.ts (2 hunks)
  • src/client/graphics/layers/SettingsModal.ts (3 hunks)
  • tests/NightModeLayer.test.ts (1 hunks)
  • tests/NightModeUserSettingModal.test.ts (1 hunks)
  • tests/NightModeUserSettings.test.ts (1 hunks)
✅ Files skipped from review due to trivial changes (1)
  • .coderabbit.yaml
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/client/graphics/layers/SettingsModal.ts
  • src/client/graphics/GameRenderer.ts
  • resources/lang/en.json
🧰 Additional context used
🧬 Code graph analysis (2)
tests/NightModeUserSettings.test.ts (1)
src/core/game/UserSettings.ts (1)
  • UserSettings (6-214)
tests/NightModeLayer.test.ts (2)
src/client/graphics/layers/NightModeLayer.ts (1)
  • NightModeLayer (6-84)
src/client/graphics/TransformHandler.ts (1)
  • TransformHandler (15-295)
🪛 GitHub Actions: 🧪 CI
tests/NightModeUserSettings.test.ts

[error] Jest test environment is not configured for DOM manipulation; reference errors: 'document is not defined' in tests that attempt to access DOM APIs.

tests/NightModeUserSettingModal.test.ts

[error] Support for ES module syntax (import statements) missing in Jest configuration, causing syntax error: Cannot use import statement outside a module.

Comment on lines +110 to +126
test("renders flashlight effect around mouse position", () => {
localStorage.setItem("settings.nightMode", "true");
layer = new NightModeLayer(mockTransformHandler);

// Simulate mouse move to center
const mouseEvent = new MouseEvent("mousemove", {
clientX: 960,
clientY: 540,
});
document.dispatchEvent(mouseEvent);

layer.renderLayer(mockContext);

// Should render multiple tiles (dark overlay + illuminated tiles)
expect(mockContext.fillRect).toHaveBeenCalled();
expect(mockContext.fillRect.mock.calls.length).toBeGreaterThan(1);
});
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 | 🟠 Major

Mouse event test doesn't properly verify flashlight position.

The test mocks document.addEventListener at line 39, which prevents the layer's mousemove handler from being registered. When document.dispatchEvent(mouseEvent) is called at line 119, the event isn't handled, so the mouse position remains at (0, 0) rather than the intended (960, 540).

To properly test mouse tracking, store the listener and invoke it directly:

+    let mouseMoveHandler: ((event: MouseEvent) => void) | undefined;
+    
     // Mock addEventListener
-    document.addEventListener = jest.fn();
+    document.addEventListener = jest.fn((event: string, handler: any) => {
+      if (event === 'mousemove') {
+        mouseMoveHandler = handler;
+      }
+    });

     // ... in beforeEach ...

   test("renders flashlight effect around mouse position", () => {
     localStorage.setItem("settings.nightMode", "true");
     layer = new NightModeLayer(mockTransformHandler);

     // Simulate mouse move to center
     const mouseEvent = new MouseEvent("mousemove", {
       clientX: 960,
       clientY: 540,
     });
-    document.dispatchEvent(mouseEvent);
+    mouseMoveHandler?.(mouseEvent);

     layer.renderLayer(mockContext);

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In tests/NightModeLayer.test.ts around lines 110 to 126, the mousemove
dispatched event isn't reaching the layer because document.addEventListener was
mocked earlier and prevented the handler from being registered; capture the
listener when addEventListener is mocked (save the callback stored for the
"mousemove" event) and invoke that saved listener directly with a fabricated
event having clientX: 960 and clientY: 540 (or stop mocking addEventListener so
the real handler registers) before calling layer.renderLayer(mockContext) so the
layer's internal mouse position is updated and the assertions verify the
flashlight at the intended coordinates.

Comment on lines +127 to +148
test("rendering completes within reasonable time for large canvas", () => {
localStorage.setItem("settings.nightMode", "true");

mockTransformHandler = {
width: jest.fn().mockReturnValue(3840), // 4k
boundingRect: jest.fn().mockReturnValue({
left: 0,
top: 0,
height: 2160, // 4k
}),
scale: 20,
} as any;

layer = new NightModeLayer(mockTransformHandler);

const startTime = performance.now();
layer.renderLayer(mockContext);
const endTime = performance.now();

// Rendering should complete in under 16ms (60fps)
expect(endTime - startTime).toBeLessThan(16);
});
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 | 🟠 Major

Performance test with hard-coded threshold is fragile.

The test expects rendering to complete in under 16ms, but this threshold is environment-dependent and will cause flaky failures on slower CI runners or under system load.

Consider one of these alternatives:

  1. Remove the hard threshold and make it informational:
-    // Rendering should complete in under 16ms (60fps)
-    expect(endTime - startTime).toBeLessThan(16);
+    // Log rendering time for informational purposes
+    console.log(`Rendering time: ${endTime - startTime}ms`);
  1. Use a much more generous threshold:
-    expect(endTime - startTime).toBeLessThan(16);
+    // Ensure rendering doesn't hang (generous threshold for CI)
+    expect(endTime - startTime).toBeLessThan(100);
  1. Move to a separate performance test suite that runs optionally and reports metrics rather than asserting hard limits.
📝 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
test("rendering completes within reasonable time for large canvas", () => {
localStorage.setItem("settings.nightMode", "true");
mockTransformHandler = {
width: jest.fn().mockReturnValue(3840), // 4k
boundingRect: jest.fn().mockReturnValue({
left: 0,
top: 0,
height: 2160, // 4k
}),
scale: 20,
} as any;
layer = new NightModeLayer(mockTransformHandler);
const startTime = performance.now();
layer.renderLayer(mockContext);
const endTime = performance.now();
// Rendering should complete in under 16ms (60fps)
expect(endTime - startTime).toBeLessThan(16);
});
test("rendering completes within reasonable time for large canvas", () => {
localStorage.setItem("settings.nightMode", "true");
mockTransformHandler = {
width: jest.fn().mockReturnValue(3840), // 4k
boundingRect: jest.fn().mockReturnValue({
left: 0,
top: 0,
height: 2160, // 4k
}),
scale: 20,
} as any;
layer = new NightModeLayer(mockTransformHandler);
const startTime = performance.now();
layer.renderLayer(mockContext);
const endTime = performance.now();
// Log rendering time for informational purposes
console.log(`Rendering time: ${endTime - startTime}ms`);
});
🤖 Prompt for AI Agents
In tests/NightModeLayer.test.ts around lines 127-148, the test uses a hard 16ms
render threshold which is flaky on CI; change the test to avoid asserting a
strict 16ms limit: compute duration as now, then if an env var like
RUN_PERF_TESTS is set perform a much more generous assertion (e.g.,
expect(duration).toBeLessThan(200)) for optional/perf runs, otherwise remove the
failing assertion and replace it with a console.info/log of the duration (or a
non-failing expect that duration is a finite number) so normal unit runs never
fail due to environment-dependent timing.

Comment on lines +150 to +169
test("flashlight radius limits tile iteration count", () => {
localStorage.setItem("settings.nightMode", "true");
layer = new NightModeLayer(mockTransformHandler);

// Set mouse at edge of screen
const mouseEvent = new MouseEvent("mousemove", {
clientX: 0,
clientY: 0,
});
document.dispatchEvent(mouseEvent);

mockContext.fillRect.mockClear();
layer.renderLayer(mockContext);

const callCount = mockContext.fillRect.mock.calls.length;

// Should only render tiles within flashlight radius, not entire screen
const maxTilesInRadius = Math.PI * 50 * 50; // π * r²
expect(callCount).toBeLessThan(maxTilesInRadius * 1.5); // Add buffer for grid cells
});
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 | 🟠 Major

Same event handling issue as previous test.

The mousemove event dispatched at line 159 won't be handled due to the mocked addEventListener. The mouse position will remain at (0, 0) instead of the intended edge position.

Apply the same fix as suggested for lines 110-126: capture the listener in the mock and invoke it directly rather than using document.dispatchEvent.

Additionally, the assertion at line 168 uses a 1.5x buffer which seems arbitrary. Consider either:

  • Tightening the bound if you want to verify optimization (e.g., 1.1x)
  • Using a more principled upper bound based on the grid layout (tiles are rectangular, not circular)
  • Adding a comment explaining why 1.5x is the expected overhead
🤖 Prompt for AI Agents
In tests/NightModeLayer.test.ts around lines 150 to 169, the dispatched
MouseEvent won't reach the handler because addEventListener is mocked; capture
the mousemove listener from the mock addEventListener when setting up the test
and invoke that captured callback directly with a synthetic event having
clientX/clientY at the screen edge so the layer sees the intended mouse
position; then adjust the assertion guard — either tighten the multiplier to 1.1
if you want a stricter bound or replace the magic 1.5x with a computed upper
bound based on tile width/height (or add a comment explaining why 1.5x is
acceptable) and update the expect accordingly.

Comment on lines +170 to +182
test("handles mouse position at canvas boundaries", () => {
localStorage.setItem("settings.nightMode", "true");
layer = new NightModeLayer(mockTransformHandler);

// Mouse at far right edge
const mouseEvent = new MouseEvent("mousemove", {
clientX: 1920,
clientY: 1080,
});
document.dispatchEvent(mouseEvent);

expect(() => layer.renderLayer(mockContext)).not.toThrow();
});
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 | 🟠 Major

Event handling issue affects boundary test.

Same issue: the mousemove event at line 179 won't update the layer's internal mouse position due to the mocked addEventListener. Apply the listener-capturing fix suggested for lines 110-126.

🤖 Prompt for AI Agents
In tests/NightModeLayer.test.ts around lines 170 to 182, the dispatched
mousemove event doesn't update the layer's internal mouse position because the
test's mocked addEventListener doesn't store/invoke the registered handler;
update the mock like you did for lines 110-126 by capturing the event listener
passed into document.addEventListener (store it in a variable) and, before
dispatching the MouseEvent, call that stored handler with a synthetic event (or
ensure dispatchEvent routes to the stored handler) so the layer's mouse position
is updated and the boundary test can exercise renderLayer without throwing.

Comment on lines 1 to 99
import { UserSettingModal } from "../src/client/UserSettingModal";

describe("UserSettingModal - Night Mode", () => {
let modal: UserSettingModal;

beforeEach(() => {
// Mock localStorage
const localStorageMock = (() => {
let store: { [key: string]: string } = {};
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => {
store[key] = value;
},
clear: () => {
store = {};
},
};
})();
Object.defineProperty(global, "localStorage", {
value: localStorageMock,
writable: true,
});

// Mock classList
const classListMock = {
add: jest.fn(),
remove: jest.fn(),
};
Object.defineProperty(document.documentElement, "classList", {
value: classListMock,
writable: true,
});

modal = new UserSettingModal();
localStorage.clear();
});

test("toggleNightMode enables night mode on valid toggle event", () => {
const event = new CustomEvent("toggle", {
detail: { checked: true },
});

modal.toggleNightMode(event);

expect(localStorage.getItem("settings.nightMode")).toBe("true");
expect(document.documentElement.classList.add).toHaveBeenCalledWith(
"night",
);
});

test("toggleNightMode disables night mode on valid toggle event", () => {
localStorage.setItem("settings.nightMode", "true");
const event = new CustomEvent("toggle", {
detail: { checked: false },
});

modal.toggleNightMode(event);

expect(localStorage.getItem("settings.nightMode")).toBe("false");
expect(document.documentElement.classList.remove).toHaveBeenCalledWith(
"night",
);
});

test("toggleNightMode dispatches night-mode-changed event", () => {
const dispatchSpy = jest.spyOn(modal, "dispatchEvent");
const event = new CustomEvent("toggle", {
detail: { checked: true },
});

modal.toggleNightMode(event);

expect(dispatchSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: "night-mode-changed",
detail: { nightMode: true },
}),
);
});

test("toggleNightMode handles invalid event payload gracefully", () => {
const consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation();
const event = new CustomEvent("toggle", {
detail: { checked: "invalid" }, // Wrong type
}) as any;

modal.toggleNightMode(event);

expect(consoleWarnSpy).toHaveBeenCalledWith(
"Unexpected toggle event payload",
event,
);
// Should not modify localStorage
expect(localStorage.getItem("settings.nightMode")).toBeNull();

consoleWarnSpy.mockRestore();
});
});
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 | 🔴 Critical

Configure Jest to support ES module imports and DOM manipulation.

The pipeline failure indicates Jest cannot parse ES module syntax. This requires proper Jest configuration for TypeScript/ES modules plus DOM support.

Add or update your Jest configuration:

+  preset: 'ts-jest',
+  testEnvironment: 'jsdom',
+  transform: {
+    '^.+\\.tsx?$': 'ts-jest',
+  },

Alternatively, if using Babel:

+  testEnvironment: 'jsdom',
+  transform: {
+    '^.+\\.(js|jsx|ts|tsx)$': 'babel-jest',
+  },

Ensure the necessary dependencies are installed: npm install -D jest ts-jest @types/jest jsdom (or equivalent for Babel).

Committable suggestion skipped: line range outside the PR's diff.

🧰 Tools
🪛 GitHub Actions: 🧪 CI

[error] Support for ES module syntax (import statements) missing in Jest configuration, causing syntax error: Cannot use import statement outside a module.

🤖 Prompt for AI Agents
In tests/NightModeUserSettingModal.test.ts around lines 1 to 99, the test
failures are caused by Jest not configured for ES modules/TypeScript and DOM
APIs; update Jest config to use a transformer (ts-jest or babel-jest) that
handles ES modules/TS, set testEnvironment to "jsdom", and if using Node ESM set
"extensionsToTreatAsEsm" or use babel-jest with "presets":
["@babel/preset-env","@babel/preset-typescript"] so imports parse correctly;
install and add dev-deps (jest, ts-jest or babel-jest, @types/jest, jsdom) and
adjust package.json scripts to run jest; after changing config, re-run tests to
validate DOM APIs (document.documentElement.classList) are supported.

@CLAassistant
Copy link

CLAassistant commented Nov 2, 2025

CLA assistant check
All committers have signed the CLA.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (5)
tests/NightModeLayer.test.ts (5)

145-161: Mousemove event isn't reaching the layer's handler.

The mock at line 50 (document.addEventListener = jest.fn()) prevents the layer's mousemove handler from being registered. When document.dispatchEvent(mouseEvent) is called at line 154, the event isn't handled, leaving the mouse position at (0, 0) rather than the intended (960, 540).

To fix, capture and invoke the listener directly:

+    let mouseMoveHandler: ((event: MouseEvent) => void) | undefined;
+    
     // Mock document
     const classListMock = {
       add: jest.fn(),
       remove: jest.fn(),
     };
     const documentMock = {
       documentElement: {
         classList: classListMock,
       },
-      addEventListener: jest.fn(),
+      addEventListener: jest.fn((event: string, handler: any) => {
+        if (event === 'mousemove') {
+          mouseMoveHandler = handler;
+        }
+      }),
       dispatchEvent: jest.fn(),
     };

Then in the test:

     // Simulate mouse move to center
     const mouseEvent = new MouseEvent("mousemove", {
       clientX: 960,
       clientY: 540,
     });
-    document.dispatchEvent(mouseEvent);
+    mouseMoveHandler?.(mouseEvent);

162-183: Performance threshold is too strict and environment-dependent.

The 16ms threshold at line 182 will cause flaky failures on slower CI runners or under system load, as rendering performance varies significantly across environments.

Consider making this test informational rather than asserting a hard limit:

-    // Rendering should complete in under 16ms (60fps)
-    expect(endTime - startTime).toBeLessThan(16);
+    // Log rendering time for performance tracking
+    const duration = endTime - startTime;
+    console.log(`4K render time: ${duration.toFixed(2)}ms`);
+    // Ensure rendering completes (generous threshold for CI)
+    expect(duration).toBeLessThan(100);

185-204: Same mousemove event handling issue.

The mousemove event dispatched at line 194 won't update the layer's mouse position due to the mocked addEventListener. Apply the same listener-capturing fix suggested for lines 145-161.

Additionally, the 1.5x buffer at line 203 seems arbitrary—consider either tightening it (e.g., 1.1x) or adding a comment explaining why this overhead is expected.


205-217: Mousemove event won't update internal state.

The mousemove event at line 214 won't update the layer's internal mouse position due to the mocked addEventListener. Apply the listener-capturing fix suggested for lines 145-161.


219-241: Mock reassignment after construction has no effect.

The mockTransformHandler is reassigned at lines 223-231 after the layer is constructed at line 221. The layer holds a reference to the original mock, so this reassignment is ineffective.

Either remove the reassignment if the original mock is sufficient:

   test("handles negative mouse coordinates", () => {
     localStorage.setItem("settings.nightMode", "true");
     layer = new NightModeLayer(mockTransformHandler);

-    mockTransformHandler = {
-      width: jest.fn().mockReturnValue(1920),
-      boundingRect: jest.fn().mockReturnValue({
-        left: 0,
-        top: 0,
-        height: 1080,
-      }),
-      scale: 20,
-    } as any;
-
     // Mouse outside canvas (negative relative position)

Or move it before construction if different values are needed:

   test("handles negative mouse coordinates", () => {
     localStorage.setItem("settings.nightMode", "true");
+    
+    mockTransformHandler = {
+      width: jest.fn().mockReturnValue(1920),
+      boundingRect: jest.fn().mockReturnValue({
+        left: 0,
+        top: 0,
+        height: 1080,
+      }),
+      scale: 20,
+    } as any;

     layer = new NightModeLayer(mockTransformHandler);
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d688bb0 and 195ad4d.

📒 Files selected for processing (3)
  • tests/NightModeLayer.test.ts (1 hunks)
  • tests/NightModeUserSettingModal.test.ts (1 hunks)
  • tests/NightModeUserSettings.test.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
tests/NightModeUserSettings.test.ts (1)
src/core/game/UserSettings.ts (1)
  • UserSettings (6-214)
tests/NightModeLayer.test.ts (2)
src/client/graphics/layers/NightModeLayer.ts (1)
  • NightModeLayer (6-84)
src/client/graphics/TransformHandler.ts (1)
  • TransformHandler (15-333)
🪛 Biome (2.1.2)
tests/NightModeUserSettingModal.test.ts

[error] 18-18: Do not shadow the global "constructor" property.

Consider renaming this variable. It's easy to confuse the origin of variables when they're named after a known global.

(lint/suspicious/noShadowRestrictedNames)

🔇 Additional comments (5)
tests/NightModeUserSettings.test.ts (1)

1-86: Test coverage is comprehensive and well-structured.

The test suite effectively validates:

  • Default night mode state
  • Reading from localStorage
  • Toggling behavior with proper classList and localStorage updates
  • State persistence across toggles

The mock setup is clean and isolates the unit under test appropriately.

tests/NightModeUserSettingModal.test.ts (1)

71-131: Test cases thoroughly validate night mode toggle behavior.

The tests properly cover:

  • Valid toggle events (enable/disable)
  • Custom event dispatching with correct detail payload
  • Graceful handling of invalid payloads with warning
tests/NightModeLayer.test.ts (3)

79-106: Tests correctly validate initialization behavior.

These tests properly verify:

  • The "night" CSS class is added/removed based on localStorage state
  • The mousemove event listener is registered during construction

108-143: Rendering tests are well-structured.

The disabled rendering test validates the early return path, and the dark overlay test uses a clever fillStyle tracking approach to verify both the rendering order and the correct RGBA values.


243-251: Good defensive edge case test.

Testing zero scale prevents potential division-by-zero bugs and ensures graceful handling of degenerate transform states.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 195ad4d and c0b6d30.

📒 Files selected for processing (1)
  • tests/NightModeUserSettingModal.test.ts (1 hunks)
🔇 Additional comments (3)
tests/NightModeUserSettingModal.test.ts (3)

1-26: Mock setup looks good; shadowing issue resolved.

The mocks for lit and lit/decorators.js are minimal but sufficient for unit testing UserSettingModal in a Node environment. The parameter shadowing issue from the previous review (line 18) has been correctly addressed by renaming constructor to ctor.


71-112: LGTM! Core scenarios are well covered.

The three main test cases effectively validate:

  • Enabling night mode (localStorage update + CSS class addition)
  • Disabling night mode (localStorage update + CSS class removal)
  • Event dispatch with correct payload

The test structure is clear, and expectations are precise.


32-69: Test setup correctly instantiates UserSettingModal.

Verification confirms that UserSettingModal does not define an explicit constructor and therefore uses the implicit default no-arg constructor. The class extends LitElement and initializes fields at instance creation time. The test setup on line 67 (modal = new UserSettingModal();) is correct and will function as intended with the provided localStorage and document mocks.

Comment on lines +114 to +130
test("toggleNightMode handles invalid event payload gracefully", () => {
const consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation();
const event = new CustomEvent("toggle", {
detail: { checked: "invalid" }, // Wrong type
}) as any;

modal.toggleNightMode(event);

expect(consoleWarnSpy).toHaveBeenCalledWith(
"Unexpected toggle event payload",
event,
);
// Should not modify localStorage
expect(localStorage.getItem("settings.nightMode")).toBeNull();

consoleWarnSpy.mockRestore();
});
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider additional edge case tests for robustness.

The invalid payload test correctly validates defensive behavior (warning + no state change). To further strengthen test coverage, consider adding tests for:

  • event.detail is null or undefined
  • event.detail.checked is other falsy values (null, undefined, 0, "")
  • event itself is null/undefined (if the method is exposed publicly)
  • Toggling night mode multiple times in sequence (idempotency)

These additions would ensure toggleNightMode handles all edge cases gracefully.

Example additional test:

test("toggleNightMode handles missing detail gracefully", () => {
  const consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation();
  const event = new CustomEvent("toggle", {
    detail: undefined,
  }) as any;

  modal.toggleNightMode(event);

  expect(consoleWarnSpy).toHaveBeenCalled();
  expect(localStorage.getItem("settings.nightMode")).toBeNull();
  consoleWarnSpy.mockRestore();
});
🤖 Prompt for AI Agents
In tests/NightModeUserSettingModal.test.ts around lines 114 to 130, add
additional unit tests to cover missing/invalid payload edge cases and
idempotency: create separate tests that pass event.detail as null/undefined,
event.detail.checked as other falsy values (null, undefined, 0, ""), and event
itself as null/undefined (if public), asserting console.warn is called and
localStorage remains unchanged; also add a test that toggles night mode multiple
times in sequence asserting state flips only when valid checked values are
provided and repeated toggles are idempotent; for each test spy on console.warn,
call modal.toggleNightMode with the specific invalid input, assert the warning
and no localStorage mutation (or correct mutation for valid toggles), and
restore the spy after each test.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 8

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c0b6d30 and 3e635d1.

📒 Files selected for processing (2)
  • src/client/graphics/GameRenderer.ts (3 hunks)
  • src/client/graphics/layers/NightModeLayer.ts (1 hunks)
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-10-18T11:00:57.142Z
Learnt from: NewYearNewPhil
Repo: openfrontio/OpenFrontIO PR: 2230
File: src/client/graphics/GameRenderer.ts:269-277
Timestamp: 2025-10-18T11:00:57.142Z
Learning: In src/client/graphics/GameRenderer.ts, the GameRecapCapture implementation does not use setCaptureRenderEnabled on layers. Instead, it uses RecapCaptureSurface.capture() to render capture layers (TerrainLayer, TerritoryLayer, RailroadLayer, StructureIconsLayer, UnitLayer) directly to an off-screen canvas without requiring layer-level capture mode methods.

Applied to files:

  • src/client/graphics/layers/NightModeLayer.ts
  • src/client/graphics/GameRenderer.ts
📚 Learning: 2025-06-09T02:20:43.637Z
Learnt from: VariableVince
Repo: openfrontio/OpenFrontIO PR: 1110
File: src/client/Main.ts:293-295
Timestamp: 2025-06-09T02:20:43.637Z
Learning: In src/client/Main.ts, during game start in the handleJoinLobby callback, UI elements are hidden using direct DOM manipulation with classList.add("hidden") for consistency. This includes modals, buttons, and error divs. The codebase follows this pattern rather than using component APIs for hiding elements during game transitions.

Applied to files:

  • src/client/graphics/GameRenderer.ts
🧬 Code graph analysis (2)
src/client/graphics/layers/NightModeLayer.ts (3)
src/client/graphics/layers/Layer.ts (1)
  • Layer (1-7)
src/core/game/UserSettings.ts (1)
  • UserSettings (6-214)
src/client/graphics/TransformHandler.ts (1)
  • TransformHandler (15-333)
src/client/graphics/GameRenderer.ts (1)
src/client/graphics/layers/NightModeLayer.ts (1)
  • NightModeLayer (7-180)
🪛 GitHub Actions: 🧪 CI
src/client/graphics/GameRenderer.ts

[error] 326-326: TS2339: Property 'setGame' does not exist on type 'typeof NightModeLayer'.

🔇 Additional comments (1)
src/client/graphics/layers/NightModeLayer.ts (1)

15-15: Inject UserSettings instead of creating a new instance.

Creating a new UserSettings instance here could lead to inconsistency if settings are modified through other instances elsewhere in the codebase. The settings should be shared.

Accept UserSettings as a constructor parameter, similar to how other layers receive their dependencies:

   constructor(
     private transformHandler: TransformHandler,
+    private userSettings: UserSettings,
     game?: GameView,
   ) {
     this.game = game ?? null;
-    if (this.userSettingsInstance.nightMode()) {
+    if (this.userSettings.nightMode()) {
       document.documentElement.classList.add("night");
     } else {
       document.documentElement.classList.remove("night");
     }
     document.addEventListener("mousemove", this.mouseMoveHandler);
   }

Update the instantiation in GameRenderer.ts accordingly:

-new NightModeLayer(transformHandler),
+new NightModeLayer(transformHandler, userSettings, game),

Then update renderLayer to use this.userSettings instead of this.userSettingsInstance.

⛔ Skipped due to learnings
Learnt from: devalnor
Repo: openfrontio/OpenFrontIO PR: 1195
File: src/client/graphics/layers/AlertFrame.ts:18-18
Timestamp: 2025-06-20T20:11:00.965Z
Learning: In the OpenFrontIO codebase, UserSettings instances are created directly with `new UserSettings()` in each component that needs them. This pattern is used consistently across at least 12+ files including OptionsMenu, EventsDisplay, DarkModeButton, Main, UserSettingModal, UsernameInput, NameLayer, AlertFrame, UILayer, InputHandler, ClientGameRunner, and GameView. This is the established architectural pattern and should be followed for consistency.
Learnt from: devalnor
Repo: openfrontio/OpenFrontIO PR: 1195
File: src/client/graphics/layers/AlertFrame.ts:18-18
Timestamp: 2025-06-20T20:11:00.965Z
Learning: In the OpenFrontIO codebase, UserSettings instances are created directly with `new UserSettings()` in components like OptionsMenu, EventsDisplay, and AlertFrame. This is the established pattern and should be followed for consistency rather than using dependency injection.

this.redraw();
rafId = requestAnimationFrame(() => this.renderGame());
});
NightModeLayer.setGame(this.game);
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 | 🔴 Critical

Fix the static method call—setGame is an instance method.

Line 326 attempts to call setGame as a static method on the NightModeLayer class, but it's defined as an instance method (see NightModeLayer.ts line 8). This causes the TypeScript error flagged in the pipeline.

You need to call setGame on the NightModeLayer instance created at line 246. Apply this diff:

+    // Store reference to NightModeLayer instance
+    const nightModeLayer = new NightModeLayer(transformHandler);
+
     const layers: Layer[] = [
       new TerrainLayer(game, transformHandler),
       new TerritoryLayer(game, eventBus, transformHandler, userSettings),
       new RailroadLayer(game, transformHandler),
       structureLayer,
       samRadiusLayer,
       new UnitLayer(game, eventBus, transformHandler),
       new FxLayer(game),
       new UILayer(game, eventBus, transformHandler),
       new StructureIconsLayer(game, eventBus, uiState, transformHandler),
-      new NightModeLayer(transformHandler),
+      nightModeLayer,
       new NameLayer(game, transformHandler, eventBus),

Then in the initialize method:

-    NightModeLayer.setGame(this.game);
+    nightModeLayer.setGame(this.game);

Alternatively, you'll need to store the nightModeLayer reference as a class field to access it from initialize().

Committable suggestion skipped: line range outside the PR's diff.

🧰 Tools
🪛 GitHub Actions: 🧪 CI

[error] 326-326: TS2339: Property 'setGame' does not exist on type 'typeof NightModeLayer'.

🤖 Prompt for AI Agents
In src/client/graphics/GameRenderer.ts around line 326, the code calls
NightModeLayer.setGame(...) but setGame is an instance method; change the call
to use the NightModeLayer instance created at line 246 (or store that instance
in a class field so it’s accessible from initialize()). Update the code to keep
a reference to the nightModeLayer when it’s constructed (make it a private
field), and call this.nightModeLayer.setGame(this.game) from initialize() (or
the appropriate place) instead of calling the static class method.

Comment on lines +37 to +41
if (this.userSettingsInstance.nightMode()) {
document.documentElement.classList.add("night");
} else {
document.documentElement.classList.remove("night");
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Remove duplicate CSS class toggling logic.

The constructor adds/removes the "night" CSS class, but UserSettings.toggleNightMode() already handles this (as seen in the relevant code snippets). This duplication could cause the class state to get out of sync.

Remove this logic from the constructor and rely solely on UserSettings.toggleNightMode() to manage the CSS class. The constructor can simply read the current state if needed for initialization:

   constructor(
     private transformHandler: TransformHandler,
     game?: GameView,
   ) {
     this.game = game ?? null;
-    if (this.userSettingsInstance.nightMode()) {
-      document.documentElement.classList.add("night");
-    } else {
-      document.documentElement.classList.remove("night");
-    }
     document.addEventListener("mousemove", this.mouseMoveHandler);
   }

If initialization of the CSS class is required at startup, handle it in a centralized location (e.g., Main.ts or where UserSettings is first instantiated) rather than in the layer constructor.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/client/graphics/layers/NightModeLayer.ts around lines 37 to 41, remove
the duplicate logic that directly adds/removes the "night" CSS class from
document.documentElement in the constructor; instead rely on
UserSettings.toggleNightMode() to manage the class. Keep any necessary use of
userSettingsInstance.nightMode() only for reading the current state (not
mutating DOM) if the layer needs to initialize internal state, and move any
required global CSS class initialization to a centralized startup location
(e.g., Main.ts where UserSettings is instantiated).

} else {
document.documentElement.classList.remove("night");
}
document.addEventListener("mousemove", (e) => this.handleMouseMove(e));
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 | 🟠 Major

Memory leak: mousemove listener is never removed.

The event listener is registered in the constructor but never cleaned up. If the NightModeLayer is ever destroyed or recreated, these listeners will accumulate in memory.

Add a cleanup method and remove the listener when appropriate:

+  private mouseMoveHandler = (e: MouseEvent) => this.handleMouseMove(e);
+
   constructor(
     private transformHandler: TransformHandler,
     game?: GameView,
   ) {
     this.game = game ?? null;
     if (this.userSettingsInstance.nightMode()) {
       document.documentElement.classList.add("night");
     } else {
       document.documentElement.classList.remove("night");
     }
-    document.addEventListener("mousemove", (e) => this.handleMouseMove(e));
+    document.addEventListener("mousemove", this.mouseMoveHandler);
   }
+
+  // Clean up resources when layer is destroyed
+  destroy?(): void {
+    document.removeEventListener("mousemove", this.mouseMoveHandler);
+  }

Note: The Layer interface may need to be extended with an optional destroy method if it doesn't already exist.

🤖 Prompt for AI Agents
In src/client/graphics/layers/NightModeLayer.ts around line 42, the mousemove
listener added in the constructor is never removed which causes a memory leak;
add a public destroy (or cleanup) method on NightModeLayer that calls
document.removeEventListener("mousemove", this.handleMouseMove) to unregister
the handler, ensure the handler is bound consistently (use a class-bound method
or saved arrow function reference) so removeEventListener matches
addEventListener, and if the shared Layer interface lacks an optional destroy
method, extend it to include destroy?: () => void and update callers to invoke
destroy when the layer is removed/replaced.

document.addEventListener("mousemove", (e) => this.handleMouseMove(e));
}

// New method to set game reference after construction
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Remove empty comment.

The comment on line 45 has no content and should be removed.

-  // New method to set game reference after construction
-
   renderLayer(context: CanvasRenderingContext2D): void {
📝 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
// New method to set game reference after construction
renderLayer(context: CanvasRenderingContext2D): void {
🤖 Prompt for AI Agents
In src/client/graphics/layers/NightModeLayer.ts around line 45, remove the empty
comment line ("// New method to set game reference after construction") because
it contains no content; simply delete that line so the file has no leftover
empty comment and run the linter/formatter to ensure spacing/line endings remain
consistent.

Comment on lines +85 to +86
const screenX = cityX * cellSize;
const screenY = cityY * cellSize;
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 | 🔴 Critical

🧩 Analysis chain

City lights are mispositioned—pan offset not accounted for.

The conversion from tile to screen coordinates only multiplies by cellSize (scale), but doesn't account for the pan offset. When the user pans the map, city lights will remain in their original positions instead of moving with the map.

Since NightModeLayer doesn't define shouldTransform(), it renders in screen space without the automatic transform. You need to properly convert world coordinates to screen coordinates including both scale and offset. The TransformHandler likely provides methods for this conversion (e.g., worldToScreenCoordinates). Apply this approach:

   private renderCityLights(
     context: CanvasRenderingContext2D,
     cellSize: number,
   ): void {
     const cities = this.game!.units(UnitType.City);
 
     for (const city of cities) {
       const tileRef = city.tile();
       const cityX = this.game!.x(tileRef);
       const cityY = this.game!.y(tileRef);
 
-      // Convert tile coordinates to screen coordinates
-      const screenX = cityX * cellSize;
-      const screenY = cityY * cellSize;
+      // Convert world/tile coordinates to screen coordinates (accounting for pan and zoom)
+      // Use transformHandler's conversion method if available, or calculate manually:
+      // const screenPos = this.transformHandler.worldToScreenCoordinates(cityX, cityY);
+      // For now, assuming you need to apply offset and scale:
+      const screenX = (cityX - this.transformHandler.offsetX) * cellSize;
+      const screenY = (cityY - this.transformHandler.offsetY) * cellSize;
 
       const cityLevel = city.level();
       this.renderCityGlow(context, screenX, screenY, cellSize, cityLevel);
     }
   }

Check the TransformHandler API to confirm the proper method for world-to-screen coordinate conversion, as I don't have full visibility into its interface. Run this script to find the available conversion methods:


🏁 Script executed:

#!/bin/bash
# Find coordinate conversion methods in TransformHandler
rg -n -A3 -B3 'worldToScreen|screenToWorld|toScreen|toWorld' src/client/graphics/TransformHandler.ts

Length of output: 538



---

🏁 Script executed:

```shell
#!/bin/bash
# Get the full NightModeLayer class to see if it has transformHandler and how renderCityLights is implemented
cat src/client/graphics/layers/NightModeLayer.ts

Length of output: 5585


🏁 Script executed:

#!/bin/bash
# Get the full worldToScreenCoordinates implementation to understand what it does
sed -n '73,98p' src/client/graphics/TransformHandler.ts

Length of output: 1190


🏁 Script executed:

#!/bin/bash
# Check what NightModeLayer extends or inherits from
rg -n -B10 'class NightModeLayer' src/client/graphics/layers/NightModeLayer.ts | head -20

Length of output: 322


🏁 Script executed:

#!/bin/bash
# Check if other similar layers use transformHandler for coordinate conversion
rg -n 'worldToScreenCoordinates|screenToWorldCoordinates' src/client/graphics/layers/ -C2

Length of output: 4922


City lights require proper screen coordinate conversion using TransformHandler.

The city lights are indeed mispositioned because the current implementation only multiplies by cellSize without accounting for pan offset. However, instead of manually calculating offsets, use the worldToScreenCoordinates() method that's already available and properly used throughout the codebase (e.g., StructureDrawingUtils.ts:143, StructureIconsLayer.ts:524).

Replace lines 85-86:

  private renderCityLights(
    context: CanvasRenderingContext2D,
    cellSize: number,
  ): void {
    const cities = this.game!.units(UnitType.City);

    for (const city of cities) {
      const tileRef = city.tile();
      const cityX = this.game!.x(tileRef);
      const cityY = this.game!.y(tileRef);

-     // Convert tile coordinates to screen coordinates
-     const screenX = cityX * cellSize;
-     const screenY = cityY * cellSize;
+     // Convert world coordinates to screen coordinates (accounting for pan and zoom)
+     const screenPos = this.transformHandler.worldToScreenCoordinates(
+       new Cell(cityX, cityY)
+     );
+     const screenX = screenPos.x;
+     const screenY = screenPos.y;

      const cityLevel = city.level();
      this.renderCityGlow(context, screenX, screenY, cellSize, cityLevel);
    }
  }

You'll need to import Cell from the appropriate location if not already imported.

📝 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
const screenX = cityX * cellSize;
const screenY = cityY * cellSize;
private renderCityLights(
context: CanvasRenderingContext2D,
cellSize: number,
): void {
const cities = this.game!.units(UnitType.City);
for (const city of cities) {
const tileRef = city.tile();
const cityX = this.game!.x(tileRef);
const cityY = this.game!.y(tileRef);
// Convert world coordinates to screen coordinates (accounting for pan and zoom)
const screenPos = this.transformHandler.worldToScreenCoordinates(
new Cell(cityX, cityY)
);
const screenX = screenPos.x;
const screenY = screenPos.y;
const cityLevel = city.level();
this.renderCityGlow(context, screenX, screenY, cellSize, cityLevel);
}
}
🤖 Prompt for AI Agents
In src/client/graphics/layers/NightModeLayer.ts around lines 85-86, the
screenX/screenY are computed by multiplying cityX/cityY by cellSize and thus
ignore pan/transform; replace that manual conversion with the
TransformHandler.worldToScreenCoordinates(...) call used elsewhere (pass the
city cell coordinates as a Cell or plain {x,y} as the handler expects) and use
the returned screen coords for drawing; also add an import for the Cell type
from its module if not already imported.

Comment on lines +110 to +127
for (let dy = -lightRadius; dy <= lightRadius; dy++) {
for (let dx = -lightRadius; dx <= lightRadius; dx++) {
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance <= lightRadius) {
// Brightness decreases with distance
const brightness = 1 - distance / lightRadius;
const alpha = this.darkenAlpha * 0.8 * brightness;

context.fillStyle = `rgba(255, 220, 150, ${alpha})`;
context.fillRect(
x + dx * cellSize,
y + dy * cellSize,
cellSize,
cellSize,
);
}
}
}
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 | 🟠 Major

Performance: nested loops scale quadratically with city level.

The nested loops iterate (2 * lightRadius + 1)² times per city. For a level-5 city, lightRadius = 15, resulting in 961 iterations. With many cities or frequent renders, this could impact frame rate.

Consider these optimizations:

  1. Use radial gradient (eliminates loops entirely):
private renderCityGlow(
  context: CanvasRenderingContext2D,
  x: number,
  y: number,
  cellSize: number,
  level: number,
): void {
  const lightRadius = (5 + level * 2) * cellSize;
  const gradient = context.createRadialGradient(
    x + cellSize / 2, y + cellSize / 2, 0,
    x + cellSize / 2, y + cellSize / 2, lightRadius
  );
  gradient.addColorStop(0, `rgba(255, 255, 200, ${this.darkenAlpha * 0.9})`);
  gradient.addColorStop(0.5, `rgba(255, 220, 150, ${this.darkenAlpha * 0.6})`);
  gradient.addColorStop(1, 'rgba(255, 220, 150, 0)');
  
  context.fillStyle = gradient;
  context.fillRect(x - lightRadius, y - lightRadius, lightRadius * 2, lightRadius * 2);
}
  1. Limit maximum radius to cap iterations per city.

  2. Cache pre-rendered glows as ImageBitmap or offscreen canvas per city level, then draw them with drawImage().

Comment on lines +164 to +178
for (let y = startY; y < endY; y += cellSize) {
for (let x = startX; x < endX; x += cellSize) {
const dist = Math.hypot(
(this.mouseX - (x + cellSize / 2)) / cellSize,
(this.mouseY - (y + cellSize / 2)) / cellSize,
);

const brightness = Math.max(0, 1 - dist / this.flashlightRadius);

if (brightness > 0) {
context.fillStyle = `rgba(200,200,130,${(this.darkenAlpha / 2) * brightness})`;
context.fillRect(x, y, cellSize, cellSize);
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Performance consideration: flashlight rendering complexity.

Similar to city glow, the flashlight effect uses nested loops that could iterate many times per frame depending on flashlightRadius and cellSize. While this is for a single cursor location (unlike multiple cities), it still runs every frame.

Consider using a radial gradient for better performance:

private renderFlashlight(
  context: CanvasRenderingContext2D,
  width: number,
  height: number,
  cellSize: number,
): void {
  const radius = this.flashlightRadius * cellSize;
  const gradient = context.createRadialGradient(
    this.mouseX, this.mouseY, 0,
    this.mouseX, this.mouseY, radius
  );
  
  gradient.addColorStop(0, `rgba(200, 200, 130, ${this.darkenAlpha / 2})`);
  gradient.addColorStop(1, 'rgba(200, 200, 130, 0)');
  
  context.fillStyle = gradient;
  context.fillRect(
    Math.max(0, this.mouseX - radius),
    Math.max(0, this.mouseY - radius),
    Math.min(width, radius * 2),
    Math.min(height, radius * 2)
  );
}

This eliminates the nested loops while achieving a similar visual effect with much better performance.

🤖 Prompt for AI Agents
In src/client/graphics/layers/NightModeLayer.ts around lines 164 to 178, the
current flashlight implementation iterates nested loops per frame which is
expensive; replace the cell-by-cell drawing with a single canvas radial
gradient: create a radial gradient centered at (this.mouseX, this.mouseY) with
inner radius 0 and outer radius this.flashlightRadius * cellSize, add two color
stops (opaque-ish at 0 using rgba(200,200,130,this.darkenAlpha/2) and fully
transparent at 1), set context.fillStyle to that gradient, and fill only the
bounding rectangle covering the gradient (clamped to canvas bounds) so the
effect matches visually but avoids per-cell loops for much better performance.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants