diff --git a/eslint.config.js b/eslint.config.js index 7b73cc355..c707ee03e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -41,7 +41,7 @@ export default [ // Disable rules that would fail. The failures should be fixed, and the entries here removed. "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-unused-expressions": "off", - "@typescript-eslint/no-unused-vars": "off", + "no-unused-vars": "off", }, }, { @@ -50,6 +50,13 @@ export default [ "@typescript-eslint/prefer-nullish-coalescing": "error", eqeqeq: "error", "no-case-declarations": "error", + "@typescript-eslint/no-unused-vars": [ + "error", + { + args: "none", + caughtErrors: "none", + }, + ], }, }, ]; diff --git a/jest.config.ts b/jest.config.ts index d1f6e3c7a..52d147124 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -17,7 +17,7 @@ export default { coverageThreshold: { global: { statements: 21.5, - branches: 17.0, + branches: 16.5, lines: 22.0, functions: 20.5, }, diff --git a/package-lock.json b/package-lock.json index 59adc6ea4..89b41aa45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3604,13 +3604,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.2.tgz", - "integrity": "sha512-4SaFZCNfJqvk/kenHpI8xvN42DMaoycy4PzKc5otHxRswww1kAt82OlBuwRVLofCACCTZEcla2Ydxv8scMXaTg==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.15.0", + "@eslint/core": "^0.15.2", "levn": "^0.4.1" }, "engines": { @@ -3618,9 +3618,9 @@ } }, "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.0.tgz", - "integrity": "sha512-b7ePw78tEWWkpgZCDYkbqDOP8dmM6qe+AOC6iuJqlq1R/0ahMAeH3qynpnqKFGkMltrp44ohV4ubGyvLX28tzw==", + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -18916,9 +18916,9 @@ } }, "node_modules/tar-fs": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", - "integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/resources/images/StopIconWhite.png b/resources/images/StopIconWhite.png new file mode 100644 index 000000000..6e0e8ef64 Binary files /dev/null and b/resources/images/StopIconWhite.png differ diff --git a/resources/images/TradingIconWhite.png b/resources/images/TradingIconWhite.png new file mode 100644 index 000000000..5e9499a76 Binary files /dev/null and b/resources/images/TradingIconWhite.png differ diff --git a/resources/images/TraitorIconLightRed.svg b/resources/images/TraitorIconLightRed.svg new file mode 100644 index 000000000..b01325b0b --- /dev/null +++ b/resources/images/TraitorIconLightRed.svg @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + diff --git a/resources/lang/en.json b/resources/lang/en.json index f4165a490..860f3f7ae 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -249,6 +249,7 @@ }, "game_starting_modal": { "title": "Game is Starting...", + "code_license": "Code licensed under AGPL-3.0", "desc": "Preparing for the lobby to start. Please wait." }, "difficulty": { @@ -496,6 +497,7 @@ "other_won": "{player} has won!", "exit": "Exit Game", "keep": "Keep Playing", + "spectate": "Spectate", "wishlist": "Wishlist on Steam!" }, "leaderboard": { @@ -566,6 +568,11 @@ "upgrade": "Upgrade", "level": "Level" }, + "player_type": { + "player": "Player", + "nation": "Nation", + "bot": "Bot" + }, "relation": { "hostile": "Hostile", "distrustful": "Distrustful", @@ -581,23 +588,37 @@ "player_panel": { "gold": "Gold", "troops": "Troops", - "betrayals": "Number of betrayals", + "betrayals": "Betrayals", "traitor": "Traitor", + "stable": "Stable", + "trust": "Trust", + "trading": "Trading", + "active": "Active", + "stopped": "Stopped", "alliance_time_remaining": "Alliance Expires In", "embargo": "Stopped trading with you", "nuke": "Nukes sent by them to you", - "start_trade": "Start trading", - "stop_trade": "Stop trading", + "start_trade": "Start Trading", + "stop_trade": "Stop Trading", "yes": "Yes", "no": "No", "none": "None", "alliances": "Alliances", - "flag": "Flag" + "flag": "Flag", + "chat": "Chat", + "target": "Target", + "break": "Break", + "break_alliance": "Break Alliance", + "alliance": "Alliance", + "send_alliance": "Send Alliance", + "send_troops": "Send Troops", + "send_gold": "Send Gold", + "emotes": "Emojis" }, "replay_panel": { "replay_speed": "Replay speed", "game_speed": "Game speed", - "fastest_game_speed": "max" + "fastest_game_speed": "Max" }, "error_modal": { "crashed": "Game crashed!", @@ -729,5 +750,17 @@ "map": "Map", "difficulty": "Difficulty", "type": "Type" + }, + "player_stats_tree": { + "public": "Public", + "private": "Private", + "singleplayer": "Single Player", + "mode": "Mode", + "stats_wins": "Wins", + "stats_losses": "Losses", + "stats_wlr": "Win:Loss Ratio", + "stats_games_played": "Games Played", + "mode_ffa": "Free-for-All", + "mode_team": "Team" } } diff --git a/src/client/GameStartingModal.ts b/src/client/GameStartingModal.ts index 9ae0ad232..ebbeea98b 100644 --- a/src/client/GameStartingModal.ts +++ b/src/client/GameStartingModal.ts @@ -84,13 +84,20 @@ export class GameStartingModal extends LitElement { .modal button:active { transform: translateY(1px); } + + .copyright { + font-size: 32px; + margin-top: 20px; + opacity: 1; + } `; render() { return html` `; } diff --git a/src/client/components/Difficulties.ts b/src/client/components/Difficulties.ts index 914c2e90e..aeafdd49d 100644 --- a/src/client/components/Difficulties.ts +++ b/src/client/components/Difficulties.ts @@ -74,6 +74,7 @@ export class DifficultyDisplay extends LitElement { > `; + // eslint-disable-next-line @typescript-eslint/no-unused-vars const kingSkull = html` = []; + + // Currently fixed to display 4 stats (can be changed if needed) + private readonly VISIBLE_STATS_COUNT = 4; + + render() { + return html` +
+ ${Array(this.VISIBLE_STATS_COUNT) + .fill(0) + .map( + (_, i) => html` +
+
${this.values[i] ?? ""}
+
${this.titles[i] ?? ""}
+
+ `, + )} +
+ `; + } +} diff --git a/src/client/components/baseComponents/stats/PlayerStatsTree.ts b/src/client/components/baseComponents/stats/PlayerStatsTree.ts new file mode 100644 index 000000000..4527529a2 --- /dev/null +++ b/src/client/components/baseComponents/stats/PlayerStatsTree.ts @@ -0,0 +1,202 @@ +import { LitElement, html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { PlayerStatsLeaf, PlayerStatsTree } from "../../../../core/ApiSchemas"; +import { + Difficulty, + GameMode, + GameType, + isDifficulty, + isGameMode, + isGameType, +} from "../../../../core/game/Game"; +import { PlayerStats } from "../../../../core/StatsSchemas"; +import { renderNumber, translateText } from "../../../Utils"; +import "./PlayerStatsGrid"; +import "./PlayerStatsTable"; + +@customElement("player-stats-tree-view") +export class PlayerStatsTreeView extends LitElement { + @property({ type: Object }) statsTree?: PlayerStatsTree; + @state() selectedType: GameType = GameType.Public; + @state() selectedMode: GameMode = GameMode.FFA; + @state() selectedDifficulty: Difficulty = Difficulty.Medium; + + private get availableTypes(): GameType[] { + if (!this.statsTree) return []; + return Object.keys(this.statsTree).filter(isGameType); + } + + private get availableModes(): GameMode[] { + const typeNode = this.statsTree?.[this.selectedType]; + if (!typeNode) return []; + return Object.keys(typeNode).filter(isGameMode); + } + + private get availableDifficulties(): Difficulty[] { + const typeNode = this.statsTree?.[this.selectedType]; + const modeNode = typeNode?.[this.selectedMode]; + if (!modeNode) return []; + return Object.keys(modeNode).filter(isDifficulty); + } + + private labelForMode(m: GameMode) { + return m === GameMode.FFA + ? translateText("player_stats_tree.mode_ffa") + : translateText("player_stats_tree.mode_team"); + } + + createRenderRoot() { + return this; + } + + private getSelectedLeaf(): PlayerStatsLeaf | null { + const typeNode = this.statsTree?.[this.selectedType]; + if (!typeNode) return null; + const modeNode = typeNode[this.selectedMode]; + if (!modeNode) return null; + const diffNode = modeNode[this.selectedDifficulty]; + if (!diffNode) return null; + return diffNode; + } + + private getDisplayedStats(): PlayerStats | null { + const leaf = this.getSelectedLeaf(); + if (!leaf || !leaf.stats) return null; + return leaf.stats; + } + + private setGameType(t: GameType) { + if (this.selectedType === t) return; + this.selectedType = t; + const modes = this.availableModes; + if (!modes.includes(this.selectedMode)) { + this.selectedMode = modes[0] ?? this.selectedMode; + } + const diffs = this.availableDifficulties; + if (!diffs.includes(this.selectedDifficulty)) { + this.selectedDifficulty = diffs[0] ?? this.selectedDifficulty; + } + this.requestUpdate(); + } + + private setMode(m: GameMode) { + if (this.selectedMode === m) return; + this.selectedMode = m; + const diffs = this.availableDifficulties; + if (!diffs.includes(this.selectedDifficulty)) { + this.selectedDifficulty = diffs[0] ?? this.selectedDifficulty; + } + this.requestUpdate(); + } + + private setDifficulty(d: Difficulty) { + if (this.selectedDifficulty === d) return; + this.selectedDifficulty = d; + this.requestUpdate(); + } + + render() { + const types = this.availableTypes; + if (types.length && !types.includes(this.selectedType)) { + this.selectedType = types[0]; + } + const modes = this.availableModes; + if (modes.length && !modes.includes(this.selectedMode)) { + this.selectedMode = modes[0]; + } + const diffs = this.availableDifficulties; + if (diffs.length && !diffs.includes(this.selectedDifficulty)) { + this.selectedDifficulty = diffs[0]; + } + + const leaf = this.getSelectedLeaf(); + const wlr = leaf + ? leaf.losses === 0n + ? Number(leaf.wins) + : Number(leaf.wins) / Number(leaf.losses) + : 0; + + return html` + +
+ ${types.map( + (t) => html` + + `, + )} +
+ + ${modes.length + ? html`
+ ${modes.map( + (m) => html` + + `, + )} +
` + : html``} + + ${diffs.length + ? html`
+ ${diffs.map( + (d) => + html` `, + )} +
` + : html``} + ${leaf + ? html` +
+ +
+ + ` + : html``} + `; + } +} diff --git a/src/client/components/ui/ActionButton.ts b/src/client/components/ui/ActionButton.ts new file mode 100644 index 000000000..e1b0fc980 --- /dev/null +++ b/src/client/components/ui/ActionButton.ts @@ -0,0 +1,69 @@ +import { html, TemplateResult } from "lit"; + +export type ButtonVariant = + | "normal" + | "red" + | "green" + | "indigo" + | "yellow" + | "sky"; +export interface ActionButtonProps { + onClick: (e: MouseEvent) => void; + type?: ButtonVariant; + icon: string; + iconAlt: string; + title: string; + label: string; + disabled?: boolean; +} + +const ICON_SIZE = + "h-5 w-5 shrink-0 transition-transform group-hover:scale-110 text-zinc-400"; +const TEXT_SIZE = + "text-base sm:text-[14px] leading-5 font-semibold tracking-tight"; + +const getButtonStyles = () => { + const btnBase = + "group w-full min-w-[50px] select-none flex flex-col items-center justify-center " + + "gap-1 rounded-lg py-1.5 border border-white/10 bg-white/[0.04] shadow-sm " + + "transition-all duration-150 " + + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20 " + + "active:translate-y-[1px]"; + + return { + normal: `${btnBase} text-white/90 hover:bg-white/10 hover:text-white`, + red: `${btnBase} text-red-400 hover:bg-red-500/10 hover:text-red-300 focus-visible:ring-red-400/30`, + green: `${btnBase} text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300 focus-visible:ring-emerald-400/30`, + yellow: `${btnBase} text-[#f59e0b] hover:bg-[#f59e0b]/10 hover:text-[#fbbf24] focus-visible:ring-[#f59e0b]/30`, + indigo: `${btnBase} text-indigo-400 hover:bg-indigo-500/10 hover:text-indigo-300 focus-visible:ring-indigo-400/30`, + sky: `${btnBase} text-[#38bdf8] hover:bg-[#38bdf8]/10 hover:text-[#0ea5e9] focus-visible:ring-[#38bdf8]/30`, + }; +}; + +export const actionButton = (props: ActionButtonProps): TemplateResult => { + const { + onClick, + type = "normal", + icon, + iconAlt, + title, + label, + disabled = false, + } = props; + const buttonStyles = getButtonStyles(); + const buttonClass = buttonStyles[type]; + + return html` + + `; +}; diff --git a/src/client/components/ui/Divider.ts b/src/client/components/ui/Divider.ts new file mode 100644 index 000000000..454413cc4 --- /dev/null +++ b/src/client/components/ui/Divider.ts @@ -0,0 +1,33 @@ +import { html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +export type DividerSpacing = "sm" | "md" | "lg"; +@customElement("ui-divider") +export class Divider extends LitElement { + @property({ type: String }) + spacing: DividerSpacing = "md"; + + @property({ type: String }) + color: string = "bg-zinc-700/80"; + + createRenderRoot() { + return this; + } + + render() { + const spacingClasses: Record = { + sm: "my-0.5", + md: "my-1", + lg: "my-2", + } as const; + const spacing = spacingClasses[this.spacing] ?? spacingClasses.md; + + const colorClass = this.color || "bg-zinc-700/80"; + + return html``; + } +} diff --git a/src/client/graphics/fx/SpriteFx.ts b/src/client/graphics/fx/SpriteFx.ts index 919533386..2926121a8 100644 --- a/src/client/graphics/fx/SpriteFx.ts +++ b/src/client/graphics/fx/SpriteFx.ts @@ -77,7 +77,6 @@ export class SpriteFx implements Fx { if (!this.animatedSprite.isActive() && !this.waitToTheEnd) return false; - const t = this.elapsedTime / this.duration; this.animatedSprite.update(frameTime); this.animatedSprite.draw(ctx, this.x, this.y); return true; diff --git a/src/client/graphics/layers/EmojiTable.ts b/src/client/graphics/layers/EmojiTable.ts index aefec7ade..ca87f009b 100644 --- a/src/client/graphics/layers/EmojiTable.ts +++ b/src/client/graphics/layers/EmojiTable.ts @@ -4,7 +4,7 @@ import { EventBus } from "../../../core/EventBus"; import { AllPlayers } from "../../../core/game/Game"; import { GameView, PlayerView } from "../../../core/game/GameView"; import { TerraNulliusImpl } from "../../../core/game/TerraNulliusImpl"; -import { emojiTable, flattenedEmojiTable } from "../../../core/Util"; +import { Emoji, emojiTable, flattenedEmojiTable } from "../../../core/Util"; import { CloseViewEvent, ShowEmojiMenuEvent } from "../../InputHandler"; import { SendEmojiIntentEvent } from "../../Transport"; import { TransformHandler } from "../TransformHandler"; @@ -42,7 +42,7 @@ export class EmojiTable extends LitElement { eventBus.emit( new SendEmojiIntentEvent( recipient, - flattenedEmojiTable.indexOf(emoji), + flattenedEmojiTable.indexOf(emoji as Emoji), ), ); this.hideTable(); diff --git a/src/client/graphics/layers/PlayerInfoOverlay.ts b/src/client/graphics/layers/PlayerInfoOverlay.ts index b99c587c8..6936c9549 100644 --- a/src/client/graphics/layers/PlayerInfoOverlay.ts +++ b/src/client/graphics/layers/PlayerInfoOverlay.ts @@ -268,13 +268,13 @@ export class PlayerInfoOverlay extends LitElement implements Layer { let playerType = ""; switch (player.type()) { case PlayerType.Bot: - playerType = translateText("player_info_overlay.bot"); + playerType = translateText("player_type.bot"); break; case PlayerType.FakeHuman: - playerType = translateText("player_info_overlay.nation"); + playerType = translateText("player_type.nation"); break; case PlayerType.Human: - playerType = translateText("player_info_overlay.player"); + playerType = translateText("player_type.player"); break; } diff --git a/src/client/graphics/layers/PlayerPanel.ts b/src/client/graphics/layers/PlayerPanel.ts index e6851115b..d3c03bc48 100644 --- a/src/client/graphics/layers/PlayerPanel.ts +++ b/src/client/graphics/layers/PlayerPanel.ts @@ -5,14 +5,24 @@ import chatIcon from "../../../../resources/images/ChatIconWhite.svg"; import donateGoldIcon from "../../../../resources/images/DonateGoldIconWhite.svg"; import donateTroopIcon from "../../../../resources/images/DonateTroopIconWhite.svg"; import emojiIcon from "../../../../resources/images/EmojiIconWhite.svg"; +import stopTradingIcon from "../../../../resources/images/StopIconWhite.png"; import targetIcon from "../../../../resources/images/TargetIconWhite.svg"; -import traitorIcon from "../../../../resources/images/TraitorIconWhite.svg"; -import { translateText } from "../../../client/Utils"; +import startTradingIcon from "../../../../resources/images/TradingIconWhite.png"; +import traitorIcon from "../../../../resources/images/TraitorIconLightRed.svg"; +import breakAllianceIcon from "../../../../resources/images/TraitorIconWhite.svg"; import { EventBus } from "../../../core/EventBus"; -import { AllPlayers, PlayerActions } from "../../../core/game/Game"; +import { + AllPlayers, + PlayerActions, + PlayerProfile, + PlayerType, + Relation, +} from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { GameView, PlayerView } from "../../../core/game/GameView"; -import { flattenedEmojiTable } from "../../../core/Util"; +import { Emoji, flattenedEmojiTable } from "../../../core/Util"; +import { actionButton } from "../../components/ui/ActionButton"; +import "../../components/ui/Divider"; import Countries from "../../data/countries.json"; import { CloseViewEvent, MouseUpEvent } from "../../InputHandler"; import { @@ -24,7 +34,12 @@ import { SendEmojiIntentEvent, SendTargetPlayerIntentEvent, } from "../../Transport"; -import { renderDuration, renderNumber, renderTroops } from "../../Utils"; +import { + renderDuration, + renderNumber, + renderTroops, + translateText, +} from "../../Utils"; import { UIState } from "../UIState"; import { ChatModal } from "./ChatModal"; import { EmojiTable } from "./EmojiTable"; @@ -36,9 +51,9 @@ export class PlayerPanel extends LitElement implements Layer { public eventBus: EventBus; public emojiTable: EmojiTable; public uiState: UIState; - private actions: PlayerActions | null = null; private tile: TileRef | null = null; + private _profileForPlayerId: number | null = null; @state() public isVisible: boolean = false; @@ -46,6 +61,74 @@ export class PlayerPanel extends LitElement implements Layer { @state() private allianceExpiryText: string | null = null; + @state() + private allianceExpirySeconds: number | null = null; + + @state() + private otherProfile: PlayerProfile | null = null; + + private ctModal: ChatModal; + + createRenderRoot() { + return this; + } + + initEventBus(eventBus: EventBus) { + this.eventBus = eventBus; + eventBus.on(CloseViewEvent, (e) => { + if (this.isVisible) { + this.hide(); + } + }); + } + + init() { + this.eventBus.on(MouseUpEvent, () => this.hide()); + + this.ctModal = document.querySelector("chat-modal") as ChatModal; + if (!this.ctModal) { + console.warn("ChatModal element not found in DOM"); + } + } + + async tick() { + if (this.isVisible && this.tile) { + const owner = this.g.owner(this.tile); + if (owner && owner.isPlayer()) { + const pv = owner as PlayerView; + const id = pv.id(); + // fetch only if we don't have it or the player changed + if (this._profileForPlayerId !== Number(id)) { + this.otherProfile = await pv.profile(); + this._profileForPlayerId = Number(id); + } + } + + // Refresh actions & alliance expiry + const myPlayer = this.g.myPlayer(); + if (myPlayer !== null && myPlayer.isAlive()) { + this.actions = await myPlayer.actions(this.tile); + if (this.actions?.interaction?.allianceExpiresAt !== undefined) { + const expiresAt = this.actions.interaction.allianceExpiresAt; + const remainingTicks = expiresAt - this.g.ticks(); + const remainingSeconds = Math.max(0, Math.floor(remainingTicks / 10)); // 10 ticks per second + + if (remainingTicks > 0) { + this.allianceExpirySeconds = remainingSeconds; + this.allianceExpiryText = renderDuration(remainingSeconds); + } else { + this.allianceExpirySeconds = null; + this.allianceExpiryText = null; + } + } else { + this.allianceExpirySeconds = null; + this.allianceExpiryText = null; + } + this.requestUpdate(); + } + } + } + public show(actions: PlayerActions, tile: TileRef) { this.actions = actions; this.tile = tile; @@ -135,12 +218,15 @@ export class PlayerPanel extends LitElement implements Layer { this.eventBus.emit( new SendEmojiIntentEvent( AllPlayers, - flattenedEmojiTable.indexOf(emoji), + flattenedEmojiTable.indexOf(emoji as Emoji), ), ); } else { this.eventBus.emit( - new SendEmojiIntentEvent(other, flattenedEmojiTable.indexOf(emoji)), + new SendEmojiIntentEvent( + other, + flattenedEmojiTable.indexOf(emoji as Emoji), + ), ); } this.emojiTable.hideTable(); @@ -149,6 +235,13 @@ export class PlayerPanel extends LitElement implements Layer { } private handleChat(e: Event, sender: PlayerView, other: PlayerView) { + e.stopPropagation(); + + if (!this.ctModal) { + console.warn("ChatModal element not found in DOM"); + return; + } + this.ctModal.open(sender, other); this.hide(); } @@ -159,68 +252,338 @@ export class PlayerPanel extends LitElement implements Layer { this.hide(); } - createRenderRoot() { - return this; - } - - private ctModal: ChatModal; - - initEventBus(eventBus: EventBus) { - this.eventBus = eventBus; - eventBus.on(CloseViewEvent, (e) => { - if (!this.hidden) { - this.hide(); - } - }); - } - - init() { - this.eventBus.on(MouseUpEvent, () => this.hide()); - this.eventBus.on(CloseViewEvent, (e) => { - this.hide(); - }); - - this.ctModal = document.querySelector("chat-modal") as ChatModal; - } - - async tick() { - if (this.isVisible && this.tile) { - const myPlayer = this.g.myPlayer(); - if (myPlayer !== null && myPlayer.isAlive()) { - this.actions = await myPlayer.actions(this.tile); - if (this.actions?.interaction?.allianceExpiresAt !== undefined) { - const expiresAt = this.actions.interaction.allianceExpiresAt; - const remainingTicks = expiresAt - this.g.ticks(); - if (remainingTicks > 0) { - const remainingSeconds = Math.max( - 0, - Math.floor(remainingTicks / 10), - ); // 10 ticks per second - this.allianceExpiryText = renderDuration(remainingSeconds); - } - } else { - this.allianceExpiryText = null; - } - this.requestUpdate(); - } + private identityChipProps(type: PlayerType) { + switch (type) { + case PlayerType.FakeHuman: + return { + labelKey: "player_type.nation", + aria: "Nation player", + classes: "border-indigo-400/25 bg-indigo-500/10 text-indigo-200", + icon: "🏛️", + }; + case PlayerType.Bot: + return { + labelKey: "player_type.bot", + aria: "Bot", + classes: "border-purple-400/25 bg-purple-500/10 text-purple-200", + icon: "🤖", + }; + case PlayerType.Human: + default: + return { + labelKey: "player_type.player", + aria: "Human player", + classes: "border-zinc-400/20 bg-zinc-500/5 text-zinc-300", + icon: "👤", + }; } } - render() { - if (!this.isVisible) { - return html``; + private getRelationClass(relation: Relation): string { + const base = + "inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 " + + "shadow-[inset_0_0_8px_rgba(255,255,255,0.04)]"; + + switch (relation) { + case Relation.Hostile: + return `${base} border-red-400/30 bg-red-500/10 text-red-200`; + case Relation.Distrustful: + return `${base} border-red-300/40 bg-red-300/10 text-red-300`; + case Relation.Friendly: + return `${base} border-emerald-400/30 bg-emerald-500/10 text-emerald-200`; + case Relation.Neutral: + default: + return `${base} border-zinc-400/30 bg-zinc-500/10 text-zinc-200`; } + } + + private getRelationName(relation: Relation): string { + switch (relation) { + case Relation.Hostile: + return translateText("relation.hostile"); + case Relation.Distrustful: + return translateText("relation.distrustful"); + case Relation.Friendly: + return translateText("relation.friendly"); + case Relation.Neutral: + default: + return translateText("relation.neutral"); + } + } + + private getExpiryColorClass(seconds: number | null): string { + if (seconds === null) return "text-white"; + if (seconds <= 30) return "text-red-400"; + if (seconds <= 60) return "text-yellow-400"; + return "text-emerald-400"; + } + + private getTraitorRemainingSeconds(player: PlayerView): number | null { + const ticksLeft = player.data.traitorRemainingTicks ?? 0; + if (!player.isTraitor() || ticksLeft <= 0) return null; + return Math.ceil(ticksLeft / 10); // 10 ticks = 1 second + } + + private renderTraitorBadge(other: PlayerView) { + if (!other.isTraitor()) return html``; + + const secs = this.getTraitorRemainingSeconds(other); + const label = secs !== null ? renderDuration(secs) : null; + const dotCls = + secs !== null + ? `mx-1 h-[4px] w-[4px] rounded-full bg-red-400/70 ${secs <= 10 ? "animate-pulse" : ""}` + : ""; + + return html` +
+ + + ${translateText("player_panel.traitor")} + ${label + ? html` + + ${label} + ` + : ""} + +
+ `; + } + + private renderRelationPillIfNation(other: PlayerView, my: PlayerView) { + if (other.type() !== PlayerType.FakeHuman) return html``; + if (other.isTraitor()) return html``; + if (my?.isAlliedWith && my.isAlliedWith(other)) return html``; + if (!this.otherProfile || !my) return html``; + + const relation = + this.otherProfile.relations?.[my.smallID()] ?? Relation.Neutral; + const cls = this.getRelationClass(relation); + const name = this.getRelationName(relation); + + return html` +
+ ${name} +
+ `; + } + + private renderIdentityRow(other: PlayerView, my: PlayerView) { + const flagCode = other.cosmetics.flag; + const country = + typeof flagCode === "string" + ? Countries.find((c) => c.code === flagCode) + : undefined; + + const chip = + other.type() === PlayerType.Human + ? null + : this.identityChipProps(other.type()); + + return html` +
+ ${country && typeof flagCode === "string" + ? html`${country?.name { + (e.target as HTMLImageElement).style.display = "none"; + }} + />` + : ""} +

+ ${other.name()} +

+ + ${chip + ? html` + + ${translateText(chip.labelKey)} + ` + : html``} +
+ ${this.renderTraitorBadge(other)} + ${this.renderRelationPillIfNation(other, my)} + `; + } + + private renderResources(other: PlayerView) { + return html` +
+
+ 💰 + + ${renderNumber(other.gold() || 0)} + + ${translateText("player_panel.gold")} +
+ +
+ 🛡️ + + ${renderTroops(other.troops() || 0)} + + ${translateText("player_panel.troops")} +
+
+ `; + } + + private renderStats(other: PlayerView, my: PlayerView) { + return html` + +
+
+ + ${translateText("player_panel.betrayals")} +
+
+ ${other.data.betrayals ?? 0} +
+
+ + +
+
+ + ${translateText("player_panel.trading")} +
+
+ ${other.hasEmbargoAgainst(my) + ? html`${translateText("player_panel.stopped")}` + : html`${translateText("player_panel.active")}`} +
+
+ `; + } + + private renderAlliances(other: PlayerView) { + const allies = other.allies(); + + return html` +
+ +
+
+ ${translateText("player_panel.alliances")} +
+ + ${allies.length} + +
+ +
+
+ ${allies.length > 0 + ? allies.map((p) => { + const color = p.territoryColor().toHex(); + return html` +
+ + + + + ${p.name()} + +
+ `; + }) + : html` +
+ ${translateText("player_panel.none")} +
+ `} +
+
+
+ `; + } + + private renderAllianceExpiry() { + if (this.allianceExpiryText === null) return html``; + return html` +
+
+ ${translateText("player_panel.alliance_time_remaining")} +
+
+ ${this.allianceExpiryText} +
+
+ `; + } + + private renderActions(my: PlayerView, other: PlayerView) { const myPlayer = this.g.myPlayer(); - if (myPlayer === null) return; - if (this.tile === null) return; - let other = this.g.owner(this.tile); - if (!other.isPlayer()) { - this.hide(); - console.warn("Tile is not owned by a player"); - return; - } - other = other as PlayerView; - const canDonateGold = this.actions?.interaction?.canDonateGold; const canDonateTroops = this.actions?.interaction?.canDonateTroops; const canSendAllianceRequest = @@ -233,273 +596,207 @@ export class PlayerPanel extends LitElement implements Layer { const canTarget = this.actions?.interaction?.canTarget; const canEmbargo = this.actions?.interaction?.canEmbargo; - //flag icon in the playerPanel - const flagCode = other.cosmetics.flag; - const country = - typeof flagCode === "string" - ? Countries.find((c) => c.code === flagCode) - : undefined; - const flagName = country?.name; + return html` +
+
+ ${actionButton({ + onClick: (e: MouseEvent) => this.handleChat(e, my, other), + icon: chatIcon, + iconAlt: "Chat", + title: translateText("player_panel.chat"), + label: translateText("player_panel.chat"), + })} + ${canSendEmoji + ? actionButton({ + onClick: (e: MouseEvent) => this.handleEmojiClick(e, my, other), + icon: emojiIcon, + iconAlt: "Emoji", + title: translateText("player_panel.emotes"), + label: translateText("player_panel.emotes"), + type: "normal", + }) + : ""} + ${canTarget + ? actionButton({ + onClick: (e: MouseEvent) => this.handleTargetClick(e, other), + icon: targetIcon, + iconAlt: "Target", + title: translateText("player_panel.target"), + label: translateText("player_panel.target"), + type: "normal", + }) + : ""} + ${canDonateTroops + ? actionButton({ + onClick: (e: MouseEvent) => + this.handleDonateTroopClick(e, my, other), + icon: donateTroopIcon, + iconAlt: "Troops", + title: translateText("player_panel.send_troops"), + label: translateText("player_panel.troops"), + type: "normal", + }) + : ""} + ${canDonateGold + ? actionButton({ + onClick: (e: MouseEvent) => + this.handleDonateGoldClick(e, my, other), + icon: donateGoldIcon, + iconAlt: "Gold", + title: translateText("player_panel.send_gold"), + label: translateText("player_panel.gold"), + type: "normal", + }) + : ""} +
+ +
+ ${other !== my + ? canEmbargo + ? actionButton({ + onClick: (e: MouseEvent) => + this.handleEmbargoClick(e, my, other), + icon: stopTradingIcon, + iconAlt: "Stop Trading", + title: translateText("player_panel.stop_trade"), + label: translateText("player_panel.stop_trade"), + type: "yellow", + }) + : actionButton({ + onClick: (e: MouseEvent) => + this.handleStopEmbargoClick(e, my, other), + icon: startTradingIcon, + iconAlt: "Start Trading", + title: translateText("player_panel.start_trade"), + label: translateText("player_panel.start_trade"), + type: "green", + }) + : ""} + ${canBreakAlliance + ? actionButton({ + onClick: (e: MouseEvent) => + this.handleBreakAllianceClick(e, my, other), + icon: breakAllianceIcon, + iconAlt: "Break Alliance", + title: translateText("player_panel.break_alliance"), + label: translateText("player_panel.break_alliance"), + type: "red", + }) + : ""} + ${canSendAllianceRequest + ? actionButton({ + onClick: (e: MouseEvent) => + this.handleAllianceClick(e, my, other), + icon: allianceIcon, + iconAlt: "Alliance", + title: translateText("player_panel.send_alliance"), + label: translateText("player_panel.send_alliance"), + type: "indigo", + }) + : ""} +
+
+ `; + } + + render() { + if (!this.isVisible) return html``; + + const my = this.g.myPlayer(); + if (!my) return html``; + if (!this.tile) return html``; + + const owner = this.g.owner(this.tile); + if (!owner || !owner.isPlayer()) { + this.hide(); + console.warn("Tile is not owned by a player"); + return html``; + } + const other = owner as PlayerView; return html` + +
e.preventDefault()} @wheel=${(e: MouseEvent) => e.stopPropagation()} + @click=${() => this.hide()} > +
e.stopPropagation()} >
-
- -
-
- ${other?.name()} -
-
- - ${country - ? html` -
-
- ${translateText("player_panel.flag")} -
-
- ${flagName} - -
-
- ` - : ""} - -
-
- -
- ${translateText("player_panel.gold")} -
-
- ${renderNumber(other.gold() || 0)} -
-
-
- -
- ${translateText("player_panel.troops")} -
-
- ${renderTroops(other.troops() || 0)} -
-
-
+
+ +
${this.renderIdentityRow(other, my)}
- -
-
- ${translateText("player_panel.traitor")} -
-
- ${other.isTraitor() - ? translateText("player_panel.yes") - : translateText("player_panel.no")} -
-
+ - -
-
- ${translateText("player_panel.betrayals")} -
-
- ${other.data.betrayals ?? 0} -
-
+ + ${this.renderResources(other)} - -
-
- ${translateText("player_panel.embargo")} -
-
- ${other.hasEmbargoAgainst(myPlayer) - ? translateText("player_panel.yes") - : translateText("player_panel.no")} -
-
+ - -
-
- ${translateText("player_panel.alliances")} - (${other.allies().length}) -
-
- ${other.allies().length > 0 - ? other - .allies() - .map((p) => p.name()) - .join(", ") - : translateText("player_panel.none")} -
-
+ + ${this.renderStats(other, my)} - ${this.allianceExpiryText !== null - ? html` -
-
- ${translateText("player_panel.alliance_time_remaining")} -
-
- ${this.allianceExpiryText} -
-
- ` - : ""} + - -
- - ${canTarget - ? html`` - : ""} - ${canBreakAlliance - ? html`` - : ""} - ${canSendAllianceRequest - ? html`` - : ""} - ${canDonateTroops - ? html`` - : ""} - ${canDonateGold - ? html`` - : ""} - ${canSendEmoji - ? html`` - : ""} -
- ${canEmbargo && other !== myPlayer - ? html`` - : ""} - ${!canEmbargo && other !== myPlayer - ? html`` - : ""} + + ${this.renderAlliances(other)} + + + ${this.renderAllianceExpiry()} + + + + + ${this.renderActions(my, other)}
diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts index 3e2445d26..9e1fe21ef 100644 --- a/src/client/graphics/layers/RadialMenuElements.ts +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -2,7 +2,7 @@ import { Config } from "../../../core/configuration/Config"; import { AllPlayers, PlayerActions, UnitType } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { GameView, PlayerView } from "../../../core/game/GameView"; -import { flattenedEmojiTable } from "../../../core/Util"; +import { Emoji, flattenedEmojiTable } from "../../../core/Util"; import { renderNumber, translateText } from "../../Utils"; import { BuildItemDisplay, BuildMenu, flattenedBuildTable } from "./BuildMenu"; import { ChatIntegration } from "./ChatIntegration"; @@ -106,6 +106,7 @@ export enum Slot { Delete = "delete", } +// eslint-disable-next-line @typescript-eslint/no-unused-vars const infoChatElement: MenuElement = { id: "info_chat", name: "chat", @@ -123,6 +124,7 @@ const infoChatElement: MenuElement = { })), }; +// eslint-disable-next-line @typescript-eslint/no-unused-vars const allyTargetElement: MenuElement = { id: "ally_target", name: "target", @@ -138,6 +140,7 @@ const allyTargetElement: MenuElement = { }, }; +// eslint-disable-next-line @typescript-eslint/no-unused-vars const allyTradeElement: MenuElement = { id: "ally_trade", name: "trade", @@ -153,6 +156,7 @@ const allyTradeElement: MenuElement = { }, }; +// eslint-disable-next-line @typescript-eslint/no-unused-vars const allyEmbargoElement: MenuElement = { id: "ally_embargo", name: "embargo", @@ -204,6 +208,7 @@ const allyBreakElement: MenuElement = { }, }; +// eslint-disable-next-line @typescript-eslint/no-unused-vars const allyDonateGoldElement: MenuElement = { id: "ally_donate_gold", name: "donate gold", @@ -217,6 +222,7 @@ const allyDonateGoldElement: MenuElement = { }, }; +// eslint-disable-next-line @typescript-eslint/no-unused-vars const allyDonateTroopsElement: MenuElement = { id: "ally_donate_troops", name: "donate troops", @@ -230,6 +236,7 @@ const allyDonateTroopsElement: MenuElement = { }, }; +// eslint-disable-next-line @typescript-eslint/no-unused-vars const infoPlayerElement: MenuElement = { id: "info_player", name: "player", @@ -241,6 +248,7 @@ const infoPlayerElement: MenuElement = { }, }; +// eslint-disable-next-line @typescript-eslint/no-unused-vars const infoEmojiElement: MenuElement = { id: "info_emoji", name: "emoji", @@ -263,7 +271,7 @@ const infoEmojiElement: MenuElement = { : params.selected; params.playerActionHandler.handleEmoji( targetPlayer!, - flattenedEmojiTable.indexOf(emoji), + flattenedEmojiTable.indexOf(emoji as Emoji), ); params.emojiTable.hideTable(); }); diff --git a/src/client/graphics/layers/RailroadLayer.ts b/src/client/graphics/layers/RailroadLayer.ts index 147cca602..fd5782738 100644 --- a/src/client/graphics/layers/RailroadLayer.ts +++ b/src/client/graphics/layers/RailroadLayer.ts @@ -88,6 +88,7 @@ export class RailroadLayer implements Layer { this.canvas.width = this.game.width() * 2; this.canvas.height = this.game.height() * 2; + // eslint-disable-next-line @typescript-eslint/no-unused-vars for (const [_, rail] of this.existingRailroads) { this.paintRail(rail.tile); } @@ -111,7 +112,9 @@ export class RailroadLayer implements Layer { private handleRailroadRendering(railUpdate: RailroadUpdate) { for (const railRoad of railUpdate.railTiles) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars const x = this.game.x(railRoad.tile); + // eslint-disable-next-line @typescript-eslint/no-unused-vars const y = this.game.y(railRoad.tile); if (railUpdate.isActive) { this.paintRailroad(railRoad); diff --git a/src/client/graphics/layers/StructureLayer.ts b/src/client/graphics/layers/StructureLayer.ts index dbfaf5cb7..15c93971c 100644 --- a/src/client/graphics/layers/StructureLayer.ts +++ b/src/client/graphics/layers/StructureLayer.ts @@ -246,6 +246,7 @@ export class StructureLayer implements Layer { ) { let color = unit.owner().borderColor(); if (unit.type() === UnitType.Construction) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars color = underConstructionColor; } diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index bf66222b3..ce84ad98b 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -447,12 +447,14 @@ export class TerritoryLayer implements Layer { return; } const owner = this.game.owner(tile) as PlayerView; + // eslint-disable-next-line @typescript-eslint/no-unused-vars const isHighlighted = this.highlightedTerritory && this.highlightedTerritory.id() === owner.id(); const myPlayer = this.game.myPlayer(); if (this.game.isBorder(tile)) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars const playerIsFocused = owner && this.game.focusedPlayer() === owner; if (myPlayer) { const alternativeColor = this.alternateViewColor(owner); diff --git a/src/client/graphics/layers/WinModal.ts b/src/client/graphics/layers/WinModal.ts index 753572f2f..af41ddde3 100644 --- a/src/client/graphics/layers/WinModal.ts +++ b/src/client/graphics/layers/WinModal.ts @@ -28,6 +28,9 @@ export class WinModal extends LitElement implements Layer { @state() showButtons = false; + @state() + private isWin = false; + @state() private patternContent: TemplateResult | null = null; @@ -68,7 +71,9 @@ export class WinModal extends LitElement implements Layer { @click=${this.hide} class="flex-1 px-3 py-3 text-base cursor-pointer bg-blue-500/60 text-white border-0 rounded transition-all duration-200 hover:bg-blue-500/80 hover:-translate-y-px active:translate-y-px" > - ${translateText("win_modal.keep")} + ${this.isWin + ? translateText("win_modal.keep") + : translateText("win_modal.spectate")}
@@ -230,10 +235,12 @@ export class WinModal extends LitElement implements Layer { this.eventBus.emit(new SendWinnerEvent(wu.winner, wu.allPlayersStats)); if (wu.winner[1] === this.game.myPlayer()?.team()) { this._title = translateText("win_modal.your_team"); + this.isWin = true; } else { this._title = translateText("win_modal.other_team", { team: wu.winner[1], }); + this.isWin = false; } this.show(); } else { @@ -250,10 +257,12 @@ export class WinModal extends LitElement implements Layer { winnerClient === this.game.myPlayer()?.clientID() ) { this._title = translateText("win_modal.you_won"); + this.isWin = true; } else { this._title = translateText("win_modal.other_won", { player: winner.name(), }); + this.isWin = false; } this.show(); } diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 2e2fbc2ee..0cea2c8e8 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -14,7 +14,6 @@ import { GameMapType, GameMode, GameType, - PlayerType, Quads, Trios, UnitType, @@ -118,7 +117,6 @@ export type PlayerCosmeticRefs = z.infer; export type PlayerPattern = z.infer; export type Flag = z.infer; export type GameStartInfo = z.infer; -const PlayerTypeSchema = z.enum(PlayerType); export interface GameInfo { gameID: GameID; diff --git a/src/core/Util.ts b/src/core/Util.ts index 8d30c1a23..d74d27b11 100644 --- a/src/core/Util.ts +++ b/src/core/Util.ts @@ -257,7 +257,7 @@ export function createRandomName( return randomName; } -export const emojiTable: string[][] = [ +export const emojiTable = [ ["😀", "😊", "🥰", "😇", "😎"], ["😞", "🥺", "😭", "😱", "😡"], ["😈", "🤡", "🖕", "🥱", "🤦‍♂️"], @@ -269,9 +269,11 @@ export const emojiTable: string[][] = [ ["⬅️", "🎯", "➡️", "🥈", "🥉"], ["↙️", "⬇️", "↘️", "❤️", "💔"], ["💰", "⚓", "⛵", "🏡", "🛡️"], -]; +] as const; // 2d to 1d array -export const flattenedEmojiTable: string[] = emojiTable.flat(); +export const flattenedEmojiTable = emojiTable.flat(); + +export type Emoji = (typeof flattenedEmojiTable)[number]; /** * JSON.stringify replacer function that converts bigint values to strings. diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 185a473d1..2a4388d11 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -669,6 +669,10 @@ export class DefaultConfig implements Config { } if (attacker.isPlayer() && defender.isPlayer()) { + if (defender.isDisconnected() && attacker.isOnSameTeam(defender)) { + // No troop loss if defender is disconnected. + mag = 0; + } if ( attacker.type() === PlayerType.Human && defender.type() === PlayerType.Bot diff --git a/src/core/configuration/DevConfig.ts b/src/core/configuration/DevConfig.ts index d530795c5..50336bc58 100644 --- a/src/core/configuration/DevConfig.ts +++ b/src/core/configuration/DevConfig.ts @@ -65,6 +65,7 @@ export class DevConfig extends DefaultConfig { unitInfo(type: UnitType): UnitInfo { const info = super.unitInfo(type); + // eslint-disable-next-line @typescript-eslint/no-unused-vars const oldCost = info.cost; // info.cost = (p: Player) => oldCost(p) / 1000000000; return info; diff --git a/src/core/execution/AttackExecution.ts b/src/core/execution/AttackExecution.ts index 402c3a0d5..13099b7b6 100644 --- a/src/core/execution/AttackExecution.ts +++ b/src/core/execution/AttackExecution.ts @@ -100,13 +100,6 @@ export class AttackExecution implements Execution { this.active = false; return; } - if (this._owner.isOnSameTeam(this.target)) { - console.warn( - `${this._owner.displayName()} cannot attack ${this.target.displayName()} because they are on the same team`, - ); - this.active = false; - return; - } } this.startTroops ??= this.mg diff --git a/src/core/execution/BotExecution.ts b/src/core/execution/BotExecution.ts index 8535a9b81..69f1f4f29 100644 --- a/src/core/execution/BotExecution.ts +++ b/src/core/execution/BotExecution.ts @@ -20,8 +20,8 @@ export class BotExecution implements Execution { this.random = new PseudoRandom(simpleHash(bot.id())); this.attackRate = this.random.nextInt(40, 80); this.attackTick = this.random.nextInt(0, this.attackRate); - this.triggerRatio = this.random.nextInt(60, 90) / 100; - this.reserveRatio = this.random.nextInt(20, 30) / 100; + this.triggerRatio = this.random.nextInt(50, 60) / 100; + this.reserveRatio = this.random.nextInt(30, 40) / 100; this.expandRatio = this.random.nextInt(10, 20) / 100; } diff --git a/src/core/execution/FakeHumanExecution.ts b/src/core/execution/FakeHumanExecution.ts index 7326375f1..aaefa84de 100644 --- a/src/core/execution/FakeHumanExecution.ts +++ b/src/core/execution/FakeHumanExecution.ts @@ -1,6 +1,5 @@ import { Cell, - Difficulty, Execution, Game, Gold, @@ -14,17 +13,18 @@ import { Unit, UnitType, } from "../game/Game"; -import { euclDistFN, manhattanDistFN, TileRef } from "../game/GameMap"; +import { TileRef, euclDistFN, manhattanDistFN } from "../game/GameMap"; import { PseudoRandom } from "../PseudoRandom"; import { GameID } from "../Schemas"; -import { calculateBoundingBox, flattenedEmojiTable, simpleHash } from "../Util"; +import { calculateBoundingBox, simpleHash } from "../Util"; import { ConstructionExecution } from "./ConstructionExecution"; import { EmojiExecution } from "./EmojiExecution"; +import { structureSpawnTileValue } from "./nation/structureSpawnTileValue"; import { NukeExecution } from "./NukeExecution"; import { SpawnExecution } from "./SpawnExecution"; import { TransportShipExecution } from "./TransportShipExecution"; import { closestTwoTiles } from "./Util"; -import { BotBehavior } from "./utils/BotBehavior"; +import { BotBehavior, EMOJI_HECKLE } from "./utils/BotBehavior"; export class FakeHumanExecution implements Execution { private active = true; @@ -39,10 +39,9 @@ export class FakeHumanExecution implements Execution { private reserveRatio: number; private expandRatio: number; - private lastEmojiSent = new Map(); - private lastNukeSent: [Tick, TileRef][] = []; - private embargoMalusApplied = new Set(); - private heckleEmoji: number[]; + private readonly lastEmojiSent = new Map(); + private readonly lastNukeSent: [Tick, TileRef][] = []; + private readonly embargoMalusApplied = new Set(); constructor( gameID: GameID, @@ -53,10 +52,9 @@ export class FakeHumanExecution implements Execution { ); this.attackRate = this.random.nextInt(40, 80); this.attackTick = this.random.nextInt(0, this.attackRate); - this.triggerRatio = this.random.nextInt(60, 90) / 100; - this.reserveRatio = this.random.nextInt(30, 60) / 100; - this.expandRatio = this.random.nextInt(15, 25) / 100; - this.heckleEmoji = ["🤡", "😡"].map((e) => flattenedEmojiTable.indexOf(e)); + this.triggerRatio = this.random.nextInt(50, 60) / 100; + this.reserveRatio = this.random.nextInt(30, 40) / 100; + this.expandRatio = this.random.nextInt(10, 20) / 100; } init(mg: Game) { @@ -224,23 +222,12 @@ export class FakeHumanExecution implements Execution { const toAlly = this.random.randElement(enemies); if (this.player.canSendAllianceRequest(toAlly)) { this.player.createAllianceRequest(toAlly); - return; } } - // 50-50 attack weakest player vs random player - const toAttack = this.random.chance(2) - ? enemies[0] - : this.random.randElement(enemies); - - if (this.shouldAttack(toAttack)) { - this.behavior.sendAttack(toAttack); - return; - } - this.behavior.forgetOldEnemies(); this.behavior.assistAllies(); - const enemy = this.behavior.selectEnemy(); + const enemy = this.behavior.selectEnemy(enemies); if (!enemy) return; this.maybeSendEmoji(enemy); this.maybeSendNuke(enemy); @@ -251,53 +238,6 @@ export class FakeHumanExecution implements Execution { } } - private shouldAttack(other: Player): boolean { - if (this.player === null) throw new Error("not initialized"); - - if (this.player.isOnSameTeam(other)) { - return false; - } - - const shouldAttack = this.attackChance(other); - - // Consider betrayal for allies - if (shouldAttack && this.player.isAlliedWith(other)) { - return this.maybeConsiderBetrayal(other); - } - - return shouldAttack; - } - - private attackChance(other: Player): boolean { - if (this.player === null) throw new Error("not initialized"); - - if (this.player.isAlliedWith(other)) { - return this.shouldDiscourageAttack(other) - ? this.random.chance(200) - : this.random.chance(50); - } else { - return this.shouldDiscourageAttack(other) ? this.random.chance(4) : true; - } - } - - private shouldDiscourageAttack(other: Player) { - if (other.isTraitor()) { - return false; - } - const difficulty = this.mg.config().gameConfig().difficulty; - if ( - difficulty === Difficulty.Hard || - difficulty === Difficulty.Impossible - ) { - return false; - } - if (other.type() !== PlayerType.Human) { - return false; - } - // Only discourage attacks on Humans who are not traitors on easy or medium difficulty. - return true; - } - private maybeSendEmoji(enemy: Player) { if (this.player === null) throw new Error("not initialized"); if (enemy.type() !== PlayerType.Human) return; @@ -308,7 +248,7 @@ export class FakeHumanExecution implements Execution { new EmojiExecution( this.player, enemy.id(), - this.random.randElement(this.heckleEmoji), + this.random.randElement(EMOJI_HECKLE), ), ); } @@ -460,6 +400,8 @@ export class FakeHumanExecution implements Execution { this.maybeSpawnStructure(UnitType.Port) || this.maybeSpawnWarship() || this.maybeSpawnStructure(UnitType.Factory) || + this.maybeSpawnStructure(UnitType.DefensePost) || + this.maybeSpawnStructure(UnitType.SAMLauncher) || this.maybeSpawnStructure(UnitType.MissileSilo) ); } @@ -486,7 +428,8 @@ export class FakeHumanExecution implements Execution { } private structureSpawnTile(type: UnitType): TileRef | null { - if (this.player === null) throw new Error("not initialized"); + if (this.mg === undefined) throw new Error("Not initialized"); + if (this.player === null) throw new Error("Not initialized"); const tiles = type === UnitType.Port ? Array.from(this.player.borderTiles()).filter((t) => @@ -494,7 +437,7 @@ export class FakeHumanExecution implements Execution { ) : Array.from(this.player.tiles()); if (tiles.length === 0) return null; - const valueFunction = this.structureSpawnTileValue(type); + const valueFunction = structureSpawnTileValue(this.mg, this.player, type); let bestTile: TileRef | null = null; let bestValue = 0; const sampledTiles = this.arraySampler(tiles); @@ -524,69 +467,6 @@ export class FakeHumanExecution implements Execution { } } - private structureSpawnTileValue(type: UnitType): (tile: TileRef) => number { - if (this.player === null) throw new Error("not initialized"); - const borderTiles = this.player.borderTiles(); - const mg = this.mg; - const otherUnits = this.player.units(type); - // Prefer spacing structures out of atom bomb range - const borderSpacing = this.mg - .config() - .nukeMagnitudes(UnitType.AtomBomb).outer; - const structureSpacing = borderSpacing * 2; - switch (type) { - case UnitType.Port: - return (tile) => { - let w = 0; - - // Prefer to be far away from other structures of the same type - const otherTiles: Set = new Set( - otherUnits.map((u) => u.tile()), - ); - otherTiles.delete(tile); - const closestOther = closestTwoTiles(mg, otherTiles, [tile]); - if (closestOther !== null) { - const d = mg.manhattanDist(closestOther.x, tile); - w += Math.min(d, structureSpacing); - } - - return w; - }; - case UnitType.City: - case UnitType.Factory: - case UnitType.MissileSilo: - return (tile) => { - let w = 0; - - // Prefer higher elevations - w += mg.magnitude(tile); - - // Prefer to be away from the border - const closestBorder = closestTwoTiles(mg, borderTiles, [tile]); - if (closestBorder !== null) { - const d = mg.manhattanDist(closestBorder.x, tile); - w += Math.min(d, borderSpacing); - } - - // Prefer to be away from other structures of the same type - const otherTiles: Set = new Set( - otherUnits.map((u) => u.tile()), - ); - otherTiles.delete(tile); - const closestOther = closestTwoTiles(mg, otherTiles, [tile]); - if (closestOther !== null) { - const d = mg.manhattanDist(closestOther.x, tile); - w += Math.min(d, structureSpacing); - } - - // TODO: Cities and factories should consider train range limits - return w; - }; - default: - throw new Error(`Value function not implemented for ${type}`); - } - } - private maybeSpawnWarship(): boolean { if (this.player === null) throw new Error("not initialized"); if (!this.random.chance(50)) { diff --git a/src/core/execution/SAMLauncherExecution.ts b/src/core/execution/SAMLauncherExecution.ts index d44522cea..baabf0fb2 100644 --- a/src/core/execution/SAMLauncherExecution.ts +++ b/src/core/execution/SAMLauncherExecution.ts @@ -50,7 +50,8 @@ class SAMTargetingSystem { private isInRange(tile: TileRef) { const samTile = this.sam.tile(); - const rangeSquared = this.mg.config().defaultSamRange() ** 2; + const range = this.mg.config().defaultSamRange(); + const rangeSquared = range * range; return this.mg.euclideanDistSquared(samTile, tile) <= rangeSquared; } diff --git a/src/core/execution/nation/structureSpawnTileValue.ts b/src/core/execution/nation/structureSpawnTileValue.ts new file mode 100644 index 000000000..5c8edae45 --- /dev/null +++ b/src/core/execution/nation/structureSpawnTileValue.ts @@ -0,0 +1,158 @@ +import { Game, Player, Relation, UnitType } from "../../game/Game"; +import { TileRef } from "../../game/GameMap"; +import { closestTwoTiles } from "../Util"; + +export function structureSpawnTileValue( + mg: Game, + player: Player, + type: UnitType, +): (tile: TileRef) => number { + const borderTiles = player.borderTiles(); + const otherUnits = player.units(type); + // Prefer spacing structures out of atom bomb range + const borderSpacing = mg.config().nukeMagnitudes(UnitType.AtomBomb).outer; + const structureSpacing = borderSpacing * 2; + switch (type) { + case UnitType.City: + case UnitType.Factory: + case UnitType.MissileSilo: { + return (tile) => { + let w = 0; + + // Prefer higher elevations + w += mg.magnitude(tile); + + // Prefer to be away from the border + const closestBorder = closestTwoTiles(mg, borderTiles, [tile]); + if (closestBorder !== null) { + const d = mg.manhattanDist(closestBorder.x, tile); + w += Math.min(d, borderSpacing); + } + + // Prefer to be away from other structures of the same type + const otherTiles: Set = new Set( + otherUnits.map((u) => u.tile()), + ); + otherTiles.delete(tile); + const closestOther = closestTwoTiles(mg, otherTiles, [tile]); + if (closestOther !== null) { + const d = mg.manhattanDist(closestOther.x, tile); + w += Math.min(d, structureSpacing); + } + + // TODO: Cities and factories should consider train range limits + return w; + }; + } + case UnitType.Port: { + return (tile) => { + let w = 0; + + // Prefer to be away from other structures of the same type + const otherTiles: Set = new Set( + otherUnits.map((u) => u.tile()), + ); + otherTiles.delete(tile); + const closestOther = closestTwoTiles(mg, otherTiles, [tile]); + if (closestOther !== null) { + const d = mg.manhattanDist(closestOther.x, tile); + w += Math.min(d, structureSpacing); + } + + return w; + }; + } + case UnitType.DefensePost: { + return (tile) => { + let w = 0; + + // Prefer higher elevations + w += mg.magnitude(tile); + + const closestBorder = closestTwoTiles(mg, borderTiles, [tile]); + if (closestBorder !== null) { + // Prefer to be borderSpacing tiles from the border + const d = mg.manhattanDist(closestBorder.x, tile); + w += Math.max(0, borderSpacing - Math.abs(borderSpacing - d)); + + // Prefer adjacent players who are hostile + const neighbors: Set = new Set(); + for (const tile of mg.neighbors(closestBorder.x)) { + if (!mg.isLand(tile)) continue; + const id = mg.ownerID(tile); + if (id === player.smallID()) continue; + const neighbor = mg.playerBySmallID(id); + if (!neighbor.isPlayer()) continue; + neighbors.add(neighbor); + } + for (const neighbor of neighbors) { + w += + borderSpacing * (Relation.Friendly - player.relation(neighbor)); + } + } + + // Prefer to be away from other structures of the same type + const otherTiles: Set = new Set( + otherUnits.map((u) => u.tile()), + ); + otherTiles.delete(tile); + const closestOther = closestTwoTiles(mg, otherTiles, [tile]); + if (closestOther !== null) { + const d = mg.manhattanDist(closestOther.x, tile); + w += Math.min(d, structureSpacing); + } + + return w; + }; + } + case UnitType.SAMLauncher: { + const protectTiles: Set = new Set(); + for (const unit of player.units()) { + switch (unit.type()) { + case UnitType.City: + case UnitType.Factory: + case UnitType.MissileSilo: + case UnitType.Port: + protectTiles.add(unit.tile()); + } + } + const range = mg.config().defaultSamRange(); + const rangeSquared = range * range; + return (tile) => { + let w = 0; + + // Prefer higher elevations + w += mg.magnitude(tile); + + // Prefer to be away from the border + const closestBorder = closestTwoTiles(mg, borderTiles, [tile]); + if (closestBorder !== null) { + const d = mg.manhattanDist(closestBorder.x, tile); + w += Math.min(d, borderSpacing); + } + + // Prefer to be away from other structures of the same type + const otherTiles: Set = new Set( + otherUnits.map((u) => u.tile()), + ); + otherTiles.delete(tile); + const closestOther = closestTwoTiles(mg, otherTiles, [tile]); + if (closestOther !== null) { + const d = mg.manhattanDist(closestOther.x, tile); + w += Math.min(d, structureSpacing); + } + + // Prefer to be in range of other structures + for (const maybeProtected of protectTiles) { + const distanceSquared = mg.euclideanDistSquared(tile, maybeProtected); + if (distanceSquared > rangeSquared) continue; + w += structureSpacing; + } + + return w; + }; + } + default: + throw new Error(`Value function not implemented for ${type}`); + } +} diff --git a/src/core/execution/utils/BotBehavior.ts b/src/core/execution/utils/BotBehavior.ts index 91c7e03a9..2fd64cc71 100644 --- a/src/core/execution/utils/BotBehavior.ts +++ b/src/core/execution/utils/BotBehavior.ts @@ -1,5 +1,6 @@ import { AllianceRequest, + Difficulty, Game, Player, PlayerType, @@ -13,11 +14,17 @@ import { AllianceExtensionExecution } from "../alliance/AllianceExtensionExecuti import { AttackExecution } from "../AttackExecution"; import { EmojiExecution } from "../EmojiExecution"; +const emojiId = (e: (typeof flattenedEmojiTable)[number]) => + flattenedEmojiTable.indexOf(e); +const EMOJI_ASSIST_ACCEPT = (["👍", "⛵", "🤝", "🎯"] as const).map(emojiId); +const EMOJI_RELATION_TOO_LOW = (["🥱", "🤦‍♂️"] as const).map(emojiId); +const EMOJI_TARGET_ME = (["🥺", "💀"] as const).map(emojiId); +const EMOJI_TARGET_ALLY = (["🕊️", "👎"] as const).map(emojiId); +export const EMOJI_HECKLE = (["🤡", "😡"] as const).map(emojiId); + export class BotBehavior { private enemy: Player | null = null; - private enemyUpdated: Tick; - - private assistAcceptEmoji = flattenedEmojiTable.indexOf("👍"); + private enemyUpdated: Tick | undefined; constructor( private random: PseudoRandom, @@ -65,23 +72,80 @@ export class BotBehavior { this.game.addExecution(new EmojiExecution(this.player, player.id(), emoji)); } - private setNewEnemy(newEnemy: Player | null) { + private setNewEnemy(newEnemy: Player | null, force = false) { + if (newEnemy !== null && !force && !this.shouldAttack(newEnemy)) return; this.enemy = newEnemy; this.enemyUpdated = this.game.ticks(); } + private shouldAttack(other: Player): boolean { + if (this.player === null) throw new Error("not initialized"); + if (this.player.isOnSameTeam(other)) { + return false; + } + const shouldAttack = this.attackChance(other); + if (shouldAttack && this.player.isAlliedWith(other)) { + this.betray(other); + return true; + } + return shouldAttack; + } + + private betray(target: Player): void { + if (this.player === null) throw new Error("not initialized"); + const alliance = this.player.allianceWith(target); + if (!alliance) return; + this.player.breakAlliance(alliance); + } + + private attackChance(other: Player): boolean { + if (this.player === null) throw new Error("not initialized"); + + if (this.player.isAlliedWith(other)) { + return this.shouldDiscourageAttack(other) + ? this.random.chance(200) + : this.random.chance(50); + } else { + return this.shouldDiscourageAttack(other) ? this.random.chance(4) : true; + } + } + + private shouldDiscourageAttack(other: Player) { + if (other.isTraitor()) { + return false; + } + const { difficulty } = this.game.config().gameConfig(); + if ( + difficulty === Difficulty.Hard || + difficulty === Difficulty.Impossible + ) { + return false; + } + if (other.type() !== PlayerType.Human) { + return false; + } + // Only discourage attacks on Humans who are not traitors on easy or medium difficulty. + return true; + } + private clearEnemy() { this.enemy = null; } forgetOldEnemies() { // Forget old enemies - if (this.game.ticks() - this.enemyUpdated > 100) { + if (this.game.ticks() - (this.enemyUpdated ?? 0) > 100) { this.clearEnemy(); } } - private hasSufficientTroops(): boolean { + private hasReserveRatioTroops(): boolean { + const maxTroops = this.game.config().maxTroops(this.player); + const ratio = this.player.troops() / maxTroops; + return ratio >= this.reserveRatio; + } + + private hasTriggerRatioTroops(): boolean { const maxTroops = this.game.config().maxTroops(this.player); const ratio = this.player.troops() / maxTroops; return ratio >= this.triggerRatio; @@ -98,7 +162,7 @@ export class BotBehavior { largestAttacker = attack.attacker(); } if (largestAttacker !== undefined) { - this.setNewEnemy(largestAttacker); + this.setNewEnemy(largestAttacker, true); } } @@ -110,34 +174,37 @@ export class BotBehavior { } assistAllies() { - outer: for (const ally of this.player.allies()) { + for (const ally of this.player.allies()) { if (ally.targets().length === 0) continue; if (this.player.relation(ally) < Relation.Friendly) { - // this.emoji(ally, "🤦"); + this.emoji(ally, this.random.randElement(EMOJI_RELATION_TOO_LOW)); continue; } for (const target of ally.targets()) { if (target === this.player) { - // this.emoji(ally, "💀"); + this.emoji(ally, this.random.randElement(EMOJI_TARGET_ME)); continue; } if (this.player.isAlliedWith(target)) { - // this.emoji(ally, "👎"); + this.emoji(ally, this.random.randElement(EMOJI_TARGET_ALLY)); continue; } // All checks passed, assist them this.player.updateRelation(ally, -20); this.setNewEnemy(target); - this.emoji(ally, this.assistAcceptEmoji); - break outer; + this.emoji(ally, this.random.randElement(EMOJI_ASSIST_ACCEPT)); + return; } } } - selectEnemy(): Player | null { + selectEnemy(enemies: Player[]): Player | null { if (this.enemy === null) { - // Save up troops until we reach the trigger ratio - if (!this.hasSufficientTroops()) return null; + // Save up troops until we reach the reserve ratio + if (!this.hasReserveRatioTroops()) return null; + + // Maybe save up troops until we reach the trigger ratio + if (!this.hasTriggerRatioTroops() && !this.random.chance(10)) return null; // Prefer neighboring bots const bots = this.player @@ -165,11 +232,13 @@ export class BotBehavior { // Retaliate against incoming attacks if (this.enemy === null) { + // Only after clearing bots this.checkIncomingAttacks(); } // Select the most hated player - if (this.enemy === null) { + if (this.enemy === null && this.random.chance(2)) { + // 50% chance const mostHated = this.player.allRelationsSorted()[0]; if ( mostHated !== undefined && @@ -178,6 +247,16 @@ export class BotBehavior { this.setNewEnemy(mostHated.player); } } + + // Select the weakest player + if (this.enemy === null && enemies.length > 0) { + this.setNewEnemy(enemies[0]); + } + + // Select a random player + if (this.enemy === null && enemies.length > 0) { + this.setNewEnemy(this.random.randElement(enemies)); + } } // Sanity check, don't attack our allies or teammates @@ -187,7 +266,7 @@ export class BotBehavior { selectRandomEnemy(): Player | TerraNullius | null { if (this.enemy === null) { // Save up troops until we reach the trigger ratio - if (!this.hasSufficientTroops()) return null; + if (!this.hasTriggerRatioTroops()) return null; // Choose a new enemy randomly const neighbors = this.player.neighbors(); diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 2a2432905..03c828fcc 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -11,6 +11,13 @@ import { RailNetwork } from "./RailNetwork"; import { Stats } from "./Stats"; import { UnitPredicate } from "./UnitGrid"; +function isEnumValue>( + enumObj: T, + value: unknown, +): value is T[keyof T] { + return Object.values(enumObj).includes(value as T[keyof T]); +} + export type PlayerID = string; export type Tick = number; export type Gold = bigint; @@ -37,6 +44,8 @@ export enum Difficulty { Hard = "Hard", Impossible = "Impossible", } +export const isDifficulty = (value: unknown): value is Difficulty => + isEnumValue(Difficulty, value); export type Team = string; @@ -134,11 +143,15 @@ export enum GameType { Public = "Public", Private = "Private", } +export const isGameType = (value: unknown): value is GameType => + isEnumValue(GameType, value); export enum GameMode { FFA = "Free For All", Team = "Team", } +export const isGameMode = (value: unknown): value is GameMode => + isEnumValue(GameMode, value); export enum GameMapSize { Compact = "Compact", diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index 80cac341e..54fae1b1e 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -160,6 +160,7 @@ export interface PlayerUpdate { allies: number[]; embargoes: Set; isTraitor: boolean; + traitorRemainingTicks?: number; targets: number[]; outgoingEmojis: EmojiMessage[]; outgoingAttacks: AttackUpdate[]; diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 648d84682..c2dec7c52 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -404,6 +404,9 @@ export class PlayerView { isTraitor(): boolean { return this.data.isTraitor; } + getTraitorRemainingTicks(): number { + return Math.max(0, this.data.traitorRemainingTicks ?? 0); + } outgoingEmojis(): EmojiMessage[] { return this.data.outgoingEmojis; } diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 1919264dc..cb395cc1a 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -141,6 +141,7 @@ export class PlayerImpl implements Player { allies: this.alliances().map((a) => a.other(this).smallID()), embargoes: new Set([...this.embargoes.keys()].map((p) => p.toString())), isTraitor: this.isTraitor(), + traitorRemainingTicks: this.getTraitorRemainingTicks(), targets: this.targets().map((p) => p.smallID()), outgoingEmojis: this.outgoingEmojis(), outgoingAttacks: this._outgoingAttacks.map((a) => { @@ -418,11 +419,15 @@ export class PlayerImpl implements Player { } isTraitor(): boolean { - return ( - this.markedTraitorTick >= 0 && - this.mg.ticks() - this.markedTraitorTick < - this.mg.config().traitorDuration() - ); + return this.getTraitorRemainingTicks() > 0; + } + + getTraitorRemainingTicks(): number { + if (this.markedTraitorTick < 0) return 0; + const elapsed = this.mg.ticks() - this.markedTraitorTick; + const duration = this.mg.config().traitorDuration(); + const remaining = duration - elapsed; + return remaining > 0 ? remaining : 0; } markTraitor(): void { @@ -741,6 +746,9 @@ export class PlayerImpl implements Player { } isFriendly(other: Player): boolean { + if (other.isDisconnected()) { + return false; + } return this.isOnSameTeam(other) || this.isAlliedWith(other); } diff --git a/src/core/game/TeamAssignment.ts b/src/core/game/TeamAssignment.ts index 1ec1bb566..535626d7d 100644 --- a/src/core/game/TeamAssignment.ts +++ b/src/core/game/TeamAssignment.ts @@ -33,6 +33,7 @@ export function assignTeams( ); // First, assign clan players + // eslint-disable-next-line @typescript-eslint/no-unused-vars for (const [_, clanPlayers] of sortedClans) { // Try to keep the clan together on the team with fewer players let team: Team | null = null; diff --git a/src/core/game/TerrainSearchMap.ts b/src/core/game/TerrainSearchMap.ts index bbcfe420a..c7366f38d 100644 --- a/src/core/game/TerrainSearchMap.ts +++ b/src/core/game/TerrainSearchMap.ts @@ -18,8 +18,6 @@ export class TerrainSearchMap { node(x: number, y: number): SearchMapTileType { const packedByte = this.mapData[4 + y * this.width + x]; const isLand = packedByte & 0b10000000; - const shoreline = !!(packedByte & 0b01000000); - const ocean = !!(packedByte & 0b00100000); const magnitude = packedByte & 0b00011111; if (isLand) { return SearchMapTileType.Land; diff --git a/src/server/Master.ts b/src/server/Master.ts index 993a4cec5..d662459df 100644 --- a/src/server/Master.ts +++ b/src/server/Master.ts @@ -285,14 +285,13 @@ async function schedulePublicGame(playlist: MapPlaylist) { if (!response.ok) { throw new Error(`Failed to schedule public game: ${response.statusText}`); } - - const data = await response.json(); } catch (error) { log.error(`Failed to schedule public game on worker ${workerPath}:`, error); throw error; } } +// eslint-disable-next-line @typescript-eslint/no-unused-vars function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/src/server/jwt.ts b/src/server/jwt.ts index d8a74384f..b0a81dc8b 100644 --- a/src/server/jwt.ts +++ b/src/server/jwt.ts @@ -27,7 +27,7 @@ export async function verifyClientToken( const issuer = config.jwtIssuer(); const audience = config.jwtAudience(); const key = await config.jwkPublicKey(); - const { payload, protectedHeader } = await jwtVerify(token, key, { + const { payload } = await jwtVerify(token, key, { algorithms: ["EdDSA"], issuer, audience, diff --git a/tests/ShellRandom.test.ts b/tests/ShellRandom.test.ts index 5c5590383..7466087ad 100644 --- a/tests/ShellRandom.test.ts +++ b/tests/ShellRandom.test.ts @@ -85,7 +85,6 @@ describe("Shell Random Damage", () => { expect(damage).toBeLessThanOrEqual(maxExpectedDamage); }); - const uniqueDamages = new Set(damages); expect(damages.length).toBeGreaterThan(0); }); @@ -231,16 +230,6 @@ describe("Shell Random Damage", () => { expect(damages.length).toBeGreaterThan(0); - const baseDamage = game.config().unitInfo(UnitType.Shell).damage ?? 250; - const expectedDamages = [ - Math.round((baseDamage / 250) * 200), - Math.round((baseDamage / 250) * 225), - Math.round((baseDamage / 250) * 250), - Math.round((baseDamage / 250) * 275), - Math.round((baseDamage / 250) * 300), - Math.round((baseDamage / 250) * 325), - ]; - const uniqueDamages = new Set(damages); expect(uniqueDamages.size).toBeGreaterThan(0); @@ -265,7 +254,6 @@ describe("Shell Random Damage", () => { ); const initialHealth = target.health(); - const seed = 12345; const shell1 = new ShellExecution( game.ref(coastX, 10), player1, diff --git a/tests/core/executions/SAMLauncherExecution.test.ts b/tests/core/executions/SAMLauncherExecution.test.ts index 0e7fc0d98..ed7ba534b 100644 --- a/tests/core/executions/SAMLauncherExecution.test.ts +++ b/tests/core/executions/SAMLauncherExecution.test.ts @@ -83,6 +83,7 @@ describe("SAM", () => { game.addExecution(new SAMLauncherExecution(defender, null, sam)); // Sam will only target nukes it can destroy before it reaches its target + // eslint-disable-next-line @typescript-eslint/no-unused-vars const nuke = attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 1), { targetTile: game.ref(3, 1), trajectory: [