diff --git a/index.html b/index.html index aca2853cd0..3c1b6531be 100644 --- a/index.html +++ b/index.html @@ -323,6 +323,9 @@ + diff --git a/resources/lang/en.json b/resources/lang/en.json index 3735db8822..da7f26b811 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -950,6 +950,8 @@ "betrayed_you": "{name} broke their alliance with you", "about_to_expire": "Your alliance with {name} is about to expire!", "alliance_expired": "Your alliance with {name} expired", + "atom_bomb_detonated": "{name} - atom bomb detonated", + "hydrogen_bomb_detonated": "{name} - hydrogen bomb detonated", "attack_request": "{name} requests you attack {target}", "sent_emoji": "Sent {name}: {emoji}", "renew_alliance": "Request to renew", diff --git a/src/client/Utils.ts b/src/client/Utils.ts index 943e764a4a..8d3bc93510 100644 --- a/src/client/Utils.ts +++ b/src/client/Utils.ts @@ -507,6 +507,7 @@ export function getMessageTypeClasses(type: MessageType): string { case MessageType.ALLIANCE_BROKEN: case MessageType.UNIT_CAPTURED_BY_ENEMY: case MessageType.UNIT_DESTROYED: + case MessageType.NUKE_DETONATED: return severityColors["fail"]; case MessageType.ATTACK_CANCELLED: case MessageType.ATTACK_REQUEST: diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 4df65faccd..64016beb96 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -6,6 +6,7 @@ import { RefreshGraphicsEvent as RedrawGraphicsEvent } from "../InputHandler"; import { FrameProfiler } from "./FrameProfiler"; import { TransformHandler } from "./TransformHandler"; import { UIState } from "./UIState"; +import { ActionableEvents } from "./layers/ActionableEvents"; import { AlertFrame } from "./layers/AlertFrame"; import { AttackingTroopsOverlay } from "./layers/AttackingTroopsOverlay"; import { AttacksDisplay } from "./layers/AttacksDisplay"; @@ -129,6 +130,16 @@ export function createRenderer( eventsDisplay.game = game; eventsDisplay.uiState = uiState; + const actionableEvents = document.querySelector( + "actionable-events", + ) as ActionableEvents; + if (!(actionableEvents instanceof ActionableEvents)) { + console.error("actionable events not found"); + } + actionableEvents.eventBus = eventBus; + actionableEvents.game = game; + actionableEvents.uiState = uiState; + const attacksDisplay = document.querySelector( "attacks-display", ) as AttacksDisplay; @@ -290,6 +301,7 @@ export function createRenderer( new NameLayer(game, transformHandler, eventBus), new AttackingTroopsOverlay(game, transformHandler, eventBus, userSettings), eventsDisplay, + actionableEvents, attacksDisplay, chatDisplay, buildMenu, diff --git a/src/client/graphics/layers/ActionableEvents.ts b/src/client/graphics/layers/ActionableEvents.ts new file mode 100644 index 0000000000..cd2d2cff8e --- /dev/null +++ b/src/client/graphics/layers/ActionableEvents.ts @@ -0,0 +1,331 @@ +import { html, LitElement } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import { EventBus } from "../../../core/EventBus"; +import { MessageType, Tick } from "../../../core/game/Game"; +import { + AllianceExtensionUpdate, + AllianceRequestUpdate, + BrokeAllianceUpdate, + GameUpdateType, +} from "../../../core/game/GameUpdates"; +import { GameView, PlayerView } from "../../../core/game/GameView"; +import { PlaySoundEffectEvent } from "../../sound/Sounds"; +import { + SendAllianceExtensionIntentEvent, + SendAllianceRejectIntentEvent, + SendAllianceRequestIntentEvent, +} from "../../Transport"; +import { getMessageTypeClasses, translateText } from "../../Utils"; +import { GoToPlayerEvent } from "../TransformHandler"; +import { UIState } from "../UIState"; +import { Layer } from "./Layer"; + +interface ActionableEvent { + description: string; + type: MessageType; + createdAt: number; + focusID: number; + buttons: { + text: string; + className: string; + action: () => void; + preventClose?: boolean; + }[]; + priority?: number; + allianceID?: number; + duration?: Tick; +} + +@customElement("actionable-events") +export class ActionableEvents extends LitElement implements Layer { + public eventBus: EventBus; + public game: GameView; + public uiState: UIState; + + private active = false; + private events: ActionableEvent[] = []; + // allianceID -> last checked at tick + private alliancesCheckedAt = new Map(); + @state() private _isVisible = false; + + private updateMap = [ + [GameUpdateType.AllianceRequest, this.onAllianceRequestEvent.bind(this)], + [GameUpdateType.BrokeAlliance, this.onBrokeAllianceEvent.bind(this)], + [ + GameUpdateType.AllianceExtension, + this.onAllianceExtensionEvent.bind(this), + ], + ] as const; + + shouldTransform(): boolean { + return false; + } + + renderLayer(): void {} + + createRenderRoot() { + return this; + } + + private addEvent(event: ActionableEvent) { + this.events = [...this.events, event]; + this.requestUpdate(); + } + + private removeEvent(index: number) { + this.events = [ + ...this.events.slice(0, index), + ...this.events.slice(index + 1), + ]; + } + + private removeAllianceRenewalEvents(allianceID: number) { + this.events = this.events.filter( + (event) => + !( + event.type === MessageType.RENEW_ALLIANCE && + event.allianceID === allianceID + ), + ); + } + + 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; + } + + this.checkForAllianceExpirations(); + + const updates = this.game.updatesSinceLastTick(); + if (updates) { + for (const [ut, fn] of this.updateMap) { + updates[ut]?.forEach(fn as (event: unknown) => void); + } + } + + const remainingEvents = this.events.filter( + (event) => + event.duration === undefined || + this.game.ticks() - event.createdAt < event.duration, + ); + + if (this.events.length !== remainingEvents.length) { + this.events = remainingEvents; + this.requestUpdate(); + } + } + + private checkForAllianceExpirations() { + const myPlayer = this.game.myPlayer(); + if (!myPlayer?.isAlive()) return; + + const currentAllianceIds = new Set(); + + for (const alliance of myPlayer.alliances()) { + currentAllianceIds.add(alliance.id); + + if ( + alliance.expiresAt > + this.game.ticks() + this.game.config().allianceExtensionPromptOffset() + ) { + continue; + } + + if ( + (this.alliancesCheckedAt.get(alliance.id) ?? 0) >= + this.game.ticks() - this.game.config().allianceExtensionPromptOffset() + ) { + // Already prompted for this alliance in the current window. + continue; + } + + this.alliancesCheckedAt.set(alliance.id, this.game.ticks()); + + const other = this.game.player(alliance.other) as PlayerView; + + this.addEvent({ + description: translateText("events_display.about_to_expire", { + name: other.displayName(), + }), + type: MessageType.RENEW_ALLIANCE, + buttons: [ + { + text: translateText("events_display.focus"), + className: "btn-gray", + action: () => this.eventBus.emit(new GoToPlayerEvent(other)), + preventClose: true, + }, + { + text: translateText("events_display.renew_alliance", { + name: other.displayName(), + }), + className: "btn", + action: () => + this.eventBus.emit(new SendAllianceExtensionIntentEvent(other)), + }, + { + text: translateText("events_display.ignore"), + className: "btn-info", + action: () => {}, + }, + ], + createdAt: this.game.ticks(), + focusID: other.smallID(), + allianceID: alliance.id, + }); + } + + for (const [allianceId] of this.alliancesCheckedAt) { + if (!currentAllianceIds.has(allianceId)) { + this.removeAllianceRenewalEvents(allianceId); + this.alliancesCheckedAt.delete(allianceId); + } + } + } + + onAllianceRequestEvent(update: AllianceRequestUpdate) { + const myPlayer = this.game.myPlayer(); + if (!myPlayer || update.recipientID !== myPlayer.smallID()) { + return; + } + + const requestor = this.game.playerBySmallID( + update.requestorID, + ) as PlayerView; + const recipient = this.game.playerBySmallID( + update.recipientID, + ) as PlayerView; + + if (!requestor.isAlliedWith(recipient)) { + this.eventBus.emit(new PlaySoundEffectEvent("alliance-suggested")); + } + this.addEvent({ + description: translateText("events_display.request_alliance", { + name: requestor.displayName(), + }), + buttons: [ + { + text: translateText("events_display.focus"), + className: "btn-gray", + action: () => this.eventBus.emit(new GoToPlayerEvent(requestor)), + preventClose: true, + }, + { + text: translateText("events_display.accept_alliance"), + className: "btn", + action: () => + this.eventBus.emit( + new SendAllianceRequestIntentEvent(recipient, requestor), + ), + }, + { + text: translateText("events_display.reject_alliance"), + className: "btn-info", + action: () => + this.eventBus.emit(new SendAllianceRejectIntentEvent(requestor)), + }, + ], + type: MessageType.ALLIANCE_REQUEST, + createdAt: this.game.ticks(), + priority: 0, + duration: this.game.config().allianceRequestDuration(), + focusID: update.requestorID, + }); + } + + onBrokeAllianceEvent(update: BrokeAllianceUpdate) { + // Cleanup-only: any open renewal prompt for this alliance is now moot. + this.removeAllianceRenewalEvents(update.allianceID); + this.alliancesCheckedAt.delete(update.allianceID); + this.requestUpdate(); + } + + private onAllianceExtensionEvent(update: AllianceExtensionUpdate) { + const myPlayer = this.game.myPlayer(); + if (!myPlayer || myPlayer.smallID() !== update.playerID) return; + this.removeAllianceRenewalEvents(update.allianceID); + this.requestUpdate(); + } + + private emitGoToPlayerEvent(focusID: number) { + const target = this.game.playerBySmallID(focusID) as PlayerView; + if (!target) return; + this.eventBus.emit(new GoToPlayerEvent(target)); + } + + render() { + if (!this.active || !this._isVisible || this.events.length === 0) { + return html``; + } + + const sorted = [...this.events].sort((a, b) => { + const aPrior = a.priority ?? 100000; + const bPrior = b.priority ?? 100000; + if (aPrior === bPrior) { + return a.createdAt - b.createdAt; + } + return bPrior - aPrior; + }); + + return html` +
+ ${sorted.map( + (event) => html` +
+ +
+ ${event.buttons.map( + (btn) => html` + + `, + )} +
+
+ `, + )} +
+ `; + } +} diff --git a/src/client/graphics/layers/ControlPanel.ts b/src/client/graphics/layers/ControlPanel.ts index 1bc72d3f53..51174e42b9 100644 --- a/src/client/graphics/layers/ControlPanel.ts +++ b/src/client/graphics/layers/ControlPanel.ts @@ -1,8 +1,10 @@ import { LitElement, html } from "lit"; import { customElement, state } from "lit/decorators.js"; +import { keyed } from "lit/directives/keyed.js"; import { assetUrl } from "../../../core/AssetUrls"; import { EventBus } from "../../../core/EventBus"; -import { Gold } from "../../../core/game/Game"; +import { Gold, MessageType } from "../../../core/game/Game"; +import { GameUpdateType } from "../../../core/game/GameUpdates"; import { GameView } from "../../../core/game/GameView"; import { UserSettings } from "../../../core/game/UserSettings"; import { ClientID } from "../../../core/Schemas"; @@ -42,14 +44,16 @@ export class ControlPanel extends LitElement implements Layer { @state() private _attackingTroops: number = 0; + @state() + private _goldGain: bigint | null = null; + @state() + private _goldGainPulseId: number = 0; + private _goldGainTimeoutId: ReturnType | null = null; + private _troopRateIsIncreasing: boolean = true; private _lastTroopIncreaseRate: number; - getTickIntervalMs() { - return 100; - } - init() { this.attackRatio = new UserSettings().attackRatio(); this.uiState.attackRatio = this.attackRatio; @@ -95,9 +99,70 @@ export class ControlPanel extends LitElement implements Layer { .map((a) => a.troops) .reduce((a, b) => a + b, 0); this.troopRate = this.game.config().troopIncreaseRate(player) * 10; + + const updates = this.game.updatesSinceLastTick(); + if (updates) { + const myID = player.id(); + const mySmallID = player.smallID(); + const bonusEvents = updates[GameUpdateType.BonusEvent]; + if (bonusEvents) { + for (const ev of bonusEvents) { + if (ev.player === myID && ev.gold > 0) { + this.addGoldGain(BigInt(ev.gold)); + } + } + } + const conquestEvents = updates[GameUpdateType.ConquestEvent]; + if (conquestEvents) { + for (const ev of conquestEvents) { + if (ev.conquerorId === myID && ev.gold > 0n) { + this.addGoldGain(ev.gold); + } + } + } + // Donations don't fire BonusEvent (no map location); pick them up from + // the existing RECEIVED_GOLD_FROM_PLAYER display message instead. + const displayEvents = updates[GameUpdateType.DisplayEvent]; + if (displayEvents) { + for (const ev of displayEvents) { + if ( + ev.messageType === MessageType.RECEIVED_GOLD_FROM_PLAYER && + ev.playerID === mySmallID && + ev.goldAmount !== undefined && + ev.goldAmount > 0n + ) { + this.addGoldGain(ev.goldAmount); + } + } + } + } + this.requestUpdate(); } + // Last-wins: when multiple gold events arrive in one tick, the pip shows + // only the most recent amount (not a sum) — each gain restarts the pulse. + private addGoldGain(amount: bigint) { + this._goldGain = amount; + this._goldGainPulseId++; + if (this._goldGainTimeoutId !== null) { + clearTimeout(this._goldGainTimeoutId); + } + this._goldGainTimeoutId = setTimeout(() => { + this._goldGain = null; + this._goldGainTimeoutId = null; + this.requestUpdate(); + }, 2000); + } + + disconnectedCallback() { + super.disconnectedCallback(); + if (this._goldGainTimeoutId !== null) { + clearTimeout(this._goldGainTimeoutId); + this._goldGainTimeoutId = null; + } + } + private updateTroopIncrease() { const player = this.game?.myPlayer(); if (player === null) return; @@ -292,9 +357,18 @@ export class ControlPanel extends LitElement implements Layer {
${this.renderDesktopTroopBar()}
+ ${this._goldGain !== null + ? keyed( + this._goldGainPulseId, + html`+${renderNumber(this._goldGain)}`, + ) + : ""} ${renderNumber(this._gold)}
@@ -337,9 +411,18 @@ export class ControlPanel extends LitElement implements Layer {
+ ${this._goldGain !== null + ? keyed( + this._goldGainPulseId, + html`+${renderNumber(this._goldGain)}`, + ) + : ""} ${renderNumber(this._gold)}
@@ -382,6 +465,21 @@ export class ControlPanel extends LitElement implements Layer { render() { return html` +
void; - preventClose?: boolean; - }[]; type: MessageType; highlight?: boolean; createdAt: number; onDelete?: () => void; - // lower number: lower on the display - priority?: number; - duration?: Tick; focusID?: number; unitView?: UnitView; - shouldDelete?: (game: GameView) => boolean; - allianceID?: number; } +const TIER_1_TYPES: ReadonlySet = new Set([ + MessageType.NUKE_INBOUND, + MessageType.HYDROGEN_BOMB_INBOUND, + MessageType.MIRV_INBOUND, + MessageType.NUKE_DETONATED, + MessageType.NAVAL_INVASION_INBOUND, + MessageType.ATTACK_REQUEST, + MessageType.ALLIANCE_BROKEN, + MessageType.CONQUERED_PLAYER, + MessageType.CHAT, +]); + +const isTier1 = (type: MessageType): boolean => TIER_1_TYPES.has(type); + @customElement("events-display") export class EventsDisplay extends LitElement implements Layer { public eventBus: EventBus; @@ -76,21 +60,7 @@ export class EventsDisplay extends LitElement implements Layer { private active: boolean = false; private events: GameEvent[] = []; - // allianceID -> last checked at tick - private alliancesCheckedAt = new Map(); - @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.NUKE, false], - [MessageCategory.TRADE, false], - [MessageCategory.ALLIANCE, false], - [MessageCategory.CHAT, false], - ]); @query(".events-container") private _eventsContainer?: HTMLDivElement; @@ -136,40 +106,9 @@ export class EventsDisplay extends LitElement implements Layer { `; } - private renderToggleButton(src: string, category: MessageCategory) { - // Adding the literal for the default size ensures tailwind will generate the class - const toggleButtonSizeMap = { default: "h-5" }; - return this.renderButton({ - content: html``, - onClick: () => this.toggleEventFilter(category), - className: "cursor-pointer pointer-events-auto", - }); - } - - private toggleHidden() { - this._hidden = !this._hidden; - if (this._hidden) { - this.newEvents = 0; - } - this.requestUpdate(); - } - - private toggleEventFilter(filterName: MessageCategory) { - const currentState = this.eventsFilters.get(filterName) ?? false; - this.eventsFilters.set(filterName, !currentState); - this.requestUpdate(); - } - private updateMap = [ [GameUpdateType.DisplayEvent, this.onDisplayMessageEvent.bind(this)], [GameUpdateType.DisplayChatEvent, this.onDisplayChatEvent.bind(this)], - [GameUpdateType.AllianceRequest, this.onAllianceRequestEvent.bind(this)], [ GameUpdateType.AllianceRequestReply, this.onAllianceRequestReplyEvent.bind(this), @@ -179,10 +118,6 @@ export class EventsDisplay extends LitElement implements Layer { [GameUpdateType.Emoji, this.onEmojiMessageEvent.bind(this)], [GameUpdateType.UnitIncoming, this.onUnitIncomingEvent.bind(this)], [GameUpdateType.AllianceExpired, this.onAllianceExpiredEvent.bind(this)], - [ - GameUpdateType.AllianceExtension, - this.onAllianceExtensionEvent.bind(this), - ], ] as const; constructor() { @@ -236,8 +171,6 @@ export class EventsDisplay extends LitElement implements Layer { return; } - this.checkForAllianceExpirations(); - const updates = this.game.updatesSinceLastTick(); if (updates) { for (const [ut, fn] of this.updateMap) { @@ -246,9 +179,17 @@ export class EventsDisplay extends LitElement implements Layer { } let remainingEvents = this.events.filter((event) => { - const shouldKeep = - this.game.ticks() - event.createdAt < (event.duration ?? 600) && - !event.shouldDelete?.(this.game); + const expired = this.game.ticks() - event.createdAt >= 80; + const isInboundWarning = + event.type === MessageType.NUKE_INBOUND || + event.type === MessageType.HYDROGEN_BOMB_INBOUND || + event.type === MessageType.MIRV_INBOUND || + event.type === MessageType.NAVAL_INVASION_INBOUND; + const unitGone = + isInboundWarning && + event.unitView !== undefined && + !event.unitView.isActive(); + const shouldKeep = !expired && !unitGone; if (!shouldKeep && event.onDelete) { event.onDelete(); } @@ -267,114 +208,17 @@ export class EventsDisplay extends LitElement implements Layer { this.requestUpdate(); } - disconnectedCallback() { - if (this.goldAmountTimeoutId !== null) { - clearTimeout(this.goldAmountTimeoutId); - this.goldAmountTimeoutId = null; - } - } - - private checkForAllianceExpirations() { - const myPlayer = this.game.myPlayer(); - if (!myPlayer?.isAlive()) return; - - const currentAllianceIds = new Set(); - - for (const alliance of myPlayer.alliances()) { - currentAllianceIds.add(alliance.id); - - if ( - alliance.expiresAt > - this.game.ticks() + this.game.config().allianceExtensionPromptOffset() - ) { - continue; - } - - if ( - (this.alliancesCheckedAt.get(alliance.id) ?? 0) >= - this.game.ticks() - this.game.config().allianceExtensionPromptOffset() - ) { - // We've already displayed a message for this alliance. - continue; - } - - this.alliancesCheckedAt.set(alliance.id, this.game.ticks()); - - const other = this.game.player(alliance.other) as PlayerView; - - this.addEvent({ - description: translateText("events_display.about_to_expire", { - name: other.displayName(), - }), - type: MessageType.RENEW_ALLIANCE, - duration: this.game.config().allianceExtensionPromptOffset() - 3 * 10, // 3 second buffer - buttons: [ - { - text: translateText("events_display.focus"), - className: "btn-gray", - action: () => this.eventBus.emit(new GoToPlayerEvent(other)), - preventClose: true, - }, - { - text: translateText("events_display.renew_alliance", { - name: other.displayName(), - }), - className: "btn", - action: () => - this.eventBus.emit(new SendAllianceExtensionIntentEvent(other)), - }, - { - text: translateText("events_display.ignore"), - className: "btn-info", - action: () => {}, - }, - ], - highlight: true, - createdAt: this.game.ticks(), - focusID: other.smallID(), - allianceID: alliance.id, - }); - } - - for (const [allianceId] of this.alliancesCheckedAt) { - if (!currentAllianceIds.has(allianceId)) { - this.removeAllianceRenewalEvents(allianceId); - this.alliancesCheckedAt.delete(allianceId); - } - } - } - private addEvent(event: GameEvent) { this.events = [...this.events, event]; - if (this._hidden === true) { - this.newEvents++; - } this.requestUpdate(); } - private removeEvent(index: number) { - this.events = [ - ...this.events.slice(0, index), - ...this.events.slice(index + 1), - ]; - } - shouldTransform(): boolean { return false; } renderLayer(): void {} - private removeAllianceRenewalEvents(allianceID: number) { - this.events = this.events.filter( - (event) => - !( - event.type === MessageType.RENEW_ALLIANCE && - event.allianceID === allianceID - ), - ); - } - onDisplayMessageEvent(event: DisplayMessageUpdate) { const myPlayer = this.game.myPlayer(); if ( @@ -384,27 +228,13 @@ 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); - } + // Trade-ship arrivals (normal and pirate-capture) are surfaced as a + // transient +gold pip in control-panel rather than as scroll-list entries. + if ( + event.messageType === MessageType.RECEIVED_GOLD_FROM_TRADE || + event.message === "events_display.received_gold_from_captured_ship" + ) { + return; } let description: string = event.message; @@ -412,12 +242,16 @@ export class EventsDisplay extends LitElement implements Layer { description = translateText(event.message, event.params ?? {}); } + const unitView = + event.unitID !== undefined ? this.game.unit(event.unitID) : undefined; this.addEvent({ description: description, createdAt: this.game.ticks(), highlight: true, type: event.messageType, unsafeDescription: true, + unitView: unitView, + focusID: event.focusPlayerID, }); } @@ -467,81 +301,9 @@ export class EventsDisplay extends LitElement implements Layer { this.eventBus.emit(new PlaySoundEffectEvent("message")); } - onAllianceRequestEvent(update: AllianceRequestUpdate) { - const myPlayer = this.game.myPlayer(); - if (!myPlayer || update.recipientID !== myPlayer.smallID()) { - return; - } - - const requestor = this.game.playerBySmallID( - update.requestorID, - ) as PlayerView; - const recipient = this.game.playerBySmallID( - update.recipientID, - ) as PlayerView; - - if (!requestor.isAlliedWith(recipient)) { - this.eventBus.emit(new PlaySoundEffectEvent("alliance-suggested")); - } - this.addEvent({ - description: translateText("events_display.request_alliance", { - name: requestor.displayName(), - }), - buttons: [ - { - text: translateText("events_display.focus"), - className: "btn-gray", - action: () => this.eventBus.emit(new GoToPlayerEvent(requestor)), - preventClose: true, - }, - { - text: translateText("events_display.accept_alliance"), - className: "btn", - action: () => - this.eventBus.emit( - new SendAllianceRequestIntentEvent(recipient, requestor), - ), - }, - { - text: translateText("events_display.reject_alliance"), - className: "btn-info", - action: () => - this.eventBus.emit(new SendAllianceRejectIntentEvent(requestor)), - }, - ], - highlight: true, - type: MessageType.ALLIANCE_REQUEST, - createdAt: this.game.ticks(), - priority: 0, - duration: this.game.config().allianceRequestDuration() - 20, // 2 second buffer - shouldDelete: (game) => { - // Recipient sent a separate request, so they became allied without the recipient responding. - return requestor.isAlliedWith(recipient); - }, - focusID: update.requestorID, - }); - } - onAllianceRequestReplyEvent(update: AllianceRequestReplyUpdate) { const myPlayer = this.game.myPlayer(); - if (!myPlayer) { - return; - } - // myPlayer can deny alliances without clicking on the button - if (update.request.recipientID === myPlayer.smallID()) { - // Remove alliance requests whose requestors are the same as the reply's requestor - // Noop unless the request was denied through other means (e.g attacking the requestor) - this.events = this.events.filter( - (event) => - !( - event.type === MessageType.ALLIANCE_REQUEST && - event.focusID === update.request.requestorID - ), - ); - this.requestUpdate(); - return; - } - if (update.request.requestorID !== myPlayer.smallID()) { + if (!myPlayer || update.request.requestorID !== myPlayer.smallID()) { return; } @@ -568,10 +330,6 @@ export class EventsDisplay extends LitElement implements Layer { const myPlayer = this.game.myPlayer(); if (!myPlayer) return; - this.removeAllianceRenewalEvents(update.allianceID); - this.alliancesCheckedAt.delete(update.allianceID); - this.requestUpdate(); - const betrayed = this.game.playerBySmallID(update.betrayedID) as PlayerView; const traitor = this.game.playerBySmallID(update.traitorID) as PlayerView; @@ -606,14 +364,6 @@ export class EventsDisplay extends LitElement implements Layer { }); } else if (betrayed === myPlayer) { this.eventBus.emit(new PlaySoundEffectEvent("alliance-broken")); - const buttons = [ - { - text: translateText("events_display.focus"), - className: "btn-gray", - action: () => this.eventBus.emit(new GoToPlayerEvent(traitor)), - preventClose: true, - }, - ]; this.addEvent({ description: translateText("events_display.betrayed_you", { name: traitor.displayName(), @@ -622,7 +372,6 @@ export class EventsDisplay extends LitElement implements Layer { highlight: true, createdAt: this.game.ticks(), focusID: update.traitorID, - buttons, }); } } @@ -652,13 +401,6 @@ export class EventsDisplay extends LitElement implements Layer { }); } - private onAllianceExtensionEvent(update: AllianceExtensionUpdate) { - const myPlayer = this.game.myPlayer(); - if (!myPlayer || myPlayer.smallID() !== update.playerID) return; - this.removeAllianceRenewalEvents(update.allianceID); - this.requestUpdate(); - } - onTargetPlayerEvent(event: TargetPlayerUpdate) { const other = this.game.playerBySmallID(event.playerID) as PlayerView; const myPlayer = this.game.myPlayer() as PlayerView; @@ -775,244 +517,107 @@ export class EventsDisplay extends LitElement implements Layer { `; } + private renderEventRow(event: GameEvent) { + return 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)} + + + `; + } + render() { if (!this.active || !this._isVisible) { return html``; } - const styles = html` - - `; + const myPlayer = this.game.myPlayer(); + const showBetrayalTimer = !!( + myPlayer && + myPlayer.isTraitor() && + myPlayer.getTraitorRemainingTicks() > 0 + ); - const filteredEvents = this.events.filter((event) => { - const category = getMessageCategory(event.type); - return !this.eventsFilters.get(category); - }); + const tier1Events: GameEvent[] = []; + let tier2Events: GameEvent[] = []; + for (const event of this.events) { + (isTier1(event.type) ? tier1Events : tier2Events).push(event); + } + tier1Events.sort((a, b) => a.createdAt - b.createdAt); + tier2Events.sort((a, b) => a.createdAt - b.createdAt); + tier2Events = tier2Events.slice(-4); - filteredEvents.sort((a, b) => { - const aPrior = a.priority ?? 100000; - const bPrior = b.priority ?? 100000; - if (aPrior === bPrior) { - return a.createdAt - b.createdAt; - } - return bPrior - aPrior; - }); + if ( + tier1Events.length === 0 && + tier2Events.length === 0 && + !showBetrayalTimer + ) { + return html``; + } return html` - ${styles} - - ${this._hidden - ? html` -
- ${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` - - `, - )} - - ${(() => { - const myPlayer = this.game.myPlayer(); - return ( - myPlayer && - myPlayer.isTraitor() && - myPlayer.getTraitorRemainingTicks() > 0 - ); - })() - ? html` - - - - ` - : ""} - - - ${filteredEvents.length === 0 && - !(() => { - const myPlayer = this.game.myPlayer(); - return ( - myPlayer && - myPlayer.isTraitor() && - myPlayer.getTraitorRemainingTicks() > 0 - ); - })() - ? 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()}
- ${this.renderBetrayalDebuffTimer()} -
-   -
-
+ ` + : ""} + +
-
- `} + ` + : ""} +
`; } 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..d431b08f0f 100644 --- a/src/core/execution/TradeShipExecution.ts +++ b/src/core/execution/TradeShipExecution.ts @@ -189,7 +189,7 @@ 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", diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index a16058e556..bc9ef6a3c4 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -922,6 +922,8 @@ export interface Game extends GameMap { playerID: PlayerID | null, goldAmount?: bigint, params?: Record, + unitID?: number, + focusPlayerID?: PlayerID, ): void; displayIncomingUnit( unitID: number, @@ -1026,6 +1028,7 @@ export enum MessageType { CONQUERED_PLAYER, MIRV_INBOUND, NUKE_INBOUND, + NUKE_DETONATED, HYDROGEN_BOMB_INBOUND, NAVAL_INVASION_INBOUND, SAM_MISS, @@ -1064,6 +1067,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, diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 75435cda82..5a998c0962 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 eeefba6564..1974ef19f0 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -253,6 +253,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 6f163598b3..24b21aa648 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -826,6 +826,8 @@ export class PlayerImpl implements Player { this.id(), undefined, { gold: renderNumber(gold), name: recipient.displayName() }, + undefined, + recipient.id(), ); this.mg.displayMessage( "events_display.received_gold_from_player", @@ -833,6 +835,8 @@ export class PlayerImpl implements Player { recipient.id(), gold, { gold: renderNumber(gold), name: this.displayName() }, + undefined, + this.id(), ); 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 82f4945eb8..fc688777d0 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/graphics/layers/EventsDisplay"; +import { ActionableEvents } from "../../../../src/client/graphics/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..d52c9cff7f 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,93 @@ 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", () => { + 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), null), + ); + 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();