diff --git a/resources/lang/en.json b/resources/lang/en.json index 05c6f7888d..ed4442a3f4 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": "{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", @@ -471,9 +472,11 @@ "duration_seconds_plural": "{seconds} seconds", "focus": "Focus", "hydrogen_bomb_detonated": "{name} - hydrogen bomb detonated", + "hydrogen_bomb_inbound": "{count, plural, one {{name} - hydrogen bomb inbound} other {{name} - {count} hydrogen bombs inbound}}", "ignore": "Ignore", + "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": "{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", @@ -487,7 +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_destroyed": "Your {unit} was destroyed", + "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" }, @@ -1409,6 +1414,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 042469ff24..1a3cfce5f0 100644 --- a/src/client/hud/layers/EventsDisplay.ts +++ b/src/client/hud/layers/EventsDisplay.ts @@ -32,6 +32,32 @@ import { translateText, } from "../../Utils"; +const UNIT_TRANSLATION_KEYS: Record = { + 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 => { + const key = UNIT_TRANSLATION_KEYS[unitType]; + if (!key) return unitType; + return translateText(plural ? `unit_type_plural.${key}` : `unit_type.${key}`); +}; + +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; unsafeDescription?: boolean; @@ -41,6 +67,12 @@ interface GameEvent { onDelete?: () => void; focusID?: number; unitView?: UnitView; + groupKey?: string; + count?: number; + unitType?: string; + targetPlayerName?: string; + messageKey?: string; + messageParams?: Record; } const TIER_1_TYPES: ReadonlySet = new Set([ @@ -242,6 +274,39 @@ 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 !== undefined) { + existing.unitView = event.unitView; + } + if (event.focusID !== undefined) { + existing.focusID = event.focusID; + } + + if (existing.messageKey) { + const params: Record = { + ...existing.messageParams, + count: existing.count, + }; + if (existing.messageParams?.unit) { + params.unit = getTranslatedUnitName( + existing.unitType ?? "", + existing.count > 1, + ); + } + existing.description = translateText(existing.messageKey, params); + } + this.requestUpdate(); + return; + } + } this.events = [...this.events, event]; this.requestUpdate(); } @@ -261,21 +326,48 @@ export class EventsDisplay extends LitElement implements Controller { return; } - let description: string = event.message; - if (event.message.startsWith("events_display.")) { - description = translateText(event.message, event.params ?? {}); + const params = { ...event.params }; + if (params.unit) { + params.unit = getTranslatedUnitName(String(params.unit), false); + } + + const description = event.message.startsWith("events_display.") + ? translateText(event.message, { ...params, count: 1 }) + : 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}`; + } else if (event.message === "events_display.missile_intercepted") { + groupKey = `intercepted_${unitType}`; } - 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, + count: 1, + unitType, + targetPlayerName, + messageKey: event.message.startsWith("events_display.") + ? event.message + : undefined, + messageParams: event.message.startsWith("events_display.") + ? event.params + : undefined, }); } @@ -530,20 +622,47 @@ export class EventsDisplay extends LitElement implements Controller { onUnitIncomingEvent(event: UnitIncomingUpdate) { const myPlayer = this.game.myPlayer(); - if (!myPlayer || myPlayer.smallID() !== event.playerID) { return; } - const unitView = this.game.unit(event.unitID); + let description = event.message; + 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({ - description: event.message, + description, type: event.messageType, unsafeDescription: false, highlight: true, createdAt: this.game.ticks(), - unitView: unitView, + unitView: this.game.unit(event.unitID), + 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 1daabb9a3b..9d52b91ec2 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,15 @@ export class UnitImpl implements Unit { } private displayMessageOnDeleted(): void { - // Only warships and transport ships are worth notifying about; everything + 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 ( this._type !== UnitType.Warship && - this._type !== UnitType.TransportShip + this._type !== UnitType.TransportShip && + !Structures.has(this._type) ) { return; } 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(); + }); +});