diff --git a/index.html b/index.html index cad490b1ca..fd3418c27d 100644 --- a/index.html +++ b/index.html @@ -241,17 +241,20 @@
-
- - -
+
+
+ + +
+
+ @@ -271,6 +274,7 @@ + diff --git a/resources/lang/en.json b/resources/lang/en.json index a9663b2b06..bb557bb8bd 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -729,6 +729,8 @@ "alliance_renewed": "Your alliance with {name} has been renewed", "wants_to_renew_alliance": "{name} wants to renew your alliance", "ignore": "Ignore", + "seconds_remaining": "{seconds}s remaining", + "wants_to_renew": "✓ Wants to renew!", "unit_voluntarily_deleted": "Unit voluntarily deleted", "betrayal_debuff_ends": "{time} seconds left until betrayal debuff ends", "attack_cancelled_retreat": "Attack cancelled, {troops} soldiers killed during retreat", diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index b4cd3eb386..37562f4ec4 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -8,6 +8,7 @@ import { TransformHandler } from "./TransformHandler"; import { UIState } from "./UIState"; import { AdTimer } from "./layers/AdTimer"; import { AlertFrame } from "./layers/AlertFrame"; +import { AllianceRequestPanel } from "./layers/AllianceRequestPanel"; import { BuildMenu } from "./layers/BuildMenu"; import { ChatDisplay } from "./layers/ChatDisplay"; import { ChatModal } from "./layers/ChatModal"; @@ -122,6 +123,15 @@ export function createRenderer( eventsDisplay.game = game; eventsDisplay.uiState = uiState; + const allianceRequestPanel = document.querySelector( + "alliance-request-panel", + ) as AllianceRequestPanel; + if (!(allianceRequestPanel instanceof AllianceRequestPanel)) { + console.error("alliance request panel not found"); + } + allianceRequestPanel.eventBus = eventBus; + allianceRequestPanel.game = game; + const chatDisplay = document.querySelector("chat-display") as ChatDisplay; if (!(chatDisplay instanceof ChatDisplay)) { console.error("chat display not found"); @@ -261,6 +271,7 @@ export function createRenderer( new DynamicUILayer(game, transformHandler, eventBus), new NameLayer(game, transformHandler, eventBus), eventsDisplay, + allianceRequestPanel, chatDisplay, buildMenu, new MainRadialMenu( diff --git a/src/client/graphics/layers/AllianceRequestPanel.ts b/src/client/graphics/layers/AllianceRequestPanel.ts new file mode 100644 index 0000000000..9fd85c86a6 --- /dev/null +++ b/src/client/graphics/layers/AllianceRequestPanel.ts @@ -0,0 +1,967 @@ +import { Colord } from "colord"; +import { base64url } from "jose"; +import { html, LitElement } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import { EventBus } from "../../../core/EventBus"; +import { Tick } from "../../../core/game/Game"; +import { + AllianceRequestReplyUpdate, + AllianceRequestUpdate, + GameUpdateType, +} from "../../../core/game/GameUpdates"; +import { GameView, PlayerView } from "../../../core/game/GameView"; +import { PatternDecoder } from "../../../core/PatternDecoder"; +import { PlayerPattern } from "../../../core/Schemas"; +import { + SendAllianceExtensionIntentEvent, + SendAllianceReplyIntentEvent, +} from "../../Transport"; +import { translateText } from "../../Utils"; +import { Layer } from "./Layer"; +import { GoToPlayerEvent } from "./Leaderboard"; + +// Cache for pattern preview images +const patternPreviewCache = new Map(); + +function generatePatternPreviewDataUrl( + pattern: PlayerPattern, + size: number, +): string { + const patternLookupKey = [ + pattern.name, + pattern.colorPalette?.primaryColor ?? "undefined", + pattern.colorPalette?.secondaryColor ?? "undefined", + size, + ].join("-"); + + if (patternPreviewCache.has(patternLookupKey)) { + return patternPreviewCache.get(patternLookupKey)!; + } + + try { + const decoder = new PatternDecoder(pattern, base64url.decode); + const scaledWidth = decoder.scaledWidth(); + const scaledHeight = decoder.scaledHeight(); + + const width = Math.max(1, Math.floor(size / scaledWidth)) * scaledWidth; + const height = Math.max(1, Math.floor(size / scaledHeight)) * scaledHeight; + + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (!ctx) return ""; + + const imageData = ctx.createImageData(width, height); + const data = imageData.data; + const primary = pattern.colorPalette?.primaryColor + ? new Colord(pattern.colorPalette.primaryColor).toRgb() + : { r: 255, g: 255, b: 255 }; + const secondary = pattern.colorPalette?.secondaryColor + ? new Colord(pattern.colorPalette.secondaryColor).toRgb() + : { r: 0, g: 0, b: 0 }; + + let i = 0; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const rgba = decoder.isPrimary(x, y) ? primary : secondary; + data[i++] = rgba.r; + data[i++] = rgba.g; + data[i++] = rgba.b; + data[i++] = 255; + } + } + + ctx.putImageData(imageData, 0, 0); + const dataUrl = canvas.toDataURL("image/png"); + patternPreviewCache.set(patternLookupKey, dataUrl); + return dataUrl; + } catch (e) { + console.error("Error generating pattern preview", e); + return ""; + } +} + +interface AllianceIndicator { + id: string; + type: "request" | "renewal"; + playerSmallID: number; + playerName: string; + playerColor: string; + playerPattern?: PlayerPattern; + createdAt: Tick; + duration: Tick; + // For requests + requestorView?: PlayerView; + recipientView?: PlayerView; + // For renewals + allianceID?: number; + otherPlayerView?: PlayerView; + otherPlayerWantsRenewal?: boolean; +} + +const MAX_VISIBLE_INDICATORS = 10; + +@customElement("alliance-request-panel") +export class AllianceRequestPanel extends LitElement implements Layer { + public eventBus: EventBus; + public game: GameView; + + private active: boolean = false; + @state() private indicators: AllianceIndicator[] = []; + @state() private hoveredIndicator: string | null = null; + @state() private popupHovered: boolean = false; + @state() private sidebarWidth: number = 0; + + // Queue of pending indicator additions while popup is open + private pendingIndicators: AllianceIndicator[] = []; + // Set of indicator IDs to remove when popup closes + private pendingRemovals: Set = new Set(); + + // allianceID -> last checked at tick (for renewal notifications) + private alliancesCheckedAt = new Map(); + + // ResizeObserver to track sidebar height changes + private sidebarObserver: ResizeObserver | null = null; + + private updateMap = [ + [GameUpdateType.AllianceRequest, this.onAllianceRequestEvent.bind(this)], + [ + GameUpdateType.AllianceRequestReply, + this.onAllianceRequestReplyEvent.bind(this), + ], + ] as const; + + constructor() { + super(); + this.indicators = []; + } + + init() { + // Observe the game-left-sidebar for size changes + this.setupSidebarObserver(); + } + + private setupSidebarObserver() { + // Look for the aside element inside the sidebar (the actual visible element) + const sidebar = document.querySelector("game-left-sidebar aside"); + if (sidebar) { + this.sidebarObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + this.sidebarWidth = entry.contentRect.width; + } + }); + this.sidebarObserver.observe(sidebar); + // Get initial width + this.sidebarWidth = sidebar.getBoundingClientRect().width; + } else { + // Sidebar not found yet, use default position + this.sidebarWidth = 0; + } + } + + disconnectedCallback() { + super.disconnectedCallback(); + if (this.sidebarObserver) { + this.sidebarObserver.disconnect(); + this.sidebarObserver = null; + } + } + + tick() { + this.active = true; + + // Try to set up sidebar observer if not already done + if (!this.sidebarObserver) { + this.setupSidebarObserver(); + } + + const myPlayer = this.game.myPlayer(); + if (!myPlayer || !myPlayer.isAlive()) { + return; + } + + // If popup is open, don't modify the indicators list at all + // Queue changes to apply when popup closes + if (this.popupHovered) { + // Still check for new events but queue them + this.checkForAllianceExpirations(); + const updates = this.game.updatesSinceLastTick(); + if (updates) { + for (const [ut, fn] of this.updateMap) { + updates[ut]?.forEach(fn as (event: unknown) => void); + } + } + // Mark expired indicators for removal later + for (const indicator of this.indicators) { + const isExpired = + this.game.ticks() - indicator.createdAt >= indicator.duration || + this.shouldDeleteIndicator(indicator); + if (isExpired) { + this.pendingRemovals.add(indicator.id); + // If the hovered indicator expired, force close popup and apply changes + if (indicator.id === this.hoveredIndicator) { + this.popupHovered = false; + this.hoveredIndicator = null; + this.flushPendingChanges(); + return; + } + } + } + // Still update for countdown animation even when popup is hovered + 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); + } + } + + // Remove expired indicators + const remainingIndicators = this.indicators.filter((indicator) => { + const shouldKeep = + this.game.ticks() - indicator.createdAt < indicator.duration && + !this.shouldDeleteIndicator(indicator); + return shouldKeep; + }); + + if (this.indicators.length !== remainingIndicators.length) { + this.indicators = remainingIndicators; + } + // Always request update for countdown overlay animation + this.requestUpdate(); + } + + private shouldDeleteIndicator(indicator: AllianceIndicator): boolean { + if (indicator.type === "request") { + // Check if requestor and recipient are now allied + if (indicator.requestorView && indicator.recipientView) { + return indicator.requestorView.isAlliedWith(indicator.recipientView); + } + } else if (indicator.type === "renewal") { + // Check if the alliance still exists and is still in expiration window + const myPlayer = this.game.myPlayer(); + if (!myPlayer) return true; + + const alliance = myPlayer + .alliances() + .find((a) => a.id === indicator.allianceID); + + // Alliance no longer exists (expired or broken) + if (!alliance) return true; + + // Alliance was renewed (expiresAt is now far in the future) + if ( + alliance.expiresAt > + this.game.ticks() + this.game.config().allianceExtensionPromptOffset() + ) { + return true; + } + } + return false; + } + + private checkForAllianceExpirations() { + const myPlayer = this.game.myPlayer(); + if (!myPlayer?.isAlive()) return; + + for (const alliance of myPlayer.alliances()) { + if ( + alliance.expiresAt > + this.game.ticks() + this.game.config().allianceExtensionPromptOffset() + ) { + continue; + } + + // Always update otherPlayerWantsRenewal for existing indicators + const existingIndicator = this.indicators.find( + (i) => i.type === "renewal" && i.allianceID === alliance.id, + ); + if ( + existingIndicator && + existingIndicator.otherPlayerWantsRenewal !== + alliance.otherWantsToExtend + ) { + this.indicators = this.indicators.map((i) => + i === existingIndicator + ? { ...i, otherPlayerWantsRenewal: alliance.otherWantsToExtend } + : i, + ); + this.requestUpdate(); + } + + 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; + if (!other.isAlive()) continue; + + const color = other.territoryColor().toHex(); + + this.addIndicator({ + id: `renewal-${alliance.id}`, + type: "renewal", + playerSmallID: other.smallID(), + playerName: other.name(), + playerColor: color, + playerPattern: other.cosmetics.pattern, + createdAt: this.game.ticks(), + duration: this.game.config().allianceExtensionPromptOffset() - 3 * 10, // 3 second buffer + allianceID: alliance.id, + otherPlayerView: other, + otherPlayerWantsRenewal: alliance.otherWantsToExtend, + }); + } + } + + private addIndicator(indicator: AllianceIndicator) { + // Check if indicator with same id already exists - always allow updating otherPlayerWantsRenewal + const existingIndex = this.indicators.findIndex( + (i) => i.id === indicator.id, + ); + if (existingIndex !== -1) { + // Update otherPlayerWantsRenewal if it changed (even during popup hover) + const existing = this.indicators[existingIndex]; + if ( + indicator.type === "renewal" && + existing.otherPlayerWantsRenewal !== indicator.otherPlayerWantsRenewal + ) { + this.indicators = this.indicators.map((i, idx) => + idx === existingIndex + ? { + ...i, + otherPlayerWantsRenewal: indicator.otherPlayerWantsRenewal, + } + : i, + ); + this.requestUpdate(); + } + return; // Already exists + } + + // If popup is open, queue new indicators for later + if (this.popupHovered) { + // Check if already in pending queue + if (!this.pendingIndicators.some((i) => i.id === indicator.id)) { + this.pendingIndicators.push(indicator); + } + return; + } + + // If we're at max capacity, queue the indicator instead of showing it + if (this.indicators.length >= MAX_VISIBLE_INDICATORS) { + if (!this.pendingIndicators.some((i) => i.id === indicator.id)) { + this.pendingIndicators.push(indicator); + } + return; + } + + // Add to the end of the array so new indicators appear on the right (row grows rightward) + this.indicators = [...this.indicators, indicator]; + this.requestUpdate(); + } + + private removeIndicator(id: string, force: boolean = false) { + // Don't remove if popup is being hovered (unless forced) + if (!force && this.popupHovered && this.hoveredIndicator === id) { + return; + } + this.indicators = this.indicators.filter((i) => i.id !== id); + + // If we have pending indicators and now have room, add one + if ( + this.indicators.length < MAX_VISIBLE_INDICATORS && + this.pendingIndicators.length > 0 + ) { + const nextIndicator = this.pendingIndicators.shift()!; + if (!this.indicators.some((i) => i.id === nextIndicator.id)) { + this.indicators = [...this.indicators, nextIndicator]; + } + } + + this.requestUpdate(); + } + + private flushPendingChanges() { + // Apply any pending removals + if (this.pendingRemovals.size > 0) { + this.indicators = this.indicators.filter( + (i) => !this.pendingRemovals.has(i.id), + ); + this.pendingRemovals.clear(); + } + + // Apply any pending additions (up to the max limit) + while ( + this.pendingIndicators.length > 0 && + this.indicators.length < MAX_VISIBLE_INDICATORS + ) { + const indicator = this.pendingIndicators.shift()!; + // Double-check it doesn't already exist + if (!this.indicators.some((i) => i.id === indicator.id)) { + this.indicators = [...this.indicators, indicator]; + } + } + + this.requestUpdate(); + } + + private closePopup() { + this.popupHovered = false; + // Don't clear hoveredIndicator - the wrapper mouseenter/mouseleave handles that + // This allows smooth transition from one popup to another circle + // Apply all pending changes now that popup is closed + this.flushPendingChanges(); + } + + 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; + + const color = requestor.territoryColor().toHex(); + + this.addIndicator({ + id: `request-${update.requestorID}-${update.createdAt}`, + type: "request", + playerSmallID: update.requestorID, + playerName: requestor.name(), + playerColor: color, + playerPattern: requestor.cosmetics.pattern, + createdAt: this.game.ticks(), + duration: this.game.config().allianceRequestDuration() - 20, // 2 second buffer + requestorView: requestor, + recipientView: recipient, + }); + } + + 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 + this.indicators = this.indicators.filter( + (indicator) => + !( + indicator.type === "request" && + indicator.playerSmallID === update.request.requestorID + ), + ); + this.requestUpdate(); + } + } + + private handleAccept(indicator: AllianceIndicator) { + if (indicator.type === "request") { + this.eventBus.emit( + new SendAllianceReplyIntentEvent( + indicator.requestorView!, + indicator.recipientView!, + true, + ), + ); + } else if (indicator.type === "renewal") { + this.eventBus.emit( + new SendAllianceExtensionIntentEvent(indicator.otherPlayerView!), + ); + } + // Close popup and flush pending changes + this.popupHovered = false; + this.hoveredIndicator = null; + // Remove the clicked indicator plus any pending removals + this.pendingRemovals.add(indicator.id); + this.flushPendingChanges(); + } + + private handleReject(indicator: AllianceIndicator) { + if (indicator.type === "request") { + this.eventBus.emit( + new SendAllianceReplyIntentEvent( + indicator.requestorView!, + indicator.recipientView!, + false, + ), + ); + } + // For renewals, "ignore" just removes the indicator + // Close popup and flush pending changes + this.popupHovered = false; + this.hoveredIndicator = null; + // Remove the clicked indicator plus any pending removals + this.pendingRemovals.add(indicator.id); + this.flushPendingChanges(); + } + + private handleFocus(indicator: AllianceIndicator) { + const player = this.game.playerBySmallID(indicator.playerSmallID); + if (player.isPlayer()) { + this.eventBus.emit(new GoToPlayerEvent(player)); + } + } + + // Helper to create SVG arc path for radial buttons + private describeArc( + x: number, + y: number, + innerRadius: number, + outerRadius: number, + startAngle: number, + endAngle: number, + ): string { + const startRad = (startAngle * Math.PI) / 180; + const endRad = (endAngle * Math.PI) / 180; + + const x1 = x + innerRadius * Math.cos(startRad); + const y1 = y + innerRadius * Math.sin(startRad); + const x2 = x + outerRadius * Math.cos(startRad); + const y2 = y + outerRadius * Math.sin(startRad); + const x3 = x + outerRadius * Math.cos(endRad); + const y3 = y + outerRadius * Math.sin(endRad); + const x4 = x + innerRadius * Math.cos(endRad); + const y4 = y + innerRadius * Math.sin(endRad); + + const largeArc = Math.abs(endAngle - startAngle) > 180 ? 1 : 0; + + return [ + `M ${x1} ${y1}`, + `L ${x2} ${y2}`, + `A ${outerRadius} ${outerRadius} 0 ${largeArc} 1 ${x3} ${y3}`, + `L ${x4} ${y4}`, + `A ${innerRadius} ${innerRadius} 0 ${largeArc} 0 ${x1} ${y1}`, + "Z", + ].join(" "); + } + + shouldTransform(): boolean { + return false; + } + + renderLayer(): void {} + + render() { + // Don't show during spawn phase or if no indicators + if ( + !this.active || + !this.game || + this.indicators.length === 0 || + this.game.inSpawnPhase() + ) { + return html``; + } + + // Position to the right of the sidebar (16px base + sidebar width + 20px gap) + const leftPosition = 16 + this.sidebarWidth + 20; + + return html` + +
+ ${this.indicators.map((indicator) => { + const elapsed = this.game.ticks() - indicator.createdAt; + const remaining = indicator.duration - elapsed; + const remainingSeconds = Math.max(0, Math.ceil(remaining / 10)); + const percentExpired = Math.min( + 100, + (elapsed / indicator.duration) * 100, + ); + + // Generate pattern preview if player has a pattern + const patternUrl = indicator.playerPattern + ? generatePatternPreviewDataUrl(indicator.playerPattern, 48) + : null; + + return html` +
(this.hoveredIndicator = indicator.id)} + @mouseleave=${() => { + if (!this.popupHovered) { + this.hoveredIndicator = null; + } + }} + > +
this.handleFocus(indicator)} + > +
+ ${patternUrl + ? html`Player pattern` + : ""} +
+
+ ${indicator.type === "request" + ? html`
?
` + : html`
+ ⏳ +
`} + ${this.hoveredIndicator === indicator.id + ? html` +
(this.popupHovered = true)} + @mouseleave=${() => this.closePopup()} + > + + + + + + + + + + + + + + + { + e.stopPropagation(); + this.handleAccept(indicator); + }} + /> + + ✓ + + + + { + e.stopPropagation(); + this.handleReject(indicator); + }} + /> + + ✕ + + +
+ +
+
+ ${indicator.playerName} +
+
${remainingSeconds}s
+ ${indicator.type === "renewal" && + indicator.otherPlayerWantsRenewal + ? html`
+ ${translateText("events_display.wants_to_renew")} +
` + : ""} +
+ ` + : ""} +
+ `; + })} + ${this.pendingIndicators.length > 0 + ? html` +
+ +${this.pendingIndicators.length} + > +
+ ` + : ""} +
+ `; + } + + createRenderRoot() { + return this; + } +} diff --git a/src/client/graphics/layers/EventsDisplay.ts b/src/client/graphics/layers/EventsDisplay.ts index 2b66f33367..fab6fe9a0e 100644 --- a/src/client/graphics/layers/EventsDisplay.ts +++ b/src/client/graphics/layers/EventsDisplay.ts @@ -28,8 +28,6 @@ import { import { CancelAttackIntentEvent, CancelBoatIntentEvent, - SendAllianceExtensionIntentEvent, - SendAllianceReplyIntentEvent, SendAttackIntentEvent, } from "../../Transport"; import { Layer } from "./Layer"; @@ -82,8 +80,6 @@ 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 incomingAttacks: AttackUpdate[] = []; @state() private outgoingAttacks: AttackUpdate[] = []; @state() private outgoingLandAttacks: AttackUpdate[] = []; @@ -283,63 +279,7 @@ export class EventsDisplay extends LitElement implements Layer { } private checkForAllianceExpirations() { - const myPlayer = this.game.myPlayer(); - if (!myPlayer?.isAlive()) return; - - for (const alliance of myPlayer.alliances()) { - 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; - if (!other.isAlive()) continue; - - this.addEvent({ - description: translateText("events_display.about_to_expire", { - name: other.name(), - }), - 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.name(), - }), - 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, - }); - } + // Alliance expirations/renewals are now handled by AllianceRequestPanel } private addEvent(event: GameEvent) { @@ -465,57 +405,8 @@ export class EventsDisplay extends LitElement implements Layer { } 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; - - this.addEvent({ - description: translateText("events_display.request_alliance", { - name: requestor.name(), - }), - 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 SendAllianceReplyIntentEvent(requestor, recipient, true), - ), - }, - { - text: translateText("events_display.reject_alliance"), - className: "btn-info", - action: () => - this.eventBus.emit( - new SendAllianceReplyIntentEvent(requestor, recipient, false), - ), - }, - ], - 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, - }); + // Alliance requests are now handled by AllianceRequestPanel + // This handler is kept to maintain the event filtering for the reply event } onAllianceRequestReplyEvent(update: AllianceRequestReplyUpdate) { diff --git a/src/core/game/AllianceImpl.ts b/src/core/game/AllianceImpl.ts index fa74ca766c..eb910fad21 100644 --- a/src/core/game/AllianceImpl.ts +++ b/src/core/game/AllianceImpl.ts @@ -62,6 +62,16 @@ export class AllianceImpl implements MutableAlliance { ); } + hasOtherRequestedExtension(player: Player): boolean { + // Check if the OTHER player (not the one passed in) has requested extension + if (this.requestor_ === player) { + return this.extensionRequestedRecipient_; + } else if (this.recipient_ === player) { + return this.extensionRequestedRequestor_; + } + return false; + } + public id(): number { return this.id_; } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 1c56d5d466..f8a8a9948a 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -447,6 +447,7 @@ export interface MutableAlliance extends Alliance { id(): number; extend(): void; onlyOneAgreedToExtend(): boolean; + hasOtherRequestedExtension(player: Player): boolean; } export class PlayerInfo { diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index a8c6c53f68..ae34559308 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -183,6 +183,7 @@ export interface AllianceView { createdAt: Tick; expiresAt: Tick; hasExtensionRequest: boolean; + otherWantsToExtend: boolean; // True if the other player has requested to extend but we haven't yet } export interface AllianceRequestUpdate { diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 15ce0d5648..166bfe2a30 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -612,6 +612,7 @@ export class GameView implements GameMap { this._cosmetics = new Map( this.humans.map((h) => [h.clientID, h.cosmetics ?? {}]), ); + for (const nation of this._mapData.nations) { // Nations don't have client ids, so we use their name as the key instead. this._cosmetics.set(nation.name, { diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index e09360acc9..1191396d1a 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -174,6 +174,7 @@ export class PlayerImpl implements Player { a.expiresAt() <= this.mg.ticks() + this.mg.config().allianceExtensionPromptOffset(), + otherWantsToExtend: a.hasOtherRequestedExtension(this), }) satisfies AllianceView, ), hasSpawned: this.hasSpawned(), diff --git a/tests/AllianceRenewalPanel.test.ts b/tests/AllianceRenewalPanel.test.ts new file mode 100644 index 0000000000..62cdc3961f --- /dev/null +++ b/tests/AllianceRenewalPanel.test.ts @@ -0,0 +1,490 @@ +import { AllianceExtensionExecution } from "../src/core/execution/alliance/AllianceExtensionExecution"; +import { AllianceRequestExecution } from "../src/core/execution/alliance/AllianceRequestExecution"; +import { AllianceRequestReplyExecution } from "../src/core/execution/alliance/AllianceRequestReplyExecution"; +import { Game, Player, PlayerType } from "../src/core/game/Game"; +import { playerInfo, setup } from "./util/Setup"; + +let game: Game; +let player1: Player; +let player2: Player; +let player3: Player; + +/** + * Tests for the alliance renewal panel logic. + * + * The AllianceRequestPanel (UI component) manages indicators for: + * 1. Incoming alliance requests + * 2. Alliance renewals when alliances are about to expire + * + * These tests verify the underlying business logic that the panel uses, + * specifically around: + * - When renewal prompts should appear (based on allianceExtensionPromptOffset) + * - When alliance indicators should be deleted (already allied, alliance renewed, etc.) + * - Alliance expiration and extension timing + */ +describe("AllianceRenewalPanel Logic", () => { + beforeEach(async () => { + game = await setup( + "ocean_and_land", + { + infiniteGold: true, + instantBuild: true, + infiniteTroops: true, + }, + [ + playerInfo("player1", PlayerType.Human), + playerInfo("player2", PlayerType.Human), + playerInfo("player3", PlayerType.Human), + ], + ); + + player1 = game.player("player1"); + player2 = game.player("player2"); + player3 = game.player("player3"); + + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + }); + + describe("Alliance expiration timing", () => { + test("alliance has expiresAt set to createdAt + allianceDuration", () => { + vi.spyOn(player1, "canSendAllianceRequest").mockReturnValue(true); + vi.spyOn(player2, "isAlive").mockReturnValue(true); + vi.spyOn(player1, "isAlive").mockReturnValue(true); + + const ticksBefore = game.ticks(); + + // Create alliance + game.addExecution(new AllianceRequestExecution(player1, player2.id())); + game.executeNextTick(); + game.addExecution( + new AllianceRequestReplyExecution(player1.id(), player2, true), + ); + game.executeNextTick(); + + const alliance = player1.allianceWith(player2); + expect(alliance).toBeTruthy(); + + // ExpiresAt should be roughly createdAt + allianceDuration + const expectedExpiry = + alliance!.createdAt() + game.config().allianceDuration(); + expect(alliance!.expiresAt()).toBe(expectedExpiry); + }); + + test("extension prompt should appear before alliance expires (within offset window)", () => { + vi.spyOn(player1, "canSendAllianceRequest").mockReturnValue(true); + vi.spyOn(player2, "isAlive").mockReturnValue(true); + vi.spyOn(player1, "isAlive").mockReturnValue(true); + + // Create alliance + game.addExecution(new AllianceRequestExecution(player1, player2.id())); + game.executeNextTick(); + game.addExecution( + new AllianceRequestReplyExecution(player1.id(), player2, true), + ); + game.executeNextTick(); + + const alliance = player1.allianceWith(player2); + expect(alliance).toBeTruthy(); + + const promptOffset = game.config().allianceExtensionPromptOffset(); + const expiresAt = alliance!.expiresAt(); + + // The prompt should be shown when: expiresAt <= ticks + promptOffset + // Which means: ticks >= expiresAt - promptOffset + const promptShowsTick = expiresAt - promptOffset; + + // At promptShowsTick, the condition (expiresAt > ticks + promptOffset) should be false + expect(expiresAt).toBeLessThanOrEqual(promptShowsTick + promptOffset); + + // Before promptShowsTick, the prompt should NOT show + const beforePrompt = promptShowsTick - 1; + expect(expiresAt).toBeGreaterThan(beforePrompt + promptOffset); + }); + }); + + describe("shouldDeleteIndicator logic for requests", () => { + test("request indicator should be deleted when players become allied", () => { + vi.spyOn(player1, "canSendAllianceRequest").mockReturnValue(true); + vi.spyOn(player2, "isAlive").mockReturnValue(true); + vi.spyOn(player1, "isAlive").mockReturnValue(true); + + // Send request but don't reply yet + game.addExecution(new AllianceRequestExecution(player1, player2.id())); + game.executeNextTick(); + + // Players are not allied yet + expect(player1.isAlliedWith(player2)).toBeFalsy(); + + // Accept the alliance + game.addExecution( + new AllianceRequestReplyExecution(player1.id(), player2, true), + ); + game.executeNextTick(); + + // Now they are allied - indicator should be deleted + expect(player1.isAlliedWith(player2)).toBeTruthy(); + }); + + test("request indicator should remain if alliance request is rejected", () => { + vi.spyOn(player1, "canSendAllianceRequest").mockReturnValue(true); + vi.spyOn(player2, "isAlive").mockReturnValue(true); + vi.spyOn(player1, "isAlive").mockReturnValue(true); + + // Send request + game.addExecution(new AllianceRequestExecution(player1, player2.id())); + game.executeNextTick(); + + expect(player1.isAlliedWith(player2)).toBeFalsy(); + + // Reject the alliance + game.addExecution( + new AllianceRequestReplyExecution(player1.id(), player2, false), + ); + game.executeNextTick(); + + // Players are still not allied + expect(player1.isAlliedWith(player2)).toBeFalsy(); + }); + }); + + describe("shouldDeleteIndicator logic for renewals", () => { + test("renewal indicator should be deleted when alliance is renewed", () => { + vi.spyOn(player1, "canSendAllianceRequest").mockReturnValue(true); + vi.spyOn(player2, "isAlive").mockReturnValue(true); + vi.spyOn(player1, "isAlive").mockReturnValue(true); + + // Create alliance + game.addExecution(new AllianceRequestExecution(player1, player2.id())); + game.executeNextTick(); + game.addExecution( + new AllianceRequestReplyExecution(player1.id(), player2, true), + ); + game.executeNextTick(); + + const alliance = player1.allianceWith(player2); + expect(alliance).toBeTruthy(); + + const originalExpiry = alliance!.expiresAt(); + const promptOffset = game.config().allianceExtensionPromptOffset(); + + // Both players agree to extend + game.addExecution(new AllianceExtensionExecution(player1, player2.id())); + game.executeNextTick(); + game.addExecution(new AllianceExtensionExecution(player2, player1.id())); + game.executeNextTick(); + + // Alliance should be renewed - expiresAt should be in the far future + const newExpiry = player1.allianceWith(player2)!.expiresAt(); + expect(newExpiry).toBeGreaterThan(originalExpiry); + + // Renewal indicator delete condition: + // alliance.expiresAt > ticks + allianceExtensionPromptOffset + // After renewal, this should be true (expiresAt is far in future) + expect(newExpiry).toBeGreaterThan(game.ticks() + promptOffset); + }); + + test("renewal indicator should be deleted when alliance no longer exists (expired)", () => { + vi.spyOn(player1, "canSendAllianceRequest").mockReturnValue(true); + vi.spyOn(player2, "isAlive").mockReturnValue(true); + vi.spyOn(player1, "isAlive").mockReturnValue(true); + + // Create alliance + game.addExecution(new AllianceRequestExecution(player1, player2.id())); + game.executeNextTick(); + game.addExecution( + new AllianceRequestReplyExecution(player1.id(), player2, true), + ); + game.executeNextTick(); + + const alliance = player1.allianceWith(player2); + expect(alliance).toBeTruthy(); + + // Verify the panel's delete condition logic: + // The panel deletes renewal indicator if alliance no longer exists + // Here we verify the alliance object methods work correctly + expect(alliance!.expiresAt()).toBeGreaterThan(alliance!.createdAt()); + + // Manually expire the alliance to test the condition + alliance!.expire(); + + // Now allianceWith should return null + expect(player1.allianceWith(player2)).toBeFalsy(); + }); + }); + + describe("Extension request tracking", () => { + test("alliance tracks when one player requests extension", () => { + vi.spyOn(player1, "canSendAllianceRequest").mockReturnValue(true); + vi.spyOn(player2, "isAlive").mockReturnValue(true); + vi.spyOn(player1, "isAlive").mockReturnValue(true); + + // Create alliance + game.addExecution(new AllianceRequestExecution(player1, player2.id())); + game.executeNextTick(); + game.addExecution( + new AllianceRequestReplyExecution(player1.id(), player2, true), + ); + game.executeNextTick(); + + const alliance = player1.allianceWith(player2); + expect(alliance).toBeTruthy(); + + // Initially no one has requested extension + expect(alliance!.onlyOneAgreedToExtend()).toBeFalsy(); + expect(alliance!.bothAgreedToExtend()).toBeFalsy(); + + // Player 1 requests extension + game.addExecution(new AllianceExtensionExecution(player1, player2.id())); + game.executeNextTick(); + + // Now only one has agreed + expect(alliance!.onlyOneAgreedToExtend()).toBeTruthy(); + expect(alliance!.bothAgreedToExtend()).toBeFalsy(); + }); + + test("alliance tracks when both players request extension", () => { + vi.spyOn(player1, "canSendAllianceRequest").mockReturnValue(true); + vi.spyOn(player2, "isAlive").mockReturnValue(true); + vi.spyOn(player1, "isAlive").mockReturnValue(true); + + // Create alliance + game.addExecution(new AllianceRequestExecution(player1, player2.id())); + game.executeNextTick(); + game.addExecution( + new AllianceRequestReplyExecution(player1.id(), player2, true), + ); + game.executeNextTick(); + + const alliance = player1.allianceWith(player2); + expect(alliance).toBeTruthy(); + + // Both players request extension + game.addExecution(new AllianceExtensionExecution(player1, player2.id())); + game.executeNextTick(); + game.addExecution(new AllianceExtensionExecution(player2, player1.id())); + game.executeNextTick(); + + // Both have agreed, so alliance should extend and reset the flags + expect(alliance!.onlyOneAgreedToExtend()).toBeFalsy(); + expect(alliance!.bothAgreedToExtend()).toBeFalsy(); + }); + + test("extension request flags reset after alliance is extended", () => { + vi.spyOn(player1, "canSendAllianceRequest").mockReturnValue(true); + vi.spyOn(player2, "isAlive").mockReturnValue(true); + vi.spyOn(player1, "isAlive").mockReturnValue(true); + + // Create alliance + game.addExecution(new AllianceRequestExecution(player1, player2.id())); + game.executeNextTick(); + game.addExecution( + new AllianceRequestReplyExecution(player1.id(), player2, true), + ); + game.executeNextTick(); + + const alliance = player1.allianceWith(player2); + expect(alliance).toBeTruthy(); + + // Extend alliance + game.addExecution(new AllianceExtensionExecution(player1, player2.id())); + game.executeNextTick(); + game.addExecution(new AllianceExtensionExecution(player2, player1.id())); + game.executeNextTick(); + + // Flags should be reset + expect(alliance!.onlyOneAgreedToExtend()).toBeFalsy(); + expect(alliance!.bothAgreedToExtend()).toBeFalsy(); + + // Can request extension again + game.addExecution(new AllianceExtensionExecution(player1, player2.id())); + game.executeNextTick(); + + expect(alliance!.onlyOneAgreedToExtend()).toBeTruthy(); + }); + }); + + describe("Indicator duration and expiration calculations", () => { + test("request indicator duration is less than allianceRequestDuration by buffer", () => { + // The panel uses: duration = allianceRequestDuration() - 20 (2 second buffer) + const requestDuration = game.config().allianceRequestDuration(); + const expectedIndicatorDuration = requestDuration - 20; + + // Indicator should have a buffer to expire before the actual request + expect(expectedIndicatorDuration).toBeLessThan(requestDuration); + expect(expectedIndicatorDuration).toBe(requestDuration - 20); + }); + + test("renewal indicator duration is less than promptOffset by buffer", () => { + // The panel uses: duration = allianceExtensionPromptOffset() - 3 * 10 (3 second buffer) + const promptOffset = game.config().allianceExtensionPromptOffset(); + const expectedIndicatorDuration = promptOffset - 30; + + // Indicator should have a buffer to expire before the prompt window ends + expect(expectedIndicatorDuration).toBeLessThan(promptOffset); + expect(expectedIndicatorDuration).toBe(promptOffset - 30); + }); + }); + + describe("Duplicate indicator prevention", () => { + test("should not add duplicate indicators for same alliance request", () => { + vi.spyOn(player1, "canSendAllianceRequest").mockReturnValue(true); + vi.spyOn(player2, "isAlive").mockReturnValue(true); + vi.spyOn(player1, "isAlive").mockReturnValue(true); + + // Sending the same request twice shouldn't create duplicate pending requests + game.addExecution(new AllianceRequestExecution(player1, player2.id())); + game.executeNextTick(); + + expect(player1.outgoingAllianceRequests()).toHaveLength(1); + + // Sending another request while one is pending should not create a new one + game.addExecution(new AllianceRequestExecution(player1, player2.id())); + game.executeNextTick(); + + expect(player1.outgoingAllianceRequests()).toHaveLength(1); + }); + }); + + describe("onAllianceRequestReplyEvent cleanup", () => { + test("accepting removes request from incoming requests", () => { + vi.spyOn(player1, "canSendAllianceRequest").mockReturnValue(true); + vi.spyOn(player2, "isAlive").mockReturnValue(true); + vi.spyOn(player1, "isAlive").mockReturnValue(true); + + // Player1 sends request to player2 + game.addExecution(new AllianceRequestExecution(player1, player2.id())); + game.executeNextTick(); + + expect(player2.incomingAllianceRequests()).toHaveLength(1); + + // Player2 accepts + game.addExecution( + new AllianceRequestReplyExecution(player1.id(), player2, true), + ); + game.executeNextTick(); + + // Request should be cleared + expect(player2.incomingAllianceRequests()).toHaveLength(0); + expect(player1.outgoingAllianceRequests()).toHaveLength(0); + }); + + test("rejecting removes request from incoming requests", () => { + vi.spyOn(player1, "canSendAllianceRequest").mockReturnValue(true); + vi.spyOn(player2, "isAlive").mockReturnValue(true); + vi.spyOn(player1, "isAlive").mockReturnValue(true); + + // Player1 sends request to player2 + game.addExecution(new AllianceRequestExecution(player1, player2.id())); + game.executeNextTick(); + + expect(player2.incomingAllianceRequests()).toHaveLength(1); + + // Player2 rejects + game.addExecution( + new AllianceRequestReplyExecution(player1.id(), player2, false), + ); + game.executeNextTick(); + + // Request should be cleared + expect(player2.incomingAllianceRequests()).toHaveLength(0); + expect(player1.outgoingAllianceRequests()).toHaveLength(0); + }); + }); + + describe("Alliance prompt window edge cases", () => { + test("prompt offset is configurable and affects when renewal shows", () => { + const defaultOffset = game.config().allianceExtensionPromptOffset(); + + // The offset should be a positive number + expect(defaultOffset).toBeGreaterThan(0); + + // The offset represents ticks before expiration when the prompt appears + // A larger offset means earlier warning + }); + + test("once prompted for an alliance, should not prompt again within offset period", () => { + vi.spyOn(player1, "canSendAllianceRequest").mockReturnValue(true); + vi.spyOn(player2, "isAlive").mockReturnValue(true); + vi.spyOn(player1, "isAlive").mockReturnValue(true); + + // The panel tracks alliancesCheckedAt to avoid duplicate prompts + // Logic: if checkedAt >= ticks - promptOffset, skip + const promptOffset = game.config().allianceExtensionPromptOffset(); + + // Create alliance + game.addExecution(new AllianceRequestExecution(player1, player2.id())); + game.executeNextTick(); + game.addExecution( + new AllianceRequestReplyExecution(player1.id(), player2, true), + ); + game.executeNextTick(); + + const alliance = player1.allianceWith(player2); + expect(alliance).toBeTruthy(); + + // The map would store when we last checked, preventing duplicate notifications + // within the prompt offset window + }); + }); + + describe("Multiple alliances handling", () => { + test("can have multiple renewal indicators for different alliances", () => { + vi.spyOn(player1, "canSendAllianceRequest").mockReturnValue(true); + vi.spyOn(player2, "isAlive").mockReturnValue(true); + vi.spyOn(player3, "isAlive").mockReturnValue(true); + vi.spyOn(player1, "isAlive").mockReturnValue(true); + + // Create alliance between player1 and player2 + game.addExecution(new AllianceRequestExecution(player1, player2.id())); + game.executeNextTick(); + game.addExecution( + new AllianceRequestReplyExecution(player1.id(), player2, true), + ); + game.executeNextTick(); + + // Create alliance between player1 and player3 + game.addExecution(new AllianceRequestExecution(player1, player3.id())); + game.executeNextTick(); + game.addExecution( + new AllianceRequestReplyExecution(player1.id(), player3, true), + ); + game.executeNextTick(); + + // Player1 should have two alliances + expect(player1.alliances()).toHaveLength(2); + expect(player1.isAlliedWith(player2)).toBeTruthy(); + expect(player1.isAlliedWith(player3)).toBeTruthy(); + }); + }); + + describe("hasExtensionRequest flag for UI", () => { + test("alliance exposes extension request status for UI display", () => { + vi.spyOn(player1, "canSendAllianceRequest").mockReturnValue(true); + vi.spyOn(player2, "isAlive").mockReturnValue(true); + vi.spyOn(player1, "isAlive").mockReturnValue(true); + + // Create alliance + game.addExecution(new AllianceRequestExecution(player1, player2.id())); + game.executeNextTick(); + game.addExecution( + new AllianceRequestReplyExecution(player1.id(), player2, true), + ); + game.executeNextTick(); + + const alliance = player1.allianceWith(player2); + expect(alliance).toBeTruthy(); + + // Initially no extension request + expect(alliance!.onlyOneAgreedToExtend()).toBeFalsy(); + + // Player2 wants to extend (from player1's perspective, the "other" wants renewal) + game.addExecution(new AllianceExtensionExecution(player2, player1.id())); + game.executeNextTick(); + + // Now the other player has requested extension + expect(alliance!.onlyOneAgreedToExtend()).toBeTruthy(); + }); + }); +});