diff --git a/resources/lang/en.json b/resources/lang/en.json index d8f49b2de..34d9b0d4b 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -402,6 +402,10 @@ "health": "Health", "attitude": "Attitude" }, + "events_display": { + "retreating": "retreating", + "boat": "Boat" + }, "relation": { "hostile": "Hostile", "distrustful": "Distrustful", diff --git a/src/client/Utils.ts b/src/client/Utils.ts index 62fc2e80e..b60dcaeb6 100644 --- a/src/client/Utils.ts +++ b/src/client/Utils.ts @@ -1,3 +1,4 @@ +import { MessageType } from "../core/game/Game"; import { LangSelector } from "./LangSelector"; export function renderTroops(troops: number): string { @@ -95,3 +96,57 @@ export const translateText = ( return langSelector.translateText(key, params); }; + +/** + * Severity colors mapping for message types + */ +export const severityColors: Record = { + fail: "text-red-400", + warn: "text-yellow-400", + success: "text-green-400", + info: "text-gray-200", + blue: "text-blue-400", + white: "text-white", +}; + +/** + * Gets the CSS classes for styling message types based on their severity + * @param type The message type to get styling for + * @returns CSS class string for the message type + */ +export function getMessageTypeClasses(type: MessageType): string { + switch (type) { + case MessageType.SAM_HIT: + case MessageType.CAPTURED_ENEMY_UNIT: + case MessageType.RECEIVED_GOLD_FROM_TRADE: + case MessageType.CONQUERED_PLAYER: + return severityColors["success"]; + case MessageType.ATTACK_FAILED: + case MessageType.ALLIANCE_REJECTED: + case MessageType.ALLIANCE_BROKEN: + case MessageType.UNIT_CAPTURED_BY_ENEMY: + case MessageType.UNIT_DESTROYED: + return severityColors["fail"]; + case MessageType.ATTACK_CANCELLED: + case MessageType.ATTACK_REQUEST: + case MessageType.ALLIANCE_ACCEPTED: + case MessageType.SENT_GOLD_TO_PLAYER: + case MessageType.SENT_TROOPS_TO_PLAYER: + case MessageType.RECEIVED_GOLD_FROM_PLAYER: + case MessageType.RECEIVED_TROOPS_FROM_PLAYER: + return severityColors["blue"]; + case MessageType.MIRV_INBOUND: + case MessageType.NUKE_INBOUND: + case MessageType.HYDROGEN_BOMB_INBOUND: + case MessageType.SAM_MISS: + case MessageType.ALLIANCE_EXPIRED: + case MessageType.NAVAL_INVASION_INBOUND: + return severityColors["warn"]; + case MessageType.CHAT: + case MessageType.ALLIANCE_REQUEST: + return severityColors["info"]; + default: + console.warn(`Message type ${type} has no explicit color`); + return severityColors["white"]; + } +} diff --git a/src/client/graphics/layers/EventsDisplay.ts b/src/client/graphics/layers/EventsDisplay.ts index 064e331dc..39b535347 100644 --- a/src/client/graphics/layers/EventsDisplay.ts +++ b/src/client/graphics/layers/EventsDisplay.ts @@ -2,9 +2,15 @@ import { html, LitElement } from "lit"; import { customElement, state } from "lit/decorators.js"; import { DirectiveResult } from "lit/directive.js"; import { unsafeHTML, UnsafeHTMLDirective } from "lit/directives/unsafe-html.js"; +import allianceIcon from "../../../../resources/images/AllianceIconWhite.svg"; +import chatIcon from "../../../../resources/images/ChatIconWhite.svg"; +import donateGoldIcon from "../../../../resources/images/DonateGoldIconWhite.svg"; +import swordIcon from "../../../../resources/images/SwordIconWhite.svg"; import { EventBus } from "../../../core/EventBus"; import { AllPlayers, + getMessageCategory, + MessageCategory, MessageType, PlayerType, Tick, @@ -32,14 +38,14 @@ import { Layer } from "./Layer"; import { GameView, PlayerView, UnitView } from "../../../core/game/GameView"; import { onlyImages } from "../../../core/Util"; -import { renderTroops } from "../../Utils"; +import { renderNumber, renderTroops } from "../../Utils"; import { GoToPlayerEvent, GoToPositionEvent, GoToUnitEvent, } from "./Leaderboard"; -import { translateText } from "../../Utils"; +import { getMessageTypeClasses, translateText } from "../../Utils"; interface GameEvent { description: string; @@ -73,7 +79,50 @@ export class EventsDisplay extends LitElement implements Layer { @state() private outgoingLandAttacks: AttackUpdate[] = []; @state() private outgoingBoats: UnitView[] = []; @state() private _hidden: boolean = false; + @state() private _isVisible: boolean = false; @state() private newEvents: number = 0; + @state() private latestGoldAmount: bigint | null = null; + @state() private goldAmountAnimating: boolean = false; + private goldAmountTimeoutId: ReturnType | null = null; + @state() private eventsFilters: Map = new Map([ + [MessageCategory.ATTACK, false], + [MessageCategory.TRADE, false], + [MessageCategory.ALLIANCE, false], + [MessageCategory.CHAT, false], + ]); + + private renderButton(options: { + content: any; // Can be string, TemplateResult, or other renderable content + onClick?: () => void; + className?: string; + disabled?: boolean; + translate?: boolean; + hidden?: boolean; + }) { + const { + content, + onClick, + className = "", + disabled = false, + translate = true, + hidden = false, + } = options; + + if (hidden) { + return html``; + } + + return html` + + `; + } private toggleHidden() { this._hidden = !this._hidden; @@ -83,6 +132,12 @@ export class EventsDisplay extends LitElement implements Layer { this.requestUpdate(); } + private toggleEventFilter(filterName: MessageCategory) { + const currentState = this.eventsFilters.get(filterName) || false; + this.eventsFilters.set(filterName, !currentState); + this.requestUpdate(); + } + private updateMap = new Map([ [GameUpdateType.DisplayEvent, (u) => this.onDisplayMessageEvent(u)], [GameUpdateType.DisplayChatEvent, (u) => this.onDisplayChatEvent(u)], @@ -109,6 +164,21 @@ export class EventsDisplay extends LitElement implements Layer { tick() { this.active = true; + + if (!this._isVisible && !this.game.inSpawnPhase()) { + this._isVisible = true; + this.requestUpdate(); + } + + const myPlayer = this.game.myPlayer(); + if (!myPlayer || !myPlayer.isAlive()) { + if (this._isVisible) { + this._isVisible = false; + this.requestUpdate(); + } + return; + } + const updates = this.game.updatesSinceLastTick(); if (updates) { for (const [ut, fn] of this.updateMap) { @@ -134,11 +204,6 @@ export class EventsDisplay extends LitElement implements Layer { this.requestUpdate(); } - const myPlayer = this.game.myPlayer(); - if (!myPlayer) { - return; - } - // Update attacks this.incomingAttacks = myPlayer.incomingAttacks().filter((a) => { const t = (this.game.playerBySmallID(a.attackerID) as PlayerView).type(); @@ -160,6 +225,13 @@ export class EventsDisplay extends LitElement implements Layer { this.requestUpdate(); } + disconnectedCallback() { + if (this.goldAmountTimeoutId !== null) { + clearTimeout(this.goldAmountTimeoutId); + this.goldAmountTimeoutId = null; + } + } + private addEvent(event: GameEvent) { this.events = [...this.events, event]; if (this._hidden === true) { @@ -190,6 +262,29 @@ export class EventsDisplay extends LitElement implements Layer { return; } + if (event.goldAmount !== undefined) { + const hasChanged = this.latestGoldAmount !== event.goldAmount; + this.latestGoldAmount = event.goldAmount; + + if (this.goldAmountTimeoutId !== null) { + clearTimeout(this.goldAmountTimeoutId); + } + + this.goldAmountTimeoutId = setTimeout(() => { + this.latestGoldAmount = null; + this.goldAmountTimeoutId = null; + this.requestUpdate(); + }, 5000); + + if (hasChanged) { + this.goldAmountAnimating = true; + setTimeout(() => { + this.goldAmountAnimating = false; + this.requestUpdate(); + }, 600); + } + } + this.addEvent({ description: event.message, createdAt: this.game.ticks(), @@ -267,7 +362,7 @@ export class EventsDisplay extends LitElement implements Layer { }, ], highlight: true, - type: MessageType.INFO, + type: MessageType.ALLIANCE_REQUEST, createdAt: this.game.ticks(), onDelete: () => this.eventBus.emit( @@ -293,7 +388,9 @@ export class EventsDisplay extends LitElement implements Layer { description: `${recipient.name()} ${ update.accepted ? "accepted" : "rejected" } your alliance request`, - type: update.accepted ? MessageType.SUCCESS : MessageType.ERROR, + type: update.accepted + ? MessageType.ALLIANCE_ACCEPTED + : MessageType.ALLIANCE_REJECTED, highlight: true, createdAt: this.game.ticks(), focusID: update.request.recipientID, @@ -322,7 +419,7 @@ export class EventsDisplay extends LitElement implements Layer { description: `You broke your alliance with ${betrayed.name()}, making you a TRAITOR ` + `(${malusPercent}% defense debuff for ${durationText})`, - type: MessageType.ERROR, + type: MessageType.ALLIANCE_BROKEN, highlight: true, createdAt: this.game.ticks(), focusID: update.betrayedID, @@ -330,7 +427,7 @@ export class EventsDisplay extends LitElement implements Layer { } else if (betrayed === myPlayer) { this.addEvent({ description: `${traitor.name()} broke their alliance with you`, - type: MessageType.ERROR, + type: MessageType.ALLIANCE_BROKEN, highlight: true, createdAt: this.game.ticks(), focusID: update.traitorID, @@ -354,7 +451,7 @@ export class EventsDisplay extends LitElement implements Layer { this.addEvent({ description: `Your alliance with ${other.name()} expired`, - type: MessageType.WARN, + type: MessageType.ALLIANCE_EXPIRED, highlight: true, createdAt: this.game.ticks(), focusID: otherID, @@ -370,7 +467,7 @@ export class EventsDisplay extends LitElement implements Layer { this.addEvent({ description: `${other.name()} requests you attack ${target.name()}`, - type: MessageType.INFO, + type: MessageType.ATTACK_REQUEST, highlight: true, createdAt: this.game.ticks(), focusID: event.targetID, @@ -419,7 +516,7 @@ export class EventsDisplay extends LitElement implements Layer { this.addEvent({ description: `${sender.displayName()}:${update.emoji.message}`, unsafeDescription: true, - type: MessageType.INFO, + type: MessageType.CHAT, highlight: true, createdAt: this.game.ticks(), focusID: update.emoji.senderID, @@ -430,7 +527,7 @@ export class EventsDisplay extends LitElement implements Layer { update.emoji.message }`, unsafeDescription: true, - type: MessageType.INFO, + type: MessageType.CHAT, highlight: true, createdAt: this.game.ticks(), focusID: recipient.smallID(), @@ -457,23 +554,6 @@ export class EventsDisplay extends LitElement implements Layer { }); } - private getMessageTypeClasses(type: MessageType): string { - switch (type) { - case MessageType.SUCCESS: - return "text-green-300"; - case MessageType.INFO: - return "text-gray-200"; - case MessageType.CHAT: - return "text-gray-200"; - case MessageType.WARN: - return "text-yellow-300"; - case MessageType.ERROR: - return "text-red-300"; - default: - return "text-white"; - } - } - private getEventDescription( event: GameEvent, ): string | DirectiveResult { @@ -506,27 +586,24 @@ export class EventsDisplay extends LitElement implements Layer { return html` ${this.incomingAttacks.length > 0 ? html` - - - ${this.incomingAttacks.map( - (attack) => html` - - ${attack.retreating ? "(retreating...)" : ""} + ${this.incomingAttacks.map( + (attack) => html` + ${this.renderButton({ + content: html` + ${renderTroops(attack.troops)} + ${( + this.game.playerBySmallID(attack.attackerID) as PlayerView + )?.name()} + ${attack.retreating + ? `(${translateText("events_display.retreating")}...)` + : ""} `, - )} - - + onClick: () => this.attackWarningOnClick(attack), + className: "text-left text-red-400", + translate: false, + })} + `, + )} ` : ""} `; @@ -536,35 +613,39 @@ export class EventsDisplay extends LitElement implements Layer { return html` ${this.outgoingAttacks.length > 0 ? html` - - - ${this.outgoingAttacks.map( - (attack) => html` - - +
+ ${this.outgoingAttacks.map( + (attack) => html` +
+ ${this.renderButton({ + content: html` + ${renderTroops(attack.troops)} + ${( + this.game.playerBySmallID( + attack.targetID, + ) as PlayerView + )?.name()} + `, + onClick: async () => this.attackWarningOnClick(attack), + className: "text-left text-blue-400", + translate: false, + })} ${!attack.retreating - ? html`` - : "(retreating...)"} - `, - )} - - + ? this.renderButton({ + content: "❌", + onClick: () => this.emitCancelAttackIntent(attack.id), + className: "text-left flex-shrink-0", + disabled: attack.retreating, + }) + : html`(${translateText( + "events_display.retreating", + )}...)`} +
+ `, + )} +
` : ""} `; @@ -574,28 +655,33 @@ export class EventsDisplay extends LitElement implements Layer { return html` ${this.outgoingLandAttacks.length > 0 ? html` - - - ${this.outgoingLandAttacks.map( - (landAttack) => html` - - +
+ ${this.outgoingLandAttacks.map( + (landAttack) => html` +
+ ${this.renderButton({ + content: html`${renderTroops(landAttack.troops)} + Wilderness`, + className: "text-left text-gray-400", + translate: false, + })} ${!landAttack.retreating - ? html`` - : "(retreating...)"} - `, - )} - - + ? this.renderButton({ + content: "❌", + onClick: () => + this.emitCancelAttackIntent(landAttack.id), + className: "text-left flex-shrink-0", + disabled: landAttack.retreating, + }) + : html`(${translateText( + "events_display.retreating", + )}...)`} +
+ `, + )} +
` : ""} `; @@ -605,41 +691,71 @@ export class EventsDisplay extends LitElement implements Layer { return html` ${this.outgoingBoats.length > 0 ? html` - - - ${this.outgoingBoats.map( - (boat) => html` - +
+ ${this.outgoingBoats.map( + (boat) => html` +
+ ${this.renderButton({ + content: html`${translateText("events_display.boat")}: + ${renderTroops(boat.troops())}`, + onClick: () => this.emitGoToUnitEvent(boat), + className: "text-left text-blue-400", + translate: false, + })} ${!boat.retreating() - ? html`` - : "(retreating...)"} - `, - )} - - + ? this.renderButton({ + content: "❌", + onClick: () => this.emitBoatCancelIntent(boat.id()), + className: "text-left flex-shrink-0", + disabled: boat.retreating(), + }) + : html`(${translateText( + "events_display.retreating", + )}...)`} +
+ `, + )} +
` : ""} `; } render() { - if (!this.active) { + if (!this.active || !this._isVisible) { return html``; } - this.events.sort((a, b) => { + const styles = html` + + `; + + const filteredEvents = this.events.filter((event) => { + const category = getMessageCategory(event.type); + return !this.eventsFilters.get(category); + }); + + filteredEvents.sort((a, b) => { const aPrior = a.priority ?? 100000; const bPrior = b.priority ?? 100000; if (aPrior === bPrior) { @@ -649,109 +765,265 @@ export class EventsDisplay extends LitElement implements Layer { }); return html` -
-
-
- -
- - - - ${this.events.map( - (event, index) => html` - + ${this._hidden + ? html` +
+ ${this.renderButton({ + content: html` + Events + ${this.newEvents} -
+ + + ` + : ""} + + + ${this.outgoingLandAttacks.length > 0 + ? html` + + + + ` + : ""} + + + ${this.outgoingBoats.length > 0 + ? html` + + + + ` + : ""} + + + ${filteredEvents.length === 0 && + this.incomingAttacks.length === 0 && + this.outgoingAttacks.length === 0 && + this.outgoingLandAttacks.length === 0 && + this.outgoingBoats.length === 0 + ? html` + + + + ` + : ""} + +
- ${event.focusID - ? html`` - : event.unitView - ? html`
+ ${this.renderOutgoingAttacks()} +
+ ${this.renderOutgoingLandAttacks()} +
+ ${this.renderBoats()} +
+   +
+
+
+ + `} `; } diff --git a/src/core/execution/AttackExecution.ts b/src/core/execution/AttackExecution.ts index 6c765a63c..bc8c78dff 100644 --- a/src/core/execution/AttackExecution.ts +++ b/src/core/execution/AttackExecution.ts @@ -180,7 +180,7 @@ export class AttackExecution implements Execution { if (deaths) { this.mg.displayMessage( `Attack cancelled, ${renderTroops(deaths)} soldiers killed during retreat.`, - MessageType.SUCCESS, + MessageType.ATTACK_CANCELLED, this._owner.id(), ); } @@ -340,8 +340,9 @@ export class AttackExecution implements Execution { `Conquered ${this.target.displayName()} received ${renderNumber( gold, )} gold`, - MessageType.SUCCESS, + MessageType.CONQUERED_PLAYER, this._owner.id(), + gold, ); this.target.removeGold(gold); this._owner.addGold(gold); diff --git a/src/core/execution/MIRVExecution.ts b/src/core/execution/MIRVExecution.ts index 18411e80d..6b2b66a23 100644 --- a/src/core/execution/MIRVExecution.ts +++ b/src/core/execution/MIRVExecution.ts @@ -67,8 +67,9 @@ export class MirvExecution implements Execution { this.mg.displayIncomingUnit( this.nuke.id(), + // TODO TranslateText `⚠️⚠️⚠️ ${this.player.name()} - MIRV INBOUND ⚠️⚠️⚠️`, - MessageType.ERROR, + MessageType.MIRV_INBOUND, this.targetPlayer.id(), ); } diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index 91ed7d725..b7cdbbe33 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -111,16 +111,18 @@ export class NukeExecution implements Execution { } else if (this.nukeType === UnitType.AtomBomb) { this.mg.displayIncomingUnit( this.nuke.id(), + // TODO TranslateText `${this.player.name()} - atom bomb inbound`, - MessageType.ERROR, + MessageType.NUKE_INBOUND, target.id(), ); this.breakAlliances(this.tilesToDestroy()); } else if (this.nukeType === UnitType.HydrogenBomb) { this.mg.displayIncomingUnit( this.nuke.id(), + // TODO TranslateText `${this.player.name()} - hydrogen bomb inbound`, - MessageType.ERROR, + MessageType.HYDROGEN_BOMB_INBOUND, target.id(), ); this.breakAlliances(this.tilesToDestroy()); diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index effb78468..4c81d4bb7 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -207,8 +207,9 @@ export class PlayerExecution implements Execution { `Conquered ${this.player.displayName()} received ${renderNumber( gold, )} gold`, - MessageType.SUCCESS, + MessageType.CONQUERED_PLAYER, capturing.id(), + gold, ); capturing.addGold(gold); this.player.removeGold(gold); diff --git a/src/core/execution/SAMLauncherExecution.ts b/src/core/execution/SAMLauncherExecution.ts index 9fffdaab3..783c2b1f4 100644 --- a/src/core/execution/SAMLauncherExecution.ts +++ b/src/core/execution/SAMLauncherExecution.ts @@ -174,7 +174,7 @@ export class SAMLauncherExecution implements Execution { if (!hit) { this.mg.displayMessage( `Missile failed to intercept ${type}`, - MessageType.ERROR, + MessageType.SAM_MISS, this.sam.owner().id(), ); } else { @@ -182,7 +182,7 @@ export class SAMLauncherExecution implements Execution { // Message this.mg.displayMessage( `${mirvWarheadTargets.length} MIRV warheads intercepted`, - MessageType.SUCCESS, + MessageType.SAM_HIT, this.sam.owner().id(), ); // Delete warheads diff --git a/src/core/execution/SAMMissileExecution.ts b/src/core/execution/SAMMissileExecution.ts index fdfe635da..c4db6d622 100644 --- a/src/core/execution/SAMMissileExecution.ts +++ b/src/core/execution/SAMMissileExecution.ts @@ -62,7 +62,7 @@ export class SAMMissileExecution implements Execution { if (result === true) { this.mg.displayMessage( `Missile intercepted ${this.target.type()}`, - MessageType.SUCCESS, + MessageType.SAM_HIT, this._owner.id(), ); this.active = false; diff --git a/src/core/execution/TradeShipExecution.ts b/src/core/execution/TradeShipExecution.ts index 4df07c454..d03f6a859 100644 --- a/src/core/execution/TradeShipExecution.ts +++ b/src/core/execution/TradeShipExecution.ts @@ -131,21 +131,24 @@ export class TradeShipExecution implements Execution { this.tradeShip!.owner().addGold(gold); this.mg.displayMessage( `Received ${renderNumber(gold)} gold from ship captured from ${this.origOwner.displayName()}`, - MessageType.SUCCESS, + MessageType.CAPTURED_ENEMY_UNIT, this.tradeShip!.owner().id(), + gold, ); } else { this.srcPort.owner().addGold(gold); this._dstPort.owner().addGold(gold); this.mg.displayMessage( `Received ${renderNumber(gold)} gold from trade with ${this.srcPort.owner().displayName()}`, - MessageType.SUCCESS, + MessageType.RECEIVED_GOLD_FROM_TRADE, this._dstPort.owner().id(), + gold, ); this.mg.displayMessage( `Received ${renderNumber(gold)} gold from trade with ${this._dstPort.owner().displayName()}`, - MessageType.SUCCESS, + MessageType.RECEIVED_GOLD_FROM_TRADE, this.srcPort.owner().id(), + gold, ); } return; diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts index 78cc71c06..c4258e1ec 100644 --- a/src/core/execution/TransportShipExecution.ts +++ b/src/core/execution/TransportShipExecution.ts @@ -62,7 +62,7 @@ export class TransportShipExecution implements Execution { ) { mg.displayMessage( `No boats available, max ${mg.config().boatMaxNumber()}`, - MessageType.WARN, + MessageType.ATTACK_FAILED, this.attacker.id(), ); this.active = false; @@ -130,8 +130,9 @@ export class TransportShipExecution implements Execution { if (this.targetID && this.targetID !== mg.terraNullius().id()) { mg.displayIncomingUnit( this.boat.id(), + // TODO TranslateText `Naval invasion incoming from ${this.attacker.displayName()}`, - MessageType.WARN, + MessageType.NAVAL_INVASION_INBOUND, this.targetID, ); } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 43433f403..6e89dee63 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -587,6 +587,7 @@ export interface Game extends GameMap { message: string, type: MessageType, playerID: PlayerID | null, + goldAmount?: bigint, ): void; displayIncomingUnit( unitID: number, @@ -653,13 +654,75 @@ export interface EmojiMessage { } export enum MessageType { - SUCCESS, - INFO, - WARN, - ERROR, + ATTACK_FAILED, + ATTACK_CANCELLED, + ATTACK_REQUEST, + CONQUERED_PLAYER, + MIRV_INBOUND, + NUKE_INBOUND, + HYDROGEN_BOMB_INBOUND, + NAVAL_INVASION_INBOUND, + SAM_MISS, + SAM_HIT, + CAPTURED_ENEMY_UNIT, + UNIT_CAPTURED_BY_ENEMY, + UNIT_DESTROYED, + ALLIANCE_ACCEPTED, + ALLIANCE_REJECTED, + 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, CHAT, } +// Message categories used for filtering events in the EventsDisplay +export enum MessageCategory { + ATTACK = "ATTACK", + ALLIANCE = "ALLIANCE", + TRADE = "TRADE", + CHAT = "CHAT", +} + +// Ensures that all message types are included in a category +export const MESSAGE_TYPE_CATEGORIES: Record = { + [MessageType.ATTACK_FAILED]: MessageCategory.ATTACK, + [MessageType.ATTACK_CANCELLED]: MessageCategory.ATTACK, + [MessageType.ATTACK_REQUEST]: MessageCategory.ATTACK, + [MessageType.CONQUERED_PLAYER]: MessageCategory.ATTACK, + [MessageType.MIRV_INBOUND]: MessageCategory.ATTACK, + [MessageType.NUKE_INBOUND]: MessageCategory.ATTACK, + [MessageType.HYDROGEN_BOMB_INBOUND]: MessageCategory.ATTACK, + [MessageType.NAVAL_INVASION_INBOUND]: MessageCategory.ATTACK, + [MessageType.SAM_MISS]: MessageCategory.ATTACK, + [MessageType.SAM_HIT]: MessageCategory.ATTACK, + [MessageType.CAPTURED_ENEMY_UNIT]: MessageCategory.ATTACK, + [MessageType.UNIT_CAPTURED_BY_ENEMY]: MessageCategory.ATTACK, + [MessageType.UNIT_DESTROYED]: MessageCategory.ATTACK, + [MessageType.ALLIANCE_ACCEPTED]: MessageCategory.ALLIANCE, + [MessageType.ALLIANCE_REJECTED]: MessageCategory.ALLIANCE, + [MessageType.ALLIANCE_REQUEST]: MessageCategory.ALLIANCE, + [MessageType.ALLIANCE_BROKEN]: MessageCategory.ALLIANCE, + [MessageType.ALLIANCE_EXPIRED]: 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.CHAT]: MessageCategory.CHAT, +} as const; + +/** + * Get the category of a message type + */ +export function getMessageCategory(messageType: MessageType): MessageCategory { + return MESSAGE_TYPE_CATEGORIES[messageType]; +} + export interface NameViewData { x: number; y: number; diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 1b26d3f83..17fb3a9db 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -612,6 +612,7 @@ export class GameImpl implements Game { message: string, type: MessageType, playerID: PlayerID | null, + goldAmount?: bigint, ): void { let id: number | null = null; if (playerID !== null) { @@ -622,6 +623,7 @@ export class GameImpl implements Game { messageType: type, message: message, playerID: id, + goldAmount: goldAmount, }); } diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index 7078d6b9f..4a67f55e5 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -162,6 +162,7 @@ export interface DisplayMessageUpdate { type: GameUpdateType.DisplayEvent; message: string; messageType: MessageType; + goldAmount?: bigint; playerID: number | null; } diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 323d8f41b..580e38bb6 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -558,12 +558,12 @@ export class PlayerImpl implements Player { this.sentDonations.push(new Donation(recipient, this.mg.ticks())); this.mg.displayMessage( `Sent ${renderTroops(troops)} troops to ${recipient.name()}`, - MessageType.INFO, + MessageType.SENT_TROOPS_TO_PLAYER, this.id(), ); this.mg.displayMessage( `Received ${renderTroops(troops)} troops from ${this.name()}`, - MessageType.SUCCESS, + MessageType.RECEIVED_TROOPS_FROM_PLAYER, recipient.id(), ); return true; @@ -578,13 +578,14 @@ export class PlayerImpl implements Player { this.sentDonations.push(new Donation(recipient, this.mg.ticks())); this.mg.displayMessage( `Sent ${renderNumber(gold)} gold to ${recipient.name()}`, - MessageType.INFO, + MessageType.SENT_GOLD_TO_PLAYER, this.id(), ); this.mg.displayMessage( `Received ${renderNumber(gold)} gold from ${this.name()}`, - MessageType.SUCCESS, + MessageType.RECEIVED_GOLD_FROM_PLAYER, recipient.id(), + gold, ); return true; } diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index 2b41b46b9..e68178eee 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -160,6 +160,7 @@ export class UnitImpl implements Unit { case UnitType.City: this.mg.stats().unitCapture(newOwner, this._type); this.mg.stats().unitLose(this._owner, this._type); + break; } this._lastOwner = this._owner; this._lastOwner._units = this._lastOwner._units.filter((u) => u !== this); @@ -168,12 +169,12 @@ export class UnitImpl implements Unit { this.mg.addUpdate(this.toUpdate()); this.mg.displayMessage( `Your ${this.type()} was captured by ${newOwner.displayName()}`, - MessageType.ERROR, + MessageType.UNIT_CAPTURED_BY_ENEMY, this._lastOwner.id(), ); this.mg.displayMessage( `Captured ${this.type()} from ${this._lastOwner.displayName()}`, - MessageType.SUCCESS, + MessageType.CAPTURED_ENEMY_UNIT, newOwner.id(), ); } @@ -200,7 +201,7 @@ export class UnitImpl implements Unit { if (displayMessage !== false && this._type !== UnitType.MIRVWarhead) { this.mg.displayMessage( `Your ${this._type} was destroyed`, - MessageType.ERROR, + MessageType.UNIT_DESTROYED, this.owner().id(), ); } diff --git a/tests/MessageTypeClasses.test.ts b/tests/MessageTypeClasses.test.ts new file mode 100644 index 000000000..706600a03 --- /dev/null +++ b/tests/MessageTypeClasses.test.ts @@ -0,0 +1,55 @@ +import { getMessageTypeClasses, severityColors } from "../src/client/Utils"; +import { MessageType } from "../src/core/game/Game"; + +describe("getMessageTypeClasses", () => { + // Spy on console.warn to track when the default case is hit + let consoleSpy: jest.SpyInstance; + + beforeEach(() => { + consoleSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + it("should return a valid CSS class for every MessageType", () => { + const messageTypes = Object.values(MessageType).filter( + (value) => typeof value === "number", + ) as MessageType[]; + + messageTypes.forEach((messageType) => { + const result = getMessageTypeClasses(messageType); + + expect(Object.values(severityColors)).toContain(result); + + expect(result).toBeTruthy(); + expect(typeof result).toBe("string"); + }); + }); + + it("should not trigger console.warn for any MessageType", () => { + const messageTypes = Object.values(MessageType).filter( + (value) => typeof value === "number", + ) as MessageType[]; + + messageTypes.forEach((messageType) => { + getMessageTypeClasses(messageType); + }); + + // No message type should fall through to the default case + expect(consoleSpy).not.toHaveBeenCalled(); + }); + + it("should return white color and warn for unknown message types", () => { + // Cast to MessageType to test the default case + const unknownType = 999 as MessageType; + + const result = getMessageTypeClasses(unknownType); + + expect(result).toBe(severityColors["white"]); + expect(consoleSpy).toHaveBeenCalledWith( + "Message type 999 has no explicit color", + ); + }); +});