- ${this.renderButton({
- content: html`
-
- ${translateText("events_display.events")}
- ${this.newEvents > 0
- ? html`${this.newEvents}`
- : ""}
-
- `,
- onClick: this.toggleHidden,
- className:
- "text-white cursor-pointer pointer-events-auto w-fit p-2 lg:p-3 min-[1200px]:rounded-lg sm:rounded-tl-lg bg-gray-800/92 backdrop-blur-sm",
- })}
-
- `
- : html`
-
-
-
+
+ ${tier2Events.length > 0
+ ? html`
-
-
- ${this.renderToggleButton(
- swordIcon,
- MessageCategory.ATTACK,
- )}
- ${this.renderToggleButton(nukeIcon, MessageCategory.NUKE)}
- ${this.renderToggleButton(
- donateGoldIcon,
- MessageCategory.TRADE,
- )}
- ${this.renderToggleButton(
- allianceIcon,
- MessageCategory.ALLIANCE,
- )}
- ${this.renderToggleButton(chatIcon, MessageCategory.CHAT)}
-
-
- ${this.latestGoldAmount !== null
- ? html`+${renderNumber(this.latestGoldAmount)}`
- : ""}
- ${this.renderButton({
- content: translateText("leaderboard.hide"),
- onClick: this.toggleHidden,
- className:
- "text-white cursor-pointer pointer-events-auto",
- })}
-
-
+
+
+ ${tier2Events.map((event) => this.renderEventRow(event))}
+
+
-
-
+ `
+ : ""}
+ ${tier1Events.length > 0 || showBetrayalTimer
+ ? html`
-
-
-
- ${filteredEvents.map(
- (event, index) => html`
+
+
+ ${tier1Events.map((event) => this.renderEventRow(event))}
+ ${showBetrayalTimer
+ ? html`
- |
- ${event.focusID
- ? this.renderButton({
- content: this.getEventDescription(event),
- onClick: () => {
- if (event.focusID)
- this.emitGoToPlayerEvent(event.focusID);
- },
- className: "text-left",
- })
- : event.unitView
- ? this.renderButton({
- content: this.getEventDescription(event),
- onClick: () => {
- if (event.unitView)
- this.emitGoToUnitEvent(
- event.unitView,
- );
- },
- className: "text-left",
- })
- : this.getEventDescription(event)}
-
- ${event.buttons
- ? html`
-
- ${event.buttons.map(
- (btn) => html`
-
- `,
- )}
-
- `
- : ""}
+ |
+ ${this.renderBetrayalDebuffTimer()}
|
- `,
- )}
-
- ${(() => {
- const myPlayer = this.game.myPlayer();
- return (
- myPlayer &&
- myPlayer.isTraitor() &&
- myPlayer.getTraitorRemainingTicks() > 0
- );
- })()
- ? html`
-
- |
- ${this.renderBetrayalDebuffTimer()}
- |
-
- `
- : ""}
-
-
- ${filteredEvents.length === 0 &&
- !(() => {
- const myPlayer = this.game.myPlayer();
- return (
- myPlayer &&
- myPlayer.isTraitor() &&
- myPlayer.getTraitorRemainingTicks() > 0
- );
- })()
- ? html`
-
- |
-
- |
-
- `
- : ""}
-
-
-
+ `
+ : ""}
+
+
-
- `}
+ `
+ : ""}
+
`;
}
diff --git a/src/client/render/types/GameUpdates.ts b/src/client/render/types/GameUpdates.ts
index 40bae1a5d2..438378c6b3 100644
--- a/src/client/render/types/GameUpdates.ts
+++ b/src/client/render/types/GameUpdates.ts
@@ -35,16 +35,6 @@ export const GameUpdateType = {
NukeDetonation: 22,
} as const;
-/** MessageType enum values from the game source. */
-export const MessageType = {
- SAM_HIT: 9,
- SENT_GOLD_TO_PLAYER: 18,
- RECEIVED_GOLD_FROM_PLAYER: 19,
- RECEIVED_GOLD_FROM_TRADE: 20,
- SENT_TROOPS_TO_PLAYER: 21,
- RECEIVED_TROOPS_FROM_PLAYER: 22,
-} as const;
-
// ---------------------------------------------------------------------------
// Typed update payloads (keyed by GameUpdateType values)
// ---------------------------------------------------------------------------
diff --git a/src/client/render/types/index.ts b/src/client/render/types/index.ts
index ff724aa0a5..5e4b1783ce 100644
--- a/src/client/render/types/index.ts
+++ b/src/client/render/types/index.ts
@@ -61,7 +61,7 @@ export type {
} from "./Replay";
// Game update type constants and event payloads (shared between shim + codec)
-export { GameUpdateType, MessageType } from "./GameUpdates";
+export { GameUpdateType } from "./GameUpdates";
export type {
AllianceExpiredUpdate,
AllianceReplyUpdate,
diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts
index 6e3e779f48..123edd1c26 100644
--- a/src/core/execution/NukeExecution.ts
+++ b/src/core/execution/NukeExecution.ts
@@ -386,6 +386,27 @@ export class NukeExecution implements Execution {
this.nuke.setReachedTarget();
this.nuke.delete(false);
+ if (
+ this.nukeType === UnitType.AtomBomb ||
+ this.nukeType === UnitType.HydrogenBomb
+ ) {
+ const messageKey =
+ this.nukeType === UnitType.AtomBomb
+ ? "events_display.atom_bomb_detonated"
+ : "events_display.hydrogen_bomb_detonated";
+ for (const [impactedPlayer] of tilesPerPlayers) {
+ mg.displayMessage(
+ messageKey,
+ MessageType.NUKE_DETONATED,
+ impactedPlayer.id(),
+ undefined,
+ { name: this.player.displayName() },
+ undefined,
+ this.player.id(),
+ );
+ }
+ }
+
// Record stats
this.mg
.stats()
diff --git a/src/core/execution/TradeShipExecution.ts b/src/core/execution/TradeShipExecution.ts
index 5c9d4b484e..01519fe40f 100644
--- a/src/core/execution/TradeShipExecution.ts
+++ b/src/core/execution/TradeShipExecution.ts
@@ -189,28 +189,8 @@ export class TradeShipExecution implements Execution {
.stats()
.boatCapturedTrade(this.tradeShip!.owner(), this.origOwner, gold);
} else {
- this.srcPort.owner().addGold(gold);
+ this.srcPort.owner().addGold(gold, this.srcPort.tile());
this._dstPort.owner().addGold(gold, this._dstPort.tile());
- this.mg.displayMessage(
- "events_display.received_gold_from_trade",
- MessageType.RECEIVED_GOLD_FROM_TRADE,
- this._dstPort.owner().id(),
- gold,
- {
- gold: renderNumber(gold),
- name: this.srcPort.owner().displayName(),
- },
- );
- this.mg.displayMessage(
- "events_display.received_gold_from_trade",
- MessageType.RECEIVED_GOLD_FROM_TRADE,
- this.srcPort.owner().id(),
- gold,
- {
- gold: renderNumber(gold),
- name: this._dstPort.owner().displayName(),
- },
- );
// Record stats
this.mg
.stats()
diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts
index e4f51bfebb..02b0b6d380 100644
--- a/src/core/game/Game.ts
+++ b/src/core/game/Game.ts
@@ -926,6 +926,8 @@ export interface Game extends GameMap {
playerID: PlayerID | null,
goldAmount?: bigint,
params?: Record
,
+ unitID?: number,
+ focusPlayerID?: PlayerID,
): void;
displayIncomingUnit(
unitID: number,
@@ -1030,6 +1032,7 @@ export enum MessageType {
CONQUERED_PLAYER,
MIRV_INBOUND,
NUKE_INBOUND,
+ NUKE_DETONATED,
HYDROGEN_BOMB_INBOUND,
NAVAL_INVASION_INBOUND,
SAM_MISS,
@@ -1042,11 +1045,8 @@ export enum MessageType {
ALLIANCE_REQUEST,
ALLIANCE_BROKEN,
ALLIANCE_EXPIRED,
- SENT_GOLD_TO_PLAYER,
- RECEIVED_GOLD_FROM_PLAYER,
- RECEIVED_GOLD_FROM_TRADE,
- SENT_TROOPS_TO_PLAYER,
- RECEIVED_TROOPS_FROM_PLAYER,
+ DONATION_SENT,
+ DONATION_RECEIVED,
CHAT,
RENEW_ALLIANCE,
}
@@ -1068,6 +1068,7 @@ export const MESSAGE_TYPE_CATEGORIES: Record = {
[MessageType.CONQUERED_PLAYER]: MessageCategory.ATTACK,
[MessageType.MIRV_INBOUND]: MessageCategory.NUKE,
[MessageType.NUKE_INBOUND]: MessageCategory.NUKE,
+ [MessageType.NUKE_DETONATED]: MessageCategory.NUKE,
[MessageType.HYDROGEN_BOMB_INBOUND]: MessageCategory.NUKE,
[MessageType.NAVAL_INVASION_INBOUND]: MessageCategory.ATTACK,
[MessageType.SAM_MISS]: MessageCategory.ATTACK,
@@ -1081,11 +1082,8 @@ export const MESSAGE_TYPE_CATEGORIES: Record = {
[MessageType.ALLIANCE_BROKEN]: MessageCategory.ALLIANCE,
[MessageType.ALLIANCE_EXPIRED]: MessageCategory.ALLIANCE,
[MessageType.RENEW_ALLIANCE]: MessageCategory.ALLIANCE,
- [MessageType.SENT_GOLD_TO_PLAYER]: MessageCategory.TRADE,
- [MessageType.RECEIVED_GOLD_FROM_PLAYER]: MessageCategory.TRADE,
- [MessageType.RECEIVED_GOLD_FROM_TRADE]: MessageCategory.TRADE,
- [MessageType.SENT_TROOPS_TO_PLAYER]: MessageCategory.TRADE,
- [MessageType.RECEIVED_TROOPS_FROM_PLAYER]: MessageCategory.TRADE,
+ [MessageType.DONATION_SENT]: MessageCategory.TRADE,
+ [MessageType.DONATION_RECEIVED]: MessageCategory.TRADE,
[MessageType.CHAT]: MessageCategory.CHAT,
} as const;
diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts
index 0979e25a11..4659960496 100644
--- a/src/core/game/GameImpl.ts
+++ b/src/core/game/GameImpl.ts
@@ -926,11 +926,17 @@ export class GameImpl implements Game {
playerID: PlayerID | null,
goldAmount?: bigint,
params?: Record,
+ unitID?: number,
+ focusPlayerID?: PlayerID,
): void {
let id: number | null = null;
if (playerID !== null) {
id = this.player(playerID).smallID();
}
+ const focusID =
+ focusPlayerID !== undefined
+ ? this.player(focusPlayerID).smallID()
+ : undefined;
this.addUpdate({
type: GameUpdateType.DisplayEvent,
messageType: type,
@@ -938,6 +944,8 @@ export class GameImpl implements Game {
playerID: id,
goldAmount: goldAmount,
params: params,
+ unitID: unitID,
+ focusPlayerID: focusID,
});
}
diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts
index 28c831ccc8..d0a0983043 100644
--- a/src/core/game/GameUpdates.ts
+++ b/src/core/game/GameUpdates.ts
@@ -68,6 +68,7 @@ export enum GameUpdateType {
EmbargoEvent,
SpawnPhaseEnd,
GamePaused,
+ DonateEvent,
}
export type GameUpdate =
@@ -92,7 +93,8 @@ export type GameUpdate =
| ConquestUpdate
| EmbargoUpdate
| SpawnPhaseEndUpdate
- | GamePausedUpdate;
+ | GamePausedUpdate
+ | DonateEventUpdate;
export interface BonusEventUpdate {
type: GameUpdateType.BonusEvent;
@@ -129,6 +131,14 @@ export interface ConquestUpdate {
gold: Gold;
}
+export interface DonateEventUpdate {
+ type: GameUpdateType.DonateEvent;
+ donationType: "troops" | "gold";
+ senderId: PlayerID;
+ recipientId: PlayerID;
+ amount: bigint;
+}
+
export interface UnitUpdate {
type: GameUpdateType.Unit;
unitType: UnitType;
@@ -262,6 +272,8 @@ export interface DisplayMessageUpdate {
goldAmount?: bigint;
playerID: number | null;
params?: Record;
+ unitID?: number;
+ focusPlayerID?: number;
}
export type DisplayChatMessageUpdate = {
diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts
index 5fbe7d41b5..c40c7d6da9 100644
--- a/src/core/game/PlayerImpl.ts
+++ b/src/core/game/PlayerImpl.ts
@@ -1,4 +1,3 @@
-import { renderNumber, renderTroops } from "../../client/Utils";
import { PseudoRandom } from "../PseudoRandom";
import { ClientID } from "../Schemas";
import {
@@ -23,7 +22,6 @@ import {
EmojiMessage,
GameMode,
Gold,
- MessageType,
MutableAlliance,
Player,
PlayerBuildable,
@@ -822,20 +820,13 @@ export class PlayerImpl implements Player {
recipient.addTroops(removed);
this.sentDonations.push(new Donation(recipient, this.mg.ticks()));
- this.mg.displayMessage(
- "events_display.sent_troops_to_player",
- MessageType.SENT_TROOPS_TO_PLAYER,
- this.id(),
- undefined,
- { troops: renderTroops(troops), name: recipient.displayName() },
- );
- this.mg.displayMessage(
- "events_display.received_troops_from_player",
- MessageType.RECEIVED_TROOPS_FROM_PLAYER,
- recipient.id(),
- undefined,
- { troops: renderTroops(troops), name: this.displayName() },
- );
+ this.mg.addUpdate({
+ type: GameUpdateType.DonateEvent,
+ donationType: "troops",
+ senderId: this.id(),
+ recipientId: recipient.id(),
+ amount: BigInt(removed),
+ });
return true;
}
@@ -846,20 +837,13 @@ export class PlayerImpl implements Player {
recipient.addGold(removed);
this.sentDonations.push(new Donation(recipient, this.mg.ticks()));
- this.mg.displayMessage(
- "events_display.sent_gold_to_player",
- MessageType.SENT_GOLD_TO_PLAYER,
- this.id(),
- undefined,
- { gold: renderNumber(gold), name: recipient.displayName() },
- );
- this.mg.displayMessage(
- "events_display.received_gold_from_player",
- MessageType.RECEIVED_GOLD_FROM_PLAYER,
- recipient.id(),
- gold,
- { gold: renderNumber(gold), name: this.displayName() },
- );
+ this.mg.addUpdate({
+ type: GameUpdateType.DonateEvent,
+ donationType: "gold",
+ senderId: this.id(),
+ recipientId: recipient.id(),
+ amount: removed,
+ });
return true;
}
diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts
index 9444ed70b8..7dde14fd39 100644
--- a/src/core/game/UnitImpl.ts
+++ b/src/core/game/UnitImpl.ts
@@ -219,6 +219,7 @@ export class UnitImpl implements Unit {
this._lastOwner.id(),
undefined,
{ unit: this.type(), name: newOwner.displayName() },
+ this.id(),
);
this.mg.displayMessage(
"events_display.captured_enemy_unit",
@@ -226,6 +227,7 @@ export class UnitImpl implements Unit {
newOwner.id(),
undefined,
{ unit: this.type(), name: this._lastOwner.displayName() },
+ this.id(),
);
}
@@ -336,6 +338,7 @@ export class UnitImpl implements Unit {
this.owner().id(),
undefined,
{ unit: this._type },
+ this.id(),
);
}
diff --git a/tests/client/graphics/layers/EventDisplayAlliance.test.ts b/tests/client/graphics/layers/ActionableEventsAlliance.test.ts
similarity index 91%
rename from tests/client/graphics/layers/EventDisplayAlliance.test.ts
rename to tests/client/graphics/layers/ActionableEventsAlliance.test.ts
index d477ee47a7..91c6baa86a 100644
--- a/tests/client/graphics/layers/EventDisplayAlliance.test.ts
+++ b/tests/client/graphics/layers/ActionableEventsAlliance.test.ts
@@ -21,10 +21,10 @@ vi.mock("lit/directives/unsafe-html.js", () => ({
UnsafeHTMLDirective: class {},
}));
-import { EventsDisplay } from "../../../../src/client/hud/layers/EventsDisplay";
+import { ActionableEvents } from "../../../../src/client/hud/layers/ActionableEvents";
import { MessageType } from "../../../../src/core/game/Game";
-describe("EventsDisplay - alliance renewal cleanup (allianceID based)", () => {
+describe("ActionableEvents - alliance renewal cleanup (allianceID based)", () => {
function makeRenewal(
allianceID: number,
focusID: number,
@@ -40,7 +40,7 @@ describe("EventsDisplay - alliance renewal cleanup (allianceID based)", () => {
}
test("removes ONLY renewal events for the broken alliance", () => {
- const display = new EventsDisplay();
+ const display = new ActionableEvents();
const allianceAB = 1;
const allianceAC = 2;
@@ -67,7 +67,7 @@ describe("EventsDisplay - alliance renewal cleanup (allianceID based)", () => {
});
test("does NOT remove renewals just because the same player is involved", () => {
- const display = new EventsDisplay();
+ const display = new ActionableEvents();
const allianceAB = 10;
const allianceAC = 11;
@@ -86,7 +86,7 @@ describe("EventsDisplay - alliance renewal cleanup (allianceID based)", () => {
});
test("breaking one alliance does not affect renewals between other players", () => {
- const display = new EventsDisplay();
+ const display = new ActionableEvents();
const allianceAB = 100;
const allianceCD = 200;
@@ -105,7 +105,7 @@ describe("EventsDisplay - alliance renewal cleanup (allianceID based)", () => {
});
test("onAllianceExtensionEvent removes renewal when playerID matches myPlayer", () => {
- const display = new EventsDisplay();
+ const display = new ActionableEvents();
const allianceID = 42;
const mySmallID = 7;
@@ -127,7 +127,7 @@ describe("EventsDisplay - alliance renewal cleanup (allianceID based)", () => {
});
test("onAllianceExtensionEvent keeps renewal when playerID does not match myPlayer", () => {
- const display = new EventsDisplay();
+ const display = new ActionableEvents();
const allianceID = 42;
const mySmallID = 7;
@@ -150,7 +150,7 @@ describe("EventsDisplay - alliance renewal cleanup (allianceID based)", () => {
});
test("onAllianceExtensionEvent keeps renewal when myPlayer is null", () => {
- const display = new EventsDisplay();
+ const display = new ActionableEvents();
const allianceID = 42;
@@ -171,7 +171,7 @@ describe("EventsDisplay - alliance renewal cleanup (allianceID based)", () => {
});
test("does not affect non-RENEW_ALLIANCE events", () => {
- const display = new EventsDisplay();
+ const display = new ActionableEvents();
(display as any).events = [
{
diff --git a/tests/core/executions/NukeExecution.test.ts b/tests/core/executions/NukeExecution.test.ts
index 5b2cca93d8..fdc8ba961c 100644
--- a/tests/core/executions/NukeExecution.test.ts
+++ b/tests/core/executions/NukeExecution.test.ts
@@ -1,6 +1,7 @@
import { NukeExecution } from "../../../src/core/execution/NukeExecution";
import {
Game,
+ MessageType,
Player,
PlayerInfo,
PlayerType,
@@ -119,6 +120,99 @@ describe("NukeExecution", () => {
expect(player.isAlliedWith(otherPlayer)).toBe(false);
});
+ test("AtomBomb detonation emits NUKE_DETONATED to each impacted player", () => {
+ player.buildUnit(UnitType.MissileSilo, game.ref(1, 1), {});
+ // Give otherPlayer a cluster around (50,50) so the blast intersects them.
+ for (let x = 48; x < 53; x++) {
+ for (let y = 48; y < 53; y++) {
+ otherPlayer.conquer(game.ref(x, y));
+ }
+ }
+
+ const displayMessageSpy = vi.spyOn(game, "displayMessage");
+
+ game.addExecution(
+ new NukeExecution(
+ UnitType.AtomBomb,
+ player,
+ game.ref(50, 50),
+ game.ref(1, 1),
+ ),
+ );
+ executeTicks(game, 200);
+
+ const detonatedCalls = displayMessageSpy.mock.calls.filter(
+ (call) => call[1] === MessageType.NUKE_DETONATED,
+ );
+ expect(detonatedCalls.length).toBeGreaterThan(0);
+ const otherCall = detonatedCalls.find(
+ (call) => call[2] === otherPlayer.id(),
+ );
+ expect(otherCall).toBeDefined();
+ expect(otherCall![0]).toBe("events_display.atom_bomb_detonated");
+ // focusPlayerID (7th positional) is the launcher
+ expect(otherCall![6]).toBe(player.id());
+
+ displayMessageSpy.mockRestore();
+ });
+
+ test("HydrogenBomb detonation emits NUKE_DETONATED with hydrogen_bomb key", () => {
+ player.buildUnit(UnitType.MissileSilo, game.ref(1, 1), {});
+ for (let x = 48; x < 53; x++) {
+ for (let y = 48; y < 53; y++) {
+ otherPlayer.conquer(game.ref(x, y));
+ }
+ }
+
+ const displayMessageSpy = vi.spyOn(game, "displayMessage");
+
+ game.addExecution(
+ new NukeExecution(
+ UnitType.HydrogenBomb,
+ player,
+ game.ref(50, 50),
+ game.ref(1, 1),
+ ),
+ );
+ executeTicks(game, 300);
+
+ const detonatedCalls = displayMessageSpy.mock.calls.filter(
+ (call) => call[1] === MessageType.NUKE_DETONATED,
+ );
+ expect(detonatedCalls.length).toBeGreaterThan(0);
+ expect(detonatedCalls[0][0]).toBe("events_display.hydrogen_bomb_detonated");
+
+ displayMessageSpy.mockRestore();
+ });
+
+ test("MIRVWarhead detonation does NOT emit NUKE_DETONATED", () => {
+ player.buildUnit(UnitType.MissileSilo, game.ref(1, 1), {});
+ for (let x = 48; x < 53; x++) {
+ for (let y = 48; y < 53; y++) {
+ otherPlayer.conquer(game.ref(x, y));
+ }
+ }
+
+ const displayMessageSpy = vi.spyOn(game, "displayMessage");
+
+ game.addExecution(
+ new NukeExecution(
+ UnitType.MIRVWarhead,
+ player,
+ game.ref(50, 50),
+ game.ref(1, 1),
+ ),
+ );
+ executeTicks(game, 200);
+
+ const detonatedCalls = displayMessageSpy.mock.calls.filter(
+ (call) => call[1] === MessageType.NUKE_DETONATED,
+ );
+ expect(detonatedCalls).toHaveLength(0);
+
+ displayMessageSpy.mockRestore();
+ });
+
test("nuke should break alliance when destroying ally's building even with few tiles", async () => {
const req = player.createAllianceRequest(otherPlayer);
req!.accept();
diff --git a/tests/core/executions/TradeShipExecution.test.ts b/tests/core/executions/TradeShipExecution.test.ts
index 72e6834dac..b05986cb4a 100644
--- a/tests/core/executions/TradeShipExecution.test.ts
+++ b/tests/core/executions/TradeShipExecution.test.ts
@@ -143,6 +143,7 @@ describe("TradeShipExecution", () => {
tradeShipExecution.tick(1);
expect(tradeShip.delete).toHaveBeenCalledWith(false);
expect(tradeShipExecution.isActive()).toBe(false);
- expect(game.displayMessage).toHaveBeenCalled();
+ expect(origOwner.addGold).toHaveBeenCalled();
+ expect(dstOwner.addGold).toHaveBeenCalled();
});
});