From b0b28c988dd4928d7e6d606f3260091bb9bb67e8 Mon Sep 17 00:00:00 2001 From: Blon Td Date: Tue, 7 Jul 2026 20:41:40 -0400 Subject: [PATCH 1/6] Group event notifications and inbound nukes in quick succession --- resources/lang/en.json | 8 ++ src/client/hud/layers/EventsDisplay.ts | 103 ++++++++++++++++++------- src/core/game/UnitImpl.ts | 24 +++++- 3 files changed, 106 insertions(+), 29 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 05c6f7888d..4895d4ab5e 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -461,6 +461,7 @@ "alliance_request_sent": "Alliance request sent to {name}.", "alliance_request_status": "{name} {status} your alliance request", "atom_bomb_detonated": "{name} - atom bomb detonated", + "atom_bomb_inbound_plural": "{name} - {count} atom bombs inbound", "attack_cancelled_retreat": "Attack cancelled, {troops} soldiers killed during retreat", "attack_request": "{name} requests you attack {target}", "betrayal_debuff_ends": "{time} seconds left until betrayal debuff ends", @@ -471,7 +472,9 @@ "duration_seconds_plural": "{seconds} seconds", "focus": "Focus", "hydrogen_bomb_detonated": "{name} - hydrogen bomb detonated", + "hydrogen_bomb_inbound_plural": "{name} - {count} hydrogen bombs inbound", "ignore": "Ignore", + "mirv_inbound_plural": "⚠️⚠️⚠️ {name} - {count} MIRVs INBOUND ⚠️⚠️⚠️", "mirv_warheads_intercepted": "{count, plural, one {{count} MIRV warhead intercepted} other {{count} MIRV warheads intercepted}}", "missile_intercepted": "Missile intercepted {unit}", "no_boats_available": "No boats available, max {max}", @@ -487,7 +490,12 @@ "sent_gold_to_player": "Sent {gold} gold to {name}", "sent_troops_to_player": "Sent {troops} troops to {name}", "trade_ship_captured": "Your trade ship was captured by {name}", + "unit_captured": "Captured {unit} from {name}", + "unit_captured_plural": "Captured {count} {unit} from {name}", "unit_destroyed": "Your {unit} was destroyed", + "unit_destroyed_plural": "{count} {unit} destroyed", + "unit_lost": "Your {unit} was captured by {name}", + "unit_lost_plural": "Lost {count} {unit} to {name}", "unit_voluntarily_deleted": "Unit voluntarily deleted", "wants_to_renew_alliance": "{name} wants to renew your alliance" }, diff --git a/src/client/hud/layers/EventsDisplay.ts b/src/client/hud/layers/EventsDisplay.ts index 042469ff24..4106a0aee2 100644 --- a/src/client/hud/layers/EventsDisplay.ts +++ b/src/client/hud/layers/EventsDisplay.ts @@ -32,6 +32,22 @@ import { translateText, } from "../../Utils"; +const pluralizeUnit = (u: string) => u === "City" ? "Cities" : u === "Factory" ? "Factories" : u + "s"; + +function parseInboundNuke(m: string): { player: string; nukeType: string } | null { + if (m.endsWith(" - atom bomb inbound")) return { player: m.slice(0, -20), nukeType: "atom" }; + if (m.endsWith(" - hydrogen bomb inbound")) return { player: m.slice(0, -24), nukeType: "hydrogen" }; + if (m.startsWith("⚠️⚠️⚠️ ") && m.endsWith(" - MIRV INBOUND ⚠️⚠️⚠️")) return { player: m.slice(7, -22), nukeType: "mirv" }; + return null; +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const _unused = [ + "events_display.atom_bomb_inbound_plural", + "events_display.hydrogen_bomb_inbound_plural", + "events_display.mirv_inbound_plural", +]; + interface GameEvent { description: string; unsafeDescription?: boolean; @@ -41,6 +57,10 @@ interface GameEvent { onDelete?: () => void; focusID?: number; unitView?: UnitView; + groupKey?: string; + count?: number; + unitType?: string; + targetPlayerName?: string; } const TIER_1_TYPES: ReadonlySet = new Set([ @@ -242,40 +262,70 @@ export class EventsDisplay extends LitElement implements Controller { } private addEvent(event: GameEvent) { + if (event.groupKey) { + const existing = this.events.find( + (e) => e.groupKey === event.groupKey && this.game.ticks() - e.createdAt <= 30 + ); + if (existing) { + existing.count = (existing.count ?? 1) + 1; + existing.createdAt = this.game.ticks(); + if (event.unitView) existing.unitView = event.unitView; + if (event.focusID) existing.focusID = event.focusID; + + const u = pluralizeUnit(existing.unitType ?? ""); + const n = existing.targetPlayerName ?? ""; + const c = existing.count; + + if (existing.groupKey?.startsWith("destroyed_")) { + existing.description = translateText("events_display.unit_destroyed_plural", { count: c, unit: u }); + } else if (existing.groupKey?.startsWith("captured_")) { + existing.description = translateText("events_display.unit_captured_plural", { count: c, unit: u, name: n }); + } else if (existing.groupKey?.startsWith("lost_")) { + existing.description = translateText("events_display.unit_lost_plural", { count: c, unit: u, name: n }); + } else if (existing.groupKey?.startsWith("inbound_")) { + const k = "events_display." + existing.unitType + (existing.unitType === "mirv" ? "" : "_bomb") + "_inbound_plural"; + existing.description = translateText(k, { count: c, name: n }); + } + this.requestUpdate(); + return; + } + } this.events = [...this.events, event]; this.requestUpdate(); } onDisplayMessageEvent(event: DisplayMessageUpdate) { const myPlayer = this.game.myPlayer(); - if ( - event.playerID !== null && - (!myPlayer || myPlayer.smallID() !== event.playerID) - ) { - return; - } - - // Captured trade-ship gold is surfaced as a transient +gold pip in - // control-panel rather than as a scroll-list entry. - if (event.message === "events_display.received_gold_from_captured_ship") { - return; - } - - let description: string = event.message; - if (event.message.startsWith("events_display.")) { - description = translateText(event.message, event.params ?? {}); + if (event.playerID !== null && (!myPlayer || myPlayer.smallID() !== event.playerID)) return; + if (event.message === "events_display.received_gold_from_captured_ship") return; + + const description = event.message.startsWith("events_display.") + ? translateText(event.message, event.params ?? {}) + : event.message; + + let groupKey: string | undefined; + const unitType = String(event.params?.unit ?? ""); + const targetPlayerName = String(event.params?.name ?? ""); + + if (event.message === "events_display.unit_destroyed") { + groupKey = `destroyed_${unitType}`; + } else if (event.message === "events_display.unit_captured") { + groupKey = `captured_${unitType}_${targetPlayerName}`; + } else if (event.message === "events_display.unit_lost") { + groupKey = `lost_${unitType}_${targetPlayerName}`; } - const unitView = - event.unitID !== undefined ? this.game.unit(event.unitID) : undefined; this.addEvent({ - description: description, + description, createdAt: this.game.ticks(), highlight: true, type: event.messageType, unsafeDescription: true, - unitView: unitView, + unitView: event.unitID !== undefined ? this.game.unit(event.unitID) : undefined, focusID: event.focusPlayerID, + groupKey, + unitType, + targetPlayerName, }); } @@ -530,20 +580,19 @@ export class EventsDisplay extends LitElement implements Controller { onUnitIncomingEvent(event: UnitIncomingUpdate) { const myPlayer = this.game.myPlayer(); + if (!myPlayer || myPlayer.smallID() !== event.playerID) return; - if (!myPlayer || myPlayer.smallID() !== event.playerID) { - return; - } - - const unitView = this.game.unit(event.unitID); - + const parsed = parseInboundNuke(event.message); this.addEvent({ description: event.message, type: event.messageType, unsafeDescription: false, highlight: true, createdAt: this.game.ticks(), - unitView: unitView, + unitView: this.game.unit(event.unitID), + groupKey: parsed ? `inbound_${parsed.nukeType}_${parsed.player}` : undefined, + unitType: parsed?.nukeType, + targetPlayerName: parsed?.player, }); } diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index 1daabb9a3b..e70c5726ec 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -3,6 +3,7 @@ import { AllUnitParams, MessageType, Player, + Structures, Tick, TrainType, TrajectoryTile, @@ -216,6 +217,24 @@ export class UnitImpl implements Unit { this.mg.stats().unitLose(this._owner, this._type); break; } + if (Structures.has(this._type)) { + this.mg.displayMessage( + "events_display.unit_captured", + MessageType.CAPTURED_ENEMY_UNIT, + newOwner.id(), + undefined, + { unit: this._type, name: this._owner.displayName() }, + this.id(), + ); + this.mg.displayMessage( + "events_display.unit_lost", + MessageType.UNIT_DESTROYED, + this._owner.id(), + undefined, + { unit: this._type, name: newOwner.displayName() }, + this.id(), + ); + } this._lastOwner = this._owner; this._lastOwner._units = this._lastOwner._units.filter((u) => u !== this); this._owner = newOwner; @@ -326,11 +345,12 @@ export class UnitImpl implements Unit { } private displayMessageOnDeleted(): void { - // Only warships and transport ships are worth notifying about; everything + // Only warships, transport ships, and structures are worth notifying about; everything // else is either visible on the map or too low-stakes to surface. if ( this._type !== UnitType.Warship && - this._type !== UnitType.TransportShip + this._type !== UnitType.TransportShip && + !Structures.has(this._type) ) { return; } From c4615e6b1026d10a93b2f7480af6cc368699b548 Mon Sep 17 00:00:00 2001 From: Blon Td Date: Wed, 8 Jul 2026 18:21:04 -0400 Subject: [PATCH 2/6] fix: translate unit type parameters and singular inbound nukes --- resources/lang/en.json | 16 ++++++++ src/client/hud/layers/EventsDisplay.ts | 51 ++++++++++++++++++++++++-- 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 4895d4ab5e..33427110fb 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -461,6 +461,7 @@ "alliance_request_sent": "Alliance request sent to {name}.", "alliance_request_status": "{name} {status} your alliance request", "atom_bomb_detonated": "{name} - atom bomb detonated", + "atom_bomb_inbound": "{name} - atom bomb inbound", "atom_bomb_inbound_plural": "{name} - {count} atom bombs inbound", "attack_cancelled_retreat": "Attack cancelled, {troops} soldiers killed during retreat", "attack_request": "{name} requests you attack {target}", @@ -472,8 +473,10 @@ "duration_seconds_plural": "{seconds} seconds", "focus": "Focus", "hydrogen_bomb_detonated": "{name} - hydrogen bomb detonated", + "hydrogen_bomb_inbound": "{name} - hydrogen bomb inbound", "hydrogen_bomb_inbound_plural": "{name} - {count} hydrogen bombs inbound", "ignore": "Ignore", + "mirv_inbound": "⚠️⚠️⚠️ {name} - MIRV INBOUND ⚠️⚠️⚠️", "mirv_inbound_plural": "⚠️⚠️⚠️ {name} - {count} MIRVs INBOUND ⚠️⚠️⚠️", "mirv_warheads_intercepted": "{count, plural, one {{count} MIRV warhead intercepted} other {{count} MIRV warheads intercepted}}", "missile_intercepted": "Missile intercepted {unit}", @@ -1417,6 +1420,19 @@ "sam_launcher": "SAM Launcher", "warship": "Warship" }, + "unit_type_plural": { + "atom_bomb": "Atom Bombs", + "boat": "Boats", + "city": "Cities", + "defense_post": "Defense Posts", + "factory": "Factories", + "hydrogen_bomb": "Hydrogen Bombs", + "mirv": "MIRVs", + "missile_silo": "Missile Silos", + "port": "Ports", + "sam_launcher": "SAM Launchers", + "warship": "Warships" + }, "user_setting": { "alert_frame_desc": "Toggle the alert frame. When enabled, the frame will be displayed when you are betrayed or attacked over land.", "alert_frame_label": "Alert Frame", diff --git a/src/client/hud/layers/EventsDisplay.ts b/src/client/hud/layers/EventsDisplay.ts index 4106a0aee2..eaf8e0a0fb 100644 --- a/src/client/hud/layers/EventsDisplay.ts +++ b/src/client/hud/layers/EventsDisplay.ts @@ -32,7 +32,25 @@ import { translateText, } from "../../Utils"; -const pluralizeUnit = (u: string) => u === "City" ? "Cities" : u === "Factory" ? "Factories" : u + "s"; +const UNIT_TRANSLATION_KEYS: Record = { + "City": "unit_type.city", + "Port": "unit_type.port", + "Defense Post": "unit_type.defense_post", + "SAM Launcher": "unit_type.sam_launcher", + "Missile Silo": "unit_type.missile_silo", + "Factory": "unit_type.factory", + "Warship": "unit_type.warship", + "Transport": "unit_type.boat", + "Atom Bomb": "unit_type.atom_bomb", + "Hydrogen Bomb": "unit_type.hydrogen_bomb", + "MIRV": "unit_type.mirv", +}; + +const getTranslatedUnitName = (unitType: string, plural: boolean): string => { + const key = UNIT_TRANSLATION_KEYS[unitType]; + if (!key) return unitType; + return translateText(plural ? `unit_type_plural.${key}` : `unit_type.${key}`); +}; function parseInboundNuke(m: string): { player: string; nukeType: string } | null { if (m.endsWith(" - atom bomb inbound")) return { player: m.slice(0, -20), nukeType: "atom" }; @@ -43,9 +61,23 @@ function parseInboundNuke(m: string): { player: string; nukeType: string } | nul // eslint-disable-next-line @typescript-eslint/no-unused-vars const _unused = [ + "events_display.atom_bomb_inbound", "events_display.atom_bomb_inbound_plural", + "events_display.hydrogen_bomb_inbound", "events_display.hydrogen_bomb_inbound_plural", + "events_display.mirv_inbound", "events_display.mirv_inbound_plural", + "unit_type_plural.atom_bomb", + "unit_type_plural.boat", + "unit_type_plural.city", + "unit_type_plural.defense_post", + "unit_type_plural.factory", + "unit_type_plural.hydrogen_bomb", + "unit_type_plural.mirv", + "unit_type_plural.missile_silo", + "unit_type_plural.port", + "unit_type_plural.sam_launcher", + "unit_type_plural.warship", ]; interface GameEvent { @@ -272,7 +304,7 @@ export class EventsDisplay extends LitElement implements Controller { if (event.unitView) existing.unitView = event.unitView; if (event.focusID) existing.focusID = event.focusID; - const u = pluralizeUnit(existing.unitType ?? ""); + const u = getTranslatedUnitName(existing.unitType ?? "", true); const n = existing.targetPlayerName ?? ""; const c = existing.count; @@ -299,8 +331,13 @@ export class EventsDisplay extends LitElement implements Controller { if (event.playerID !== null && (!myPlayer || myPlayer.smallID() !== event.playerID)) return; if (event.message === "events_display.received_gold_from_captured_ship") return; + const params = { ...event.params }; + if (params.unit) { + params.unit = getTranslatedUnitName(String(params.unit), false); + } + const description = event.message.startsWith("events_display.") - ? translateText(event.message, event.params ?? {}) + ? translateText(event.message, params) : event.message; let groupKey: string | undefined; @@ -583,8 +620,14 @@ export class EventsDisplay extends LitElement implements Controller { if (!myPlayer || myPlayer.smallID() !== event.playerID) return; const parsed = parseInboundNuke(event.message); + let description = event.message; + if (parsed) { + const k = "events_display." + parsed.nukeType + (parsed.nukeType === "mirv" ? "" : "_bomb") + "_inbound"; + description = translateText(k, { name: parsed.player }); + } + this.addEvent({ - description: event.message, + description, type: event.messageType, unsafeDescription: false, highlight: true, From ab42d1ea6cae4fa5ee4e993b010734a45cc7c3de Mon Sep 17 00:00:00 2001 From: Blon Td Date: Thu, 9 Jul 2026 16:14:34 -0400 Subject: [PATCH 3/6] fix: correct translation key lookup and apply prettier formatting --- src/client/hud/layers/EventsDisplay.ts | 82 ++++++++++++++++++-------- 1 file changed, 57 insertions(+), 25 deletions(-) diff --git a/src/client/hud/layers/EventsDisplay.ts b/src/client/hud/layers/EventsDisplay.ts index eaf8e0a0fb..5a08a2007c 100644 --- a/src/client/hud/layers/EventsDisplay.ts +++ b/src/client/hud/layers/EventsDisplay.ts @@ -33,17 +33,17 @@ import { } from "../../Utils"; const UNIT_TRANSLATION_KEYS: Record = { - "City": "unit_type.city", - "Port": "unit_type.port", - "Defense Post": "unit_type.defense_post", - "SAM Launcher": "unit_type.sam_launcher", - "Missile Silo": "unit_type.missile_silo", - "Factory": "unit_type.factory", - "Warship": "unit_type.warship", - "Transport": "unit_type.boat", - "Atom Bomb": "unit_type.atom_bomb", - "Hydrogen Bomb": "unit_type.hydrogen_bomb", - "MIRV": "unit_type.mirv", + City: "city", + Port: "port", + "Defense Post": "defense_post", + "SAM Launcher": "sam_launcher", + "Missile Silo": "missile_silo", + Factory: "factory", + Warship: "warship", + Transport: "boat", + "Atom Bomb": "atom_bomb", + "Hydrogen Bomb": "hydrogen_bomb", + MIRV: "mirv", }; const getTranslatedUnitName = (unitType: string, plural: boolean): string => { @@ -52,10 +52,15 @@ const getTranslatedUnitName = (unitType: string, plural: boolean): string => { return translateText(plural ? `unit_type_plural.${key}` : `unit_type.${key}`); }; -function parseInboundNuke(m: string): { player: string; nukeType: string } | null { - if (m.endsWith(" - atom bomb inbound")) return { player: m.slice(0, -20), nukeType: "atom" }; - if (m.endsWith(" - hydrogen bomb inbound")) return { player: m.slice(0, -24), nukeType: "hydrogen" }; - if (m.startsWith("⚠️⚠️⚠️ ") && m.endsWith(" - MIRV INBOUND ⚠️⚠️⚠️")) return { player: m.slice(7, -22), nukeType: "mirv" }; +function parseInboundNuke( + m: string, +): { player: string; nukeType: string } | null { + if (m.endsWith(" - atom bomb inbound")) + return { player: m.slice(0, -20), nukeType: "atom" }; + if (m.endsWith(" - hydrogen bomb inbound")) + return { player: m.slice(0, -24), nukeType: "hydrogen" }; + if (m.startsWith("⚠️⚠️⚠️ ") && m.endsWith(" - MIRV INBOUND ⚠️⚠️⚠️")) + return { player: m.slice(7, -22), nukeType: "mirv" }; return null; } @@ -296,7 +301,9 @@ export class EventsDisplay extends LitElement implements Controller { private addEvent(event: GameEvent) { if (event.groupKey) { const existing = this.events.find( - (e) => e.groupKey === event.groupKey && this.game.ticks() - e.createdAt <= 30 + (e) => + e.groupKey === event.groupKey && + this.game.ticks() - e.createdAt <= 30, ); if (existing) { existing.count = (existing.count ?? 1) + 1; @@ -309,13 +316,26 @@ export class EventsDisplay extends LitElement implements Controller { const c = existing.count; if (existing.groupKey?.startsWith("destroyed_")) { - existing.description = translateText("events_display.unit_destroyed_plural", { count: c, unit: u }); + existing.description = translateText( + "events_display.unit_destroyed_plural", + { count: c, unit: u }, + ); } else if (existing.groupKey?.startsWith("captured_")) { - existing.description = translateText("events_display.unit_captured_plural", { count: c, unit: u, name: n }); + existing.description = translateText( + "events_display.unit_captured_plural", + { count: c, unit: u, name: n }, + ); } else if (existing.groupKey?.startsWith("lost_")) { - existing.description = translateText("events_display.unit_lost_plural", { count: c, unit: u, name: n }); + existing.description = translateText( + "events_display.unit_lost_plural", + { count: c, unit: u, name: n }, + ); } else if (existing.groupKey?.startsWith("inbound_")) { - const k = "events_display." + existing.unitType + (existing.unitType === "mirv" ? "" : "_bomb") + "_inbound_plural"; + const k = + "events_display." + + existing.unitType + + (existing.unitType === "mirv" ? "" : "_bomb") + + "_inbound_plural"; existing.description = translateText(k, { count: c, name: n }); } this.requestUpdate(); @@ -328,8 +348,13 @@ export class EventsDisplay extends LitElement implements Controller { onDisplayMessageEvent(event: DisplayMessageUpdate) { const myPlayer = this.game.myPlayer(); - if (event.playerID !== null && (!myPlayer || myPlayer.smallID() !== event.playerID)) return; - if (event.message === "events_display.received_gold_from_captured_ship") return; + if ( + event.playerID !== null && + (!myPlayer || myPlayer.smallID() !== event.playerID) + ) + return; + if (event.message === "events_display.received_gold_from_captured_ship") + return; const params = { ...event.params }; if (params.unit) { @@ -358,7 +383,8 @@ export class EventsDisplay extends LitElement implements Controller { highlight: true, type: event.messageType, unsafeDescription: true, - unitView: event.unitID !== undefined ? this.game.unit(event.unitID) : undefined, + unitView: + event.unitID !== undefined ? this.game.unit(event.unitID) : undefined, focusID: event.focusPlayerID, groupKey, unitType, @@ -622,7 +648,11 @@ export class EventsDisplay extends LitElement implements Controller { const parsed = parseInboundNuke(event.message); let description = event.message; if (parsed) { - const k = "events_display." + parsed.nukeType + (parsed.nukeType === "mirv" ? "" : "_bomb") + "_inbound"; + const k = + "events_display." + + parsed.nukeType + + (parsed.nukeType === "mirv" ? "" : "_bomb") + + "_inbound"; description = translateText(k, { name: parsed.player }); } @@ -633,7 +663,9 @@ export class EventsDisplay extends LitElement implements Controller { highlight: true, createdAt: this.game.ticks(), unitView: this.game.unit(event.unitID), - groupKey: parsed ? `inbound_${parsed.nukeType}_${parsed.player}` : undefined, + groupKey: parsed + ? `inbound_${parsed.nukeType}_${parsed.player}` + : undefined, unitType: parsed?.nukeType, targetPlayerName: parsed?.player, }); From 030d920c787612a14a63bc37aff2c42563cbb79d Mon Sep 17 00:00:00 2001 From: Blon Td Date: Thu, 9 Jul 2026 16:27:54 -0400 Subject: [PATCH 4/6] feat: group repeated missile interception notifications --- resources/lang/en.json | 1 + src/client/hud/layers/EventsDisplay.ts | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/resources/lang/en.json b/resources/lang/en.json index 33427110fb..64f1443414 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -480,6 +480,7 @@ "mirv_inbound_plural": "⚠️⚠️⚠️ {name} - {count} MIRVs INBOUND ⚠️⚠️⚠️", "mirv_warheads_intercepted": "{count, plural, one {{count} MIRV warhead intercepted} other {{count} MIRV warheads intercepted}}", "missile_intercepted": "Missile intercepted {unit}", + "missile_intercepted_plural": "{count} missiles intercepted ({unit})", "no_boats_available": "No boats available, max {max}", "received_gold_from_captured_ship": "Received {gold} gold from ship captured from {name}", "received_gold_from_conquest": "Conquered {name}, received {gold} gold", diff --git a/src/client/hud/layers/EventsDisplay.ts b/src/client/hud/layers/EventsDisplay.ts index 5a08a2007c..ff7860dad5 100644 --- a/src/client/hud/layers/EventsDisplay.ts +++ b/src/client/hud/layers/EventsDisplay.ts @@ -337,6 +337,14 @@ export class EventsDisplay extends LitElement implements Controller { (existing.unitType === "mirv" ? "" : "_bomb") + "_inbound_plural"; existing.description = translateText(k, { count: c, name: n }); + } else if (existing.groupKey?.startsWith("intercepted_")) { + existing.description = translateText( + "events_display.missile_intercepted_plural", + { + count: c, + unit: getTranslatedUnitName(existing.unitType ?? "", true), + }, + ); } this.requestUpdate(); return; @@ -375,6 +383,8 @@ export class EventsDisplay extends LitElement implements Controller { groupKey = `captured_${unitType}_${targetPlayerName}`; } else if (event.message === "events_display.unit_lost") { groupKey = `lost_${unitType}_${targetPlayerName}`; + } else if (event.message === "events_display.missile_intercepted") { + groupKey = `intercepted_${unitType}`; } this.addEvent({ From 63ecef8de3ab1ed9ddf5ba37fbad9e6f05998f02 Mon Sep 17 00:00:00 2001 From: Blon Td Date: Sat, 11 Jul 2026 11:15:45 -0400 Subject: [PATCH 5/6] Fix structure capture/destroy notifications with ICU pluralization --- resources/lang/en.json | 21 +-- src/client/hud/layers/EventsDisplay.ts | 154 ++++++++----------- src/core/execution/MIRVExecution.ts | 1 + src/core/execution/NukeExecution.ts | 2 + src/core/execution/TransportShipExecution.ts | 1 + src/core/game/Game.ts | 1 + src/core/game/GameImpl.ts | 2 + src/core/game/GameUpdates.ts | 1 + src/core/game/UnitImpl.ts | 3 + tests/StructureEvents.test.ts | 100 ++++++++++++ 10 files changed, 186 insertions(+), 100 deletions(-) create mode 100644 tests/StructureEvents.test.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index 64f1443414..ed4442a3f4 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -461,8 +461,7 @@ "alliance_request_sent": "Alliance request sent to {name}.", "alliance_request_status": "{name} {status} your alliance request", "atom_bomb_detonated": "{name} - atom bomb detonated", - "atom_bomb_inbound": "{name} - atom bomb inbound", - "atom_bomb_inbound_plural": "{name} - {count} atom bombs inbound", + "atom_bomb_inbound": "{count, plural, one {{name} - atom bomb inbound} other {{name} - {count} atom bombs inbound}}", "attack_cancelled_retreat": "Attack cancelled, {troops} soldiers killed during retreat", "attack_request": "{name} requests you attack {target}", "betrayal_debuff_ends": "{time} seconds left until betrayal debuff ends", @@ -473,14 +472,11 @@ "duration_seconds_plural": "{seconds} seconds", "focus": "Focus", "hydrogen_bomb_detonated": "{name} - hydrogen bomb detonated", - "hydrogen_bomb_inbound": "{name} - hydrogen bomb inbound", - "hydrogen_bomb_inbound_plural": "{name} - {count} hydrogen bombs inbound", + "hydrogen_bomb_inbound": "{count, plural, one {{name} - hydrogen bomb inbound} other {{name} - {count} hydrogen bombs inbound}}", "ignore": "Ignore", - "mirv_inbound": "⚠️⚠️⚠️ {name} - MIRV INBOUND ⚠️⚠️⚠️", - "mirv_inbound_plural": "⚠️⚠️⚠️ {name} - {count} MIRVs INBOUND ⚠️⚠️⚠️", + "mirv_inbound": "{count, plural, one {⚠️⚠️⚠️ {name} - MIRV INBOUND ⚠️⚠️⚠️} other {⚠️⚠️⚠️ {name} - {count} MIRVs INBOUND ⚠️⚠️⚠️}}", "mirv_warheads_intercepted": "{count, plural, one {{count} MIRV warhead intercepted} other {{count} MIRV warheads intercepted}}", - "missile_intercepted": "Missile intercepted {unit}", - "missile_intercepted_plural": "{count} missiles intercepted ({unit})", + "missile_intercepted": "{count, plural, one {Missile intercepted {unit}} other {{count} missiles intercepted ({unit})}}", "no_boats_available": "No boats available, max {max}", "received_gold_from_captured_ship": "Received {gold} gold from ship captured from {name}", "received_gold_from_conquest": "Conquered {name}, received {gold} gold", @@ -494,12 +490,9 @@ "sent_gold_to_player": "Sent {gold} gold to {name}", "sent_troops_to_player": "Sent {troops} troops to {name}", "trade_ship_captured": "Your trade ship was captured by {name}", - "unit_captured": "Captured {unit} from {name}", - "unit_captured_plural": "Captured {count} {unit} from {name}", - "unit_destroyed": "Your {unit} was destroyed", - "unit_destroyed_plural": "{count} {unit} destroyed", - "unit_lost": "Your {unit} was captured by {name}", - "unit_lost_plural": "Lost {count} {unit} to {name}", + "unit_captured": "{count, plural, one {Captured {unit} from {name}} other {Captured {count} {unit} from {name}}}", + "unit_destroyed": "{count, plural, one {Your {unit} was destroyed} other {Your {count} {unit} were destroyed}}", + "unit_lost": "{count, plural, one {Your {unit} was captured by {name}} other {Your {count} {unit} were captured by {name}}}", "unit_voluntarily_deleted": "Unit voluntarily deleted", "wants_to_renew_alliance": "{name} wants to renew your alliance" }, diff --git a/src/client/hud/layers/EventsDisplay.ts b/src/client/hud/layers/EventsDisplay.ts index ff7860dad5..1514626f44 100644 --- a/src/client/hud/layers/EventsDisplay.ts +++ b/src/client/hud/layers/EventsDisplay.ts @@ -52,38 +52,11 @@ const getTranslatedUnitName = (unitType: string, plural: boolean): string => { return translateText(plural ? `unit_type_plural.${key}` : `unit_type.${key}`); }; -function parseInboundNuke( - m: string, -): { player: string; nukeType: string } | null { - if (m.endsWith(" - atom bomb inbound")) - return { player: m.slice(0, -20), nukeType: "atom" }; - if (m.endsWith(" - hydrogen bomb inbound")) - return { player: m.slice(0, -24), nukeType: "hydrogen" }; - if (m.startsWith("⚠️⚠️⚠️ ") && m.endsWith(" - MIRV INBOUND ⚠️⚠️⚠️")) - return { player: m.slice(7, -22), nukeType: "mirv" }; - return null; -} - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const _unused = [ - "events_display.atom_bomb_inbound", - "events_display.atom_bomb_inbound_plural", - "events_display.hydrogen_bomb_inbound", - "events_display.hydrogen_bomb_inbound_plural", - "events_display.mirv_inbound", - "events_display.mirv_inbound_plural", - "unit_type_plural.atom_bomb", - "unit_type_plural.boat", - "unit_type_plural.city", - "unit_type_plural.defense_post", - "unit_type_plural.factory", - "unit_type_plural.hydrogen_bomb", - "unit_type_plural.mirv", - "unit_type_plural.missile_silo", - "unit_type_plural.port", - "unit_type_plural.sam_launcher", - "unit_type_plural.warship", -]; +const INBOUND_NUKE_KEYS: Record = { + [MessageType.NUKE_INBOUND]: "events_display.atom_bomb_inbound", + [MessageType.HYDROGEN_BOMB_INBOUND]: "events_display.hydrogen_bomb_inbound", + [MessageType.MIRV_INBOUND]: "events_display.mirv_inbound", +}; interface GameEvent { description: string; @@ -98,6 +71,8 @@ interface GameEvent { count?: number; unitType?: string; targetPlayerName?: string; + messageKey?: string; + messageParams?: Record; } const TIER_1_TYPES: ReadonlySet = new Set([ @@ -308,43 +283,22 @@ export class EventsDisplay extends LitElement implements Controller { if (existing) { existing.count = (existing.count ?? 1) + 1; existing.createdAt = this.game.ticks(); - if (event.unitView) existing.unitView = event.unitView; - if (event.focusID) existing.focusID = event.focusID; - - const u = getTranslatedUnitName(existing.unitType ?? "", true); - const n = existing.targetPlayerName ?? ""; - const c = existing.count; - - if (existing.groupKey?.startsWith("destroyed_")) { - existing.description = translateText( - "events_display.unit_destroyed_plural", - { count: c, unit: u }, - ); - } else if (existing.groupKey?.startsWith("captured_")) { - existing.description = translateText( - "events_display.unit_captured_plural", - { count: c, unit: u, name: n }, - ); - } else if (existing.groupKey?.startsWith("lost_")) { - existing.description = translateText( - "events_display.unit_lost_plural", - { count: c, unit: u, name: n }, - ); - } else if (existing.groupKey?.startsWith("inbound_")) { - const k = - "events_display." + - existing.unitType + - (existing.unitType === "mirv" ? "" : "_bomb") + - "_inbound_plural"; - existing.description = translateText(k, { count: c, name: n }); - } else if (existing.groupKey?.startsWith("intercepted_")) { - existing.description = translateText( - "events_display.missile_intercepted_plural", - { - count: c, - unit: getTranslatedUnitName(existing.unitType ?? "", true), - }, - ); + if (event.unitView !== undefined) { + existing.unitView = event.unitView; + } + if (event.focusID !== undefined) { + existing.focusID = event.focusID; + } + + if (existing.messageKey) { + const params = { ...existing.messageParams, count: existing.count }; + if (params.unit) { + params.unit = getTranslatedUnitName( + existing.unitType ?? "", + existing.count > 1, + ); + } + existing.description = translateText(existing.messageKey, params); } this.requestUpdate(); return; @@ -359,10 +313,15 @@ export class EventsDisplay extends LitElement implements Controller { if ( event.playerID !== null && (!myPlayer || myPlayer.smallID() !== event.playerID) - ) + ) { return; - if (event.message === "events_display.received_gold_from_captured_ship") + } + + // Captured trade-ship gold is surfaced as a transient +gold pip in + // control-panel rather than as a scroll-list entry. + if (event.message === "events_display.received_gold_from_captured_ship") { return; + } const params = { ...event.params }; if (params.unit) { @@ -370,7 +329,7 @@ export class EventsDisplay extends LitElement implements Controller { } const description = event.message.startsWith("events_display.") - ? translateText(event.message, params) + ? translateText(event.message, { ...params, count: 1 }) : event.message; let groupKey: string | undefined; @@ -397,8 +356,15 @@ export class EventsDisplay extends LitElement implements Controller { event.unitID !== undefined ? this.game.unit(event.unitID) : undefined, focusID: event.focusPlayerID, groupKey, + count: 1, unitType, targetPlayerName, + messageKey: event.message.startsWith("events_display.") + ? event.message + : undefined, + messageParams: event.message.startsWith("events_display.") + ? event.params + : undefined, }); } @@ -653,17 +619,32 @@ export class EventsDisplay extends LitElement implements Controller { onUnitIncomingEvent(event: UnitIncomingUpdate) { const myPlayer = this.game.myPlayer(); - if (!myPlayer || myPlayer.smallID() !== event.playerID) return; + if (!myPlayer || myPlayer.smallID() !== event.playerID) { + return; + } - const parsed = parseInboundNuke(event.message); let description = event.message; - if (parsed) { - const k = - "events_display." + - parsed.nukeType + - (parsed.nukeType === "mirv" ? "" : "_bomb") + - "_inbound"; - description = translateText(k, { name: parsed.player }); + let groupKey: string | undefined; + let unitType: string | undefined; + let messageKey: string | undefined; + let messageParams: Record | undefined; + + if (event.attackerName) { + messageKey = INBOUND_NUKE_KEYS[event.messageType]; + if (messageKey) { + messageParams = { name: event.attackerName }; + description = translateText(messageKey, { ...messageParams, count: 1 }); + if (event.messageType === MessageType.NUKE_INBOUND) { + unitType = "Atom Bomb"; + groupKey = `inbound_atom_${event.attackerName}`; + } else if (event.messageType === MessageType.HYDROGEN_BOMB_INBOUND) { + unitType = "Hydrogen Bomb"; + groupKey = `inbound_hydrogen_${event.attackerName}`; + } else if (event.messageType === MessageType.MIRV_INBOUND) { + unitType = "MIRV"; + groupKey = `inbound_mirv_${event.attackerName}`; + } + } } this.addEvent({ @@ -673,11 +654,12 @@ export class EventsDisplay extends LitElement implements Controller { highlight: true, createdAt: this.game.ticks(), unitView: this.game.unit(event.unitID), - groupKey: parsed - ? `inbound_${parsed.nukeType}_${parsed.player}` - : undefined, - unitType: parsed?.nukeType, - targetPlayerName: parsed?.player, + groupKey, + count: 1, + unitType, + targetPlayerName: event.attackerName, + messageKey, + messageParams, }); } diff --git a/src/core/execution/MIRVExecution.ts b/src/core/execution/MIRVExecution.ts index d06d9e23cd..f35d36bb61 100644 --- a/src/core/execution/MIRVExecution.ts +++ b/src/core/execution/MIRVExecution.ts @@ -93,6 +93,7 @@ export class MirvExecution implements Execution { `⚠️⚠️⚠️ ${this.player.displayName()} - MIRV INBOUND ⚠️⚠️⚠️`, MessageType.MIRV_INBOUND, this.targetPlayer.id(), + this.player.displayName(), ); } diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index d4eaefcdbc..b4f15cba2a 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -220,6 +220,7 @@ export class NukeExecution implements Execution { `${this.player.displayName()} - atom bomb inbound`, MessageType.NUKE_INBOUND, target.id(), + this.player.displayName(), ); } else if (this.nukeType === UnitType.HydrogenBomb) { this.mg.displayIncomingUnit( @@ -228,6 +229,7 @@ export class NukeExecution implements Execution { `${this.player.displayName()} - hydrogen bomb inbound`, MessageType.HYDROGEN_BOMB_INBOUND, target.id(), + this.player.displayName(), ); } diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts index 65ceb7ec10..b8aae02908 100644 --- a/src/core/execution/TransportShipExecution.ts +++ b/src/core/execution/TransportShipExecution.ts @@ -157,6 +157,7 @@ export class TransportShipExecution implements Execution { `Naval invasion incoming from ${this.attacker.displayName()} (${renderTroops(this.boat.troops())})`, MessageType.NAVAL_INVASION_INBOUND, this.target.id(), + this.attacker.displayName(), ); } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index d2cf39c76e..7e57760cb4 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -809,6 +809,7 @@ export interface Game extends GameMap { message: string, type: MessageType, playerID: PlayerID | null, + attackerName?: string, ): void; displayChat( diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index a48335f7dd..8c9f85be52 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -1009,6 +1009,7 @@ export class GameImpl implements Game { message: string, type: MessageType, playerID: PlayerID, + attackerName?: string, ): void { const id = this.player(playerID).smallID(); @@ -1018,6 +1019,7 @@ export class GameImpl implements Game { message: message, messageType: type, playerID: id, + attackerName: attackerName, }); } diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index d6decf3b16..f839c6a17e 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -338,6 +338,7 @@ export interface UnitIncomingUpdate { message: string; messageType: MessageType; playerID: number; + attackerName?: string; } export interface EmbargoUpdate { diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index e70c5726ec..9d52b91ec2 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -345,6 +345,9 @@ export class UnitImpl implements Unit { } private displayMessageOnDeleted(): void { + if (!this._owner.isAlive()) { + return; + } // Only warships, transport ships, and structures are worth notifying about; everything // else is either visible on the map or too low-stakes to surface. if ( diff --git a/tests/StructureEvents.test.ts b/tests/StructureEvents.test.ts new file mode 100644 index 0000000000..e8cd315959 --- /dev/null +++ b/tests/StructureEvents.test.ts @@ -0,0 +1,100 @@ +import { vi } from "vitest"; +import { + Game, + MessageType, + Player, + PlayerInfo, + PlayerType, + UnitType, +} from "../src/core/game/Game"; +import { setup } from "./util/Setup"; + +describe("StructureEvents Tests", () => { + let game: Game; + let player1: Player; + let player2: Player; + + beforeEach(async () => { + game = await setup("plains", { + infiniteGold: true, + instantBuild: true, + infiniteTroops: true, + }); + + const player1Info = new PlayerInfo( + "Player1", + PlayerType.Human, + null, + "Player1", + ); + const player2Info = new PlayerInfo( + "Player2", + PlayerType.Human, + null, + "Player2", + ); + + game.addPlayer(player1Info); + game.addPlayer(player2Info); + + player1 = game.player(player1Info.id); + player2 = game.player(player2Info.id); + + // conquer some tiles for players + player1.conquer(game.ref(0, 10)); + player2.conquer(game.ref(0, 15)); + }); + + test("Capturing a structure emits captured and lost messages", () => { + const tile = Array.from(player1.tiles())[0]; + const structure = player1.buildUnit(UnitType.City, tile, {}); + + const displayMessageSpy = vi.spyOn(game, "displayMessage"); + + // capture the structure + structure.setOwner(player2); + + // verify messages + expect(displayMessageSpy).toHaveBeenCalledWith( + "events_display.unit_captured", + MessageType.CAPTURED_ENEMY_UNIT, + player2.id(), + undefined, + { unit: UnitType.City, name: player1.displayName() }, + structure.id(), + ); + + expect(displayMessageSpy).toHaveBeenCalledWith( + "events_display.unit_lost", + MessageType.UNIT_DESTROYED, + player1.id(), + undefined, + { unit: UnitType.City, name: player2.displayName() }, + structure.id(), + ); + + displayMessageSpy.mockRestore(); + }); + + test("Destroying a structure emits unit_destroyed message", () => { + const tile = Array.from(player1.tiles())[0]; + const structure = player1.buildUnit(UnitType.City, tile, {}); + + const displayMessageSpy = vi.spyOn(game, "displayMessage"); + + // delete the structure + structure.delete(); + + // verify message + expect(displayMessageSpy).toHaveBeenCalledWith( + "events_display.unit_destroyed", + MessageType.UNIT_DESTROYED, + player1.id(), + undefined, + { unit: UnitType.City }, + structure.id(), + ); + + displayMessageSpy.mockRestore(); + }); +}); From 7d789da53d030370ef104872f00b96cceabef709 Mon Sep 17 00:00:00 2001 From: Blon Td Date: Sat, 11 Jul 2026 11:23:30 -0400 Subject: [PATCH 6/6] Fix TypeScript typing for unit in messageParams --- src/client/hud/layers/EventsDisplay.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/client/hud/layers/EventsDisplay.ts b/src/client/hud/layers/EventsDisplay.ts index 1514626f44..1a3cfce5f0 100644 --- a/src/client/hud/layers/EventsDisplay.ts +++ b/src/client/hud/layers/EventsDisplay.ts @@ -291,8 +291,11 @@ export class EventsDisplay extends LitElement implements Controller { } if (existing.messageKey) { - const params = { ...existing.messageParams, count: existing.count }; - if (params.unit) { + const params: Record = { + ...existing.messageParams, + count: existing.count, + }; + if (existing.messageParams?.unit) { params.unit = getTranslatedUnitName( existing.unitType ?? "", existing.count > 1,