diff --git a/.gitignore b/.gitignore index 8b3f0c0dbd..3540ace7ee 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ resources/.DS_Store .env* .DS_Store .clinic/ +.idea CLAUDE.md diff --git a/resources/lang/en.json b/resources/lang/en.json index 103f0f579b..b70397570f 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -307,6 +307,8 @@ "view_options": "View Options", "toggle_view": "Toggle View", "toggle_view_desc": "Alternate view (terrain/countries)", + "force_player_info_mouse_overlay": "Force player info on cursor", + "force_player_info_mouse_overlay_desc": "Hold to display player information overlay at cursor position", "attack_ratio_controls": "Attack Ratio Controls", "attack_ratio_up": "Increase Attack Ratio", "attack_ratio_up_desc": "Increase attack ratio by 10%", @@ -341,7 +343,15 @@ "terrain_enabled": "Terrain view enabled", "terrain_disabled": "Terrain view disabled", "exit_game_label": "Exit Game", - "exit_game_info": "Return to main menu" + "exit_game_info": "Return to main menu", + "info_display_mode_label": "Information Display", + "info_display_mode_desc": "Choose where player information is displayed", + "info_display_overlay_only": "Top right corner only", + "info_display_mousehud_only": "Near cursor only", + "info_display_both": "Top right corner + cursor", + "info_display_overlay": "Overlay", + "info_display_mousehud": "Cursor", + "info_display_both_short": "Both" }, "chat": { "title": "Quick Chat", diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 5b9d397a83..238ca2444e 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -68,6 +68,10 @@ export class AlternateViewEvent implements GameEvent { constructor(public readonly alternateView: boolean) {} } +export class ForcePlayerInfoMouseOverlayEvent implements GameEvent { + constructor(public readonly forcePlayerInfoMouseOverlay: boolean) {} +} + export class CloseViewEvent implements GameEvent {} export class RefreshGraphicsEvent implements GameEvent {} @@ -128,6 +132,7 @@ export class InputHandler { private pointerDown: boolean = false; private alternateView = false; + private forcePlayerInfoMouseOverlay = false; private moveInterval: NodeJS.Timeout | null = null; private activeKeys = new Set(); @@ -146,6 +151,7 @@ export class InputHandler { initialize() { this.keybinds = { toggleView: "Space", + forcePlayerInfoMouseOverlay: "ControlRight", centerCamera: "KeyC", moveUp: "KeyW", moveDown: "KeyS", @@ -262,6 +268,14 @@ export class InputHandler { } } + if (e.code === this.keybinds.forcePlayerInfoMouseOverlay) { + e.preventDefault(); + if (!this.forcePlayerInfoMouseOverlay) { + this.forcePlayerInfoMouseOverlay = true; + this.eventBus.emit(new ForcePlayerInfoMouseOverlayEvent(true)); + } + } + if (e.code === "Escape") { e.preventDefault(); this.eventBus.emit(new CloseViewEvent()); @@ -300,6 +314,12 @@ export class InputHandler { this.eventBus.emit(new AlternateViewEvent(false)); } + if (e.code === this.keybinds.forcePlayerInfoMouseOverlay) { + e.preventDefault(); + this.forcePlayerInfoMouseOverlay = false; + this.eventBus.emit(new ForcePlayerInfoMouseOverlayEvent(false)); + } + if (e.key.toLowerCase() === "r" && e.altKey && !e.ctrlKey) { e.preventDefault(); this.eventBus.emit(new RefreshGraphicsEvent()); diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index 90e3b2e072..e4bee07e85 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -437,6 +437,17 @@ export class UserSettingModal extends LitElement { @change=${this.handleKeybindChange} > + +
${translateText("user_setting.attack_ratio_controls")}
diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 3a089f33ea..8a1484a169 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -23,6 +23,7 @@ import { Leaderboard } from "./layers/Leaderboard"; import { MainRadialMenu } from "./layers/MainRadialMenu"; import { MultiTabModal } from "./layers/MultiTabModal"; import { NameLayer } from "./layers/NameLayer"; +import { PlayerInfoMouseOverlay } from "./layers/PlayerInfoMouseOverlay"; import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay"; import { PlayerPanel } from "./layers/PlayerPanel"; import { RailroadLayer } from "./layers/RailroadLayer"; @@ -128,6 +129,18 @@ export function createRenderer( playerInfo.eventBus = eventBus; playerInfo.transform = transformHandler; playerInfo.game = game; + playerInfo.userSettings = userSettings; + + const mouseHUD = document.querySelector( + "mouse-hud", + ) as PlayerInfoMouseOverlay; + if (!(mouseHUD instanceof PlayerInfoMouseOverlay)) { + console.error("mouse hud not found"); + } + mouseHUD.eventBus = eventBus; + mouseHUD.transform = transformHandler; + mouseHUD.game = game; + mouseHUD.userSettings = userSettings; const winModal = document.querySelector("win-modal") as WinModal; if (!(winModal instanceof WinModal)) { @@ -258,6 +271,7 @@ export function createRenderer( gameRightSidebar, controlPanel, playerInfo, + mouseHUD, winModal, replayPanel, settingsModal, diff --git a/src/client/graphics/layers/PlayerInfoManager.ts b/src/client/graphics/layers/PlayerInfoManager.ts new file mode 100644 index 0000000000..c3dea28b94 --- /dev/null +++ b/src/client/graphics/layers/PlayerInfoManager.ts @@ -0,0 +1,120 @@ +import { EventBus } from "../../../core/EventBus"; +import { GameView } from "../../../core/game/GameView"; +import { MouseMoveEvent } from "../../InputHandler"; +import { TransformHandler } from "../TransformHandler"; +import { + HoverInfo, + OVERLAY_CONFIG, + PlayerInfoService, +} from "./PlayerInfoService"; + +export class PlayerInfoManager { + private static instance: PlayerInfoManager | null = null; + private readonly playerInfoService: PlayerInfoService; + private eventBus: EventBus; + private lastDataUpdate = 0; + private currentHoverInfo: HoverInfo | null = null; + private currentMousePosition = { x: 0, y: 0 }; + private dataSubscribers: Set<(hoverInfo: HoverInfo) => void> = new Set(); + private mouseSubscribers: Set<(x: number, y: number) => void> = new Set(); + private mouseMoveCallback: ((event: MouseMoveEvent) => void) | null = null; + private isActive = false; + + private constructor( + game: GameView, + transform: TransformHandler, + eventBus: EventBus, + ) { + this.playerInfoService = new PlayerInfoService(game, transform); + this.eventBus = eventBus; + } + + static getInstance( + game: GameView, + transform: TransformHandler, + eventBus: EventBus, + ): PlayerInfoManager { + PlayerInfoManager.instance ??= new PlayerInfoManager( + game, + transform, + eventBus, + ); + return PlayerInfoManager.instance; + } + + init() { + if (this.isActive) return; + + this.mouseMoveCallback = (e: MouseMoveEvent) => this.onMouseMove(e); + this.eventBus.on(MouseMoveEvent, this.mouseMoveCallback); + this.isActive = true; + } + + destroy() { + if (this.mouseMoveCallback) { + this.eventBus.off(MouseMoveEvent, this.mouseMoveCallback); + this.mouseMoveCallback = null; + } + this.dataSubscribers.clear(); + this.mouseSubscribers.clear(); + this.isActive = false; + PlayerInfoManager.instance = null; + } + + subscribeToData(callback: (hoverInfo: HoverInfo) => void) { + this.dataSubscribers.add(callback); + if (this.currentHoverInfo) { + callback(this.currentHoverInfo); + } + } + + unsubscribeFromData(callback: (hoverInfo: HoverInfo) => void) { + this.dataSubscribers.delete(callback); + } + + subscribeToMouse(callback: (x: number, y: number) => void) { + this.mouseSubscribers.add(callback); + callback(this.currentMousePosition.x, this.currentMousePosition.y); + } + + unsubscribeFromMouse(callback: (x: number, y: number) => void) { + this.mouseSubscribers.delete(callback); + } + + private async onMouseMove(event: MouseMoveEvent) { + this.currentMousePosition.x = event.x; + this.currentMousePosition.y = event.y; + + this.notifyMouseSubscribers(); + + const now = Date.now(); + if (now - this.lastDataUpdate < OVERLAY_CONFIG.updateThrottleMs) { + return; + } + this.lastDataUpdate = now; + + this.currentHoverInfo = await this.playerInfoService.getHoverInfo( + event.x, + event.y, + ); + this.notifyDataSubscribers(); + } + + private notifyDataSubscribers() { + if (this.currentHoverInfo) { + this.dataSubscribers.forEach((callback) => + callback(this.currentHoverInfo!), + ); + } + } + + private notifyMouseSubscribers() { + this.mouseSubscribers.forEach((callback) => + callback(this.currentMousePosition.x, this.currentMousePosition.y), + ); + } + + getPlayerInfoService(): PlayerInfoService { + return this.playerInfoService; + } +} diff --git a/src/client/graphics/layers/PlayerInfoMouseOverlay.ts b/src/client/graphics/layers/PlayerInfoMouseOverlay.ts new file mode 100644 index 0000000000..1473322ef2 --- /dev/null +++ b/src/client/graphics/layers/PlayerInfoMouseOverlay.ts @@ -0,0 +1,271 @@ +import { html, LitElement, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { EventBus } from "../../../core/EventBus"; +import { GameView, PlayerView, UnitView } from "../../../core/game/GameView"; +import { UserSettings } from "../../../core/game/UserSettings"; +import { ForcePlayerInfoMouseOverlayEvent } from "../../InputHandler"; +import { TransformHandler } from "../TransformHandler"; +import { Layer } from "./Layer"; +import { PlayerInfoManager } from "./PlayerInfoManager"; +import { HoverInfo, OVERLAY_CONFIG } from "./PlayerInfoService"; + +@customElement("mouse-hud") +export class PlayerInfoMouseOverlay extends LitElement implements Layer { + @property({ type: Object }) + public game!: GameView; + + @property({ type: Object }) + public eventBus!: EventBus; + + @property({ type: Object }) + public transform!: TransformHandler; + + @property({ type: Object }) + public userSettings!: UserSettings; + + @state() + private mouseX = 0; + + @state() + private mouseY = 0; + + @state() + private isDragging = false; + + @state() + private player: PlayerView | null = null; + + @state() + private unit: UnitView | null = null; + + @state() + private hasInfo = false; + + @state() + private forcePlayerInfoMouseOverlay = false; + + private playerInfoManager!: PlayerInfoManager; + private _isActive = false; + private canvas: HTMLCanvasElement | null = null; + private handleMouseDown = () => (this.isDragging = true); + private handleMouseUp = () => (this.isDragging = false); + private handleMouseLeave = () => (this.isDragging = false); + private hoverCallback = (hoverInfo: HoverInfo) => + this.onHoverInfoUpdate(hoverInfo); + private mouseCallback = (x: number, y: number) => + this.onMousePositionUpdate(x, y); + private forceOverlayEventHandler = ( + event: ForcePlayerInfoMouseOverlayEvent, + ) => this.onForcePlayerInfoMouseOverlayEvent(event); + + init() { + if (this._isActive) return; + + this.playerInfoManager = PlayerInfoManager.getInstance( + this.game, + this.transform, + this.eventBus, + ); + + this.playerInfoManager.init(); + this.playerInfoManager.subscribeToData(this.hoverCallback); + this.playerInfoManager.subscribeToMouse(this.mouseCallback); + this.setupEventListeners(); + this._isActive = true; + } + + destroy() { + this.playerInfoManager?.unsubscribeFromData(this.hoverCallback); + this.playerInfoManager?.unsubscribeFromMouse(this.mouseCallback); + this.eventBus.off( + ForcePlayerInfoMouseOverlayEvent, + this.forceOverlayEventHandler, + ); + this.removeCanvasEventListeners(); + this._isActive = false; + } + + private onMousePositionUpdate(x: number, y: number) { + this.mouseX = x; + this.mouseY = y; + this.requestUpdate(); + } + + private onHoverInfoUpdate(hoverInfo: HoverInfo) { + this.player = hoverInfo.player; + this.unit = hoverInfo.unit; + this.hasInfo = !!(this.player ?? this.unit); + this.requestUpdate(); + } + + connectedCallback() { + super.connectedCallback(); + this.setupCanvasEventListeners(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.removeCanvasEventListeners(); + if (this.eventBus) { + this.eventBus.off( + ForcePlayerInfoMouseOverlayEvent, + this.forceOverlayEventHandler, + ); + } + } + + protected setupEventListeners() { + this.eventBus.on( + ForcePlayerInfoMouseOverlayEvent, + this.forceOverlayEventHandler, + ); + } + + private setupCanvasEventListeners() { + this.canvas = document.querySelector("canvas"); + if (this.canvas) { + this.canvas.addEventListener("mousedown", this.handleMouseDown); + this.canvas.addEventListener("mouseup", this.handleMouseUp); + this.canvas.addEventListener("mouseleave", this.handleMouseLeave); + } + } + + private removeCanvasEventListeners() { + if (this.canvas) { + this.canvas.removeEventListener("mousedown", this.handleMouseDown); + this.canvas.removeEventListener("mouseup", this.handleMouseUp); + this.canvas.removeEventListener("mouseleave", this.handleMouseLeave); + this.canvas = null; + } + } + + protected shouldRender(): boolean { + return ( + this._isActive && + (this.userSettings?.showPlayerInfoMouseOverlay() || + this.forcePlayerInfoMouseOverlay) && + this.hasInfo && + !this.isDragging + ); + } + + private getHudElement(): HTMLElement | null { + return this.querySelector(".mouse-hud") as HTMLElement; + } + + private getHUDPosition(): { x: number; y: number } { + const hudElement = this.getHudElement(); + if (!hudElement) return { x: this.mouseX, y: this.mouseY }; + + const w = hudElement.offsetWidth || OVERLAY_CONFIG.defaultWidth; + const h = hudElement.offsetHeight || OVERLAY_CONFIG.defaultHeight; + const vw = window.innerWidth; + const vh = window.innerHeight; + + let x = this.mouseX - w / 2; + let y = this.mouseY + OVERLAY_CONFIG.mouseOffset; + + if (x < 0) x = OVERLAY_CONFIG.margin; + if (x + w > vw) x = vw - w - OVERLAY_CONFIG.margin; + if (y + h > vh) y = this.mouseY - h - OVERLAY_CONFIG.margin; + if (y < OVERLAY_CONFIG.margin) y = OVERLAY_CONFIG.margin; + + return { x, y }; + } + + private renderPlayerInfo(player: PlayerView): TemplateResult { + const playerInfoService = this.playerInfoManager.getPlayerInfoService(); + const { row1, row2 } = playerInfoService.formatStats(player); + const displayName = playerInfoService.getShortDisplayName(player); + const relation = playerInfoService.getRelation(player); + const relationClass = playerInfoService.getRelationClass(relation); + + if (row1.length === 0 && row2.length === 0) { + return html` +
+
${displayName}
+
+ `; + } + + return html` +
+
+ ${displayName} +
+
+ ${row1.length > 0 + ? html`
${row1.join(" ")}
` + : ""} + ${row2.length > 0 + ? html`
${row2.join(" ")}
` + : ""} +
+
+ `; + } + + private onForcePlayerInfoMouseOverlayEvent( + event: ForcePlayerInfoMouseOverlayEvent, + ) { + this.forcePlayerInfoMouseOverlay = event.forcePlayerInfoMouseOverlay; + + this.requestUpdate(); + } + + private renderUnitInfo(unit: UnitView): TemplateResult { + const playerInfoService = this.playerInfoManager.getPlayerInfoService(); + const relation = playerInfoService.getRelation(unit.owner()); + const relationClass = playerInfoService.getRelationClass(relation); + + return html` +
+
+ ${playerInfoService.getShortDisplayName(unit.owner())} +
+
+
${unit.type()}
+ ${unit.hasHealth() + ? html` +
Health: ${unit.health()}
+ ` + : ""} +
+
+ `; + } + + tick() { + this.requestUpdate(); + } + + renderLayer(context: CanvasRenderingContext2D) {} + + shouldTransform(): boolean { + return false; + } + + createRenderRoot() { + return this; + } + + render() { + if (!this.shouldRender()) { + return html``; + } + + const position = this.getHUDPosition(); + const opacity = + this.isDragging || this.getHudElement() === null ? "0" : "1"; + + return html` +
+ ${this.player ? this.renderPlayerInfo(this.player) : ""} + ${this.unit ? this.renderUnitInfo(this.unit) : ""} +
+ `; + } +} diff --git a/src/client/graphics/layers/PlayerInfoOverlay.ts b/src/client/graphics/layers/PlayerInfoOverlay.ts index dab0350230..6041150f36 100644 --- a/src/client/graphics/layers/PlayerInfoOverlay.ts +++ b/src/client/graphics/layers/PlayerInfoOverlay.ts @@ -1,44 +1,24 @@ -import { LitElement, TemplateResult, html } from "lit"; +import { html, LitElement, TemplateResult } from "lit"; import { ref } from "lit-html/directives/ref.js"; import { customElement, property, state } from "lit/decorators.js"; -import { translateText } from "../../../client/Utils"; import { renderPlayerFlag } from "../../../core/CustomFlag"; import { EventBus } from "../../../core/EventBus"; import { PlayerProfile, PlayerType, Relation, - Unit, UnitType, } from "../../../core/game/Game"; -import { TileRef } from "../../../core/game/GameMap"; import { GameView, PlayerView, UnitView } from "../../../core/game/GameView"; -import { ContextMenuEvent, MouseMoveEvent } from "../../InputHandler"; -import { renderNumber, renderTroops } from "../../Utils"; +import { UserSettings } from "../../../core/game/UserSettings"; +import { ContextMenuEvent } from "../../InputHandler"; +import { renderNumber, renderTroops, translateText } from "../../Utils"; import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; +import { PlayerInfoManager } from "./PlayerInfoManager"; +import { HoverInfo } from "./PlayerInfoService"; import { CloseRadialMenuEvent } from "./RadialMenu"; -function euclideanDistWorld( - coord: { x: number; y: number }, - tileRef: TileRef, - game: GameView, -): number { - const x = game.x(tileRef); - const y = game.y(tileRef); - const dx = coord.x - x; - const dy = coord.y - y; - return Math.sqrt(dx * dx + dy * dy); -} - -function distSortUnitWorld(coord: { x: number; y: number }, game: GameView) { - return (a: Unit | UnitView, b: Unit | UnitView) => { - const distA = euclideanDistWorld(coord, a.tile(), game); - const distB = euclideanDistWorld(coord, b.tile(), game); - return distA - distB; - }; -} - @customElement("player-info-overlay") export class PlayerInfoOverlay extends LitElement implements Layer { @property({ type: Object }) @@ -50,6 +30,12 @@ export class PlayerInfoOverlay extends LitElement implements Layer { @property({ type: Object }) public transform!: TransformHandler; + @property({ type: Object }) + public userSettings!: UserSettings; + + @state() + private _isInfoVisible: boolean = false; + @state() private player: PlayerView | null = null; @@ -59,82 +45,94 @@ export class PlayerInfoOverlay extends LitElement implements Layer { @state() private unit: UnitView | null = null; - @state() - private _isInfoVisible: boolean = false; - + private playerInfoManager!: PlayerInfoManager; private _isActive = false; - - private lastMouseUpdate = 0; + private hoverCallback = (hoverInfo: HoverInfo) => + this.onHoverInfoUpdate(hoverInfo); + private contextMenuHandler = (e: ContextMenuEvent) => + this.maybeShow(e.x, e.y); + private closeRadialMenuHandler = () => this.hide(); private showDetails = true; init() { - this.eventBus.on(MouseMoveEvent, (e: MouseMoveEvent) => - this.onMouseEvent(e), + this.playerInfoManager = PlayerInfoManager.getInstance( + this.game, + this.transform, + this.eventBus, ); - this.eventBus.on(ContextMenuEvent, (e: ContextMenuEvent) => - this.maybeShow(e.x, e.y), - ); - this.eventBus.on(CloseRadialMenuEvent, () => this.hide()); + + this.playerInfoManager.init(); + this.playerInfoManager.subscribeToData(this.hoverCallback); + this.setupEventListeners(); this._isActive = true; } - private onMouseEvent(event: MouseMoveEvent) { - const now = Date.now(); - if (now - this.lastMouseUpdate < 100) { + destroy() { + this.playerInfoManager?.unsubscribeFromData(this.hoverCallback); + this.removeEventListeners(); + this._isActive = false; + } + + private onHoverInfoUpdate(hoverInfo: HoverInfo) { + if (!this.userSettings?.showPlayerInfoOverlay()) { + this.hide(); return; } - this.lastMouseUpdate = now; - this.maybeShow(event.x, event.y); + + this.player = hoverInfo.player; + this.playerProfile = hoverInfo.playerProfile; + this.unit = hoverInfo.unit; + + if (this.player || this.unit) { + this.setVisible(true); + } else { + this.hide(); + } + this.requestUpdate(); } - public hide() { - this.setVisible(false); - this.unit = null; - this.player = null; + connectedCallback() { + super.connectedCallback(); + this.setupEventListeners(); } - public maybeShow(x: number, y: number) { - this.hide(); - const worldCoord = this.transform.screenToWorldCoordinates(x, y); - if (!this.game.isValidCoord(worldCoord.x, worldCoord.y)) { - return; - } + disconnectedCallback() { + super.disconnectedCallback(); + this.removeEventListeners(); + } - const tile = this.game.ref(worldCoord.x, worldCoord.y); - if (!tile) return; + protected setupEventListeners() { + this.eventBus.on(ContextMenuEvent, this.contextMenuHandler); + this.eventBus.on(CloseRadialMenuEvent, this.closeRadialMenuHandler); + } - const owner = this.game.owner(tile); + private removeEventListeners() { + this.eventBus.off(ContextMenuEvent, this.contextMenuHandler); + this.eventBus.off(CloseRadialMenuEvent, this.closeRadialMenuHandler); + } - if (owner && owner.isPlayer()) { - this.player = owner as PlayerView; - this.player.profile().then((p) => { - this.playerProfile = p; - }); - this.setVisible(true); - } else if (!this.game.isLand(tile)) { - const units = this.game - .units(UnitType.Warship, UnitType.TradeShip, UnitType.TransportShip) - .filter((u) => euclideanDistWorld(worldCoord, u.tile(), this.game) < 50) - .sort(distSortUnitWorld(worldCoord, this.game)); - - if (units.length > 0) { - this.unit = units[0]; - this.setVisible(true); - } - } + protected shouldRender(): boolean { + return this._isActive && this.userSettings?.showPlayerInfoOverlay(); } - tick() { - this.requestUpdate(); + public hide() { + this.setVisible(false); + this.resetHoverState(); } - renderLayer(context: CanvasRenderingContext2D) { - // Implementation for Layer interface + public async maybeShow(x: number, y: number) { + this.hide(); + const hoverInfo = await this.playerInfoManager + .getPlayerInfoService() + .getHoverInfo(x, y); + this.onHoverInfoUpdate(hoverInfo); } - shouldTransform(): boolean { - return false; + private resetHoverState() { + this.player = null; + this.playerProfile = null; + this.unit = null; } setVisible(visible: boolean) { @@ -142,21 +140,6 @@ export class PlayerInfoOverlay extends LitElement implements Layer { this.requestUpdate(); } - private getRelationClass(relation: Relation): string { - switch (relation) { - case Relation.Hostile: - return "text-red-500"; - case Relation.Distrustful: - return "text-red-300"; - case Relation.Neutral: - return "text-white"; - case Relation.Friendly: - return "text-green-500"; - default: - return "text-white"; - } - } - private getRelationName(relation: Relation): string { switch (relation) { case Relation.Hostile: @@ -184,6 +167,31 @@ export class PlayerInfoOverlay extends LitElement implements Layer { : ""; } + private renderUnitInfo(unit: UnitView): TemplateResult { + const playerInfoService = this.playerInfoManager.getPlayerInfoService(); + const relation = playerInfoService.getRelation(unit.owner()); + const relationClass = playerInfoService.getRelationClass(relation); + + return html` +
+
+ ${playerInfoService.getShortDisplayName(unit.owner())} +
+
+
${unit.type()}
+ ${unit.hasHealth() + ? html` +
+ ${translateText("player_info_overlay.health")}: + ${unit.health()} +
+ ` + : ""} +
+
+ `; + } + private renderPlayerInfo(player: PlayerView) { const myPlayer = this.game.myPlayer(); const isFriendly = myPlayer?.isFriendly(player); @@ -193,10 +201,12 @@ export class PlayerInfoOverlay extends LitElement implements Layer { .map((a) => a.troops) .reduce((a, b) => a + b, 0); + const playerInfoService = this.playerInfoManager.getPlayerInfoService(); + if (player.type() === PlayerType.FakeHuman && myPlayer !== null) { const relation = this.playerProfile?.relations[myPlayer.smallID()] ?? Relation.Neutral; - const relationClass = this.getRelationClass(relation); + const relationClass = playerInfoService.getRelationClass(relation); const relationName = this.getRelationName(relation); relationHtml = html` @@ -315,34 +325,22 @@ export class PlayerInfoOverlay extends LitElement implements Layer { `; } - private renderUnitInfo(unit: UnitView) { - const isAlly = - (unit.owner() === this.game.myPlayer() || - this.game.myPlayer()?.isFriendly(unit.owner())) ?? - false; + tick() { + this.requestUpdate(); + } + + renderLayer(context: CanvasRenderingContext2D) {} - return html` -
-
- ${unit.owner().name()} -
-
-
${unit.type()}
- ${unit.hasHealth() - ? html` -
- ${translateText("player_info_overlay.health")}: - ${unit.health()} -
- ` - : ""} -
-
- `; + shouldTransform(): boolean { + return false; + } + + createRenderRoot() { + return this; } render() { - if (!this._isActive) { + if (!this.shouldRender()) { return html``; } @@ -364,8 +362,4 @@ export class PlayerInfoOverlay extends LitElement implements Layer { `; } - - createRenderRoot() { - return this; // Disable shadow DOM to allow Tailwind styles - } } diff --git a/src/client/graphics/layers/PlayerInfoService.ts b/src/client/graphics/layers/PlayerInfoService.ts new file mode 100644 index 0000000000..0858f5658a --- /dev/null +++ b/src/client/graphics/layers/PlayerInfoService.ts @@ -0,0 +1,214 @@ +import { PlayerProfile, Relation, UnitType } from "../../../core/game/Game"; +import { TileRef } from "../../../core/game/GameMap"; +import { GameView, PlayerView, UnitView } from "../../../core/game/GameView"; +import { renderNumber, renderTroops } from "../../Utils"; +import { TransformHandler } from "../TransformHandler"; + +interface StatDefinition { + code: string; + emoji: string; + row: number; +} + +const STAT_DEFINITIONS: StatDefinition[] = [ + { code: "defending_troops", emoji: "🛡️", row: 1 }, + { code: "attacking_troops", emoji: "⚔️", row: 1 }, + { code: "gold", emoji: "💰", row: 1 }, + { code: "ports", emoji: "⚓", row: 2 }, + { code: "cities", emoji: "🏙️", row: 2 }, + { code: "missile_launchers", emoji: "🚀", row: 2 }, + { code: "sams", emoji: "🎯", row: 2 }, + { code: "warships", emoji: "🚢", row: 2 }, +]; + +const OVERLAY_CONFIG = { + updateThrottleMs: 100, + mouseOffset: 28, + margin: 10, + defaultWidth: 200, + defaultHeight: 100, + maxNameLength: 20, + unitDetectionRadius: 50, +} as const; + +function euclideanDistWorld( + coord: { x: number; y: number }, + tileRef: TileRef, + game: GameView, +): number { + const x = game.x(tileRef); + const y = game.y(tileRef); + const dx = coord.x - x; + const dy = coord.y - y; + return Math.sqrt(dx * dx + dy * dy); +} + +function distSortUnitWorld(coord: { x: number; y: number }, game: GameView) { + return (a: UnitView, b: UnitView) => { + const distA = euclideanDistWorld(coord, a.tile(), game); + const distB = euclideanDistWorld(coord, b.tile(), game); + return distA - distB; + }; +} + +export interface HoverInfo { + player: PlayerView | null; + playerProfile: PlayerProfile | null; + unit: UnitView | null; + mouseX: number; + mouseY: number; +} + +export class PlayerInfoService { + private readonly game: GameView; + private transform: TransformHandler; + + private readonly emojiMap = Object.fromEntries( + STAT_DEFINITIONS.map(({ code, emoji }) => [code, emoji]), + ); + + private readonly rowMap = Object.fromEntries( + STAT_DEFINITIONS.map(({ code, row }) => [code, row]), + ); + + constructor(game: GameView, transform: TransformHandler) { + this.game = game; + this.transform = transform; + } + + findNearestUnit(worldCoord: { x: number; y: number }): UnitView | null { + const units = this.game + .units(UnitType.Warship, UnitType.TradeShip, UnitType.TransportShip) + .filter( + (u) => + euclideanDistWorld(worldCoord, u.tile(), this.game) < + OVERLAY_CONFIG.unitDetectionRadius, + ) + .sort(distSortUnitWorld(worldCoord, this.game)); + + return units.length > 0 ? units[0] : null; + } + + async getHoverInfo(x: number, y: number): Promise { + const hoverInfo: HoverInfo = { + player: null, + playerProfile: null, + unit: null, + mouseX: x, + mouseY: y, + }; + + const worldCoord = this.transform.screenToWorldCoordinates(x, y); + if (!this.game.isValidCoord(worldCoord.x, worldCoord.y)) { + return hoverInfo; + } + + const tile = this.game.ref(worldCoord.x, worldCoord.y); + if (!tile) return hoverInfo; + + const owner = this.game.owner(tile); + + if (owner && owner.isPlayer()) { + hoverInfo.player = owner as PlayerView; + hoverInfo.playerProfile = await hoverInfo.player.profile(); + } else if (!this.game.isLand(tile)) { + const nearestUnit = this.findNearestUnit(worldCoord); + if (nearestUnit) { + hoverInfo.unit = nearestUnit; + } + } + + return hoverInfo; + } + + getRelationClass(relation: Relation): string { + switch (relation) { + case Relation.Hostile: + return "text-red-500"; + case Relation.Distrustful: + return "text-red-300"; + case Relation.Neutral: + return "text-white"; + case Relation.Friendly: + return "text-green-500"; + default: + return "text-white"; + } + } + + getRelation(player: PlayerView): Relation { + const myPlayer = this.game.myPlayer(); + + if (myPlayer === null) { + return Relation.Neutral; + } + + if (player === myPlayer) { + return Relation.Friendly; + } + + if (myPlayer?.isFriendly(player)) { + return Relation.Friendly; + } + + return Relation.Neutral; + } + + getShortDisplayName(player: PlayerView): string { + const name = player.name(); + return name.length > OVERLAY_CONFIG.maxNameLength + ? name.slice(0, OVERLAY_CONFIG.maxNameLength - 2) + "…" + : name; + } + + calculatePlayerStats(player: PlayerView): Array<[string, string]> { + const attackingTroops = player + .outgoingAttacks() + .map((a) => a.troops) + .reduce((a, b) => a + b, 0); + + return [ + ["defending_troops", renderTroops(player.troops())], + ["attacking_troops", renderTroops(attackingTroops)], + ["gold", renderNumber(player.gold())], + ["ports", player.totalUnitLevels(UnitType.Port).toString()], + ["cities", player.totalUnitLevels(UnitType.City).toString()], + [ + "missile_launchers", + player.totalUnitLevels(UnitType.MissileSilo).toString(), + ], + ["sams", player.totalUnitLevels(UnitType.SAMLauncher).toString()], + ["warships", player.units(UnitType.Warship).length.toString()], + ]; + } + + private isStatValueEmpty(value: string): boolean { + return ["0", "0.0", "0K"].includes(value); + } + + formatStats(player: PlayerView): { + row1: string[]; + row2: string[]; + } { + const row1: string[] = []; + const row2: string[] = []; + const stats = this.calculatePlayerStats(player); + + for (const [statLabel, rawValue] of stats) { + if (!this.emojiMap[statLabel] || this.isStatValueEmpty(rawValue)) { + continue; + } + + const display = `${this.emojiMap[statLabel]} ${rawValue}`; + if (this.rowMap[statLabel] === 1) { + row1.push(display); + } else { + row2.push(display); + } + } + + return { row1, row2 }; + } +} + +export { OVERLAY_CONFIG, STAT_DEFINITIONS }; diff --git a/src/client/graphics/layers/SettingsModal.ts b/src/client/graphics/layers/SettingsModal.ts index 72d4c6cf3e..351bac1d1f 100644 --- a/src/client/graphics/layers/SettingsModal.ts +++ b/src/client/graphics/layers/SettingsModal.ts @@ -145,6 +145,27 @@ export class SettingsModal extends LitElement implements Layer { this.requestUpdate(); } + private onInfoDisplayModeClick() { + const currentMode = this.userSettings.infoDisplayMode(); + let nextMode: string; + + switch (currentMode) { + case "overlay": + nextMode = "mousehud"; + break; + case "mousehud": + nextMode = "both"; + break; + case "both": + default: + nextMode = "overlay"; + break; + } + + this.userSettings.setInfoDisplayMode(nextMode); + this.requestUpdate(); + } + private onExitButtonClick() { // redirect to the home page window.location.href = "/"; @@ -385,6 +406,32 @@ export class SettingsModal extends LitElement implements Layer { + +
+
diff --git a/src/core/game/UserSettings.ts b/src/core/game/UserSettings.ts index 35f3c727b6..9bebf34e65 100644 --- a/src/core/game/UserSettings.ts +++ b/src/core/game/UserSettings.ts @@ -62,6 +62,26 @@ export class UserSettings { this.get("settings.focusLocked", true); } + infoDisplayMode(): string { + return localStorage.getItem("settings.infoDisplayMode") ?? "overlay"; + } + + setInfoDisplayMode(mode: string): void { + localStorage.setItem("settings.infoDisplayMode", mode); + } + + showPlayerInfoOverlay(): boolean { + const mode = this.infoDisplayMode(); + + return mode === "overlay" || mode === "both"; + } + + showPlayerInfoMouseOverlay(): boolean { + const mode = this.infoDisplayMode(); + + return mode === "mousehud" || mode === "both"; + } + toggleLeftClickOpenMenu() { this.set("settings.leftClickOpensMenu", !this.leftClickOpensMenu()); } diff --git a/tests/client/graphics/layers/PlayerInfoManager.test.ts b/tests/client/graphics/layers/PlayerInfoManager.test.ts new file mode 100644 index 0000000000..ce2bfdfda0 --- /dev/null +++ b/tests/client/graphics/layers/PlayerInfoManager.test.ts @@ -0,0 +1,180 @@ +/** + * @jest-environment jsdom + */ +import { MouseMoveEvent } from "../../../../src/client/InputHandler"; +import { TransformHandler } from "../../../../src/client/graphics/TransformHandler"; +import { PlayerInfoManager } from "../../../../src/client/graphics/layers/PlayerInfoManager"; +import { PlayerInfoService } from "../../../../src/client/graphics/layers/PlayerInfoService"; +import { EventBus } from "../../../../src/core/EventBus"; +import { GameView } from "../../../../src/core/game/GameView"; + +jest.mock("../../../../src/client/graphics/layers/PlayerInfoService"); + +describe("PlayerInfoManager", () => { + let game: GameView; + let transform: TransformHandler; + let eventBus: EventBus; + let playerInfoManager: PlayerInfoManager; + let mockPlayerInfoService: jest.Mocked; + + beforeEach(() => { + game = {} as GameView; + transform = {} as TransformHandler; + eventBus = { + on: jest.fn(), + off: jest.fn(), + } as any; + + mockPlayerInfoService = { + getHoverInfo: jest.fn().mockResolvedValue({ + player: null, + playerProfile: null, + unit: null, + mouseX: 0, + mouseY: 0, + }), + } as any; + + ( + PlayerInfoService as jest.MockedClass + ).mockImplementation(() => mockPlayerInfoService); + + PlayerInfoManager["instance"] = null; + playerInfoManager = PlayerInfoManager.getInstance( + game, + transform, + eventBus, + ); + }); + + afterEach(() => { + playerInfoManager.destroy(); + }); + + it("should create singleton instance", () => { + const instance1 = PlayerInfoManager.getInstance(game, transform, eventBus); + const instance2 = PlayerInfoManager.getInstance(game, transform, eventBus); + + expect(instance1).toBe(instance2); + }); + + it("should initialize and setup event listeners", () => { + playerInfoManager.init(); + + expect(eventBus.on).toHaveBeenCalledWith( + MouseMoveEvent, + expect.any(Function), + ); + }); + + it("should destroy and cleanup properly", () => { + playerInfoManager.init(); + playerInfoManager.destroy(); + + expect(eventBus.off).toHaveBeenCalledWith( + MouseMoveEvent, + expect.any(Function), + ); + }); + + it("should subscribe and unsubscribe to data updates", () => { + const callback = jest.fn(); + + playerInfoManager.subscribeToData(callback); + expect(callback).not.toHaveBeenCalled(); + + playerInfoManager.unsubscribeFromData(callback); + }); + + it("should subscribe and unsubscribe to mouse updates", () => { + const callback = jest.fn(); + + playerInfoManager.subscribeToMouse(callback); + expect(callback).toHaveBeenCalledWith(0, 0); + + playerInfoManager.unsubscribeFromMouse(callback); + }); + + it("should handle mouse move events", async () => { + const dataCallback = jest.fn(); + const mouseCallback = jest.fn(); + + playerInfoManager.init(); + playerInfoManager.subscribeToData(dataCallback); + playerInfoManager.subscribeToMouse(mouseCallback); + + const mouseMoveEvent = new MouseMoveEvent(100, 200); + const onMouseMoveCallback = (eventBus.on as jest.Mock).mock.calls.find( + (call) => call[0] === MouseMoveEvent, + )[1]; + + await onMouseMoveCallback(mouseMoveEvent); + + expect(mouseCallback).toHaveBeenCalledWith(100, 200); + expect(mockPlayerInfoService.getHoverInfo).toHaveBeenCalledWith(100, 200); + }); + + it("should throttle data updates", async () => { + playerInfoManager.init(); + + const mouseMoveEvent1 = new MouseMoveEvent(100, 200); + const mouseMoveEvent2 = new MouseMoveEvent(101, 201); + + const onMouseMoveCallback = (eventBus.on as jest.Mock).mock.calls.find( + (call) => call[0] === MouseMoveEvent, + )[1]; + + await onMouseMoveCallback(mouseMoveEvent1); + await onMouseMoveCallback(mouseMoveEvent2); + + expect(mockPlayerInfoService.getHoverInfo).toHaveBeenCalledTimes(1); + }); + + it("should notify data subscribers when hover info changes", async () => { + const callback = jest.fn(); + const mockHoverInfo = { + player: { name: () => "TestPlayer" } as any, + playerProfile: null, + unit: null, + mouseX: 100, + mouseY: 200, + }; + + mockPlayerInfoService.getHoverInfo.mockResolvedValue(mockHoverInfo); + + playerInfoManager.init(); + playerInfoManager.subscribeToData(callback); + + const mouseMoveEvent = new MouseMoveEvent(100, 200); + const onMouseMoveCallback = (eventBus.on as jest.Mock).mock.calls.find( + (call) => call[0] === MouseMoveEvent, + )[1]; + + await onMouseMoveCallback(mouseMoveEvent); + + expect(callback).toHaveBeenCalledWith(mockHoverInfo); + }); + + it("should provide access to player info service", () => { + const service = playerInfoManager.getPlayerInfoService(); + expect(service).toBe(mockPlayerInfoService); + }); + + it("should handle multiple subscribers correctly", () => { + const dataCallback1 = jest.fn(); + const dataCallback2 = jest.fn(); + const mouseCallback1 = jest.fn(); + const mouseCallback2 = jest.fn(); + + playerInfoManager.subscribeToData(dataCallback1); + playerInfoManager.subscribeToData(dataCallback2); + playerInfoManager.subscribeToMouse(mouseCallback1); + playerInfoManager.subscribeToMouse(mouseCallback2); + + expect(mouseCallback1).toHaveBeenCalledWith(0, 0); + expect(mouseCallback2).toHaveBeenCalledWith(0, 0); + + playerInfoManager.unsubscribeFromData(dataCallback1); + playerInfoManager.unsubscribeFromMouse(mouseCallback1); + }); +}); diff --git a/tests/client/graphics/layers/PlayerInfoMouseOverlay.test.ts b/tests/client/graphics/layers/PlayerInfoMouseOverlay.test.ts new file mode 100644 index 0000000000..c47cdff70f --- /dev/null +++ b/tests/client/graphics/layers/PlayerInfoMouseOverlay.test.ts @@ -0,0 +1,661 @@ +/** + * @jest-environment jsdom + */ +import { PlayerInfoManager } from "../../../../src/client/graphics/layers/PlayerInfoManager"; +import { TransformHandler } from "../../../../src/client/graphics/TransformHandler"; +import { ForcePlayerInfoMouseOverlayEvent } from "../../../../src/client/InputHandler"; +import { EventBus } from "../../../../src/core/EventBus"; +import { Relation } from "../../../../src/core/game/Game"; +import { + GameView, + PlayerView, + UnitView, +} from "../../../../src/core/game/GameView"; +import { UserSettings } from "../../../../src/core/game/UserSettings"; + +jest.mock("../../../../src/client/graphics/layers/PlayerInfoManager"); + +class MockPlayerInfoMouseOverlay { + public game!: GameView; + public eventBus!: EventBus; + public transform!: TransformHandler; + public userSettings!: UserSettings; + + private mouseX = 0; + private mouseY = 0; + private isDragging = false; + private player: PlayerView | null = null; + private unit: UnitView | null = null; + private hasInfo = false; + private forcePlayerInfoMouseOverlay = false; + private playerInfoManager: any; + private _isActive = false; + private canvas: HTMLCanvasElement | null = null; + private forceOverlayEventHandler = ( + event: ForcePlayerInfoMouseOverlayEvent, + ) => this.onForcePlayerInfoMouseOverlayEvent(event); + + init() { + if (this._isActive) return; + + this.playerInfoManager = PlayerInfoManager.getInstance( + this.game, + this.transform, + this.eventBus, + ); + this.playerInfoManager.init(); + this.playerInfoManager.subscribeToData(this.onHoverInfoUpdate.bind(this)); + this.playerInfoManager.subscribeToMouse( + this.onMousePositionUpdate.bind(this), + ); + this.setupEventListeners(); + this._isActive = true; + } + + destroy() { + this.playerInfoManager?.unsubscribeFromData( + this.onHoverInfoUpdate.bind(this), + ); + this.playerInfoManager?.unsubscribeFromMouse( + this.onMousePositionUpdate.bind(this), + ); + this.eventBus.off( + ForcePlayerInfoMouseOverlayEvent, + this.forceOverlayEventHandler, + ); + this.removeCanvasEventListeners(); + this._isActive = false; + } + + private onMousePositionUpdate(x: number, y: number) { + this.mouseX = x; + this.mouseY = y; + } + + private onHoverInfoUpdate(hoverInfo: any) { + this.player = hoverInfo.player; + this.unit = hoverInfo.unit; + this.hasInfo = !!(this.player ?? this.unit); + } + + private onForcePlayerInfoMouseOverlayEvent( + event: ForcePlayerInfoMouseOverlayEvent, + ) { + this.forcePlayerInfoMouseOverlay = event.forcePlayerInfoMouseOverlay; + } + + connectedCallback() { + this.setupCanvasEventListeners(); + } + + disconnectedCallback() { + this.removeCanvasEventListeners(); + if (this.eventBus) { + this.eventBus.off( + ForcePlayerInfoMouseOverlayEvent, + this.forceOverlayEventHandler, + ); + } + } + + private setupEventListeners() { + this.eventBus.on = jest.fn(); + this.eventBus.on( + ForcePlayerInfoMouseOverlayEvent, + this.forceOverlayEventHandler, + ); + } + + private setupCanvasEventListeners() { + this.canvas = document.querySelector("canvas"); + if (this.canvas) { + this.canvas.addEventListener( + "mousedown", + this.handleMouseDown.bind(this), + ); + this.canvas.addEventListener("mouseup", this.handleMouseUp.bind(this)); + this.canvas.addEventListener( + "mouseleave", + this.handleMouseLeave.bind(this), + ); + } + } + + private removeCanvasEventListeners() { + if (this.canvas) { + this.canvas.removeEventListener( + "mousedown", + this.handleMouseDown.bind(this), + ); + this.canvas.removeEventListener("mouseup", this.handleMouseUp.bind(this)); + this.canvas.removeEventListener( + "mouseleave", + this.handleMouseLeave.bind(this), + ); + this.canvas = null; + } + } + + private handleMouseDown() { + this.isDragging = true; + } + + private handleMouseUp() { + this.isDragging = false; + } + + private handleMouseLeave() { + this.isDragging = false; + } + + private shouldRender(): boolean { + return ( + this._isActive && + (this.userSettings?.showPlayerInfoMouseOverlay() || + this.forcePlayerInfoMouseOverlay) && + this.hasInfo && + !this.isDragging + ); + } + + private getHudElement(): HTMLElement | null { + return this.querySelector(".mouse-hud") as HTMLElement; + } + + private getHUDPosition(): { x: number; y: number } { + const hudElement = this.getHudElement(); + if (!hudElement) return { x: this.mouseX, y: this.mouseY }; + + const w = hudElement.offsetWidth || 200; + const h = hudElement.offsetHeight || 100; + const vw = window.innerWidth; + const vh = window.innerHeight; + + let x = this.mouseX - w / 2; + let y = this.mouseY + 28; + + if (x < 0) x = 10; + if (x + w > vw) x = vw - w - 10; + if (y + h > vh) y = this.mouseY - h - 10; + if (y < 10) y = 10; + + return { x, y }; + } + + querySelector(selector: string): Element | null { + return { + offsetWidth: 150, + offsetHeight: 80, + } as any; + } + + tick() {} + + renderLayer(context: CanvasRenderingContext2D) {} + + shouldTransform(): boolean { + return false; + } + + render() { + if (!this.shouldRender()) { + return { strings: [""] }; + } + + const position = this.getHUDPosition(); + const opacity = + this.isDragging || this.getHudElement() === null ? "0" : "1"; + let content = ""; + + if (this.player) { + content += this.player.name(); + } + if (this.unit) { + content += this.unit.type() + "Health: " + this.unit.health(); + } + + return { + strings: [ + `left: ${position.x}px; top: ${position.y}px; opacity: ${opacity}${content}`, + ], + }; + } +} + +describe("PlayerInfoMouseOverlay", () => { + let game: GameView; + let eventBus: EventBus; + let transform: TransformHandler; + let userSettings: UserSettings; + let overlay: MockPlayerInfoMouseOverlay; + let mockPlayerInfoManager: any; + let mockPlayer: PlayerView; + let mockUnit: UnitView; + let mockCanvas: HTMLCanvasElement; + + beforeEach(() => { + game = {} as GameView; + eventBus = { + on: jest.fn(), + off: jest.fn(), + } as any; + transform = {} as TransformHandler; + + userSettings = { + showPlayerInfoMouseOverlay: jest.fn().mockReturnValue(true), + } as any; + + mockPlayer = { + name: jest.fn().mockReturnValue("TestPlayer"), + } as any; + + mockUnit = { + type: jest.fn().mockReturnValue("Warship"), + owner: jest.fn().mockReturnValue(mockPlayer), + hasHealth: jest.fn().mockReturnValue(true), + health: jest.fn().mockReturnValue(80), + } as any; + + const mockPlayerInfoService = { + formatStats: jest.fn().mockReturnValue({ + row1: ["🛡️ 100", "💰 5.0K"], + row2: ["⚓ 3", "🏙️ 2"], + }), + getShortDisplayName: jest.fn().mockReturnValue("TestPlayer"), + getRelation: jest.fn().mockReturnValue(Relation.Neutral), + getRelationClass: jest.fn().mockReturnValue("text-white"), + }; + + mockPlayerInfoManager = { + init: jest.fn(), + subscribeToData: jest.fn(), + unsubscribeFromData: jest.fn(), + subscribeToMouse: jest.fn(), + unsubscribeFromMouse: jest.fn(), + getPlayerInfoService: jest.fn().mockReturnValue(mockPlayerInfoService), + }; + + (PlayerInfoManager.getInstance as jest.Mock).mockReturnValue( + mockPlayerInfoManager, + ); + + mockCanvas = document.createElement("canvas"); + document.querySelector = jest.fn().mockReturnValue(mockCanvas); + + overlay = new MockPlayerInfoMouseOverlay(); + overlay.game = game; + overlay.eventBus = eventBus; + overlay.transform = transform; + overlay.userSettings = userSettings; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should initialize correctly", () => { + overlay.init(); + + expect(mockPlayerInfoManager.init).toHaveBeenCalled(); + expect(mockPlayerInfoManager.subscribeToData).toHaveBeenCalled(); + expect(mockPlayerInfoManager.subscribeToMouse).toHaveBeenCalled(); + }); + + it("should destroy and cleanup properly", () => { + eventBus.off = jest.fn(); + overlay.eventBus = eventBus; + overlay.init(); + overlay.destroy(); + + expect(mockPlayerInfoManager.unsubscribeFromData).toHaveBeenCalled(); + expect(mockPlayerInfoManager.unsubscribeFromMouse).toHaveBeenCalled(); + expect(eventBus.off).toHaveBeenCalledWith( + ForcePlayerInfoMouseOverlayEvent, + overlay["forceOverlayEventHandler"], + ); + }); + + it("should update mouse position", () => { + overlay.init(); + const mouseCallback = + mockPlayerInfoManager.subscribeToMouse.mock.calls[0][0]; + + mouseCallback(150, 250); + + expect(overlay["mouseX"]).toBe(150); + expect(overlay["mouseY"]).toBe(250); + }); + + it("should show overlay when hover info contains data", () => { + overlay.init(); + const dataCallback = mockPlayerInfoManager.subscribeToData.mock.calls[0][0]; + + dataCallback({ + player: mockPlayer, + unit: null, + mouseX: 100, + mouseY: 200, + }); + + expect(overlay["player"]).toBe(mockPlayer); + expect(overlay["hasInfo"]).toBe(true); + }); + + it("should update hover info without checking user settings", () => { + userSettings.showPlayerInfoMouseOverlay = jest.fn().mockReturnValue(false); + overlay.init(); + const dataCallback = mockPlayerInfoManager.subscribeToData.mock.calls[0][0]; + + dataCallback({ + player: mockPlayer, + unit: null, + mouseX: 100, + mouseY: 200, + }); + + expect(overlay["player"]).toBe(mockPlayer); + expect(overlay["hasInfo"]).toBe(true); + }); + + it("should show overlay when forced even if user settings disable it", () => { + userSettings.showPlayerInfoMouseOverlay = jest.fn().mockReturnValue(false); + overlay.init(); + overlay["hasInfo"] = true; + overlay["forcePlayerInfoMouseOverlay"] = true; + overlay["isDragging"] = false; + + const result = overlay.render(); + const htmlString = result.strings.join(""); + + expect(htmlString).not.toBe(""); + }); + + it("should handle ForcePlayerInfoMouseOverlayEvent", () => { + const event = { + forcePlayerInfoMouseOverlay: true, + } as ForcePlayerInfoMouseOverlayEvent; + + overlay["onForcePlayerInfoMouseOverlayEvent"](event); + + expect(overlay["forcePlayerInfoMouseOverlay"]).toBe(true); + }); + + it("should setup event bus listener for ForcePlayerInfoMouseOverlayEvent", () => { + eventBus.on = jest.fn(); + overlay.eventBus = eventBus; + + overlay["setupEventListeners"](); + + expect(eventBus.on).toHaveBeenCalledWith( + ForcePlayerInfoMouseOverlayEvent, + expect.any(Function), + ); + }); + + it("should hide overlay when user settings disable it", () => { + userSettings.showPlayerInfoMouseOverlay = jest.fn().mockReturnValue(false); + overlay.init(); + overlay["hasInfo"] = true; + overlay["forcePlayerInfoMouseOverlay"] = false; + + const result = overlay.render(); + const htmlString = result.strings.join(""); + + expect(htmlString).toBe(""); + }); + + it("should hide overlay when hover info is empty", () => { + overlay.init(); + const dataCallback = mockPlayerInfoManager.subscribeToData.mock.calls[0][0]; + + dataCallback({ + player: null, + unit: null, + mouseX: 100, + mouseY: 200, + }); + + expect(overlay["hasInfo"]).toBe(false); + }); + + it("should setup canvas event listeners on connected", () => { + const addEventListenerSpy = jest.spyOn(mockCanvas, "addEventListener"); + + overlay.connectedCallback(); + + expect(addEventListenerSpy).toHaveBeenCalledWith( + "mousedown", + expect.any(Function), + ); + expect(addEventListenerSpy).toHaveBeenCalledWith( + "mouseup", + expect.any(Function), + ); + expect(addEventListenerSpy).toHaveBeenCalledWith( + "mouseleave", + expect.any(Function), + ); + }); + + it("should remove canvas event listeners on disconnected", () => { + const removeEventListenerSpy = jest.spyOn( + mockCanvas, + "removeEventListener", + ); + overlay["canvas"] = mockCanvas; + + overlay.disconnectedCallback(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + "mousedown", + expect.any(Function), + ); + expect(removeEventListenerSpy).toHaveBeenCalledWith( + "mouseup", + expect.any(Function), + ); + expect(removeEventListenerSpy).toHaveBeenCalledWith( + "mouseleave", + expect.any(Function), + ); + }); + + it("should handle mouse events for dragging state", () => { + overlay.connectedCallback(); + + expect(overlay["isDragging"]).toBe(false); + + overlay["handleMouseDown"](); + expect(overlay["isDragging"]).toBe(true); + + overlay["handleMouseUp"](); + expect(overlay["isDragging"]).toBe(false); + + overlay["handleMouseDown"](); + overlay["handleMouseLeave"](); + expect(overlay["isDragging"]).toBe(false); + }); + + it("should calculate HUD position correctly", () => { + overlay["mouseX"] = 100; + overlay["mouseY"] = 200; + + Object.defineProperty(window, "innerWidth", { + value: 1920, + configurable: true, + }); + Object.defineProperty(window, "innerHeight", { + value: 1080, + configurable: true, + }); + + const position = overlay["getHUDPosition"](); + + expect(position.x).toBe(25); + expect(position.y).toBe(228); + }); + + it("should adjust HUD position when near screen edges", () => { + overlay["mouseX"] = 50; + overlay["mouseY"] = 1000; + + Object.defineProperty(window, "innerWidth", { + value: 1920, + configurable: true, + }); + Object.defineProperty(window, "innerHeight", { + value: 1080, + configurable: true, + }); + + const position = overlay["getHUDPosition"](); + + expect(position.x).toBe(10); + expect(position.y).toBe(910); + }); + + it("should render player info correctly", () => { + overlay.init(); + overlay["player"] = mockPlayer; + overlay["hasInfo"] = true; + overlay["isDragging"] = false; + overlay["mouseX"] = 100; + overlay["mouseY"] = 200; + + const result = overlay.render(); + const htmlString = result.strings.join(""); + + expect(htmlString).toContain("TestPlayer"); + }); + + it("should render unit info correctly", () => { + overlay.init(); + overlay["unit"] = mockUnit; + overlay["hasInfo"] = true; + overlay["isDragging"] = false; + overlay["mouseX"] = 100; + overlay["mouseY"] = 200; + + const result = overlay.render(); + const htmlString = result.strings.join(""); + + expect(htmlString).toContain("Warship"); + expect(htmlString).toContain("Health: 80"); + }); + + it("should include opacity in render output", () => { + overlay.init(); + overlay["player"] = mockPlayer; + overlay["hasInfo"] = true; + overlay["isDragging"] = false; + overlay["mouseX"] = 100; + overlay["mouseY"] = 200; + + const result = overlay.render(); + const htmlString = result.strings.join(""); + + expect(htmlString).toContain("opacity: 1"); + }); + + it("should set opacity to 0 when dragging", () => { + overlay.init(); + overlay["player"] = mockPlayer; + overlay["hasInfo"] = true; + overlay["isDragging"] = true; + overlay["mouseX"] = 100; + overlay["mouseY"] = 200; + + const result = overlay.render(); + const htmlString = result.strings.join(""); + + expect(htmlString).toBe(""); + }); + + it("should not render when user settings disable overlay", () => { + userSettings.showPlayerInfoMouseOverlay = jest.fn().mockReturnValue(false); + + const result = overlay.render(); + const htmlString = result.strings.join(""); + + expect(htmlString).toBe(""); + }); + + it("should not render when not visible", () => { + overlay.init(); + overlay["hasInfo"] = false; + + const result = overlay.render(); + const htmlString = result.strings.join(""); + + expect(htmlString).toBe(""); + }); + + it("should not render when dragging", () => { + overlay.init(); + overlay["isDragging"] = true; + + const result = overlay.render(); + const htmlString = result.strings.join(""); + + expect(htmlString).toBe(""); + }); + + it("should handle tick updates", () => { + expect(() => overlay.tick()).not.toThrow(); + }); + + it("should handle empty render layer", () => { + const mockContext = {} as CanvasRenderingContext2D; + + expect(() => overlay.renderLayer(mockContext)).not.toThrow(); + }); + + it("should not transform", () => { + expect(overlay.shouldTransform()).toBe(false); + }); + + it("should not initialize if already active", () => { + overlay.init(); + const firstManagerCall = mockPlayerInfoManager.init.mock.calls.length; + + overlay.init(); + + expect(mockPlayerInfoManager.init.mock.calls.length).toBe(firstManagerCall); + }); + + it("should unsubscribe from event bus in disconnectedCallback", () => { + eventBus.off = jest.fn(); + overlay.eventBus = eventBus; + + overlay.disconnectedCallback(); + + expect(eventBus.off).toHaveBeenCalledWith( + ForcePlayerInfoMouseOverlayEvent, + overlay["forceOverlayEventHandler"], + ); + }); + + it("should not call eventBus.off if eventBus is null in disconnectedCallback", () => { + overlay.eventBus = null as any; + + expect(() => overlay.disconnectedCallback()).not.toThrow(); + }); + + it("should adjust HUD position when y is below margin", () => { + overlay["mouseX"] = 100; + overlay["mouseY"] = -25; + + Object.defineProperty(window, "innerWidth", { + value: 1920, + configurable: true, + }); + Object.defineProperty(window, "innerHeight", { + value: 1080, + configurable: true, + }); + + const position = overlay["getHUDPosition"](); + + expect(position.y).toBe(10); + }); +}); diff --git a/tests/client/graphics/layers/PlayerInfoOverlay.test.ts b/tests/client/graphics/layers/PlayerInfoOverlay.test.ts new file mode 100644 index 0000000000..e72884e55e --- /dev/null +++ b/tests/client/graphics/layers/PlayerInfoOverlay.test.ts @@ -0,0 +1,342 @@ +/** + * @jest-environment jsdom + */ +import { ContextMenuEvent } from "../../../../src/client/InputHandler"; +import { TransformHandler } from "../../../../src/client/graphics/TransformHandler"; +import { PlayerInfoManager } from "../../../../src/client/graphics/layers/PlayerInfoManager"; +import { EventBus } from "../../../../src/core/EventBus"; +import { Relation } from "../../../../src/core/game/Game"; +import { + GameView, + PlayerView, + UnitView, +} from "../../../../src/core/game/GameView"; +import { UserSettings } from "../../../../src/core/game/UserSettings"; + +jest.mock("../../../../src/client/graphics/layers/PlayerInfoManager"); + +class MockPlayerInfoOverlay { + public game!: GameView; + public eventBus!: EventBus; + public transform!: TransformHandler; + public userSettings!: UserSettings; + + private _isInfoVisible: boolean = false; + private player: PlayerView | null = null; + private unit: UnitView | null = null; + private playerInfoManager: any; + private _isActive = false; + + init() { + this.playerInfoManager = PlayerInfoManager.getInstance( + this.game, + this.transform, + this.eventBus, + ); + this.playerInfoManager.init(); + this.playerInfoManager.subscribeToData(this.onHoverInfoUpdate.bind(this)); + this.setupEventListeners(); + this._isActive = true; + } + + destroy() { + this.playerInfoManager?.unsubscribeFromData( + this.onHoverInfoUpdate.bind(this), + ); + this._isActive = false; + } + + private onHoverInfoUpdate(hoverInfo: any) { + if (!this.userSettings?.showPlayerInfoOverlay()) { + this.hide(); + return; + } + + this.player = hoverInfo.player; + this.unit = hoverInfo.unit; + + if (this.player || this.unit) { + this.setVisible(true); + } else { + this.hide(); + } + } + + private setupEventListeners() { + this.eventBus.on(ContextMenuEvent, (e: ContextMenuEvent) => + this.maybeShow(e.x, e.y), + ); + } + + public hide() { + this.setVisible(false); + this.player = null; + this.unit = null; + } + + public async maybeShow(x: number, y: number) { + this.hide(); + const hoverInfo = await this.playerInfoManager + .getPlayerInfoService() + .getHoverInfo(x, y); + this.onHoverInfoUpdate(hoverInfo); + } + + setVisible(visible: boolean) { + this._isInfoVisible = visible; + } + + tick() {} + + renderLayer(context: CanvasRenderingContext2D) {} + + shouldTransform(): boolean { + return false; + } + + requestUpdate() {} + + render() { + if (!this.userSettings?.showPlayerInfoOverlay()) { + return { strings: [""] }; + } + + const containerClasses = this._isInfoVisible + ? "opacity-100 visible" + : "opacity-0 invisible"; + let content = ""; + + if (this.player) { + content += this.player.name(); + } + if (this.unit) { + content += this.unit.type() + this.unit.health(); + } + + return { strings: [containerClasses + content] }; + } +} + +describe("PlayerInfoOverlay", () => { + let game: GameView; + let eventBus: EventBus; + let transform: TransformHandler; + let userSettings: UserSettings; + let overlay: MockPlayerInfoOverlay; + let mockPlayerInfoManager: any; + let mockPlayer: PlayerView; + let mockUnit: UnitView; + + beforeEach(() => { + game = { + config: jest.fn().mockReturnValue({ + isUnitDisabled: jest.fn().mockReturnValue(false), + }), + myPlayer: jest.fn().mockReturnValue({ + smallID: jest.fn().mockReturnValue(1), + isFriendly: jest.fn().mockReturnValue(false), + }), + } as any; + + eventBus = { + on: jest.fn(), + off: jest.fn(), + } as any; + + transform = {} as TransformHandler; + + userSettings = { + showPlayerInfoOverlay: jest.fn().mockReturnValue(true), + } as any; + + mockPlayer = { + name: jest.fn().mockReturnValue("TestPlayer"), + troops: jest.fn().mockReturnValue(100), + outgoingAttacks: jest.fn().mockReturnValue([{ troops: 50 }]), + gold: jest.fn().mockReturnValue(5000), + totalUnitLevels: jest.fn().mockReturnValue(5), + cosmetics: { flag: "test-flag" }, + } as any; + + mockUnit = { + type: jest.fn().mockReturnValue("Warship"), + owner: jest.fn().mockReturnValue(mockPlayer), + hasHealth: jest.fn().mockReturnValue(true), + health: jest.fn().mockReturnValue(80), + } as any; + + const mockPlayerInfoService = { + getHoverInfo: jest.fn().mockResolvedValue({ + player: null, + unit: null, + mouseX: 0, + mouseY: 0, + }), + getRelation: jest.fn().mockReturnValue(Relation.Neutral), + getRelationClass: jest.fn().mockReturnValue("text-white"), + getShortDisplayName: jest.fn().mockReturnValue("TestPlayer"), + }; + + mockPlayerInfoManager = { + init: jest.fn(), + subscribeToData: jest.fn(), + unsubscribeFromData: jest.fn(), + getPlayerInfoService: jest.fn().mockReturnValue(mockPlayerInfoService), + }; + + (PlayerInfoManager.getInstance as jest.Mock).mockReturnValue( + mockPlayerInfoManager, + ); + + overlay = new MockPlayerInfoOverlay(); + overlay.game = game; + overlay.eventBus = eventBus; + overlay.transform = transform; + overlay.userSettings = userSettings; + }); + + it("should initialize correctly", () => { + overlay.init(); + + expect(mockPlayerInfoManager.init).toHaveBeenCalled(); + expect(mockPlayerInfoManager.subscribeToData).toHaveBeenCalled(); + expect(eventBus.on).toHaveBeenCalledWith( + ContextMenuEvent, + expect.any(Function), + ); + }); + + it("should destroy and cleanup properly", () => { + overlay.init(); + overlay.destroy(); + + expect(mockPlayerInfoManager.unsubscribeFromData).toHaveBeenCalled(); + }); + + it("should hide overlay when user settings disable it", () => { + userSettings.showPlayerInfoOverlay = jest.fn().mockReturnValue(false); + const hideSpy = jest.spyOn(overlay, "hide"); + + overlay.init(); + const callback = mockPlayerInfoManager.subscribeToData.mock.calls[0][0]; + callback({ + player: mockPlayer, + unit: null, + mouseX: 0, + mouseY: 0, + }); + + expect(hideSpy).toHaveBeenCalled(); + }); + + it("should show overlay when hover info contains player or unit", () => { + const setVisibleSpy = jest.spyOn(overlay, "setVisible"); + + overlay.init(); + const callback = mockPlayerInfoManager.subscribeToData.mock.calls[0][0]; + callback({ + player: mockPlayer, + unit: null, + mouseX: 0, + mouseY: 0, + }); + + expect(setVisibleSpy).toHaveBeenCalledWith(true); + }); + + it("should hide overlay when hover info is empty", () => { + const hideSpy = jest.spyOn(overlay, "hide"); + + overlay.init(); + const callback = mockPlayerInfoManager.subscribeToData.mock.calls[0][0]; + callback({ + player: null, + unit: null, + mouseX: 0, + mouseY: 0, + }); + + expect(hideSpy).toHaveBeenCalled(); + }); + + it("should handle context menu events", async () => { + const maybeShowSpy = jest.spyOn(overlay, "maybeShow").mockResolvedValue(); + + overlay.init(); + const contextMenuCallback = (eventBus.on as jest.Mock).mock.calls.find( + (call) => call[0] === ContextMenuEvent, + )[1]; + + const event = new ContextMenuEvent(100, 200); + contextMenuCallback(event); + + expect(maybeShowSpy).toHaveBeenCalledWith(100, 200); + }); + + it("should render player info correctly", () => { + overlay.init(); + overlay["player"] = mockPlayer; + overlay["_isInfoVisible"] = true; + + const result = overlay.render(); + const htmlString = result.strings.join(""); + + expect(htmlString).toContain("TestPlayer"); + }); + + it("should render unit info correctly", () => { + overlay.init(); + overlay["unit"] = mockUnit; + overlay["_isInfoVisible"] = true; + + const result = overlay.render(); + const htmlString = result.strings.join(""); + + expect(htmlString).toContain("Warship"); + expect(htmlString).toContain("80"); + }); + + it("should not render when user settings disable overlay", () => { + userSettings.showPlayerInfoOverlay = jest.fn().mockReturnValue(false); + + const result = overlay.render(); + const htmlString = result.strings.join(""); + + expect(htmlString).toBe(""); + }); + + it("should not render when not visible", () => { + overlay.init(); + overlay["_isInfoVisible"] = false; + + const result = overlay.render(); + const htmlString = result.strings.join(""); + + expect(htmlString).toContain("opacity-0 invisible"); + }); + + it("should reset hover state when hiding", () => { + overlay.init(); + overlay["player"] = mockPlayer; + overlay["unit"] = mockUnit; + + overlay.hide(); + + expect(overlay["player"]).toBeNull(); + expect(overlay["unit"]).toBeNull(); + }); + + it("should handle tick updates", () => { + expect(() => overlay.tick()).not.toThrow(); + }); + + it("should handle empty render layer", () => { + const mockContext = {} as CanvasRenderingContext2D; + + expect(() => overlay.renderLayer(mockContext)).not.toThrow(); + }); + + it("should not transform", () => { + expect(overlay.shouldTransform()).toBe(false); + }); +}); diff --git a/tests/client/graphics/layers/PlayerInfoService.test.ts b/tests/client/graphics/layers/PlayerInfoService.test.ts new file mode 100644 index 0000000000..a8e399e4b3 --- /dev/null +++ b/tests/client/graphics/layers/PlayerInfoService.test.ts @@ -0,0 +1,191 @@ +/** + * @jest-environment jsdom + */ +import { TransformHandler } from "../../../../src/client/graphics/TransformHandler"; +import { PlayerInfoService } from "../../../../src/client/graphics/layers/PlayerInfoService"; +import { + PlayerProfile, + Relation, + UnitType, +} from "../../../../src/core/game/Game"; +import { + GameView, + PlayerView, + UnitView, +} from "../../../../src/core/game/GameView"; + +describe("PlayerInfoService", () => { + let game: GameView; + let transform: TransformHandler; + let playerInfoService: PlayerInfoService; + let mockPlayer: PlayerView; + let mockUnit: UnitView; + + beforeEach(() => { + game = { + isValidCoord: jest.fn().mockReturnValue(true), + ref: jest.fn().mockReturnValue({ x: 10, y: 10 }), + owner: jest.fn(), + isLand: jest.fn().mockReturnValue(false), + units: jest.fn().mockReturnValue([]), + x: jest.fn().mockReturnValue(100), + y: jest.fn().mockReturnValue(100), + myPlayer: jest.fn().mockReturnValue(null), + } as any; + + transform = { + screenToWorldCoordinates: jest.fn().mockReturnValue({ x: 10, y: 10 }), + } as any; + + mockPlayer = { + name: jest.fn().mockReturnValue("TestPlayer"), + isPlayer: jest.fn().mockReturnValue(true), + troops: jest.fn().mockReturnValue(10), + gold: jest.fn().mockReturnValue(5000), + outgoingAttacks: jest.fn().mockReturnValue([{ troops: 5 }]), + totalUnitLevels: jest.fn().mockReturnValue(5), + units: jest.fn().mockReturnValue([]), + profile: jest.fn().mockResolvedValue({} as PlayerProfile), + } as any; + + mockUnit = { + type: jest.fn().mockReturnValue("Warship"), + tile: jest.fn().mockReturnValue({ x: 10, y: 10 }), + owner: jest.fn().mockReturnValue(mockPlayer), + hasHealth: jest.fn().mockReturnValue(true), + health: jest.fn().mockReturnValue(80), + } as any; + + playerInfoService = new PlayerInfoService(game, transform); + }); + + it("should initialize correctly", () => { + expect(playerInfoService).toBeDefined(); + }); + + it("should find nearest unit within detection radius", () => { + const mockUnits = [mockUnit]; + game.units = jest.fn().mockReturnValue(mockUnits); + + const result = playerInfoService.findNearestUnit({ x: 100, y: 100 }); + + expect(result).toBe(mockUnit); + expect(game.units).toHaveBeenCalledWith( + UnitType.Warship, + UnitType.TradeShip, + UnitType.TransportShip, + ); + }); + + it("should return null if no unit within detection radius", () => { + game.units = jest.fn().mockReturnValue([]); + + const result = playerInfoService.findNearestUnit({ x: 100, y: 100 }); + + expect(result).toBeNull(); + }); + + it("should get hover info for player territory", async () => { + game.owner = jest.fn().mockReturnValue(mockPlayer); + + const result = await playerInfoService.getHoverInfo(50, 50); + + expect(result.player).toBe(mockPlayer); + expect(result.unit).toBeNull(); + expect(result.mouseX).toBe(50); + expect(result.mouseY).toBe(50); + }); + + it("should get hover info for unit in water", async () => { + game.owner = jest.fn().mockReturnValue(null); + game.units = jest.fn().mockReturnValue([mockUnit]); + + const mockFindNearestUnit = jest + .spyOn(playerInfoService, "findNearestUnit") + .mockReturnValue(mockUnit); + + const result = await playerInfoService.getHoverInfo(50, 50); + + expect(result.player).toBeNull(); + expect(result.unit).toBe(mockUnit); + expect(mockFindNearestUnit).toHaveBeenCalled(); + }); + + it("should return empty hover info for invalid coordinates", async () => { + game.isValidCoord = jest.fn().mockReturnValue(false); + + const result = await playerInfoService.getHoverInfo(50, 50); + + expect(result.player).toBeNull(); + expect(result.unit).toBeNull(); + }); + + it("should get correct relation class", () => { + expect(playerInfoService.getRelationClass(Relation.Hostile)).toBe( + "text-red-500", + ); + expect(playerInfoService.getRelationClass(Relation.Distrustful)).toBe( + "text-red-300", + ); + expect(playerInfoService.getRelationClass(Relation.Neutral)).toBe( + "text-white", + ); + expect(playerInfoService.getRelationClass(Relation.Friendly)).toBe( + "text-green-500", + ); + }); + + it("should get correct relation for players", () => { + const myPlayer = { isFriendly: jest.fn().mockReturnValue(false) } as any; + game.myPlayer = jest.fn().mockReturnValue(myPlayer); + + expect(playerInfoService.getRelation(myPlayer)).toBe(Relation.Friendly); + + myPlayer.isFriendly.mockReturnValue(true); + expect(playerInfoService.getRelation(mockPlayer)).toBe(Relation.Friendly); + + myPlayer.isFriendly.mockReturnValue(false); + expect(playerInfoService.getRelation(mockPlayer)).toBe(Relation.Neutral); + }); + + it("should shorten display name when too long", () => { + mockPlayer.name = jest + .fn() + .mockReturnValue("VeryLongPlayerNameThatShouldBeShortened"); + + const result = playerInfoService.getShortDisplayName(mockPlayer); + + expect(result).toBe("VeryLongPlayerName…"); + expect(result.length).toBeLessThanOrEqual(20); + }); + + it("should calculate player stats correctly", () => { + const stats = playerInfoService.calculatePlayerStats(mockPlayer); + + expect(stats).toContainEqual(["defending_troops", "1"]); + expect(stats).toContainEqual(["attacking_troops", "0"]); + expect(stats).toContainEqual(["gold", "5.00K"]); + }); + + it("should format stats into rows correctly", () => { + const { row1, row2 } = playerInfoService.formatStats(mockPlayer); + + expect(row1.length).toBeGreaterThan(0); + expect(row2.length).toBeGreaterThan(0); + expect(row1.some((stat) => stat.includes("🛡️"))).toBe(true); + expect(row2.some((stat) => stat.includes("⚓"))).toBe(true); + }); + + it("should filter out empty stat values", () => { + mockPlayer.troops = jest.fn().mockReturnValue(0); + mockPlayer.gold = jest.fn().mockReturnValue(0); + mockPlayer.outgoingAttacks = jest.fn().mockReturnValue([]); + mockPlayer.totalUnitLevels = jest.fn().mockReturnValue(0); + mockPlayer.units = jest.fn().mockReturnValue([]); + + const { row1, row2 } = playerInfoService.formatStats(mockPlayer); + + expect(row1.length).toBe(0); + expect(row2.length).toBe(0); + }); +});