From ea8d9ba5cbfa72c23e978501b6fb163b2d150d8e Mon Sep 17 00:00:00 2001 From: El-Magico777 Date: Fri, 6 Feb 2026 23:08:48 +0100 Subject: [PATCH 1/5] Fix: SAM and Airfield stars now update when research completes Auto-refresh structure texture cache when player unlocks tech upgrades to immediately display updated star indicators on the map. Problem: - Commit dd427b04 added star level indicators to SAM/Airfield structures - Textures were cached with tech level in the key (-lvl1, -lvl2, -lvl3) - Cache was never invalidated when research completed - Build menu showed correct stars (recalculates each render) - Map showed stale stars until game reload or upgrade mode toggle Solution: - Listen for GameUpdateType.Player updates in tick() method - Detect when researchTreeTechs array is updated - Rebuild textures for SAM/Airfield structures owned by that player - Follows same pattern as TechUnlockNotification layer Implementation: - Import PlayerUpdate type from GameUpdates - Add player update handler before unit updates in tick() - Filter for SAM/Airfield structures matching updated player ID - Recreate texture using createTexture() (includes new tech level) - Set shouldRedraw flag to trigger render Performance: - Runs only when research completes (rare event, ~1-10 per game) - Iterates rendered structures (typically 10-100 items) - Rebuilds 1-20 textures per event (typical SAM/Airfield count) - Identical pattern to upgrade mode toggle (proven acceptable) - Negligible impact on frame rate Visual result: - Stars update immediately when research unlocks SAM Level 2/3 - Stars update immediately when research unlocks Bomber Level 2/3 - Map display now matches build menu star indicators - No reload or mode toggle required --- src/client/graphics/layers/StructureLayer.ts | 32 +++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/client/graphics/layers/StructureLayer.ts b/src/client/graphics/layers/StructureLayer.ts index 6ea28fb0a..1373779dc 100644 --- a/src/client/graphics/layers/StructureLayer.ts +++ b/src/client/graphics/layers/StructureLayer.ts @@ -16,7 +16,10 @@ import { Theme } from "../../../core/configuration/Config"; import { EventBus } from "../../../core/EventBus"; import { computeUpgradeStepCost } from "../../../core/game/Costs"; import { Cell, PlayerID, UnitType } from "../../../core/game/Game"; -import { GameUpdateType } from "../../../core/game/GameUpdates"; +import { + GameUpdateType, + type PlayerUpdate, +} from "../../../core/game/GameUpdates"; import { GameView, UnitView } from "../../../core/game/GameView"; import { getUnitUpgradeCost } from "../../../core/game/UnitUpgrades"; import { @@ -257,6 +260,33 @@ export class StructureLayer implements Layer { tick() { const updates = this.game.updatesSinceLastTick(); + + // Handle player updates for research tech changes (rebuild textures for SAM/Airfield stars) + const playerUpdates = + updates !== null + ? (updates[GameUpdateType.Player] as PlayerUpdate[]) + : []; + for (const playerUpdate of playerUpdates) { + if ( + playerUpdate.researchTreeTechs && + playerUpdate.researchTreeTechs.length > 0 + ) { + // Research tech unlocked - rebuild textures for SAM and Airfield structures + // to update their star indicators + for (const r of this.renders) { + const unitType = r.unit.type(); + if ( + (unitType === UnitType.SAMLauncher || + unitType === UnitType.Airfield) && + r.unit.owner().id() === playerUpdate.id + ) { + r.pixiSprite.texture = this.createTexture(r.unit); + this.shouldRedraw = true; + } + } + } + } + const unitUpdates = updates !== null ? updates[GameUpdateType.Unit] : []; for (const u of unitUpdates) { const unitView = this.game.unit(u.id); From f4783731859f73c98422effaebfe713fa548a751 Mon Sep 17 00:00:00 2001 From: El-Magico777 Date: Fri, 6 Feb 2026 23:27:21 +0100 Subject: [PATCH 2/5] Fix TypeScript errors and prevent per-tick texture rebuilds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address performance regression and type errors from previous commit: Type fixes: - Import PlayerType from Game module - Change lastPlayerTechLevels Map key from PlayerID (string) to number (smallID) - Use smallID for player lookups (playerBySmallID returns PlayerView|TerraNullius) - Check for hasUpgrade property instead of PlayerType to distinguish PlayerView from TerraNullius - Use smallID for comparisons (owner().smallID() not owner().id()) Performance fix: - Cache previous tech levels per player (samLevel, airfieldLevel) - Only rebuild textures when tech level actually changes - Prevents O(players × structures) texture rebuilds every tick - Was rebuilding 60 times/second, now only rebuilds on research completion Implementation: - Track last known tech levels in lastPlayerTechLevels Map - Compare current vs cached levels each tick - Only recreate textures when levels differ - Update cache after rebuilding Result: - No more per-tick texture recreation performance regression - Textures still update immediately when research completes - TypeScript compilation passes without errors --- src/client/graphics/layers/StructureLayer.ts | 44 ++++++++++++++++---- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/src/client/graphics/layers/StructureLayer.ts b/src/client/graphics/layers/StructureLayer.ts index 1373779dc..f72c65fa4 100644 --- a/src/client/graphics/layers/StructureLayer.ts +++ b/src/client/graphics/layers/StructureLayer.ts @@ -113,6 +113,11 @@ export class StructureLayer implements Layer { number, { primary: number; secondary: number } >(); + // Track tech levels per player to detect changes (for star display refresh) + private lastPlayerTechLevels = new Map< + number, + { samLevel: number; airfieldLevel: number } + >(); // Icons registry private structures: Map< @@ -262,23 +267,48 @@ export class StructureLayer implements Layer { const updates = this.game.updatesSinceLastTick(); // Handle player updates for research tech changes (rebuild textures for SAM/Airfield stars) + // Only rebuild when tech level actually changes to avoid per-tick texture recreation const playerUpdates = updates !== null ? (updates[GameUpdateType.Player] as PlayerUpdate[]) : []; for (const playerUpdate of playerUpdates) { + const player = this.game.playerBySmallID(playerUpdate.smallID); + // Skip if player not found or is TerraNullius (no research) + if (!player || !("hasUpgrade" in player)) continue; + + const currentSamLevel = playerMaxStructureTechLevel( + player, + UnitType.SAMLauncher, + ); + const currentAirfieldLevel = playerMaxStructureTechLevel( + player, + UnitType.Airfield, + ); + const cached = this.lastPlayerTechLevels.get(playerUpdate.smallID); + + // Check if tech levels changed since last tick if ( - playerUpdate.researchTreeTechs && - playerUpdate.researchTreeTechs.length > 0 + !cached || + cached.samLevel !== currentSamLevel || + cached.airfieldLevel !== currentAirfieldLevel ) { - // Research tech unlocked - rebuild textures for SAM and Airfield structures - // to update their star indicators + // Update cache with new levels + this.lastPlayerTechLevels.set(playerUpdate.smallID, { + samLevel: currentSamLevel, + airfieldLevel: currentAirfieldLevel, + }); + + // Rebuild textures only for structures whose tech level changed for (const r of this.renders) { const unitType = r.unit.type(); + if (r.unit.owner().smallID() !== playerUpdate.smallID) continue; + if ( - (unitType === UnitType.SAMLauncher || - unitType === UnitType.Airfield) && - r.unit.owner().id() === playerUpdate.id + (unitType === UnitType.SAMLauncher && + cached?.samLevel !== currentSamLevel) || + (unitType === UnitType.Airfield && + cached?.airfieldLevel !== currentAirfieldLevel) ) { r.pixiSprite.texture = this.createTexture(r.unit); this.shouldRedraw = true; From 36ca9e947be1a64e4a599da2fdcfb3ec36594325 Mon Sep 17 00:00:00 2001 From: El-Magico777 Date: Fri, 6 Feb 2026 23:32:19 +0100 Subject: [PATCH 3/5] Throttle tech level checks to every 10 ticks Reduce per-tick overhead by only checking tech level changes every 10 ticks instead of every tick. Research completion is rare and a ~0.1-0.2s delay before stars update is imperceptible. --- src/client/graphics/layers/StructureLayer.ts | 96 +++++++++++--------- 1 file changed, 52 insertions(+), 44 deletions(-) diff --git a/src/client/graphics/layers/StructureLayer.ts b/src/client/graphics/layers/StructureLayer.ts index f72c65fa4..f81be6654 100644 --- a/src/client/graphics/layers/StructureLayer.ts +++ b/src/client/graphics/layers/StructureLayer.ts @@ -118,6 +118,8 @@ export class StructureLayer implements Layer { number, { samLevel: number; airfieldLevel: number } >(); + private techLevelCheckCounter = 0; + private static readonly TECH_LEVEL_CHECK_INTERVAL = 10; // Icons registry private structures: Map< @@ -267,51 +269,57 @@ export class StructureLayer implements Layer { const updates = this.game.updatesSinceLastTick(); // Handle player updates for research tech changes (rebuild textures for SAM/Airfield stars) - // Only rebuild when tech level actually changes to avoid per-tick texture recreation - const playerUpdates = - updates !== null - ? (updates[GameUpdateType.Player] as PlayerUpdate[]) - : []; - for (const playerUpdate of playerUpdates) { - const player = this.game.playerBySmallID(playerUpdate.smallID); - // Skip if player not found or is TerraNullius (no research) - if (!player || !("hasUpgrade" in player)) continue; - - const currentSamLevel = playerMaxStructureTechLevel( - player, - UnitType.SAMLauncher, - ); - const currentAirfieldLevel = playerMaxStructureTechLevel( - player, - UnitType.Airfield, - ); - const cached = this.lastPlayerTechLevels.get(playerUpdate.smallID); - - // Check if tech levels changed since last tick - if ( - !cached || - cached.samLevel !== currentSamLevel || - cached.airfieldLevel !== currentAirfieldLevel - ) { - // Update cache with new levels - this.lastPlayerTechLevels.set(playerUpdate.smallID, { - samLevel: currentSamLevel, - airfieldLevel: currentAirfieldLevel, - }); + // Only check every 10 ticks — research is rare and a brief delay is imperceptible + this.techLevelCheckCounter++; + if ( + this.techLevelCheckCounter >= StructureLayer.TECH_LEVEL_CHECK_INTERVAL + ) { + this.techLevelCheckCounter = 0; + const playerUpdates = + updates !== null + ? (updates[GameUpdateType.Player] as PlayerUpdate[]) + : []; + for (const playerUpdate of playerUpdates) { + const player = this.game.playerBySmallID(playerUpdate.smallID); + // Skip if player not found or is TerraNullius (no research) + if (!player || !("hasUpgrade" in player)) continue; + + const currentSamLevel = playerMaxStructureTechLevel( + player, + UnitType.SAMLauncher, + ); + const currentAirfieldLevel = playerMaxStructureTechLevel( + player, + UnitType.Airfield, + ); + const cached = this.lastPlayerTechLevels.get(playerUpdate.smallID); - // Rebuild textures only for structures whose tech level changed - for (const r of this.renders) { - const unitType = r.unit.type(); - if (r.unit.owner().smallID() !== playerUpdate.smallID) continue; - - if ( - (unitType === UnitType.SAMLauncher && - cached?.samLevel !== currentSamLevel) || - (unitType === UnitType.Airfield && - cached?.airfieldLevel !== currentAirfieldLevel) - ) { - r.pixiSprite.texture = this.createTexture(r.unit); - this.shouldRedraw = true; + // Check if tech levels changed since last tick + if ( + !cached || + cached.samLevel !== currentSamLevel || + cached.airfieldLevel !== currentAirfieldLevel + ) { + // Update cache with new levels + this.lastPlayerTechLevels.set(playerUpdate.smallID, { + samLevel: currentSamLevel, + airfieldLevel: currentAirfieldLevel, + }); + + // Rebuild textures only for structures whose tech level changed + for (const r of this.renders) { + const unitType = r.unit.type(); + if (r.unit.owner().smallID() !== playerUpdate.smallID) continue; + + if ( + (unitType === UnitType.SAMLauncher && + cached?.samLevel !== currentSamLevel) || + (unitType === UnitType.Airfield && + cached?.airfieldLevel !== currentAirfieldLevel) + ) { + r.pixiSprite.texture = this.createTexture(r.unit); + this.shouldRedraw = true; + } } } } From 6a75c0c4812b1bb4653d7c3ff972a56297973441 Mon Sep 17 00:00:00 2001 From: El-Magico777 Date: Fri, 6 Feb 2026 23:43:54 +0100 Subject: [PATCH 4/5] Seed tech level cache on first encounter to avoid unnecessary texture rebuilds When a player is first seen, seed the cache with current levels instead of rebuilding all their SAM/Airfield textures. Textures are already created with the correct tech level, so only subsequent changes need rebuilds. --- src/client/graphics/layers/StructureLayer.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/client/graphics/layers/StructureLayer.ts b/src/client/graphics/layers/StructureLayer.ts index f81be6654..100e7c6b0 100644 --- a/src/client/graphics/layers/StructureLayer.ts +++ b/src/client/graphics/layers/StructureLayer.ts @@ -294,9 +294,17 @@ export class StructureLayer implements Layer { ); const cached = this.lastPlayerTechLevels.get(playerUpdate.smallID); - // Check if tech levels changed since last tick + // First encounter: seed cache without rebuilding (textures already correct) + if (!cached) { + this.lastPlayerTechLevels.set(playerUpdate.smallID, { + samLevel: currentSamLevel, + airfieldLevel: currentAirfieldLevel, + }); + continue; + } + + // Check if tech levels actually changed if ( - !cached || cached.samLevel !== currentSamLevel || cached.airfieldLevel !== currentAirfieldLevel ) { @@ -313,9 +321,9 @@ export class StructureLayer implements Layer { if ( (unitType === UnitType.SAMLauncher && - cached?.samLevel !== currentSamLevel) || + cached.samLevel !== currentSamLevel) || (unitType === UnitType.Airfield && - cached?.airfieldLevel !== currentAirfieldLevel) + cached.airfieldLevel !== currentAirfieldLevel) ) { r.pixiSprite.texture = this.createTexture(r.unit); this.shouldRedraw = true; From 99d48f369a018f5577d8b9225fca63f5e444c6d3 Mon Sep 17 00:00:00 2001 From: El-Magico777 Date: Fri, 6 Feb 2026 23:56:16 +0100 Subject: [PATCH 5/5] Fix edge case and use proper type guard - Use isPlayer() type guard instead of brittle 'hasUpgrade' in check - On first encounter: only rebuild if level > 1 (research already happened) This handles the edge case where textures were created before research completed but cache is seeded after - On subsequent checks: rebuild only if cached level differs from current - Simplifies logic by always updating cache and computing change flags --- src/client/graphics/layers/StructureLayer.ts | 43 ++++++++------------ 1 file changed, 18 insertions(+), 25 deletions(-) diff --git a/src/client/graphics/layers/StructureLayer.ts b/src/client/graphics/layers/StructureLayer.ts index 100e7c6b0..d8ffc3f64 100644 --- a/src/client/graphics/layers/StructureLayer.ts +++ b/src/client/graphics/layers/StructureLayer.ts @@ -282,7 +282,7 @@ export class StructureLayer implements Layer { for (const playerUpdate of playerUpdates) { const player = this.game.playerBySmallID(playerUpdate.smallID); // Skip if player not found or is TerraNullius (no research) - if (!player || !("hasUpgrade" in player)) continue; + if (!player || !player.isPlayer()) continue; const currentSamLevel = playerMaxStructureTechLevel( player, @@ -294,36 +294,29 @@ export class StructureLayer implements Layer { ); const cached = this.lastPlayerTechLevels.get(playerUpdate.smallID); - // First encounter: seed cache without rebuilding (textures already correct) - if (!cached) { - this.lastPlayerTechLevels.set(playerUpdate.smallID, { - samLevel: currentSamLevel, - airfieldLevel: currentAirfieldLevel, - }); - continue; - } - - // Check if tech levels actually changed - if ( - cached.samLevel !== currentSamLevel || - cached.airfieldLevel !== currentAirfieldLevel - ) { - // Update cache with new levels - this.lastPlayerTechLevels.set(playerUpdate.smallID, { - samLevel: currentSamLevel, - airfieldLevel: currentAirfieldLevel, - }); + // Check if levels changed (or first encounter with upgraded levels) + const samChanged = !cached + ? currentSamLevel > 1 + : cached.samLevel !== currentSamLevel; + const airfieldChanged = !cached + ? currentAirfieldLevel > 1 + : cached.airfieldLevel !== currentAirfieldLevel; + + // Always update cache with current levels + this.lastPlayerTechLevels.set(playerUpdate.smallID, { + samLevel: currentSamLevel, + airfieldLevel: currentAirfieldLevel, + }); - // Rebuild textures only for structures whose tech level changed + // Rebuild textures if levels changed + if (samChanged || airfieldChanged) { for (const r of this.renders) { const unitType = r.unit.type(); if (r.unit.owner().smallID() !== playerUpdate.smallID) continue; if ( - (unitType === UnitType.SAMLauncher && - cached.samLevel !== currentSamLevel) || - (unitType === UnitType.Airfield && - cached.airfieldLevel !== currentAirfieldLevel) + (unitType === UnitType.SAMLauncher && samChanged) || + (unitType === UnitType.Airfield && airfieldChanged) ) { r.pixiSprite.texture = this.createTexture(r.unit); this.shouldRedraw = true;