Merge branch 'main' into canbuildtransport-perf

This commit is contained in:
VariableVince
2025-10-26 10:34:32 +01:00
committed by GitHub
71 changed files with 1887 additions and 658 deletions
+2 -3
View File
@@ -8,7 +8,6 @@ jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@8f717f0dfca33b78d3c933452e42558e4456c8e7
@@ -21,6 +20,6 @@ jobs:
exempt-pr-assignees: evanpelle
exempt-pr-labels: "will not stale"
stale-pr-label: "Stale"
stale-pr-message: "This pull request is stale because it has been open for 14 days with no activity. If you want to keep this pull request open, add a comment or update the branch."
close-pr-message: "This pull request has been closed because twenty-eight days have passed without activity. If someone wants to keep working on it, feel free to take the code."
stale-pr-message: "This pull request is stale because it has been open for fourteen days with no activity. If you want to keep this pull request open, add a comment or update the branch."
close-pr-message: "This pull request has been closed because fourteen days have passed without activity. If you want to continue work on the code, comment or create a new PR."
close-pr-label: "Orphaned"
+1
View File
@@ -10,3 +10,4 @@ resources/.DS_Store
.DS_Store
.clinic/
CLAUDE.md
.idea/
+8 -5
View File
@@ -151,11 +151,14 @@ Contributions are welcome! Please feel free to submit a Pull Request.
Translators are welcome! Please feel free to help translate into your language.
How to help?
1. Request to join the dev [Discord](https://discord.gg/K9zernJB5z) (in the application form, say you want to help translate)
1. Go to the project's Crowdin translation page: [https://crowdin.com/project/openfront-mls](https://crowdin.com/project/openfront-mls)
1. Login if you already have an account/ Sign up if you don't have one
1. Select the language you want to translate in/ If your language isn't on the list, click the "Request New Language" button and enter the language you want added there.
1. Translate the strings
1. Join the translation [Discord](https://discord.gg/3zZzacjWFr)
2. Go to the project's Crowdin translation page: [https://crowdin.com/project/openfront-mls](https://crowdin.com/project/openfront-mls)
3. Login if you already have an account / Sign up if you don't have one
4. Join the project
5. Select the language you want to translate in. If your language isn't on the list, click the "Request New Language" button and enter the language you want added there.
6. Translate the strings
Feel free to ask questions in the translation Discord server!
### Project Governance
Binary file not shown.

After

Width:  |  Height:  |  Size: 981 KiB

@@ -0,0 +1,29 @@
{
"name": "Achiran",
"nations": [
{
"coordinates": [785, 985],
"flag": "ie",
"name": "Inishmore",
"strength": 1
},
{
"coordinates": [1360, 1360],
"flag": "ie",
"name": "Inishmann",
"strength": 2
},
{
"coordinates": [1630, 1515],
"flag": "ie",
"name": "Inisheer",
"strength": 1
},
{
"coordinates": [1400, 480],
"flag": "ie",
"name": "Achill",
"strength": 2
}
]
}
+1
View File
@@ -16,6 +16,7 @@ var maps = []struct {
{Name: "africa"},
{Name: "asia"},
{Name: "australia"},
{Name: "achiran"},
{Name: "baikal"},
{Name: "betweentwoseas"},
{Name: "blacksea"},
+13 -1
View File
@@ -157,6 +157,7 @@
"account_modal": {
"title": "Account",
"logged_in_as": "Logged in as {email}",
"fetching_account": "Fetching account information...",
"logged_in_with_discord": "Logged in with Discord",
"recovery_email_sent": "Recovery email sent to {email}"
},
@@ -192,7 +193,8 @@
"japan": "Japan",
"yenisei": "Yenisei",
"pluto": "Pluto",
"montreal": "Montreal"
"montreal": "Montreal",
"achiran": "Achiran"
},
"map_categories": {
"continental": "Continental",
@@ -222,6 +224,12 @@
"teams_Quads": "Quads (teams of 4)",
"teams": "{num} teams"
},
"matchmaking_modal": {
"title": "Matchmaking",
"connecting": "Connecting to matchmaking server...",
"searching": "Searching for game...",
"waiting_for_game": "Waiting for game to start..."
},
"username": {
"enter_username": "Enter your username",
"not_string": "Username must be a string.",
@@ -346,6 +354,8 @@
"build_atom_bomb_desc": "Build an Atom Bomb under your cursor.",
"build_hydrogen_bomb": "Build Hydrogen Bomb",
"build_hydrogen_bomb_desc": "Build a Hydrogen Bomb under your cursor.",
"build_mirv": "Build MIRV",
"build_mirv_desc": "Build a MIRV under your cursor.",
"attack_ratio_controls": "Attack Ratio Controls",
"attack_ratio_up": "Increase Attack Ratio",
"attack_ratio_up_desc": "Increase attack ratio by 10%",
@@ -596,6 +606,8 @@
"nuke": "Nukes sent by them to you",
"start_trade": "Start Trading",
"stop_trade": "Stop Trading",
"stop_trade_all": "Stop Trading with All",
"start_trade_all": "Start Trading with All",
"alliances": "Alliances",
"flag": "Flag",
"chat": "Chat",
+71 -7
View File
@@ -130,9 +130,8 @@
"disable_nations": "Deaktiviraj nacije",
"instant_build": "Instantna gradnja",
"infinite_gold": "Neomejeno zlata",
"donate_gold": "Doniraj zlato",
"infinite_troops": "Neomejene enote",
"donate_troops": "Doniraj enote",
"compact_map": "Mini mapa",
"disable_nukes": "Deaktiviraj bombe",
"enables_title": "Aktiviraj nastavitve",
"start": "Začni igro"
@@ -177,8 +176,10 @@
"halkidiki": "Halkidiki",
"straitofgibraltar": "Gibraltarska ožina",
"italia": "Italija",
"japan": "Japonska",
"yenisei": "Jenisej",
"pluto": "Pluto"
"pluto": "Pluto",
"montreal": "Montreal"
},
"map_categories": {
"continental": "Celinska",
@@ -196,8 +197,9 @@
"join_lobby": "Pridruži se sobi",
"checking": "Preverjanje sobe...",
"not_found": "Sobe ni mogoče najti. Prosimo preverite ID in poskusite znova.",
"error": "Prišlo je do napake. Prosimo, poskusite znova.",
"joined_waiting": "Uspešna pridružitev! Čakanje na začetek igre..."
"error": "Prišlo je do napake. Poskusite znova ali se obrnite na podporo.",
"joined_waiting": "Uspešna pridružitev! Čakanje na začetek igre...",
"version_mismatch": "Ta soba je bila ustvarjena z drugo verzijo igre. Napaka pridružitve."
},
"public_lobby": {
"join": "Pridruži se naslednji igri",
@@ -227,6 +229,7 @@
"donate_gold": "Doniraj zlato",
"infinite_troops": "Neomejene enote",
"donate_troops": "Doniraj enote",
"compact_map": "Mini mapa",
"enables_title": "Aktiviraj nastavitve",
"player": "Igralec",
"players": "Igralci",
@@ -314,6 +317,8 @@
"territory_patterns_desc": "Izberite, ali želite v igri prikazati vzorce ozemlja",
"performance_overlay_label": "Prekrivni sloj zmogljivosti",
"performance_overlay_desc": "Preklopi prekrivni sloj zmogljivosti. Ko je omogočen, bo prikazan prekrivni sloj zmogljivosti. Med igro pritisnite shift-D za preklop.",
"performance_overlay_enabled": "Prekrivanje zmogljivosti je omogočeno",
"performance_overlay_disabled": "Prekrivanje zmogljivosti je onemogočeno",
"easter_writing_speed_label": "Množitelj hitrosti pisanja",
"easter_writing_speed_desc": "Prilagodite hitrost pretvarjanja, da programirate (x1x100)",
"easter_bug_count_label": "Število hroščev",
@@ -494,7 +499,8 @@
"nation": "Narod",
"player": "Igralec",
"team": "Ekipa",
"d_troops": "Obrambne enote",
"alliance_timeout": "Zavezništvo poteče čez",
"troops": "Enote",
"a_troops": "Napadalne enote",
"gold": "Zlato",
"ports": "Luka",
@@ -585,7 +591,7 @@
"choose_spawn": "Izberite začetno lokacijo"
},
"territory_patterns": {
"title": "Izberite vzorec ozemlja",
"title": "Izberi poslikavo območja",
"purchase": "Nakup",
"blocked": {
"login": "Za dostop do tega vzorca morate biti prijavljeni.",
@@ -644,5 +650,63 @@
"radial_menu": {
"delete_unit_title": "Pobriši enoto",
"delete_unit_description": "Pritisni za odstranitev najbližjih enot"
},
"discord_user_header": {
"avatar_alt": "Avatar"
},
"player_stats_table": {
"building_stats": "Statistika zgradbe",
"ship_arrivals": "Prihajajoče ladje",
"nuke_stats": "Statistika bomb",
"player_metrics": "Metrika igralca",
"building": "Zgradba",
"ship_type": "Vrsta ladje",
"weapon": "Orožje",
"built": "Zgrajeno",
"destroyed": "Uničeno",
"captured": "Ujeto",
"lost": "Zgubljeno",
"hits": "Zadetki",
"launched": "Izstreljeno",
"landed": "Pristanek",
"sent": "Poslano",
"arrived": "Prišlo",
"attack": "Napad",
"received": "Prejeto",
"cancelled": "Prekinjeno",
"count": "Število",
"gold": "Zlato",
"workers": "Delavci",
"war": "Vojna",
"trade": "Trgovanje",
"steal": "Kraja",
"unit": {
"city": "Mesto",
"port": "Luka",
"defp": "Obrambni stolp",
"saml": "SAM Izstreljevalec",
"silo": "Izstreljevalec raket",
"wshp": "Bojna ladja",
"fact": "Tovarna",
"trade": "Trgovska ladja",
"trans": "Transportna ladja",
"abomb": "Atomska bomba",
"hbomb": "Vodikova bomba",
"mirv": "MIRV",
"mirvw": "MIRV bojna glava"
}
},
"game_list": {
"recent_games": "Nedavne igre",
"game_id": "ID sobe",
"mode": "Način",
"mode_ffa": "Prosto za vse",
"mode_team": "Ekipe",
"view": "Poglej",
"details": "Detajli",
"started": "Začeto",
"map": "Mapa",
"difficulty": "Težavnost",
"type": "Tip"
}
}
+71 -7
View File
@@ -130,9 +130,8 @@
"disable_nations": "禁用国家",
"instant_build": "立即建造",
"infinite_gold": "无限黄金",
"donate_gold": "捐赠金币",
"infinite_troops": "无限军队",
"donate_troops": "捐赠军队",
"compact_map": "小地图",
"disable_nukes": "禁用核弹",
"enables_title": "启用设置",
"start": "开始游戏"
@@ -177,8 +176,10 @@
"halkidiki": "哈尔基季基",
"straitofgibraltar": "直布罗陀海峡",
"italia": "意大利",
"japan": "日本",
"yenisei": "叶尼塞河",
"pluto": "冥王星"
"pluto": "冥王星",
"montreal": "蒙特利尔"
},
"map_categories": {
"continental": "大陆",
@@ -196,8 +197,9 @@
"join_lobby": "加入房间",
"checking": "正在确认房间...",
"not_found": "找不到房间。请检查 ID 然后重试。",
"error": "发生错误请再试一次。",
"joined_waiting": "加入成功!正在等待游戏开始..."
"error": "发生错误请再试一次或联系支持人员。",
"joined_waiting": "加入成功!正在等待游戏开始...",
"version_mismatch": "这场游戏基于另一个版本,无法加入。"
},
"public_lobby": {
"join": "加入下一场游戏",
@@ -227,6 +229,7 @@
"donate_gold": "捐赠金币",
"infinite_troops": "无限军队",
"donate_troops": "捐赠军队",
"compact_map": "小地图",
"enables_title": "启用设置",
"player": "玩家",
"players": "玩家",
@@ -314,6 +317,8 @@
"territory_patterns_desc": "选择是否在游戏中显示领土皮肤",
"performance_overlay_label": "性能叠层",
"performance_overlay_desc": "切换性能叠层。启用后将显示性能叠层。在游戏过程中按下 Shift+D 可进行切换。",
"performance_overlay_enabled": "已启用性能叠层",
"performance_overlay_disabled": "已禁用性能叠层",
"easter_writing_speed_label": "写入速度乘数",
"easter_writing_speed_desc": "调节你“假装写代码”的速度 (x1x100)",
"easter_bug_count_label": "Bug 计数",
@@ -494,7 +499,8 @@
"nation": "国家",
"player": "玩家",
"team": "队伍",
"d_troops": "防守军队",
"alliance_timeout": "结盟剩余时长",
"troops": "军队",
"a_troops": "进攻军队",
"gold": "黄金",
"ports": "港口",
@@ -541,7 +547,7 @@
},
"relation": {
"hostile": "敌对",
"distrustful": "不可信",
"distrustful": "可疑",
"neutral": "中立",
"friendly": "友好",
"default": "默认"
@@ -644,5 +650,63 @@
"radial_menu": {
"delete_unit_title": "删除单位",
"delete_unit_description": "点击删除最近的单位"
},
"discord_user_header": {
"avatar_alt": "头像"
},
"player_stats_table": {
"building_stats": "建筑统计",
"ship_arrivals": "船只抵达",
"nuke_stats": "核弹统计",
"player_metrics": "玩家指标",
"building": "建筑",
"ship_type": "船只类型",
"weapon": "武器",
"built": "已建造",
"destroyed": "已摧毁",
"captured": "已捕获",
"lost": "损失",
"hits": "击中",
"launched": "已发射",
"landed": "已登陆",
"sent": "已发送",
"arrived": "已抵达",
"attack": "攻击",
"received": "已收到",
"cancelled": "已取消",
"count": "总计",
"gold": "黄金",
"workers": "工人",
"war": "战争",
"trade": "交易",
"steal": "偷窃",
"unit": {
"city": "城市",
"port": "港口",
"defp": "防守据点",
"saml": "防空塔",
"silo": "导弹发射井",
"wshp": "军舰",
"fact": "工厂",
"trade": "贸易船",
"trans": "运输船",
"abomb": "原子弹",
"hbomb": "氢弹",
"mirv": "MIRV",
"mirvw": "MIRV 弹头"
}
},
"game_list": {
"recent_games": "近期对局",
"game_id": "游戏 ID",
"mode": "模式",
"mode_ffa": "混战",
"mode_team": "团队",
"view": "视图",
"details": "详细信息",
"started": "已开始",
"map": "地图",
"difficulty": "难度",
"type": "类型"
}
}
+44
View File
@@ -0,0 +1,44 @@
{
"map": {
"height": 1700,
"num_land_tiles": 1149943,
"width": 2000
},
"map16x": {
"height": 425,
"num_land_tiles": 69861,
"width": 500
},
"map4x": {
"height": 850,
"num_land_tiles": 284530,
"width": 1000
},
"name": "Achiran",
"nations": [
{
"coordinates": [785, 985],
"flag": "ie",
"name": "Inishmore",
"strength": 1
},
{
"coordinates": [1360, 1360],
"flag": "ie",
"name": "Inishmann",
"strength": 2
},
{
"coordinates": [1630, 1515],
"flag": "ie",
"name": "Inisheer",
"strength": 1
},
{
"coordinates": [1400, 480],
"flag": "ie",
"name": "Achill",
"strength": 2
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 884 B

+115 -11
View File
@@ -1,9 +1,23 @@
import { html, LitElement, TemplateResult } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { UserMeResponse } from "../core/ApiSchemas";
import {
PlayerGame,
PlayerStatsTree,
UserMeResponse,
} from "../core/ApiSchemas";
import "./components/baseComponents/stats/DiscordUserHeader";
import "./components/baseComponents/stats/GameList";
import "./components/baseComponents/stats/PlayerStatsTable";
import "./components/baseComponents/stats/PlayerStatsTree";
import "./components/Difficulties";
import "./components/PatternButton";
import { discordLogin, getApiBase, getUserMe, logOut } from "./jwt";
import {
discordLogin,
fetchPlayerById,
getApiBase,
getUserMe,
logOut,
} from "./jwt";
import { isInIframe, translateText } from "./Utils";
@customElement("account-modal")
@@ -14,12 +28,33 @@ export class AccountModal extends LitElement {
};
@state() private email: string = "";
@state() private isLoadingUser: boolean = false;
private loggedInEmail: string | null = null;
private loggedInDiscord: string | null = null;
private userMeResponse: UserMeResponse | null = null;
private playerId: string | null = null;
private statsTree: PlayerStatsTree | null = null;
private recentGames: PlayerGame[] = [];
constructor() {
super();
document.addEventListener("userMeResponse", (event: Event) => {
const customEvent = event as CustomEvent;
if (customEvent.detail) {
this.userMeResponse = customEvent.detail as UserMeResponse;
this.playerId = this.userMeResponse?.player?.publicId;
if (this.playerId === undefined) {
this.statsTree = null;
this.recentGames = [];
}
} else {
this.statsTree = null;
this.recentGames = [];
this.requestUpdate();
}
});
}
createRenderRoot() {
@@ -38,6 +73,16 @@ export class AccountModal extends LitElement {
}
private renderInner() {
if (this.isLoadingUser) {
return html`
<div class="flex flex-col items-center justify-center p-6 text-white">
<p class="mb-2">${translateText("account_modal.fetching_account")}</p>
<div
class="w-6 h-6 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"
></div>
</div>
`;
}
if (this.loggedInDiscord) {
return this.renderLoggedInDiscord();
} else if (this.loggedInEmail) {
@@ -47,15 +92,39 @@ export class AccountModal extends LitElement {
}
}
private viewGame(gameId: string): void {
this.close();
const path = location.pathname;
const { search } = location;
const hash = `#join=${encodeURIComponent(gameId)}`;
const newUrl = `${path}${search}${hash}`;
history.pushState({ join: gameId }, "", newUrl);
window.dispatchEvent(new HashChangeEvent("hashchange"));
}
private renderLoggedInDiscord() {
return html`
<div class="p-6">
<div class="mb-4">
<p class="text-white text-center mb-4">
<div class="mb-4 text-center">
<p class="text-white mb-4">
Logged in with Discord as ${this.loggedInDiscord}
</p>
${this.logoutButton()}
</div>
<div class="flex flex-col items-center mt-2 mb-4">
<discord-user-header
.data=${this.userMeResponse?.user?.discord ?? null}
></discord-user-header>
<player-stats-tree-view
.statsTree=${this.statsTree}
></player-stats-tree-view>
<hr class="w-2/3 border-gray-600 my-2" />
<game-list
.games=${this.recentGames}
.onViewGame=${(id: string) => this.viewGame(id)}
></game-list>
</div>
${this.logoutButton()}
</div>
`;
}
@@ -208,13 +277,30 @@ export class AccountModal extends LitElement {
discordLogin();
}
public async open() {
const userMe = await getUserMe();
if (userMe) {
this.loggedInEmail = userMe.user.email ?? null;
this.loggedInDiscord = userMe.user.discord?.global_name ?? null;
}
public open() {
this.modalEl?.open();
this.isLoadingUser = true;
void getUserMe()
.then((userMe) => {
if (userMe) {
this.loggedInEmail = userMe.user.email ?? null;
this.loggedInDiscord = userMe.user.discord?.global_name ?? null;
if (this.playerId) {
this.loadFromApi(this.playerId);
}
} else {
this.loggedInEmail = null;
this.loggedInDiscord = null;
}
this.isLoadingUser = false;
this.requestUpdate();
})
.catch((err) => {
console.warn("Failed to fetch user info in AccountModal.open():", err);
this.isLoadingUser = false;
this.requestUpdate();
});
this.requestUpdate();
}
@@ -228,6 +314,24 @@ export class AccountModal extends LitElement {
// Refresh the page after logout to update the UI state
window.location.reload();
}
private async loadFromApi(playerId: string): Promise<void> {
try {
const data = await fetchPlayerById(playerId);
if (!data) {
this.requestUpdate();
return;
}
this.recentGames = data.games;
this.statsTree = data.stats;
this.requestUpdate();
} catch (err) {
console.warn("Failed to load player data:", err);
this.requestUpdate();
}
}
}
@customElement("account-button")
+3 -2
View File
@@ -74,10 +74,11 @@ export async function fetchCosmetics(): Promise<Cosmetics | null> {
export function patternRelationship(
pattern: Pattern,
colorPalette: { name: string; isArchived?: boolean } | null,
userMeResponse: UserMeResponse | null,
userMeResponse: UserMeResponse | false,
affiliateCode: string | null,
): "owned" | "purchasable" | "blocked" {
const flares = userMeResponse?.player.flares ?? [];
const flares =
userMeResponse === false ? [] : (userMeResponse.player.flares ?? []);
if (flares.includes("pattern:*")) {
return "owned";
}
+31 -1
View File
@@ -1,5 +1,6 @@
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { UserMeResponse } from "../core/ApiSchemas";
import { isInIframe } from "./Utils";
const LEFT_FUSE = "gutter-ad-container-left";
@@ -17,6 +18,31 @@ export class GutterAds extends LitElement {
return this;
}
private readonly boundUserMeHandler = (event: Event) =>
this.onUserMe((event as CustomEvent<UserMeResponse | false>).detail);
connectedCallback() {
super.connectedCallback();
document.addEventListener(
"userMeResponse",
this.boundUserMeHandler as EventListener,
);
}
private onUserMe(userMeResponse: UserMeResponse | false): void {
const flares =
userMeResponse === false ? [] : (userMeResponse.player.flares ?? []);
const hasFlare = flares.some((flare) => flare.startsWith("pattern:"));
if (hasFlare) {
console.log("No ads because you have patterns");
window.enableAds = false;
} else {
console.log("No flares, showing ads");
this.show();
window.enableAds = true;
}
}
private isScreenLargeEnough(): boolean {
return window.innerWidth >= MIN_SCREEN_WIDTH;
}
@@ -52,6 +78,10 @@ export class GutterAds extends LitElement {
this.isVisible = false;
console.log("hiding GutterAds");
this.destroyAds();
document.removeEventListener(
"userMeResponse",
this.boundUserMeHandler as EventListener,
);
this.requestUpdate();
}
@@ -95,7 +125,7 @@ export class GutterAds extends LitElement {
disconnectedCallback() {
super.disconnectedCallback();
this.destroyAds();
this.hide();
}
render() {
+20 -111
View File
@@ -2,7 +2,6 @@ import version from "../../resources/version.txt";
import { UserMeResponse } from "../core/ApiSchemas";
import { EventBus } from "../core/EventBus";
import { GameRecord, GameStartInfo, ID } from "../core/Schemas";
import { ServerConfig } from "../core/configuration/Config";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { UserSettings } from "../core/game/UserSettings";
import "./AccountModal";
@@ -22,6 +21,8 @@ import { JoinPrivateLobbyModal } from "./JoinPrivateLobbyModal";
import "./LangSelector";
import { LangSelector } from "./LangSelector";
import { LanguageModal } from "./LanguageModal";
import "./Matchmaking";
import { MatchmakingModal } from "./Matchmaking";
import { NewsModal } from "./NewsModal";
import "./PublicLobby";
import { PublicLobby } from "./PublicLobby";
@@ -36,17 +37,17 @@ import {
generateCryptoRandomUUID,
incrementGamesPlayed,
isInIframe,
translateText,
} from "./Utils";
import "./components/NewsButton";
import { NewsButton } from "./components/NewsButton";
import "./components/baseComponents/Button";
import "./components/baseComponents/Modal";
import { discordLogin, getUserMe, isLoggedIn } from "./jwt";
import { getUserMe, isLoggedIn } from "./jwt";
import "./styles.css";
declare global {
interface Window {
enableAds: boolean;
PageOS: {
session: {
newPageView: () => void;
@@ -57,6 +58,7 @@ declare global {
destroyZone: (id: string) => void;
pageInit: (options?: any) => void;
que: Array<() => void>;
destroySticky: () => void;
};
ramp: {
que: Array<() => void>;
@@ -100,6 +102,7 @@ class Client {
private userSettings: UserSettings = new UserSettings();
private patternsModal: TerritoryPatternsModal;
private tokenLoginModal: TokenLoginModal;
private matchmakingModal: MatchmakingModal;
private gutterAds: GutterAds;
@@ -256,6 +259,16 @@ class Client {
console.warn("Token login modal element not found");
}
this.matchmakingModal = document.querySelector(
"matchmaking-modal",
) as MatchmakingModal;
if (
!this.matchmakingModal ||
!(this.matchmakingModal instanceof MatchmakingModal)
) {
console.warn("Matchmaking modal element not found");
}
const onUserMe = async (userMeResponse: UserMeResponse | false) => {
document.dispatchEvent(
new CustomEvent("userMeResponse", {
@@ -265,98 +278,12 @@ class Client {
}),
);
const config = await getServerConfigFromClient();
if (!hasAllowedFlare(userMeResponse, config)) {
if (userMeResponse === false) {
// Login is required
document.body.innerHTML = `
<div style="
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
font-family: sans-serif;
background-size: cover;
background-position: center;
">
<div style="
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 2em;
margin: 5em;
border-radius: 12px;
text-align: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
">
<p style="margin-bottom: 1em;">${translateText("auth.login_required")}</p>
<p style="margin-bottom: 1.5em;">${translateText("auth.redirecting")}</p>
<div style="width: 100%; height: 8px; background-color: #444; border-radius: 4px; overflow: hidden;">
<div style="
height: 100%;
width: 0%;
background-color: #4caf50;
animation: fillBar 5s linear forwards;
"></div>
</div>
</div>
</div>
<div class="bg-image"></div>
<style>
@keyframes fillBar {
from { width: 0%; }
to { width: 100%; }
}
</style>
`;
setTimeout(discordLogin, 5000);
} else {
// Unauthorized
document.body.innerHTML = `
<div style="
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
font-family: sans-serif;
background-size: cover;
background-position: center;
">
<div style="
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 2em;
margin: 5em;
border-radius: 12px;
text-align: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
">
<p style="margin-bottom: 1em;">${translateText("auth.not_authorized")}</p>
<p>${translateText("auth.contact_admin")}</p>
</div>
</div>
<div class="bg-image"></div>
`;
}
return;
} else if (userMeResponse === false) {
// Not logged in
this.patternsModal.onUserMe(null);
} else {
if (userMeResponse !== false) {
// Authorized
console.log(
`Your player ID is ${userMeResponse.player.publicId}\n` +
"Sharing this ID will allow others to view your game history and stats.",
);
this.patternsModal.onUserMe(userMeResponse);
const flares = (userMeResponse.player.flares ?? []).filter((flare) =>
flare.startsWith("pattern:"),
);
if (flares.length > 0) {
console.log("Hiding gutter ads because you have patterns");
this.gutterAds.hide();
}
}
};
@@ -598,6 +525,7 @@ class Client {
"flag-input-modal",
"account-button",
"token-login",
"matchmaking-modal",
].forEach((tag) => {
const modal = document.querySelector(tag) as HTMLElement & {
close?: () => void;
@@ -605,7 +533,7 @@ class Client {
};
if (modal?.close) {
modal.close();
} else if ("isModalOpen" in modal) {
} else if (modal && "isModalOpen" in modal) {
modal.isModalOpen = false;
}
});
@@ -628,12 +556,6 @@ class Client {
this.publicLobby.stop();
incrementGamesPlayed();
try {
window.PageOS.session.newPageView();
} catch (e) {
console.error("Error calling newPageView", e);
}
document.querySelectorAll(".ad").forEach((ad) => {
(ad as HTMLElement).style.display = "none";
});
@@ -675,7 +597,6 @@ class Client {
window.fusetag.pageInit({
blockingFuseIds: ["lhs_sticky_vrec", "rhs_sticky_vrec"],
});
this.gutterAds.show();
});
return true;
} else {
@@ -697,7 +618,7 @@ document.addEventListener("DOMContentLoaded", () => {
});
// WARNING: DO NOT EXPOSE THIS ID
function getPlayToken(): string {
export function getPlayToken(): string {
const result = isLoggedIn();
if (result !== false) return result.token;
return getPersistentIDFromCookie();
@@ -735,15 +656,3 @@ function getPersistentIDFromCookie(): string {
return newID;
}
function hasAllowedFlare(
userMeResponse: UserMeResponse | false,
config: ServerConfig,
) {
const allowed = config.allowedFlares();
if (allowed === undefined) return true;
if (userMeResponse === false) return false;
const flares = userMeResponse.player.flares;
if (flares === undefined) return false;
return allowed.length === 0 || allowed.some((f) => flares.includes(f));
}
+193
View File
@@ -0,0 +1,193 @@
import { html, LitElement } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { generateID } from "../core/Util";
import "./components/Difficulties";
import "./components/PatternButton";
import { getPlayToken, JoinLobbyEvent } from "./Main";
import { translateText } from "./Utils";
@customElement("matchmaking-modal")
export class MatchmakingModal extends LitElement {
private gameCheckInterval: ReturnType<typeof setInterval> | null = null;
private connected = false;
@state() private socket: WebSocket | null = null;
@state() private gameID: string | null = null;
@query("o-modal") private modalEl!: HTMLElement & {
open: () => void;
close: () => void;
};
constructor() {
super();
}
createRenderRoot() {
return this;
}
render() {
return html`
<o-modal
id="matchmaking-modal"
title="${translateText("matchmaking_modal.title")}"
>
${this.renderInner()}
</o-modal>
`;
}
private renderInner() {
if (!this.connected) {
return html`${translateText("matchmaking_modal.connecting")}`;
}
if (this.gameID === null) {
return html`${translateText("matchmaking_modal.searching")}`;
} else {
return html`${translateText("matchmaking_modal.waiting_for_game")}`;
}
}
private async connect() {
const config = await getServerConfigFromClient();
this.socket = new WebSocket(`${config.jwtIssuer()}/matchmaking/join`);
this.socket.onopen = () => {
console.log("Connected to matchmaking server");
setTimeout(() => {
// Set a delay so the user can see the "connecting" message,
// otherwise the "searching" message will be shown immediately.
this.connected = true;
this.requestUpdate();
}, 1000);
this.socket?.send(
JSON.stringify({
type: "auth",
playToken: getPlayToken(),
}),
);
};
this.socket.onmessage = (event) => {
console.log(event.data);
const data = JSON.parse(event.data);
if (data.type === "match-assignment") {
this.socket?.close();
console.log(`matchmaking: got game ID: ${data.gameId}`);
this.gameID = data.gameId;
}
};
this.socket.onerror = (event: ErrorEvent) => {
console.error("WebSocket error occurred:", event);
};
this.socket.onclose = (event) => {
console.log("Matchmaking server closed connection");
};
}
public close() {
this.connected = false;
this.socket?.close();
this.modalEl?.close();
if (this.gameCheckInterval) {
clearInterval(this.gameCheckInterval);
this.gameCheckInterval = null;
}
}
public async open() {
this.modalEl?.open();
this.requestUpdate();
this.connect();
this.gameCheckInterval = setInterval(() => this.checkGame(), 3000);
}
private async checkGame() {
if (this.gameID === null) {
return;
}
const config = await getServerConfigFromClient();
const url = `/${config.workerPath(this.gameID)}/api/game/${this.gameID}/exists`;
const response = await fetch(url, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
const gameInfo = await response.json();
if (response.status !== 200) {
console.error(`Error checking game ${this.gameID}: ${response.status}`);
return;
}
if (!gameInfo.exists) {
console.info(`Game ${this.gameID} does not exist or hasn't started yet`);
return;
}
if (this.gameCheckInterval) {
clearInterval(this.gameCheckInterval);
this.gameCheckInterval = null;
}
this.dispatchEvent(
new CustomEvent("join-lobby", {
detail: {
gameID: this.gameID,
clientID: generateID(),
} as JoinLobbyEvent,
bubbles: true,
composed: true,
}),
);
}
}
@customElement("matchmaking-button")
export class MatchmakingButton extends LitElement {
@query("matchmaking-modal") private matchmakingModal: MatchmakingModal;
@state() private matchmakingEnabled = false;
constructor() {
super();
}
async connectedCallback() {
super.connectedCallback();
const config = await getServerConfigFromClient();
this.matchmakingEnabled = config.enableMatchmaking();
}
createRenderRoot() {
return this;
}
render() {
if (!this.matchmakingEnabled) {
return html``;
}
return html`
<div class="z-[9999]">
<button
@click="${this.open}"
class="w-full h-16 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-2xl hover:shadow-3xl transition-all duration-200 flex items-center justify-center text-xl focus:outline-none focus:ring-4 focus:ring-blue-500 focus:ring-offset-4"
title="${translateText("matchmaking_modal.title")}"
>
Matchmaking
</button>
</div>
<matchmaking-modal></matchmaking-modal>
`;
}
private open() {
this.matchmakingModal?.open();
}
public close() {
this.matchmakingModal?.close();
this.requestUpdate();
}
}
+18 -4
View File
@@ -37,14 +37,24 @@ export class TerritoryPatternsModal extends LitElement {
private affiliateCode: string | null = null;
private userMeResponse: UserMeResponse | null = null;
private userMeResponse: UserMeResponse | false = false;
constructor() {
super();
}
async onUserMe(userMeResponse: UserMeResponse | null) {
if (userMeResponse === null) {
connectedCallback() {
super.connectedCallback();
document.addEventListener(
"userMeResponse",
(event: CustomEvent<UserMeResponse | false>) => {
this.onUserMe(event.detail);
},
);
}
async onUserMe(userMeResponse: UserMeResponse | false) {
if (userMeResponse === false) {
this.userSettings.setSelectedPatternName(undefined);
this.selectedPattern = null;
this.selectedColor = null;
@@ -136,7 +146,11 @@ export class TerritoryPatternsModal extends LitElement {
}
private renderColorSwatchGrid(): TemplateResult {
const hexCodes = (this.userMeResponse?.player.flares ?? [])
const hexCodes = (
this.userMeResponse === false
? []
: (this.userMeResponse.player.flares ?? [])
)
.filter((flare) => flare.startsWith("color:"))
.map((flare) => "#" + flare.split(":")[1]);
return html`
+15
View File
@@ -132,6 +132,10 @@ export class SendEmbargoIntentEvent implements GameEvent {
) {}
}
export class SendEmbargoAllIntentEvent implements GameEvent {
constructor(public readonly action: "start" | "stop") {}
}
export class SendDeleteUnitIntentEvent implements GameEvent {
constructor(public readonly unitId: number) {}
}
@@ -226,6 +230,9 @@ export class Transport {
this.eventBus.on(SendEmbargoIntentEvent, (e) =>
this.onSendEmbargoIntent(e),
);
this.eventBus.on(SendEmbargoAllIntentEvent, (e) =>
this.onSendEmbargoAllIntent(e),
);
this.eventBus.on(BuildUnitIntentEvent, (e) => this.onBuildUnitIntent(e));
this.eventBus.on(PauseGameEvent, (e) => this.onPauseGameEvent(e));
@@ -528,6 +535,14 @@ export class Transport {
});
}
private onSendEmbargoAllIntent(event: SendEmbargoAllIntentEvent) {
this.sendIntent({
type: "embargo_all",
clientID: this.lobbyConfig.clientID,
action: event.action,
});
}
private onBuildUnitIntent(event: BuildUnitIntentEvent) {
this.sendIntent({
type: "build_unit",
+2 -2
View File
@@ -523,8 +523,8 @@ export class UserSettingModal extends LitElement {
<setting-keybind
action="buildMIRV"
label=${translateText("user_setting.build_MIRV")}
description=${translateText("user_setting.build_MIRV_desc")}
label=${translateText("user_setting.build_mirv")}
description=${translateText("user_setting.build_mirv_desc")}
defaultKey="Digit0"
.value=${this.keybinds["buildMIRV"]?.key ?? ""}
@change=${this.handleKeybindChange}
+1
View File
@@ -36,6 +36,7 @@ export const MapDescription: Record<keyof typeof GameMapType, string> = {
Yenisei: "Yenisei",
Pluto: "Pluto",
Montreal: "Montreal",
Achiran: "Achiran",
};
@customElement("map-display")
-1
View File
@@ -137,7 +137,6 @@ export function renderPatternPreview(
width: number,
height: number,
): TemplateResult {
console.log("renderPatternPreview", pattern);
if (pattern === null) {
return renderBlankPreview(width, height);
}
@@ -23,12 +23,19 @@ export class SettingKeybind extends LitElement {
<div class="setting-label-group">
<label class="setting-label block mb-1">${this.label}</label>
<div class="setting-keybind-box">
<div class="setting-keybind-description">${this.description}</div>
<div class="setting-keybind-box flex flex-wrap items-start gap-2">
<div
class="setting-keybind-description flex-1 min-w-[240px] max-w-full whitespace-normal break-words text-sm text-gray-300"
style="word-break: break-word;"
>
${this.description}
</div>
<div class="flex items-center gap-2">
<div
class="flex flex-wrap items-center gap-2 gap-y-1 basis-full sm:basis-auto min-w-0"
>
<span
class="setting-key"
class="setting-key shrink-0"
tabindex="0"
@keydown=${this.handleKeydown}
@click=${this.startListening}
@@ -37,13 +44,13 @@ export class SettingKeybind extends LitElement {
</span>
<button
class="text-xs text-gray-400 hover:text-white border border-gray-500 px-2 py-0.5 rounded transition"
class="text-xs text-gray-400 hover:text-white border border-gray-500 px-2 py-0.5 rounded transition whitespace-normal break-words max-w-full"
@click=${this.resetToDefault}
>
${translateText("user_setting.reset")}
</button>
<button
class="text-xs text-gray-400 hover:text-white border border-gray-500 px-2 py-0.5 rounded transition"
class="text-xs text-gray-400 hover:text-white border border-gray-500 px-2 py-0.5 rounded transition whitespace-normal break-words max-w-full"
@click=${this.unbindKey}
>
${translateText("user_setting.unbind")}
@@ -1,4 +1,5 @@
import miniBigSmoke from "../../../resources/sprites/bigsmoke.png";
import buildingExplosion from "../../../resources/sprites/buildingExplosion.png";
import conquestSword from "../../../resources/sprites/conquestSword.png";
import dust from "../../../resources/sprites/dust.png";
import miniExplosion from "../../../resources/sprites/miniExplosion.png";
@@ -89,6 +90,15 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
originX: 9,
originY: 9,
},
[FxType.BuildingExplosion]: {
url: buildingExplosion,
frameWidth: 17,
frameCount: 10,
frameDuration: 70,
looping: false,
originX: 8,
originY: 8,
},
[FxType.SinkingShip]: {
url: sinkingShip,
frameWidth: 16,
+10 -9
View File
@@ -5,6 +5,7 @@ import { GameStartingModal } from "../GameStartingModal";
import { RefreshGraphicsEvent as RedrawGraphicsEvent } from "../InputHandler";
import { TransformHandler } from "./TransformHandler";
import { UIState } from "./UIState";
import { AdTimer } from "./layers/AdTimer";
import { AlertFrame } from "./layers/AlertFrame";
import { BuildMenu } from "./layers/BuildMenu";
import { ChatDisplay } from "./layers/ChatDisplay";
@@ -27,7 +28,6 @@ import { PlayerPanel } from "./layers/PlayerPanel";
import { RailroadLayer } from "./layers/RailroadLayer";
import { ReplayPanel } from "./layers/ReplayPanel";
import { SettingsModal } from "./layers/SettingsModal";
import { SpawnAd } from "./layers/SpawnAd";
import { SpawnTimer } from "./layers/SpawnTimer";
import { StructureIconsLayer } from "./layers/StructureIconsLayer";
import { StructureLayer } from "./layers/StructureLayer";
@@ -209,18 +209,19 @@ export function createRenderer(
fpsDisplay.eventBus = eventBus;
fpsDisplay.userSettings = userSettings;
const spawnAd = document.querySelector("spawn-ad") as SpawnAd;
if (!(spawnAd instanceof SpawnAd)) {
console.error("spawn ad not found");
}
spawnAd.g = game;
const alertFrame = document.querySelector("alert-frame") as AlertFrame;
if (!(alertFrame instanceof AlertFrame)) {
console.error("alert frame not found");
}
alertFrame.game = game;
const spawnTimer = document.querySelector("spawn-timer") as SpawnTimer;
if (!(spawnTimer instanceof SpawnTimer)) {
console.error("spawn timer not found");
}
spawnTimer.game = game;
spawnTimer.transformHandler = transformHandler;
// When updating these layers please be mindful of the order.
// Try to group layers by the return value of shouldTransform.
// Not grouping the layers may cause excessive calls to context.save() and context.restore().
@@ -246,7 +247,7 @@ export function createRenderer(
uiState,
playerPanel,
),
new SpawnTimer(game, transformHandler),
spawnTimer,
leaderboard,
gameLeftSidebar,
unitDisplay,
@@ -260,7 +261,7 @@ export function createRenderer(
playerPanel,
headsUpMessage,
multiTabModal,
spawnAd,
new AdTimer(game),
alertFrame,
fpsDisplay,
];
+1
View File
@@ -9,6 +9,7 @@ export enum FxType {
MiniSmokeAndFire = "MiniSmokeAndFire",
MiniExplosion = "MiniExplosion",
UnitExplosion = "UnitExplosion",
BuildingExplosion = "BuildingExplosion",
SinkingShip = "SinkingShip",
Nuke = "Nuke",
SAMExplosion = "SAMExplosion",
+28
View File
@@ -0,0 +1,28 @@
import { GameView } from "../../../core/game/GameView";
import { Layer } from "./Layer";
const AD_SHOW_TICKS = 60 * 10; // 1 minute
export class AdTimer implements Layer {
private isHidden: boolean = false;
constructor(private g: GameView) {}
init() {}
public async tick() {
if (this.isHidden) {
return;
}
const gameTicks = this.g.ticks() - this.g.config().numSpawnPhaseTurns();
if (gameTicks > AD_SHOW_TICKS) {
console.log("destroying sticky ads");
window.fusetag?.que?.push(() => {
window.fusetag?.destroySticky?.();
});
this.isHidden = true;
return;
}
}
}
+22
View File
@@ -151,6 +151,14 @@ export class FxLayer implements Layer {
case UnitType.Train:
this.onTrainEvent(unit);
break;
case UnitType.DefensePost:
case UnitType.City:
case UnitType.Port:
case UnitType.MissileSilo:
case UnitType.SAMLauncher:
case UnitType.Factory:
this.onStructureEvent(unit);
break;
}
}
@@ -246,6 +254,20 @@ export class FxLayer implements Layer {
}
}
onStructureEvent(unit: UnitView) {
if (!unit.isActive()) {
const x = this.game.x(unit.lastTile());
const y = this.game.y(unit.lastTile());
const explosion = new SpriteFx(
this.animatedSpriteLoader,
x,
y,
FxType.BuildingExplosion,
);
this.allFx.push(explosion);
}
}
onNukeEvent(unit: UnitView, radius: number) {
if (!unit.isActive()) {
if (!unit.reachedTarget()) {
+4 -1
View File
@@ -14,6 +14,7 @@ interface Entry {
gold: string;
troops: string;
isMyPlayer: boolean;
isOnSameTeam: boolean;
player: PlayerView;
}
@@ -115,6 +116,7 @@ export class Leaderboard extends LitElement implements Layer {
gold: renderNumber(player.gold()),
troops: renderNumber(troops),
isMyPlayer: player === myPlayer,
isOnSameTeam: player === myPlayer || player.isOnSameTeam(myPlayer!),
player: player,
};
});
@@ -143,6 +145,7 @@ export class Leaderboard extends LitElement implements Layer {
gold: renderNumber(myPlayer.gold()),
troops: renderNumber(myPlayerTroops),
isMyPlayer: true,
isOnSameTeam: true,
player: myPlayer,
});
}
@@ -225,7 +228,7 @@ export class Leaderboard extends LitElement implements Layer {
(p) => p.player.id(),
(player) => html`
<div
class="contents hover:bg-slate-600/60 ${player.isMyPlayer
class="contents hover:bg-slate-600/60 ${player.isOnSameTeam
? "font-bold"
: ""} cursor-pointer"
@click=${() => this.handleRowClickPlayer(player.player)}
+170 -117
View File
@@ -28,6 +28,7 @@ import { CloseViewEvent, MouseUpEvent } from "../../InputHandler";
import {
SendAllianceRequestIntentEvent,
SendBreakAllianceIntentEvent,
SendEmbargoAllIntentEvent,
SendEmbargoIntentEvent,
SendEmojiIntentEvent,
SendTargetPlayerIntentEvent,
@@ -193,6 +194,7 @@ export class PlayerPanel extends LitElement implements Layer {
private closeSend = () => {
this.sendTarget = null;
this.sendMode = "none";
};
private confirmSend = (
@@ -222,6 +224,16 @@ export class PlayerPanel extends LitElement implements Layer {
this.hide();
}
private onStopTradingAllClick(e: Event) {
e.stopPropagation();
this.eventBus.emit(new SendEmbargoAllIntentEvent("start"));
}
private onStartTradingAllClick(e: Event) {
e.stopPropagation();
this.eventBus.emit(new SendEmbargoAllIntentEvent("stop"));
}
private handleEmojiClick(e: Event, myPlayer: PlayerView, other: PlayerView) {
e.stopPropagation();
this.emojiTable.showTable((emoji: string) => {
@@ -418,10 +430,15 @@ export class PlayerPanel extends LitElement implements Layer {
}}
/>`
: ""}
<h1 class="text-2xl font-bold tracking-[-0.01em] truncate text-zinc-50">
${other.name()}
</h1>
<div class="flex-1 min-w-0">
<h2
class="text-xl font-bold tracking-[-0.01em] text-zinc-50 truncate"
title=${other.name()}
>
${other.name()}
</h2>
</div>
${chip
? html`<span
class=${`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-semibold ${chip.classes}`}
@@ -445,28 +462,28 @@ export class PlayerPanel extends LitElement implements Layer {
return html`
<div class="mb-1 flex justify-between gap-2">
<div
class="inline-flex items-center gap-1.5 rounded-full bg-white/[0.04] px-2.5 py-1
text-base font-semibold text-zinc-200"
class="inline-flex items-center gap-1.5 rounded-lg bg-white/[0.04] px-3 py-1.5
text-white w-[140px] min-w-[140px] flex-shrink-0"
>
<span class="mr-0.5">💰</span>
<span translate="no" class="inline-block w-[45px] text-right">
<span translate="no" class="tabular-nums w-[5ch]font-semibold">
${renderNumber(other.gold() || 0)}
</span>
<span class="opacity-95 whitespace-nowrap"
>${translateText("player_panel.gold")}</span
<span class="text-zinc-200 whitespace-nowrap">
${translateText("player_panel.gold")}</span
>
</div>
<div
class="inline-flex items-center gap-1.5 rounded-full bg-white/[0.04] px-2.5 py-1
text-base font-semibold text-zinc-200"
class="inline-flex items-center gap-1.5 rounded-lg bg-white/[0.04] px-3 py-1.5
text-white w-[140px] min-w-[140px] flex-shrink-0"
>
<span class="mr-0.5">🛡️</span>
<span translate="no" class="inline-block w-[45px] text-right">
<span translate="no" class="tabular-nums w-[5ch] font-semibold">
${renderTroops(other.troops() || 0)}
</span>
<span class="opacity-95 whitespace-nowrap"
>${translateText("player_panel.troops")}</span
<span class="text-zinc-200 whitespace-nowrap">
${translateText("player_panel.troops")}</span
>
</div>
</div>
@@ -476,32 +493,34 @@ export class PlayerPanel extends LitElement implements Layer {
private renderStats(other: PlayerView, my: PlayerView) {
return html`
<!-- Betrayals -->
<div class="grid grid-cols-[auto,1fr] gap-x-6 gap-y-2 text-base">
<div class="grid grid-cols-[auto,1fr] gap-x-6 gap-y-2">
<div
class="flex items-center gap-2 font-semibold text-zinc-300 leading-snug"
class="flex items-center gap-2 text-[15px] font-medium text-zinc-100 leading-snug"
>
<span aria-hidden="true">⚠️</span>
<span>${translateText("player_panel.betrayals")}</span>
</div>
<div class="text-right font-semibold text-zinc-200">
<div class="text-right text-[14px] font-semibold text-zinc-200">
${other.data.betrayals ?? 0}
</div>
</div>
<!-- Trading / Embargo -->
<div class="grid grid-cols-[auto,1fr] gap-x-6 gap-y-2 text-base">
<div class="grid grid-cols-[auto,1fr] gap-x-6 gap-y-2">
<div
class="flex items-center gap-2 font-semibold text-zinc-300 leading-snug"
class="flex items-center gap-2 text-[15px] font-medium text-zinc-100 leading-snug"
>
<span aria-hidden="true">⚓</span>
<span>${translateText("player_panel.trading")}</span>
</div>
<div class="flex items-center justify-end gap-2 font-semibold">
<div
class="flex items-center justify-end gap-2 text-[14px] font-semibold"
>
${other.hasEmbargoAgainst(my)
? html`<span class="text-[#f59e0b]"
? html`<span class="text-amber-400"
>${translateText("player_panel.stopped")}</span
>`
: html`<span class="text-emerald-400"
: html`<span class="text-blue-400"
>${translateText("player_panel.active")}</span
>`}
</div>
@@ -512,60 +531,57 @@ export class PlayerPanel extends LitElement implements Layer {
private renderAlliances(other: PlayerView) {
const allies = other.allies();
const nameCollator = new Intl.Collator(undefined, { sensitivity: "base" });
const alliesSorted = [...allies].sort((a, b) =>
nameCollator.compare(a.name(), b.name()),
);
return html`
<div class="text-base select-none">
<!-- Header -->
<div class="select-none">
<div class="flex items-center justify-between mb-2">
<div class="font-semibold text-zinc-300 text-base">
<div
id="alliances-title"
class="text-[15px] font-medium text-zinc-200"
>
${translateText("player_panel.alliances")}
</div>
<span
aria-label="Alliance count"
class="inline-flex items-center justify-center min-w-[20px] h-5 px-[6px] rounded-[10px]
text-[12px] text-zinc-100 bg-white/10 border border-white/20"
aria-labelledby="alliances-title"
class="inline-flex items-center justify-center min-w-[20px] h-5 px-[6px] rounded-[10px]
text-[12px] text-zinc-100 bg-white/10 border border-white/20"
>
${allies.length}
</span>
</div>
<div class="mt-1 rounded-lg border border-zinc-600 bg-zinc-800/80">
<div
class="max-h-[72px] overflow-y-auto p-2 text-zinc-200 text-[12.5px] leading-relaxed"
<div
class="rounded-lg bg-zinc-800/70 ring-1 ring-zinc-700/60 w-full min-w-0"
>
<ul
class="max-h-[120px] overflow-y-auto p-2
flex flex-wrap gap-1.5
scrollbar-thin scrollbar-thumb-zinc-600 hover:scrollbar-thumb-zinc-500 scrollbar-track-zinc-800"
role="list"
aria-label="Alliance list"
aria-labelledby="alliances-title"
translate="no"
>
${allies.length > 0
? allies.map((p) => {
const color = p.territoryColor().toHex();
return html`
<div
role="listitem"
class="grid grid-cols-[16px_1fr] items-center gap-2 w-full h-[30px]
px-2 rounded-lg border border-transparent text-left
hover:bg-[#141821] hover:border-white/30 transition-colors"
${alliesSorted.length === 0
? html`<li class="text-zinc-400 text-[14px] px-1">
${translateText("common.none")}
</li>`
: alliesSorted.map(
(p) =>
html`<li
class="max-w-full inline-flex items-center gap-1.5
rounded-md border border-white/10 bg-white/[0.05]
px-2.5 py-1 text-[14px] text-zinc-100
hover:bg-white/[0.08] active:scale-[0.99] transition"
title=${p.name()}
>
<span
class="inline-block w-3 h-3 rounded-full mr-2"
style="background-color: ${color}"
>
</span>
<span
class="truncate select-none pointer-events-none font-medium"
>
${p.name()}
</span>
</div>
`;
})
: html`
<div class="py-2 text-zinc-300">
${translateText("common.none")}
</div>
`}
</div>
<span class="truncate">${p.name()}</span>
</li>`,
)}
</ul>
</div>
</div>
`;
@@ -580,7 +596,7 @@ export class PlayerPanel extends LitElement implements Layer {
</div>
<div class="text-right font-semibold">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-base font-bold ${this.getExpiryColorClass(
class="inline-flex items-center rounded-full px-2 py-0.5 text-[14px] font-bold ${this.getExpiryColorClass(
this.allianceExpirySeconds,
)}"
>${this.allianceExpiryText}</span
@@ -605,7 +621,7 @@ export class PlayerPanel extends LitElement implements Layer {
const canEmbargo = this.actions?.interaction?.canEmbargo;
return html`
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-2.5">
<div class="grid auto-cols-fr grid-flow-col gap-1">
${actionButton({
onClick: (e: MouseEvent) => this.handleChat(e, my, other),
@@ -657,6 +673,7 @@ export class PlayerPanel extends LitElement implements Layer {
})
: ""}
</div>
<ui-divider></ui-divider>
<div class="grid auto-cols-fr grid-flow-col gap-1">
${other !== my
@@ -703,6 +720,37 @@ export class PlayerPanel extends LitElement implements Layer {
})
: ""}
</div>
${other === my
? html`<div class="grid auto-cols-fr grid-flow-col gap-1">
${actionButton({
onClick: (e: MouseEvent) => this.onStopTradingAllClick(e),
icon: stopTradingIcon,
iconAlt: "Stop Trading With All",
title: !this.actions?.canEmbargoAll
? `${translateText("player_panel.stop_trade_all")} - ${translateText("cooldown")}`
: translateText("player_panel.stop_trade_all"),
label: !this.actions?.canEmbargoAll
? `${translateText("player_panel.stop_trade_all")}`
: translateText("player_panel.stop_trade_all"),
type: "yellow",
disabled: !this.actions?.canEmbargoAll,
})}
${actionButton({
onClick: (e: MouseEvent) => this.onStartTradingAllClick(e),
icon: startTradingIcon,
iconAlt: "Start Trading With All",
title: !this.actions?.canEmbargoAll
? `${translateText("player_panel.start_trade_all")} - ${translateText("cooldown")}`
: translateText("player_panel.start_trade_all"),
label: !this.actions?.canEmbargoAll
? `${translateText("player_panel.start_trade_all")}`
: translateText("player_panel.start_trade_all"),
type: "green",
disabled: !this.actions?.canEmbargoAll,
})}
</div>`
: ""}
</div>
`;
}
@@ -754,80 +802,85 @@ export class PlayerPanel extends LitElement implements Layer {
<div
class="fixed inset-0 z-[1001] flex items-center justify-center overflow-auto
bg-black/15 backdrop-blur-sm backdrop-brightness-110 pointer-events-auto"
bg-black/15 backdrop-brightness-110 pointer-events-auto"
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
@wheel=${(e: MouseEvent) => e.stopPropagation()}
@click=${() => this.hide()}
>
<div
class="pointer-events-auto max-h-[90vh] overflow-y-auto min-w-[240px] w-auto px-4 py-2"
class="pointer-events-auto max-h-[90vh] min-w-[300px] max-w-[400px] px-4 py-2"
@click=${(e: MouseEvent) => e.stopPropagation()}
>
<div
class=${`relative mt-2 w-full bg-zinc-900/90 backdrop-blur-sm p-5 shadow-2xl rounded-xl text-zinc-200
${other.isTraitor() ? "traitor-ring" : "ring-1 ring-zinc-700"}`}
>
<!-- Close button -->
<button
@click=${this.handleClose}
class="absolute -top-3 -right-3 flex h-7 w-7 items-center justify-center
rounded-full bg-zinc-700 text-white shadow hover:bg-red-500 transition-colors"
aria-label=${translateText("common.close") || "Close"}
title=${translateText("common.close") || "Close"}
>
</button>
<div class="relative">
<div
class="flex flex-col gap-2 font-sans antialiased text-[14px] leading-relaxed"
class="absolute inset-2 -z-10 rounded-2xl bg-black/25 backdrop-blur-[2px]"
></div>
<div
class=${`relative w-full bg-zinc-900/95 p-6 rounded-2xl text-zinc-100 overflow-visible shadow-2xl shadow-black/50
${other.isTraitor() ? "traitor-ring" : "ring-1 ring-white/5"}`}
>
<!-- Identity (flag, name, type, traitor, relation) -->
<div class="mb-1">${this.renderIdentityRow(other, my)}</div>
<!-- Close button -->
<button
@click=${this.handleClose}
class="absolute -top-3 -right-3 flex h-7 w-7 items-center justify-center
rounded-full bg-zinc-700 text-white shadow hover:bg-red-500 transition-colors"
aria-label=${translateText("common.close") || "Close"}
title=${translateText("common.close") || "Close"}
>
</button>
${this.sendTarget
? html`
<send-resource-modal
.open=${this.sendMode !== "none"}
.mode=${this.sendMode}
.total=${this.sendMode === "troops"
? myTroopsNum
: myGoldNum}
.uiState=${this.uiState}
.myPlayer=${my}
.target=${this.sendTarget}
.gameView=${this.g}
.eventBus=${this.eventBus}
.format=${this.sendMode === "troops"
? renderTroops
: renderNumber}
@confirm=${this.confirmSend}
@close=${this.closeSend}
></send-resource-modal>
`
: ""}
<div
class="flex flex-col gap-2 font-sans antialiased text-[14.5px] leading-relaxed"
>
<!-- Identity (flag, name, type, traitor, relation) -->
<div class="mb-1">${this.renderIdentityRow(other, my)}</div>
<ui-divider></ui-divider>
${this.sendTarget
? html`
<send-resource-modal
.open=${this.sendMode !== "none"}
.mode=${this.sendMode}
.total=${this.sendMode === "troops"
? myTroopsNum
: myGoldNum}
.uiState=${this.uiState}
.myPlayer=${my}
.target=${this.sendTarget}
.gameView=${this.g}
.eventBus=${this.eventBus}
.format=${this.sendMode === "troops"
? renderTroops
: renderNumber}
@confirm=${this.confirmSend}
@close=${this.closeSend}
></send-resource-modal>
`
: ""}
<!-- Resources -->
${this.renderResources(other)}
<ui-divider></ui-divider>
<ui-divider></ui-divider>
<!-- Resources -->
${this.renderResources(other)}
<!-- Stats: betrayals / trading -->
${this.renderStats(other, my)}
<ui-divider></ui-divider>
<ui-divider></ui-divider>
<!-- Stats: betrayals / trading -->
${this.renderStats(other, my)}
<!-- Alliances list -->
${this.renderAlliances(other)}
<ui-divider></ui-divider>
<!-- Alliance time remaining -->
${this.renderAllianceExpiry()}
<!-- Alliances list -->
${this.renderAlliances(other)}
<ui-divider></ui-divider>
<!-- Alliance time remaining -->
${this.renderAllianceExpiry()}
<!-- Actions -->
${this.renderActions(my, other)}
<ui-divider class="mt-1"></ui-divider>
<!-- Actions -->
${this.renderActions(my, other)}
</div>
</div>
</div>
</div>
+25 -4
View File
@@ -149,10 +149,6 @@ export class RadialMenu implements Layer {
.style("position", "absolute")
.style("top", "50%")
.style("left", "50%")
.style(
"transition",
`top ${this.config.menuTransitionDuration}ms ease, left ${this.config.menuTransitionDuration}ms ease`,
)
.style("transform", "translate(-50%, -50%)")
.style("pointer-events", "all")
.on("click", (event) => this.hideRadialMenu());
@@ -554,6 +550,20 @@ export class RadialMenu implements Layer {
.attr("x", arc.centroid(d)[0] - this.config.iconSize / 2)
.attr("y", arc.centroid(d)[1] - this.config.iconSize / 2)
.attr("opacity", disabled ? 0.5 : 1);
if (this.params && d.data.cooldown?.(this.params)) {
const cooldown = Math.ceil(d.data.cooldown?.(this.params));
content
.append("text")
.attr("class", `cooldown-text`)
.text(cooldown + "s")
.attr("fill", "white")
.attr("opacity", disabled ? 0.5 : 1)
.attr("font-size", "14px")
.attr("font-weight", "bold")
.attr("x", arc.centroid(d)[0] - this.config.iconSize / 4)
.attr("y", arc.centroid(d)[1] + this.config.iconSize / 2 + 7);
}
}
this.menuIcons.set(contentId, content as any);
@@ -998,6 +1008,17 @@ export class RadialMenu implements Layer {
if (!imageElement.empty()) {
imageElement.attr("opacity", disabled ? 0.5 : 1);
}
// Update cooldown text if applicable
const cooldownElement = icon.select(".cooldown-text");
if (this.params && !cooldownElement.empty() && item.cooldown) {
const cooldown = Math.ceil(item.cooldown(this.params));
if (cooldown <= 0) {
cooldownElement.remove();
} else {
cooldownElement.text(cooldown + "s");
}
}
}
}
});
@@ -51,6 +51,7 @@ export interface MenuElement {
tooltipItems?: TooltipItem[];
tooltipKeys?: TooltipKey[];
cooldown?: (params: MenuElementParams) => number;
disabled: (params: MenuElementParams) => boolean;
action?: (params: MenuElementParams) => void; // For leaf items that perform actions
subMenu?: (params: MenuElementParams) => MenuElement[]; // For non-leaf items that open submenus
@@ -425,6 +426,7 @@ export const attackMenuElement: MenuElement = {
export const deleteUnitElement: MenuElement = {
id: Slot.Delete,
name: "delete",
cooldown: (params: MenuElementParams) => params.myPlayer.deleteUnitCooldown(),
disabled: (params: MenuElementParams) => {
const tileOwner = params.game.owner(params.tile);
const isLand = params.game.isLand(params.tile);
@@ -441,7 +443,7 @@ export const deleteUnitElement: MenuElement = {
return true;
}
if (!params.myPlayer.canDeleteUnit()) {
if (params.myPlayer.deleteUnitCooldown() > 0) {
return true;
}
@@ -450,8 +452,10 @@ export const deleteUnitElement: MenuElement = {
.units()
.filter(
(unit) =>
unit.constructionType() === undefined &&
unit.markedForDeletion() === false &&
params.game.manhattanDist(unit.tile(), params.tile) <=
DELETE_SELECTION_RADIUS,
DELETE_SELECTION_RADIUS,
);
return myUnits.length === 0;
+10 -21
View File
@@ -244,19 +244,22 @@ export class SendResourceModal extends LitElement {
private renderHeader() {
const name = this.target?.name?.() ?? "";
return html`
<div class="mb-3 flex items-center justify-between">
<div class="mb-3 flex items-center justify-between relative">
<h2
id="send-title"
class="text-lg font-semibold tracking-tight text-zinc-100"
>
${this.heading ?? this.i18n.title(name)}
</h2>
<!-- Close button -->
<button
class="rounded-md px-2 text-2xl leading-none text-zinc-300 hover:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-white/30"
type="button"
@click=${() => this.closeModal()}
class="absolute -top-3 -right-3 flex h-7 w-7 items-center justify-center rounded-full bg-zinc-700 text-white shadow hover:bg-red-500 transition-colors focus-visible:ring-2 focus-visible:ring-white/30 focus:outline-none"
aria-label=${this.i18n.closeLabel()}
title=${this.i18n.closeLabel()}
>
×
</button>
</div>
`;
@@ -264,7 +267,6 @@ export class SendResourceModal extends LitElement {
private renderAvailable() {
const total = this.getTotalNumber();
const cap = this.getCapacityLeft();
return html`
<div class="mb-4 pb-3 border-b border-zinc-800">
@@ -277,21 +279,6 @@ export class SendResourceModal extends LitElement {
<span class="opacity-90">${this.i18n.availableChip()}</span>
<span class="font-mono tabular-nums">${this.format(total)}</span>
</span>
${cap !== null
? html`
<!-- Cap -->
<span
class="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2 py-0.5 ring-1 ring-amber-400/40 text-amber-200"
title=${this.i18n.capTooltip()}
>
<span class="opacity-90">${this.i18n.cap()}</span>
<span class="font-mono tabular-nums"
>${this.format(cap)}</span
>
</span>
`
: html``}
</div>
</div>
`;
@@ -554,9 +541,11 @@ export class SendResourceModal extends LitElement {
const allowed = this.limitAmount(this.sendAmount);
return html`
<div class="fixed inset-0 z-[1100] flex items-center justify-center p-4">
<div
class="absolute inset-0 z-[1100] flex items-center justify-center p-4"
>
<div
class="absolute inset-0 bg-black/60 backdrop-blur-sm rounded-2xl"
class="absolute inset-0 bg-black/60 rounded-2xl"
@click=${() => this.closeModal()}
></div>
-135
View File
@@ -1,135 +0,0 @@
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { translateText } from "../../../client/Utils";
import { GameView } from "../../../core/game/GameView";
import { getGamesPlayed } from "../../Utils";
import { Layer } from "./Layer";
const AD_TYPE = "bottom_rail";
const AD_CONTAINER_ID = "bottom-rail-ad-container";
@customElement("spawn-ad")
export class SpawnAd extends LitElement implements Layer {
public g: GameView;
@state()
private isVisible: boolean = false;
@state()
private adLoaded: boolean = false;
private gamesPlayed: number = 0;
// Override createRenderRoot to disable shadow DOM
createRenderRoot() {
return this;
}
static styles = css``;
constructor() {
super();
}
init() {
this.gamesPlayed = getGamesPlayed();
}
public show(): void {
this.isVisible = true;
this.loadAd();
this.requestUpdate();
}
public hide(): void {
// Destroy the ad when hiding
this.destroyAd();
this.isVisible = false;
this.adLoaded = false;
this.requestUpdate();
}
public async tick() {
if (
!this.isVisible &&
this.g.inSpawnPhase() &&
this.g.ticks() > 10 &&
this.gamesPlayed > 5
) {
console.log("not showing spawn ad");
// this.show();
}
if (this.isVisible && !this.g.inSpawnPhase()) {
console.log("hiding bottom left ad");
this.hide();
}
}
private loadAd(): void {
if (!window.ramp) {
console.warn("Playwire RAMP not available");
return;
}
if (this.adLoaded) {
console.log("Ad already loaded, skipping");
return;
}
try {
window.ramp.que.push(() => {
window.ramp.spaAddAds([
{
type: AD_TYPE,
selectorId: AD_CONTAINER_ID,
},
]);
this.adLoaded = true;
console.log("Playwire ad loaded:", AD_TYPE);
});
} catch (error) {
console.error("Failed to load Playwire ad:", error);
}
}
private destroyAd(): void {
if (!window.ramp || !this.adLoaded) {
return;
}
try {
window.ramp.que.push(() => {
window.ramp.destroyUnits("all");
console.log("Playwire spawn ad destroyed");
});
} catch (error) {
console.error("Failed to destroy Playwire ad:", error);
}
}
disconnectedCallback() {
super.disconnectedCallback();
// Clean up ad when component is removed
this.destroyAd();
}
render() {
if (!this.isVisible) {
return html``;
}
return html`
<div
class="fixed bottom-0 left-0 w-full min-h-[100px] bg-gray-900 border border-gray-600 flex items-center justify-center z-50"
>
<div
id="${AD_CONTAINER_ID}"
class="w-full h-full flex items-center justify-center"
>
${!this.adLoaded
? html`<span class="text-white text-sm"
>${translateText("spawn_ad.loading")}</span
>`
: ""}
</div>
</div>
`;
}
}
+50 -24
View File
@@ -1,18 +1,34 @@
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import { GameMode, Team } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
export class SpawnTimer implements Layer {
@customElement("spawn-timer")
export class SpawnTimer extends LitElement implements Layer {
public game: GameView;
public transformHandler: TransformHandler;
private ratios = [0];
private colors = ["rgba(0, 128, 255, 0.7)", "rgba(0, 0, 0, 0.5)"];
constructor(
private game: GameView,
private transformHandler: TransformHandler,
) {}
private isVisible = false;
init() {}
createRenderRoot() {
this.style.position = "fixed";
this.style.top = "0";
this.style.left = "0";
this.style.width = "100%";
this.style.height = "7px";
this.style.zIndex = "1000";
this.style.pointerEvents = "none";
return this;
}
init() {
this.isVisible = true;
}
tick() {
if (this.game.inSpawnPhase()) {
@@ -21,6 +37,7 @@ export class SpawnTimer implements Layer {
this.game.ticks() / this.game.config().numSpawnPhaseTurns(),
];
this.colors = ["rgba(0, 128, 255, 0.7)"];
this.requestUpdate();
return;
}
@@ -28,6 +45,7 @@ export class SpawnTimer implements Layer {
this.colors = [];
if (this.game.config().gameConfig().gameMode !== GameMode.Team) {
this.requestUpdate();
return;
}
@@ -41,44 +59,52 @@ export class SpawnTimer implements Layer {
const theme = this.game.config().theme();
const total = sumIterator(teamTiles.values());
if (total === 0) return;
if (total === 0) {
this.requestUpdate();
return;
}
for (const [team, count] of teamTiles) {
const ratio = count / total;
this.ratios.push(ratio);
this.colors.push(theme.teamColor(team).toRgbString());
}
this.requestUpdate();
}
shouldTransform(): boolean {
return false;
}
renderLayer(context: CanvasRenderingContext2D) {
if (this.ratios.length === 0 || this.colors.length === 0) return;
render() {
if (!this.isVisible) {
return html``;
}
const barHeight = 10;
const barWidth = this.transformHandler.width();
if (this.ratios.length === 0 || this.colors.length === 0) {
return html``;
}
if (
!this.game.inSpawnPhase() &&
this.game.config().gameConfig().gameMode !== GameMode.Team
) {
return;
return html``;
}
let x = 0;
let filledRatio = 0;
for (let i = 0; i < this.ratios.length && i < this.colors.length; i++) {
const ratio = this.ratios[i] ?? 1 - filledRatio;
const segmentWidth = barWidth * ratio;
context.fillStyle = this.colors[i];
context.fillRect(x, 0, segmentWidth, barHeight);
x += segmentWidth;
filledRatio += ratio;
}
return html`
<div class="w-full h-full flex z-[999]">
${this.ratios.map((ratio, i) => {
const color = this.colors[i] || "rgba(0, 0, 0, 0.5)";
return html`
<div
class="h-full transition-all duration-100 ease-in-out"
style="width: ${ratio * 100}%; background-color: ${color};"
></div>
`;
})}
</div>
`;
}
}
@@ -99,10 +99,7 @@ export class SpriteFactory {
private invalidateTextureCache(unitType: UnitType) {
for (const key of Array.from(this.textureCache.keys())) {
if (
key.endsWith(`-${unitType}-icon`) ||
key === `construction-${unitType}-icon`
) {
if (key.includes(`-${unitType}`)) {
this.textureCache.delete(key);
}
}
@@ -115,7 +112,13 @@ export class SpriteFactory {
structureType: UnitType,
): PIXI.Container {
const parentContainer = new PIXI.Container();
const texture = this.createTexture(structureType, player, false, true);
const texture = this.createTexture(
structureType,
player,
false,
false,
true,
);
const sprite = new PIXI.Sprite(texture);
sprite.anchor.set(0.5);
sprite.alpha = 0.5;
@@ -139,6 +142,7 @@ export class SpriteFactory {
const worldPos = new Cell(this.game.x(tile), this.game.y(tile));
const screenPos = this.transformHandler.worldToScreenCoordinates(worldPos);
const isMarkedForDeletion = unit.markedForDeletion() !== false;
const isConstruction = unit.type() === UnitType.Construction;
const constructionType = unit.constructionType();
const structureType = isConstruction ? constructionType! : unit.type();
@@ -156,6 +160,7 @@ export class SpriteFactory {
structureType,
unit.owner(),
isConstruction,
isMarkedForDeletion,
type === "icon",
);
const sprite = new PIXI.Sprite(texture);
@@ -202,19 +207,30 @@ export class SpriteFactory {
type: UnitType,
owner: PlayerView,
isConstruction: boolean,
isMarkedForDeletion: boolean,
renderIcon: boolean,
): PIXI.Texture {
const cacheKey = isConstruction
? `construction-${type}` + (renderIcon ? "-icon" : "")
: `${this.theme.territoryColor(owner).toRgbString()}-${type}` +
(renderIcon ? "-icon" : "");
const cacheKeyBase = isConstruction
? `construction-${type}`
: `${this.theme.territoryColor(owner).toRgbString()}-${type}`;
const cacheKey =
cacheKeyBase +
(renderIcon ? "-icon" : "") +
(isMarkedForDeletion ? "-deleted" : "");
if (this.textureCache.has(cacheKey)) {
return this.textureCache.get(cacheKey)!;
}
const shape = STRUCTURE_SHAPES[type];
const texture = shape
? this.createIcon(owner, type, isConstruction, shape, renderIcon)
? this.createIcon(
owner,
type,
isConstruction,
isMarkedForDeletion,
shape,
renderIcon,
)
: PIXI.Texture.EMPTY;
this.textureCache.set(cacheKey, texture);
return texture;
@@ -224,6 +240,7 @@ export class SpriteFactory {
owner: PlayerView,
structureType: UnitType,
isConstruction: boolean,
isMarkedForDeletion: boolean,
shape: string,
renderIcon: boolean,
): PIXI.Texture {
@@ -370,11 +387,8 @@ export class SpriteFactory {
}
const structureInfo = this.structuresInfos.get(structureType);
if (!structureInfo?.image) {
return PIXI.Texture.from(structureCanvas);
}
if (renderIcon) {
if (structureInfo?.image && renderIcon) {
const SHAPE_OFFSETS = {
triangle: [6, 11],
square: [5, 5],
@@ -390,6 +404,22 @@ export class SpriteFactory {
offsetY,
);
}
if (isMarkedForDeletion) {
context.save();
context.strokeStyle = "rgba(255, 64, 64, 0.95)";
context.lineWidth = Math.max(2, Math.round(iconSize * 0.12));
context.lineCap = "round";
const padding = Math.max(2, iconSize * 0.12);
context.beginPath();
context.moveTo(padding, padding);
context.lineTo(iconSize - padding, iconSize - padding);
context.moveTo(iconSize - padding, padding);
context.lineTo(padding, iconSize - padding);
context.stroke();
context.restore();
}
return PIXI.Texture.from(structureCanvas);
}
@@ -418,6 +418,7 @@ export class StructureIconsLayer implements Layer {
const render = this.findRenderByUnit(unitView);
if (render) {
this.checkForConstructionState(render, unitView);
this.checkForDeletionState(render, unitView);
this.checkForOwnershipChange(render, unitView);
this.checkForLevelChange(render, unitView);
}
@@ -467,6 +468,16 @@ export class StructureIconsLayer implements Layer {
}
}
private checkForDeletionState(render: StructureRenderInfo, unit: UnitView) {
if (unit.markedForDeletion() !== false) {
render.iconContainer?.destroy();
render.dotContainer?.destroy();
render.iconContainer = this.createIconSprite(unit);
render.dotContainer = this.createDotSprite(unit);
this.modifyVisibility(render);
}
}
private checkForConstructionState(
render: StructureRenderInfo,
unit: UnitView,
+1
View File
@@ -221,5 +221,6 @@ export class TeamStats extends LitElement implements Layer {
function formatPercentage(value: number): string {
const perc = value * 100;
if (Number.isNaN(perc)) return "0%";
if (perc === 100) return "100%";
return perc.toPrecision(2) + "%";
}
+59 -24
View File
@@ -74,6 +74,10 @@ export class TerritoryLayer implements Layer {
}
tick() {
if (this.game.inSpawnPhase()) {
this.spawnHighlight();
}
this.game.recentlyUpdatedTiles().forEach((t) => this.enqueueTile(t));
const updates = this.game.updatesSinceLastTick();
const unitUpdates = updates !== null ? updates[GameUpdateType.Unit] : [];
@@ -145,12 +149,6 @@ export class TerritoryLayer implements Layer {
}
this.lastFocusedPlayer = focusedPlayer;
}
if (!this.game.inSpawnPhase()) {
return;
}
this.spawnHighlight();
}
private spawnHighlight() {
@@ -186,13 +184,16 @@ export class TerritoryLayer implements Layer {
}
let color = this.theme.spawnHighlightColor();
const myPlayer = this.game.myPlayer();
if (
myPlayer !== null &&
myPlayer !== human &&
myPlayer.isFriendly(human)
) {
color = this.theme.selfColor();
if (myPlayer !== null && myPlayer !== human && myPlayer.team() === null) {
// In FFA games (when team === null), use default yellow spawn highlight color
color = this.theme.spawnHighlightColor();
} else if (myPlayer !== null && myPlayer !== human) {
// In Team games, the spawn highlight color becomes that player's team color
// Optionally, this could be broken down to teammate or enemy and simplified to green and red, respectively
const team = human.team();
if (team !== null) color = this.theme.teamColor(team);
}
for (const tile of this.game.bfs(
centerTile,
euclDistFN(centerTile, 9, true),
@@ -215,20 +216,20 @@ export class TerritoryLayer implements Layer {
return;
}
// Breathing border animation
this.borderAnimTime += 3;
const minPadding = 6;
const maxPadding = 12;
this.borderAnimTime += 0.5;
const minRad = 8;
const maxRad = 24;
// Range: [minPadding..maxPadding]
const breathingPadding =
minPadding +
(maxPadding - minPadding) *
(0.5 + 0.5 * Math.sin(this.borderAnimTime * 0.3));
const radius =
minRad + (maxRad - minRad) * (0.5 + 0.5 * Math.sin(this.borderAnimTime));
this.drawBreathingRing(
center.x,
center.y,
breathingPadding,
this.theme.spawnHighlightColor(),
minRad,
maxRad,
radius,
this.theme.spawnHighlightSelfColor(), // Always draw breathing ring with self spawn highlight color
);
}
@@ -558,18 +559,52 @@ export class TerritoryLayer implements Layer {
const y = this.game.y(tile);
this.highlightContext.clearRect(x, y, 1, 1);
}
private drawBreathingRing(
cx: number,
cy: number,
minRad: number,
maxRad: number,
radius: number,
color: Colord,
) {
const ctx = this.highlightContext;
if (!ctx) return;
// Draw a semi-transparent ring around the starting location
ctx.beginPath();
// Transparency matches the highlight color provided
const transparent = color.toHex() + "00";
const c = color.toHex();
const radGrad = ctx.createRadialGradient(cx, cy, minRad, cx, cy, maxRad);
// Pixels with radius < minRad are transparent
radGrad.addColorStop(0, transparent);
// The ring then starts with solid highlight color
radGrad.addColorStop(0.01, c);
radGrad.addColorStop(0.1, c);
// The outer edge of the ring is transparent
radGrad.addColorStop(1, transparent);
// Draw an arc at the max radius and fill with the created radial gradient
ctx.arc(cx, cy, maxRad, 0, Math.PI * 2);
ctx.fillStyle = radGrad;
ctx.closePath();
ctx.fill();
// Draw a solid ring around the starting location with outer radius = the breathing radius
ctx.beginPath();
const radGrad2 = ctx.createRadialGradient(cx, cy, minRad, cx, cy, radius);
// Pixels with radius < minRad are transparent
radGrad2.addColorStop(0, transparent);
// The ring then starts with solid highlight color
radGrad2.addColorStop(0.01, c);
// The ring is solid throughout
radGrad2.addColorStop(1, c);
// Draw an arc at the current breathing radius and fill with the created "gradient"
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.strokeStyle = color.toRgbString();
ctx.lineWidth = 4;
ctx.stroke();
ctx.fillStyle = radGrad2;
ctx.fill();
}
}
+27 -4
View File
@@ -117,11 +117,18 @@ export class UILayer implements Layer {
this.drawHealthBar(unit);
break;
}
case UnitType.City:
case UnitType.Factory:
case UnitType.DefensePost:
case UnitType.Port:
case UnitType.MissileSilo:
this.createLoadingBar(unit);
break;
case UnitType.SAMLauncher:
this.createLoadingBar(unit);
if (
unit.markedForDeletion() !== false ||
unit.missileReadinesss() < 1
) {
this.createLoadingBar(unit);
}
break;
default:
return;
@@ -329,12 +336,28 @@ export class UILayer implements Layer {
}
case UnitType.MissileSilo:
case UnitType.SAMLauncher:
return unit.missileReadinesss();
return !unit.markedForDeletion()
? unit.missileReadinesss()
: this.deletionProgress(this.game, unit);
case UnitType.City:
case UnitType.Factory:
case UnitType.Port:
case UnitType.DefensePost:
return this.deletionProgress(this.game, unit);
default:
return 1;
}
}
private deletionProgress(game: GameView, unit: UnitView): number {
const deleteAt = unit.markedForDeletion();
if (deleteAt === false) return 1;
return Math.max(
0,
(deleteAt - game.ticks()) / game.config().deletionMarkDuration(),
);
}
public createLoadingBar(unit: UnitView) {
if (!this.context) {
return;
+1 -6
View File
@@ -132,12 +132,7 @@ export class WinModal extends LitElement implements Layer {
for (const pattern of Object.values(patterns?.patterns ?? {})) {
for (const colorPalette of pattern.colorPalettes ?? []) {
if (
patternRelationship(
pattern,
colorPalette,
me !== false ? me : null,
null,
) === "purchasable"
patternRelationship(pattern, colorPalette, me, null) === "purchasable"
) {
const palette = patterns?.colorPalettes?.[colorPalette.name];
if (palette) {
+11 -1
View File
@@ -97,6 +97,13 @@
src="https://cdn.fuseplatform.net/publift/tags/2/4121/fuse.js"
></script>
<script>
window.googletag = window.googletag || { cmd: [] };
googletag.cmd.push(function () {
googletag.pubads().set("page_url", "http://openfront.io ");
});
</script>
<!-- Analytics -->
<script
async
@@ -221,6 +228,9 @@
<div>
<public-lobby class="block"></public-lobby>
</div>
<div>
<matchmaking-button class="w-[20%] md:w-[15%]"></matchmaking-button>
</div>
<div class="container__row container__row--equal">
<o-button
id="host-lobby-button"
@@ -395,6 +405,7 @@
</div>
<settings-modal></settings-modal>
<player-panel></player-panel>
<spawn-timer></spawn-timer>
<help-modal></help-modal>
<dark-mode-button></dark-mode-button>
<alert-frame></alert-frame>
@@ -403,7 +414,6 @@
<multi-tab-modal></multi-tab-modal>
<news-modal></news-modal>
<game-left-sidebar></game-left-sidebar>
<spawn-ad></spawn-ad>
<flag-input-modal></flag-input-modal>
<fps-display></fps-display>
<div
+1
View File
@@ -191,6 +191,7 @@ export class GameRunner {
canAttack: tile !== null && player.canAttack(tile),
buildableUnits: player.buildableUnits(tile, transportShipFilter),
canSendEmojiAllPlayers: player.canSendEmoji(AllPlayers),
canEmbargoAll: player.canEmbargoAll(),
} as PlayerActions;
if (tile !== null && this.game.hasOwner(tile)) {
+8
View File
@@ -43,6 +43,7 @@ export type Intent =
| QuickChatIntent
| MoveWarshipIntent
| MarkDisconnectedIntent
| EmbargoAllIntent
| UpgradeStructureIntent
| DeleteUnitIntent
| KickPlayerIntent;
@@ -51,6 +52,7 @@ export type AttackIntent = z.infer<typeof AttackIntentSchema>;
export type CancelAttackIntent = z.infer<typeof CancelAttackIntentSchema>;
export type SpawnIntent = z.infer<typeof SpawnIntentSchema>;
export type BoatAttackIntent = z.infer<typeof BoatAttackIntentSchema>;
export type EmbargoAllIntent = z.infer<typeof EmbargoAllIntentSchema>;
export type CancelBoatIntent = z.infer<typeof CancelBoatIntentSchema>;
export type AllianceRequestIntent = z.infer<typeof AllianceRequestIntentSchema>;
export type AllianceRequestReplyIntent = z.infer<
@@ -276,6 +278,11 @@ export const EmbargoIntentSchema = BaseIntentSchema.extend({
action: z.union([z.literal("start"), z.literal("stop")]),
});
export const EmbargoAllIntentSchema = BaseIntentSchema.extend({
type: z.literal("embargo_all"),
action: z.union([z.literal("start"), z.literal("stop")]),
});
export const DonateGoldIntentSchema = BaseIntentSchema.extend({
type: z.literal("donate_gold"),
recipient: ID,
@@ -355,6 +362,7 @@ const IntentSchema = z.discriminatedUnion("type", [
BuildUnitIntentSchema,
UpgradeStructureIntentSchema,
EmbargoIntentSchema,
EmbargoAllIntentSchema,
MoveWarshipIntentSchema,
QuickChatIntentSchema,
AllianceExtensionIntentSchema,
+277 -72
View File
@@ -262,104 +262,309 @@ export const botColors: Colord[] = [
// Fallback colors for when the color palette is exhausted. Currently 100 colors.
export const fallbackColors: Colord[] = [
colord({ r: 0, g: 5, b: 0 }), // Black Mint
colord({ r: 0, g: 15, b: 0 }), // Deep Forest
colord({ r: 0, g: 25, b: 0 }), // Jungle
colord({ r: 0, g: 35, b: 0 }), // Dark Emerald
colord({ r: 0, g: 45, b: 0 }), // Green Moss
colord({ r: 0, g: 55, b: 0 }), // Moss Shadow
colord({ r: 0, g: 65, b: 0 }), // Dark Meadow
colord({ r: 0, g: 75, b: 0 }), // Forest Fern
colord({ r: 0, g: 85, b: 0 }), // Pine Leaf
colord({ r: 0, g: 95, b: 0 }), // Shadow Grass
colord({ r: 0, g: 105, b: 0 }), // Classic Green
colord({ r: 0, g: 115, b: 0 }), // Deep Lime
colord({ r: 0, g: 125, b: 0 }), // Dense Leaf
colord({ r: 0, g: 135, b: 0 }), // Basil Green
colord({ r: 0, g: 145, b: 0 }), // Organic Green
colord({ r: 0, g: 155, b: 0 }), // Bitter Herb
colord({ r: 0, g: 165, b: 0 }), // Raw Spinach
colord({ r: 0, g: 175, b: 0 }), // Woodland
colord({ r: 0, g: 185, b: 0 }), // Spring Weed
colord({ r: 0, g: 195, b: 5 }), // Apple Stem
colord({ r: 0, g: 205, b: 10 }), // Crisp Lettuce
colord({ r: 0, g: 215, b: 15 }), // Vibrant Green
colord({ r: 0, g: 225, b: 20 }), // Bright Herb
colord({ r: 0, g: 235, b: 25 }), // Green Splash
colord({ r: 0, g: 245, b: 30 }), // Mint Leaf
colord({ r: 0, g: 255, b: 35 }), // Fresh Mint
colord({ r: 10, g: 255, b: 45 }), // Neon Grass
colord({ r: 20, g: 255, b: 55 }), // Lemon Balm
colord({ r: 30, g: 255, b: 65 }), // Juicy Green
colord({ r: 40, g: 255, b: 75 }), // Pear Tint
colord({ r: 50, g: 255, b: 85 }), // Avocado Pastel
colord({ r: 60, g: 255, b: 95 }), // Lime Glow
colord({ r: 70, g: 255, b: 105 }), // Light Leaf
colord({ r: 80, g: 255, b: 115 }), // Soft Fern
colord({ r: 90, g: 255, b: 125 }), // Pastel Green
colord({ r: 100, g: 255, b: 135 }), // Green Melon
colord({ r: 110, g: 255, b: 145 }), // Herbal Mist
colord({ r: 120, g: 255, b: 155 }), // Kiwi Foam
colord({ r: 130, g: 255, b: 165 }), // Aloe Fresh
colord({ r: 140, g: 255, b: 175 }), // Light Mint
colord({ r: 35, g: 0, b: 0 }),
colord({ r: 45, g: 0, b: 0 }),
colord({ r: 55, g: 0, b: 0 }),
colord({ r: 65, g: 0, b: 0 }),
colord({ r: 75, g: 0, b: 0 }),
colord({ r: 85, g: 0, b: 0 }),
colord({ r: 95, g: 0, b: 0 }),
colord({ r: 105, g: 0, b: 0 }),
colord({ r: 115, g: 0, b: 0 }),
colord({ r: 125, g: 0, b: 0 }),
colord({ r: 135, g: 0, b: 0 }),
colord({ r: 145, g: 0, b: 0 }),
colord({ r: 155, g: 0, b: 0 }),
colord({ r: 165, g: 0, b: 0 }),
colord({ r: 175, g: 0, b: 0 }),
colord({ r: 185, g: 0, b: 0 }),
colord({ r: 195, g: 0, b: 5 }),
colord({ r: 205, g: 0, b: 10 }),
colord({ r: 215, g: 0, b: 15 }),
colord({ r: 225, g: 0, b: 20 }),
colord({ r: 235, g: 0, b: 25 }),
colord({ r: 245, g: 0, b: 30 }),
colord({ r: 255, g: 0, b: 35 }),
colord({ r: 255, g: 10, b: 45 }),
colord({ r: 255, g: 20, b: 55 }),
colord({ r: 255, g: 30, b: 65 }),
colord({ r: 255, g: 40, b: 75 }),
colord({ r: 255, g: 50, b: 85 }),
colord({ r: 255, g: 60, b: 95 }),
colord({ r: 255, g: 70, b: 105 }),
colord({ r: 255, g: 80, b: 115 }),
colord({ r: 255, g: 90, b: 125 }),
colord({ r: 255, g: 100, b: 135 }),
colord({ r: 255, g: 110, b: 145 }),
colord({ r: 255, g: 120, b: 155 }),
colord({ r: 255, g: 130, b: 165 }),
colord({ r: 255, g: 140, b: 175 }),
colord({ r: 255, g: 150, b: 185 }),
colord({ r: 255, g: 160, b: 195 }),
colord({ r: 255, g: 170, b: 205 }),
colord({ r: 255, g: 180, b: 215 }),
colord({ r: 255, g: 190, b: 225 }),
colord({ r: 255, g: 200, b: 235 }),
colord({ r: 0, g: 45, b: 0 }),
colord({ r: 0, g: 55, b: 0 }),
colord({ r: 0, g: 65, b: 0 }),
colord({ r: 0, g: 75, b: 0 }),
colord({ r: 0, g: 85, b: 0 }),
colord({ r: 0, g: 95, b: 0 }),
colord({ r: 0, g: 105, b: 0 }),
colord({ r: 0, g: 115, b: 0 }),
colord({ r: 0, g: 125, b: 0 }),
colord({ r: 0, g: 135, b: 0 }),
colord({ r: 0, g: 145, b: 0 }),
colord({ r: 0, g: 155, b: 0 }),
colord({ r: 0, g: 165, b: 0 }),
colord({ r: 0, g: 175, b: 0 }),
colord({ r: 0, g: 185, b: 0 }),
colord({ r: 0, g: 195, b: 5 }),
colord({ r: 0, g: 205, b: 10 }),
colord({ r: 0, g: 215, b: 15 }),
colord({ r: 0, g: 225, b: 20 }),
colord({ r: 0, g: 235, b: 25 }),
colord({ r: 0, g: 245, b: 30 }),
colord({ r: 0, g: 255, b: 35 }),
colord({ r: 10, g: 255, b: 45 }),
colord({ r: 20, g: 255, b: 55 }),
colord({ r: 30, g: 255, b: 65 }),
colord({ r: 40, g: 255, b: 75 }),
colord({ r: 50, g: 255, b: 85 }),
colord({ r: 60, g: 255, b: 95 }),
colord({ r: 70, g: 255, b: 105 }),
colord({ r: 80, g: 255, b: 115 }),
colord({ r: 90, g: 255, b: 125 }),
colord({ r: 100, g: 255, b: 135 }),
colord({ r: 110, g: 255, b: 145 }),
colord({ r: 120, g: 255, b: 155 }),
colord({ r: 130, g: 255, b: 165 }),
colord({ r: 140, g: 255, b: 175 }),
colord({ r: 150, g: 255, b: 185 }),
colord({ r: 160, g: 255, b: 195 }),
colord({ r: 170, g: 255, b: 205 }),
colord({ r: 180, g: 255, b: 215 }),
colord({ r: 190, g: 255, b: 225 }),
colord({ r: 200, g: 255, b: 235 }),
colord({ r: 0, g: 0, b: 35 }),
colord({ r: 0, g: 0, b: 45 }),
colord({ r: 0, g: 0, b: 55 }),
colord({ r: 0, g: 0, b: 65 }),
colord({ r: 0, g: 0, b: 75 }),
colord({ r: 0, g: 0, b: 85 }),
colord({ r: 0, g: 0, b: 95 }),
colord({ r: 0, g: 0, b: 105 }),
colord({ r: 0, g: 0, b: 115 }),
colord({ r: 0, g: 0, b: 125 }),
colord({ r: 0, g: 0, b: 135 }),
colord({ r: 0, g: 0, b: 145 }),
colord({ r: 0, g: 0, b: 155 }),
colord({ r: 0, g: 0, b: 165 }),
colord({ r: 0, g: 0, b: 175 }),
colord({ r: 0, g: 0, b: 185 }),
colord({ r: 5, g: 0, b: 195 }),
colord({ r: 10, g: 0, b: 205 }),
colord({ r: 15, g: 0, b: 215 }),
colord({ r: 20, g: 0, b: 225 }),
colord({ r: 25, g: 0, b: 235 }),
colord({ r: 30, g: 0, b: 245 }),
colord({ r: 35, g: 0, b: 255 }),
colord({ r: 45, g: 10, b: 255 }),
colord({ r: 55, g: 20, b: 255 }),
colord({ r: 65, g: 30, b: 255 }),
colord({ r: 75, g: 40, b: 255 }),
colord({ r: 85, g: 50, b: 255 }),
colord({ r: 95, g: 60, b: 255 }),
colord({ r: 105, g: 70, b: 255 }),
colord({ r: 115, g: 80, b: 255 }),
colord({ r: 125, g: 90, b: 255 }),
colord({ r: 135, g: 100, b: 255 }),
colord({ r: 145, g: 110, b: 255 }),
colord({ r: 155, g: 120, b: 255 }),
colord({ r: 165, g: 130, b: 255 }),
colord({ r: 175, g: 140, b: 255 }),
colord({ r: 185, g: 150, b: 255 }),
colord({ r: 195, g: 160, b: 255 }),
colord({ r: 205, g: 170, b: 255 }),
colord({ r: 215, g: 180, b: 255 }),
colord({ r: 225, g: 190, b: 255 }),
colord({ r: 235, g: 200, b: 255 }),
colord({ r: 35, g: 0, b: 35 }),
colord({ r: 45, g: 0, b: 45 }),
colord({ r: 55, g: 0, b: 55 }),
colord({ r: 65, g: 0, b: 65 }),
colord({ r: 75, g: 0, b: 75 }),
colord({ r: 85, g: 0, b: 85 }),
colord({ r: 95, g: 0, b: 95 }),
colord({ r: 105, g: 0, b: 105 }),
colord({ r: 115, g: 0, b: 115 }),
colord({ r: 125, g: 0, b: 125 }),
colord({ r: 135, g: 0, b: 135 }),
colord({ r: 145, g: 0, b: 145 }),
colord({ r: 155, g: 0, b: 155 }),
colord({ r: 165, g: 0, b: 165 }),
colord({ r: 175, g: 0, b: 175 }),
colord({ r: 185, g: 0, b: 185 }),
colord({ r: 195, g: 5, b: 195 }),
colord({ r: 205, g: 10, b: 205 }),
colord({ r: 215, g: 15, b: 215 }),
colord({ r: 225, g: 20, b: 225 }),
colord({ r: 235, g: 25, b: 235 }),
colord({ r: 245, g: 30, b: 245 }),
colord({ r: 255, g: 35, b: 255 }),
colord({ r: 255, g: 45, b: 255 }),
colord({ r: 255, g: 55, b: 255 }),
colord({ r: 255, g: 65, b: 255 }),
colord({ r: 255, g: 75, b: 255 }),
colord({ r: 255, g: 85, b: 255 }),
colord({ r: 255, g: 95, b: 255 }),
colord({ r: 255, g: 105, b: 255 }),
colord({ r: 255, g: 115, b: 255 }),
colord({ r: 255, g: 125, b: 255 }),
colord({ r: 255, g: 135, b: 255 }),
colord({ r: 255, g: 145, b: 255 }),
colord({ r: 255, g: 155, b: 255 }),
colord({ r: 255, g: 165, b: 255 }),
colord({ r: 255, g: 175, b: 255 }),
colord({ r: 255, g: 185, b: 255 }),
colord({ r: 255, g: 195, b: 255 }),
colord({ r: 255, g: 205, b: 255 }),
colord({ r: 255, g: 215, b: 255 }),
colord({ r: 0, g: 35, b: 35 }),
colord({ r: 0, g: 45, b: 45 }),
colord({ r: 0, g: 55, b: 55 }),
colord({ r: 0, g: 65, b: 65 }),
colord({ r: 0, g: 75, b: 75 }),
colord({ r: 0, g: 85, b: 85 }),
colord({ r: 0, g: 95, b: 95 }),
colord({ r: 0, g: 105, b: 105 }),
colord({ r: 0, g: 115, b: 115 }),
colord({ r: 0, g: 125, b: 125 }),
colord({ r: 0, g: 135, b: 135 }),
colord({ r: 0, g: 145, b: 145 }),
colord({ r: 0, g: 155, b: 155 }),
colord({ r: 0, g: 165, b: 165 }),
colord({ r: 0, g: 175, b: 175 }),
colord({ r: 0, g: 185, b: 185 }),
colord({ r: 5, g: 195, b: 195 }),
colord({ r: 10, g: 205, b: 205 }),
colord({ r: 15, g: 215, b: 215 }),
colord({ r: 20, g: 225, b: 225 }),
colord({ r: 25, g: 235, b: 235 }),
colord({ r: 30, g: 245, b: 245 }),
colord({ r: 35, g: 255, b: 255 }),
colord({ r: 45, g: 255, b: 255 }),
colord({ r: 55, g: 255, b: 255 }),
colord({ r: 65, g: 255, b: 255 }),
colord({ r: 75, g: 255, b: 255 }),
colord({ r: 85, g: 255, b: 255 }),
colord({ r: 95, g: 255, b: 255 }),
colord({ r: 105, g: 255, b: 255 }),
colord({ r: 115, g: 255, b: 255 }),
colord({ r: 125, g: 255, b: 255 }),
colord({ r: 135, g: 255, b: 255 }),
colord({ r: 145, g: 255, b: 255 }),
colord({ r: 155, g: 255, b: 255 }),
colord({ r: 165, g: 255, b: 255 }),
colord({ r: 175, g: 255, b: 255 }),
colord({ r: 185, g: 255, b: 255 }),
colord({ r: 195, g: 255, b: 255 }),
colord({ r: 205, g: 255, b: 255 }),
colord({ r: 215, g: 255, b: 255 }),
colord({ r: 35, g: 35, b: 0 }),
colord({ r: 45, g: 45, b: 0 }),
colord({ r: 55, g: 55, b: 0 }),
colord({ r: 65, g: 65, b: 0 }),
colord({ r: 75, g: 75, b: 0 }),
colord({ r: 85, g: 85, b: 0 }),
colord({ r: 95, g: 95, b: 0 }),
colord({ r: 105, g: 105, b: 0 }),
colord({ r: 115, g: 115, b: 0 }),
colord({ r: 125, g: 125, b: 0 }),
colord({ r: 135, g: 135, b: 0 }),
colord({ r: 145, g: 145, b: 0 }),
colord({ r: 155, g: 155, b: 0 }),
colord({ r: 165, g: 165, b: 0 }),
colord({ r: 175, g: 175, b: 0 }),
colord({ r: 185, g: 185, b: 0 }),
colord({ r: 195, g: 195, b: 5 }),
colord({ r: 205, g: 205, b: 10 }),
colord({ r: 215, g: 215, b: 15 }),
colord({ r: 225, g: 225, b: 20 }),
colord({ r: 235, g: 235, b: 25 }),
colord({ r: 245, g: 245, b: 30 }),
colord({ r: 255, g: 255, b: 35 }),
colord({ r: 255, g: 255, b: 45 }),
colord({ r: 255, g: 255, b: 55 }),
colord({ r: 255, g: 255, b: 65 }),
colord({ r: 255, g: 255, b: 75 }),
colord({ r: 255, g: 255, b: 85 }),
colord({ r: 255, g: 255, b: 95 }),
colord({ r: 255, g: 255, b: 105 }),
colord({ r: 255, g: 255, b: 115 }),
colord({ r: 255, g: 255, b: 125 }),
colord({ r: 255, g: 255, b: 135 }),
colord({ r: 255, g: 255, b: 145 }),
colord({ r: 255, g: 255, b: 155 }),
colord({ r: 255, g: 255, b: 165 }),
colord({ r: 255, g: 255, b: 175 }),
colord({ r: 255, g: 255, b: 185 }),
colord({ r: 255, g: 255, b: 195 }),
colord({ r: 255, g: 255, b: 205 }),
colord({ r: 255, g: 255, b: 215 }),
colord({ r: 215, g: 255, b: 200 }), // Fresh Mint
colord({ r: 225, g: 255, b: 175 }), // Soft Lime
colord({ r: 240, g: 250, b: 160 }), // Citrus Wash
colord({ r: 245, g: 245, b: 175 }), // Lemon Mist
colord({ r: 150, g: 200, b: 255 }), // Cornflower Mist
colord({ r: 150, g: 255, b: 185 }), // Green Sorbet
colord({ r: 160, g: 215, b: 255 }), // Powder Blue
colord({ r: 160, g: 255, b: 195 }), // Pastel Apple
colord({ r: 170, g: 190, b: 255 }), // Periwinkle Ice
colord({ r: 170, g: 225, b: 255 }), // Baby Sky
colord({ r: 170, g: 255, b: 205 }), // Aloe Breeze
colord({ r: 180, g: 180, b: 255 }), // Pale Indigo
colord({ r: 180, g: 235, b: 250 }), // Aqua Pastel
colord({ r: 180, g: 255, b: 215 }), // Pale Mint
colord({ r: 190, g: 140, b: 195 }), // Fuchsia Tint
colord({ r: 190, g: 245, b: 240 }), // Ice Mint
colord({ r: 190, g: 255, b: 225 }), // Mint Water
colord({ r: 210, g: 255, b: 245 }), // Sea Mist
colord({ r: 220, g: 255, b: 255 }), // Pale Aqua
colord({ r: 230, g: 250, b: 255 }), // Sky Haze
colord({ r: 240, g: 240, b: 255 }), // Frosted Lilac
colord({ r: 250, g: 230, b: 255 }), // Misty Mauve
colord({ r: 170, g: 190, b: 255 }), // Periwinkle Ice
colord({ r: 180, g: 180, b: 255 }), // Pale Indigo
colord({ r: 200, g: 170, b: 255 }), // Lilac Bloom
colord({ r: 190, g: 140, b: 195 }), // Fuchsia Tint
colord({ r: 195, g: 145, b: 200 }), // Dusky Rose
colord({ r: 200, g: 150, b: 205 }), // Plum Frost
colord({ r: 200, g: 170, b: 255 }), // Lilac Bloom
colord({ r: 200, g: 255, b: 215 }), // Cool Aloe
colord({ r: 200, g: 255, b: 235 }), // Cool Mist
colord({ r: 205, g: 155, b: 210 }), // Berry Foam
colord({ r: 210, g: 160, b: 215 }), // Grape Cloud
colord({ r: 210, g: 255, b: 245 }), // Sea Mist
colord({ r: 215, g: 165, b: 220 }), // Light Bloom
colord({ r: 215, g: 255, b: 200 }), // Fresh Mint
colord({ r: 220, g: 160, b: 255 }), // Violet Mist
colord({ r: 220, g: 170, b: 225 }), // Cherry Blossom
colord({ r: 220, g: 255, b: 255 }), // Pale Aqua
colord({ r: 225, g: 175, b: 230 }), // Faded Rose
colord({ r: 225, g: 255, b: 175 }), // Soft Lime
colord({ r: 230, g: 180, b: 235 }), // Dreamy Mauve
colord({ r: 230, g: 250, b: 255 }), // Sky Haze
colord({ r: 235, g: 150, b: 255 }), // Orchid Glow
colord({ r: 235, g: 185, b: 240 }), // Powder Violet
colord({ r: 240, g: 190, b: 245 }), // Pastel Violet
colord({ r: 240, g: 240, b: 255 }), // Frosted Lilac
colord({ r: 240, g: 250, b: 160 }), // Citrus Wash
colord({ r: 245, g: 160, b: 240 }), // Rose Lilac
colord({ r: 245, g: 195, b: 250 }), // Soft Magenta
colord({ r: 245, g: 245, b: 175 }), // Lemon Mist
colord({ r: 250, g: 200, b: 255 }), // Lilac Cream
colord({ r: 250, g: 230, b: 255 }), // Misty Mauve
colord({ r: 255, g: 205, b: 255 }), // Violet Bloom
colord({ r: 255, g: 210, b: 255 }), // Orchid Mist
colord({ r: 255, g: 210, b: 250 }), // Lavender Mist
colord({ r: 255, g: 205, b: 245 }), // Pastel Orchid
colord({ r: 255, g: 215, b: 245 }), // Rose Whisper
colord({ r: 220, g: 160, b: 255 }), // Violet Mist
colord({ r: 235, g: 150, b: 255 }), // Orchid Glow
colord({ r: 245, g: 160, b: 240 }), // Rose Lilac
colord({ r: 255, g: 170, b: 225 }), // Bubblegum Pink
colord({ r: 255, g: 185, b: 215 }), // Blush Mist
colord({ r: 255, g: 195, b: 235 }), // Faded Fuchsia
colord({ r: 255, g: 200, b: 220 }), // Cotton Rose
colord({ r: 255, g: 205, b: 245 }), // Pastel Orchid
colord({ r: 255, g: 205, b: 255 }), // Violet Bloom
colord({ r: 255, g: 210, b: 230 }), // Pastel Blush
colord({ r: 255, g: 210, b: 250 }), // Lavender Mist
colord({ r: 255, g: 210, b: 255 }), // Orchid Mist
colord({ r: 255, g: 215, b: 195 }), // Apricot Glow
colord({ r: 255, g: 215, b: 245 }), // Rose Whisper
colord({ r: 255, g: 220, b: 235 }), // Pink Mist
colord({ r: 255, g: 220, b: 250 }), // Powder Petal
colord({ r: 255, g: 225, b: 180 }), // Butter Peach
colord({ r: 255, g: 225, b: 255 }), // Petal Mist
colord({ r: 255, g: 230, b: 245 }), // Light Rose
colord({ r: 255, g: 235, b: 200 }), // Cream Peach
colord({ r: 255, g: 235, b: 235 }), // Blushed Petal
colord({ r: 255, g: 240, b: 220 }), // Pastel Sand
colord({ r: 255, g: 215, b: 195 }), // Apricot Glow
colord({ r: 255, g: 225, b: 180 }), // Butter Peach
colord({ r: 255, g: 230, b: 190 }),
colord({ r: 255, g: 235, b: 200 }), // Cream Peach
colord({ r: 255, g: 245, b: 210 }), // Soft Banana
colord({ r: 255, g: 240, b: 220 }), // Pastel Sand
];
+6
View File
@@ -63,6 +63,7 @@ export interface ServerConfig {
cloudflareCredsPath(): string;
stripePublishableKey(): string;
allowedFlares(): string[] | undefined;
enableMatchmaking(): boolean;
}
export interface NukeMagnitude {
@@ -130,6 +131,8 @@ export interface Config {
emojiMessageCooldown(): Tick;
emojiMessageDuration(): Tick;
donateCooldown(): Tick;
embargoAllCooldown(): Tick;
deletionMarkDuration(): Tick;
deleteUnitCooldown(): Tick;
defaultDonationAmount(sender: Player): number;
unitInfo(type: UnitType): UnitInfo;
@@ -198,4 +201,7 @@ export interface Theme {
neutralColor(): Colord;
enemyColor(): Colord;
spawnHighlightColor(): Colord;
spawnHighlightSelfColor(): Colord;
spawnHighlightTeamColor(): Colord;
spawnHighlightEnemyColor(): Colord;
}
+10
View File
@@ -48,6 +48,7 @@ const numPlayersConfig = {
[GameMapType.Africa]: [100, 70, 50],
[GameMapType.Asia]: [50, 40, 30],
[GameMapType.Australia]: [70, 40, 30],
[GameMapType.Achiran]: [40, 36, 30],
[GameMapType.Baikal]: [100, 70, 50],
[GameMapType.BetweenTwoSeas]: [70, 50, 40],
[GameMapType.BlackSea]: [50, 30, 30],
@@ -213,6 +214,9 @@ export abstract class DefaultServerConfig implements ServerConfig {
workerPortByIndex(index: number): number {
return 3001 + index;
}
enableMatchmaking(): boolean {
return false;
}
}
export class DefaultConfig implements Config {
@@ -569,6 +573,12 @@ export class DefaultConfig implements Config {
donateCooldown(): Tick {
return 10 * 10;
}
embargoAllCooldown(): Tick {
return 10 * 10;
}
deletionMarkDuration(): Tick {
return 15 * 10;
}
deleteUnitCooldown(): Tick {
return 5 * 10;
}
+23
View File
@@ -29,12 +29,23 @@ export class PastelTheme implements Theme {
private water = colord({ r: 70, g: 132, b: 180 });
private shorelineWater = colord({ r: 100, g: 143, b: 255 });
/** Alternate View colors for self, green */
private _selfColor = colord({ r: 0, g: 255, b: 0 });
/** Alternate View colors for allies, yellow */
private _allyColor = colord({ r: 255, g: 255, b: 0 });
/** Alternate View colors for neutral, gray */
private _neutralColor = colord({ r: 128, g: 128, b: 128 });
/** Alternate View colors for enemies, red */
private _enemyColor = colord({ r: 255, g: 0, b: 0 });
/** Default spawn highlight colors for other players in FFA, yellow */
private _spawnHighlightColor = colord({ r: 255, g: 213, b: 79 });
/** Added non-default spawn highlight colors for self, full white */
private _spawnHighlightSelfColor = colord({ r: 255, g: 255, b: 255 });
/** Added non-default spawn highlight colors for teammates, green */
private _spawnHighlightTeamColor = colord({ r: 0, g: 255, b: 0 });
/** Added non-default spawn highlight colors for enemies, red */
private _spawnHighlightEnemyColor = colord({ r: 255, g: 0, b: 0 });
teamColor(team: Team): Colord {
return this.teamColorAllocator.assignTeamColor(team);
@@ -144,4 +155,16 @@ export class PastelTheme implements Theme {
spawnHighlightColor(): Colord {
return this._spawnHighlightColor;
}
/** Return spawn highlight color for self */
spawnHighlightSelfColor(): Colord {
return this._spawnHighlightSelfColor;
}
/** Return spawn highlight color for teammates */
spawnHighlightTeamColor(): Colord {
return this._spawnHighlightTeamColor;
}
/** Return spawn highlight color for enemies */
spawnHighlightEnemyColor(): Colord {
return this._spawnHighlightEnemyColor;
}
}
+23 -11
View File
@@ -1,8 +1,9 @@
import { Execution, Game, MessageType, Player } from "../game/Game";
import { Execution, Game, MessageType, Player, Unit } from "../game/Game";
export class DeleteUnitExecution implements Execution {
private active: boolean = true;
private mg: Game;
private unit: Unit | null = null;
constructor(
private player: Player,
@@ -33,6 +34,7 @@ export class DeleteUnitExecution implements Execution {
this.active = false;
return;
}
this.unit = unit;
const tileOwner = mg.owner(unit.tile());
if (!tileOwner.isPlayer() || tileOwner.id() !== this.player.id()) {
@@ -61,19 +63,29 @@ export class DeleteUnitExecution implements Execution {
return;
}
unit.delete(false);
this.player.recordDeleteUnit();
this.mg.displayMessage(
`events_display.unit_voluntarily_deleted`,
MessageType.UNIT_DESTROYED,
this.player.id(),
);
this.active = false;
unit.markForDeletion();
}
tick(ticks: number) {}
tick(ticks: number) {
if (!this.active || !this.unit) {
return;
}
if (!this.unit.isActive()) {
this.active = false;
return;
}
if (this.unit.isOverdueDeletion()) {
this.unit.delete(false);
this.mg.displayMessage(
`events_display.unit_voluntarily_deleted`,
MessageType.UNIT_DESTROYED,
this.player.id(),
);
this.active = false;
}
}
isActive(): boolean {
return this.active;
+38
View File
@@ -0,0 +1,38 @@
import { Execution, Game, Player, PlayerType } from "../game/Game";
export class EmbargoAllExecution implements Execution {
constructor(
private readonly player: Player,
private readonly action: "start" | "stop",
) {}
init(mg: Game, _: number): void {
if (!this.player.canEmbargoAll()) {
return;
}
const me = this.player;
for (const p of mg.players()) {
if (p.id() === me.id()) continue;
if (p.type() === PlayerType.Bot) continue;
if (me.isOnSameTeam(p)) continue;
if (this.action === "start") {
if (!me.hasEmbargoAgainst(p)) me.addEmbargo(p, false);
} else {
if (me.hasEmbargoAgainst(p)) me.stopEmbargo(p);
}
}
this.player.recordEmbargoAll();
}
tick(_: number): void {}
isActive(): boolean {
return false;
}
activeDuringSpawnPhase(): boolean {
return false;
}
}
+3
View File
@@ -13,6 +13,7 @@ import { ConstructionExecution } from "./ConstructionExecution";
import { DeleteUnitExecution } from "./DeleteUnitExecution";
import { DonateGoldExecution } from "./DonateGoldExecution";
import { DonateTroopsExecution } from "./DonateTroopExecution";
import { EmbargoAllExecution } from "./EmbargoAllExecution";
import { EmbargoExecution } from "./EmbargoExecution";
import { EmojiExecution } from "./EmojiExecution";
import { FakeHumanExecution } from "./FakeHumanExecution";
@@ -100,6 +101,8 @@ export class Executor {
return new DonateGoldExecution(player, intent.recipient, intent.gold);
case "embargo":
return new EmbargoExecution(player, intent.targetID, intent.action);
case "embargo_all":
return new EmbargoAllExecution(player, intent.action);
case "build_unit":
return new ConstructionExecution(player, intent.unit, intent.tile);
case "allianceExtension": {
+4 -1
View File
@@ -17,6 +17,7 @@ import { TileRef, euclDistFN } from "../game/GameMap";
import { PseudoRandom } from "../PseudoRandom";
import { GameID } from "../Schemas";
import { boundingBoxTiles, calculateBoundingBox, simpleHash } from "../Util";
import { AllianceRequestExecution } from "./alliance/AllianceRequestExecution";
import { ConstructionExecution } from "./ConstructionExecution";
import { EmojiExecution } from "./EmojiExecution";
import { structureSpawnTileValue } from "./nation/structureSpawnTileValue";
@@ -221,7 +222,9 @@ export class FakeHumanExecution implements Execution {
if (this.random.chance(20)) {
const toAlly = this.random.randElement(enemies);
if (this.player.canSendAllianceRequest(toAlly)) {
this.player.createAllianceRequest(toAlly);
this.mg.addExecution(
new AllianceRequestExecution(this.player, toAlly.id()),
);
}
}
+1 -1
View File
@@ -118,7 +118,7 @@ export class PlayerExecution implements Execution {
if (main === undefined) throw new Error("No clusters");
this.player.largestClusterBoundingBox = calculateBoundingBox(this.mg, main);
const surroundedBy = this.surroundedBySamePlayer(main);
if (surroundedBy && !this.player.isFriendly(surroundedBy)) {
if (surroundedBy && !surroundedBy.isFriendly(this.player)) {
this.removeCluster(main);
}
@@ -19,7 +19,7 @@ export class UpgradeStructureExecution implements Execution {
return;
}
if (!this.player.canUpgradeUnit(this.structure.type())) {
if (!this.player.canUpgradeUnit(this.structure)) {
console.warn(
`[UpgradeStructureExecution] unit type ${this.structure.type()} cannot be upgraded`,
);
+13 -4
View File
@@ -95,6 +95,7 @@ export enum GameMapType {
Yenisei = "Yenisei",
Pluto = "Pluto",
Montreal = "Montreal",
Achiran = "Achiran",
}
export type GameMapName = keyof typeof GameMapType;
@@ -135,6 +136,7 @@ export const mapCategories: Record<string, GameMapType[]> = {
GameMapType.Pluto,
GameMapType.Mars,
GameMapType.DeglaciatedAntarctica,
GameMapType.Achiran,
],
};
@@ -202,6 +204,7 @@ const _structureTypes: ReadonlySet<UnitType> = new Set([
UnitType.SAMLauncher,
UnitType.MissileSilo,
UnitType.Port,
UnitType.Factory,
]);
export function isStructureType(type: UnitType): boolean {
@@ -405,11 +408,11 @@ export class PlayerInfo {
public readonly nation?: Nation | null,
) {
// Compute clan from name
if (!name.startsWith("[") || !name.includes("]")) {
if (!name.includes("[") || !name.includes("]")) {
this.clan = null;
} else {
const clanMatch = name.match(/^\[([a-zA-Z]{2,5})\]/);
this.clan = clanMatch ? clanMatch[1] : null;
const clanMatch = name.match(/\[([a-zA-Z0-9]{2,5})\]/);
this.clan = clanMatch ? clanMatch[1].toUpperCase() : null;
}
}
}
@@ -432,6 +435,9 @@ export interface Unit {
type(): UnitType;
owner(): Player;
info(): UnitInfo;
isMarkedForDeletion(): boolean;
markForDeletion(): void;
isOverdueDeletion(): boolean;
delete(displayMessage?: boolean, destroyer?: Player): void;
tile(): TileRef;
lastTile(): TileRef;
@@ -576,7 +582,7 @@ export interface Player {
// New units of the same type can upgrade existing units.
// e.g. if a place a new city here, can it upgrade an existing city?
findUnitToUpgrade(type: UnitType, targetTile: TileRef): Unit | false;
canUpgradeUnit(unitType: UnitType): boolean;
canUpgradeUnit(unit: Unit): boolean;
upgradeUnit(unit: Unit): void;
captureUnit(unit: Unit): void;
@@ -621,6 +627,8 @@ export interface Player {
donateGold(recipient: Player, gold: Gold): boolean;
canDeleteUnit(): boolean;
recordDeleteUnit(): void;
canEmbargoAll(): boolean;
recordEmbargoAll(): void;
// Embargo
hasEmbargoAgainst(other: Player): boolean;
@@ -743,6 +751,7 @@ export interface PlayerActions {
canAttack: boolean;
buildableUnits: BuildableUnit[];
canSendEmojiAllPlayers: boolean;
canEmbargoAll?: boolean;
interaction?: PlayerInteraction;
}
+1
View File
@@ -123,6 +123,7 @@ export interface UnitUpdate {
reachedTarget: boolean;
retreating: boolean;
targetable: boolean;
markedForDeletion: number | false;
targetUnitId?: number; // Only for trade ships
targetTile?: TileRef; // Only for nukes
health?: number;
+11 -4
View File
@@ -88,6 +88,10 @@ export class UnitView {
return this.data.targetable;
}
markedForDeletion(): number | false {
return this.data.markedForDeletion;
}
type(): UnitType {
return this.data.unitType;
}
@@ -435,10 +439,13 @@ export class PlayerView {
return this.data.lastDeleteUnitTick;
}
canDeleteUnit(): boolean {
deleteUnitCooldown(): number {
return (
this.game.ticks() + 1 - this.lastDeleteUnitTick() >=
this.game.config().deleteUnitCooldown()
Math.max(
0,
this.game.config().deleteUnitCooldown() -
(this.game.ticks() + 1 - this.lastDeleteUnitTick()),
) / 10
);
}
}
@@ -578,7 +585,7 @@ export class GameView implements GameMap {
tile: TileRef,
searchRange: number,
type: UnitType,
playerId: PlayerID,
playerId?: PlayerID,
) {
return this.unitGrid.hasUnitNearby(tile, searchRange, type, playerId);
}
+36 -6
View File
@@ -95,6 +95,7 @@ export class PlayerImpl implements Player {
private relations = new Map<Player, number>();
private lastDeleteUnitTick: Tick = -1;
private lastEmbargoAllTick: Tick = -1;
public _incomingAttacks: Attack[] = [];
public _outgoingAttacks: Attack[] = [];
@@ -391,6 +392,13 @@ export class PlayerImpl implements Player {
if (other === this) {
return false;
}
if (this.isDisconnected() || other.isDisconnected()) {
// Disconnected players are marked as not-friendly even if they are allies,
// so we need to return early if either player is disconnected.
// Otherise we could end up sending an alliance request to someone
// we are already allied with.
return false;
}
if (this.isFriendly(other) || !this.isAlive()) {
return false;
}
@@ -683,6 +691,28 @@ export class PlayerImpl implements Player {
this.lastDeleteUnitTick = this.mg.ticks();
}
canEmbargoAll(): boolean {
// Cooldown gate
if (
this.mg.ticks() - this.lastEmbargoAllTick <
this.mg.config().embargoAllCooldown()
) {
return false;
}
// At least one eligible player exists
for (const p of this.mg.players()) {
if (p.id() === this.id()) continue;
if (p.type() === PlayerType.Bot) continue;
if (this.isOnSameTeam(p)) continue;
return true;
}
return false;
}
recordEmbargoAll(): void {
this.lastEmbargoAllTick = this.mg.ticks();
}
hasEmbargoAgainst(other: Player): boolean {
return this.embargoes.has(other.id());
}
@@ -865,14 +895,14 @@ export class PlayerImpl implements Player {
return existing[0].unit;
}
public canUpgradeUnit(
unitType: UnitType,
skipBuildCheck: boolean = false,
): boolean {
if (!this.mg.config().unitInfo(unitType).upgradable) {
public canUpgradeUnit(unit: Unit, skipBuildCheck: boolean = false): boolean {
if (unit.isMarkedForDeletion()) {
return false;
}
if (!skipBuildCheck && this.canBuildUnit(unitType) === false) {
if (!this.mg.config().unitInfo(unit.type()).upgradable) {
return false;
}
if (!skipBuildCheck && this.canBuildUnit(unit.type()) === false) {
return false;
}
return true;
+27
View File
@@ -39,6 +39,7 @@ export class UnitImpl implements Unit {
// Nuke only
private _trajectoryIndex: number = 0;
private _trajectory: TrajectoryTile[];
private _deletionAt: number | null = null;
constructor(
private _type: UnitType,
@@ -126,6 +127,7 @@ export class UnitImpl implements Unit {
reachedTarget: this._reachedTarget,
retreating: this._retreating,
pos: this._tile,
markedForDeletion: this._deletionAt ?? false,
targetable: this._targetable,
lastPos: this._lastTile,
health: this.hasHealth() ? Number(this._health) : undefined,
@@ -182,6 +184,7 @@ export class UnitImpl implements Unit {
}
setOwner(newOwner: PlayerImpl): void {
this.clearPendingDeletion();
switch (this._type) {
case UnitType.Warship:
case UnitType.Port:
@@ -221,6 +224,30 @@ export class UnitImpl implements Unit {
}
}
clearPendingDeletion(): void {
this._deletionAt = null;
}
isMarkedForDeletion(): boolean {
return this._deletionAt !== null;
}
markForDeletion(): void {
if (!this.isActive()) {
return;
}
this._deletionAt =
this.mg.ticks() + this.mg.config().deletionMarkDuration();
this.mg.addUpdate(this.toUpdate());
}
isOverdueDeletion(): boolean {
if (!this.isActive()) {
return false;
}
return this._deletionAt !== null && this.mg.ticks() - this._deletionAt > 0;
}
delete(displayMessage?: boolean, destroyer?: Player): void {
if (!this.isActive()) {
throw new Error(`cannot delete ${this} not active`);
+7 -3
View File
@@ -106,6 +106,7 @@ export class PathFinder {
private curr: TileRef | null = null;
private dst: TileRef | null = null;
private path: TileRef[] | null = null;
private path_idx: number = 0;
private aStar: AStar<TileRef>;
private computeFinished = true;
@@ -148,6 +149,7 @@ export class PathFinder {
}
if (this.game.manhattanDist(curr, dst) < dist) {
this.path = null;
return { type: PathFindResultType.Completed, node: curr };
}
@@ -156,11 +158,12 @@ export class PathFinder {
this.curr = curr;
this.dst = dst;
this.path = null;
this.path_idx = 0;
this.aStar = this.newAStar(curr, dst);
this.computeFinished = false;
return this.nextTile(curr, dst);
} else {
const tile = this.path?.shift();
const tile = this.path?.[this.path_idx++];
if (tile === undefined) {
throw new Error("missing tile");
}
@@ -172,8 +175,9 @@ export class PathFinder {
case PathFindResultType.Completed:
this.computeFinished = true;
this.path = this.aStar.reconstructPath();
// Remove the start tile
this.path.shift();
// exclude first tile
this.path_idx = 1;
return this.nextTile(curr, dst);
case PathFindResultType.Pending:
+7 -2
View File
@@ -24,6 +24,7 @@ const frequency: Partial<Record<GameMapName, number>> = {
Africa: 7,
Asia: 6,
Australia: 4,
Achiran: 14,
Baikal: 5,
BetweenTwoSeas: 5,
BlackSea: 6,
@@ -71,6 +72,8 @@ const TEAM_COUNTS = [
export class MapPlaylist {
private mapsPlaylist: MapWithMode[] = [];
constructor(private disableTeams: boolean = false) {}
public gameConfig(): GameConfig {
const { map, mode } = this.getNextMap();
@@ -135,8 +138,10 @@ export class MapPlaylist {
if (!this.addNextMap(this.mapsPlaylist, ffa, GameMode.FFA)) {
return false;
}
if (!this.addNextMap(this.mapsPlaylist, team, GameMode.Team)) {
return false;
if (!this.disableTeams) {
if (!this.addNextMap(this.mapsPlaylist, team, GameMode.Team)) {
return false;
}
}
}
return true;
-5
View File
@@ -291,11 +291,6 @@ async function schedulePublicGame(playlist: MapPlaylist) {
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// SPA fallback route
app.get("*", function (req, res) {
res.sendFile(path.join(__dirname, "../../static/index.html"));
+9 -4
View File
@@ -1,6 +1,7 @@
import { Cosmetics } from "../core/CosmeticSchemas";
import { decodePatternData } from "../core/PatternDecoder";
import {
FlagSchema,
PlayerColor,
PlayerCosmeticRefs,
PlayerCosmetics,
@@ -42,10 +43,14 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker {
}
}
if (refs.flag) {
cosmetics.flag = cosmetics.flag = refs.flag.replace(
/[^a-z0-9-_ ()]/gi,
"",
);
const result = FlagSchema.safeParse(refs.flag);
if (!result.success) {
return {
type: "forbidden",
reason: "invalid flag: " + result.error.message,
};
}
cosmetics.flag = result.data;
}
return { type: "allowed", cosmetics };
+90 -1
View File
@@ -11,11 +11,12 @@ import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { GameType } from "../core/game/Game";
import {
ClientMessageSchema,
GameID,
ID,
PartialGameRecordSchema,
ServerErrorMessage,
} from "../core/Schemas";
import { replacer } from "../core/Util";
import { generateID, replacer } from "../core/Util";
import { CreateGameInputSchema, GameInputSchema } from "../core/WorkerSchemas";
import { archive, finalizeGameRecord } from "./Archive";
import { Client } from "./Client";
@@ -23,6 +24,7 @@ import { GameManager } from "./GameManager";
import { getUserMe, verifyClientToken } from "./jwt";
import { logger } from "./Logger";
import { MapPlaylist } from "./MapPlaylist";
import { PrivilegeRefresher } from "./PrivilegeRefresher";
import { initWorkerMetrics } from "./WorkerMetrics";
@@ -30,11 +32,24 @@ const config = getServerConfigFromServer();
const workerId = parseInt(process.env.WORKER_ID ?? "0");
const log = logger.child({ comp: `w_${workerId}` });
const playlist = new MapPlaylist(true);
// Worker setup
export async function startWorker() {
log.info(`Worker starting...`);
if (config.enableMatchmaking()) {
log.info("Starting matchmaking");
setTimeout(
() => {
pollLobby(gm);
},
1000 + Math.random() * 2000,
);
} else {
log.info("Matchmaking disabled");
}
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -450,3 +465,77 @@ export async function startWorker() {
log.error(`unhandled rejection at:`, promise, "reason:", reason);
});
}
async function pollLobby(gm: GameManager) {
try {
const url = `${config.jwtIssuer() + "/matchmaking/checkin"}`;
const gameId = generateGameIdForWorker();
if (gameId === null) {
log.warn(`Failed to generate game ID for worker ${workerId}`);
return;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 20000);
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": config.apiKey(),
},
body: JSON.stringify({
id: workerId,
gameId: gameId,
ccu: gm.activeClients(),
}),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
log.warn(
`Failed to poll lobby: ${response.status} ${response.statusText}`,
);
return;
}
const data = await response.json();
log.info(`Lobby poll successful:`, data);
if (data.assignment) {
// TODO: Only allow specified players to join the game.
console.log(`Creating game ${gameId}`);
const game = gm.createGame(gameId, playlist.gameConfig());
setTimeout(() => {
// Wait a few seconds to allow clients to connect.
console.log(`Starting game ${gameId}`);
game.start();
}, 5000);
}
} catch (error) {
log.error(`Error polling lobby:`, error);
} finally {
setTimeout(
() => {
pollLobby(gm);
},
5000 + Math.random() * 1000,
);
}
}
// TODO: This is a hack to generate a game ID for the worker.
// It should be replaced with a more robust solution.
function generateGameIdForWorker(): GameID | null {
let attempts = 1000;
while (attempts > 0) {
const gameId = generateID();
if (workerId === config.workerIndex(gameId)) {
return gameId;
}
attempts--;
}
log.warn(`Failed to generate game ID for worker ${workerId}`);
return null;
}
+29 -9
View File
@@ -10,6 +10,7 @@ import {
} from "../src/core/game/Game";
import { TileRef } from "../src/core/game/GameMap";
import { setup } from "./util/Setup";
import { executeTicks } from "./util/utils";
describe("DeleteUnitExecution Security Tests", () => {
let game: Game;
@@ -79,6 +80,7 @@ describe("DeleteUnitExecution Security Tests", () => {
execution.init(game, 0);
expect(execution.isActive()).toBe(false);
expect(enemyUnit.isMarkedForDeletion()).toBe(false);
});
it("should prevent deleting units on enemy territory", () => {
@@ -90,6 +92,7 @@ describe("DeleteUnitExecution Security Tests", () => {
execution.init(game, 0);
expect(execution.isActive()).toBe(false);
expect(unit.isMarkedForDeletion()).toBe(false);
}
});
@@ -100,15 +103,7 @@ describe("DeleteUnitExecution Security Tests", () => {
execution.init(game, 0);
expect(execution.isActive()).toBe(false);
});
it("should allow deleting the last city (suicide)", () => {
jest.spyOn(game, "inSpawnPhase").mockReturnValue(false);
const execution = new DeleteUnitExecution(player, unit.id());
execution.init(game, 0);
expect(unit.isActive()).toBe(false);
expect(unit.isMarkedForDeletion()).toBe(false);
});
it("should allow deleting units when all conditions are met", () => {
@@ -117,7 +112,32 @@ describe("DeleteUnitExecution Security Tests", () => {
const execution = new DeleteUnitExecution(player, unit.id());
execution.init(game, 0);
expect(unit.isMarkedForDeletion()).toBe(true);
});
it("should delete after deletion delay", () => {
jest.spyOn(game, "inSpawnPhase").mockReturnValue(false);
const execution = new DeleteUnitExecution(player, unit.id());
game.addExecution(execution);
game.executeNextTick();
expect(unit.isMarkedForDeletion()).toBe(true);
expect(unit.isOverdueDeletion()).toBe(false);
executeTicks(game, game.config().deletionMarkDuration() + 1);
expect(unit.isActive()).toBe(false);
});
it("should reset deletion if captured", () => {
jest.spyOn(game, "inSpawnPhase").mockReturnValue(false);
const execution = new DeleteUnitExecution(player, unit.id());
game.addExecution(execution);
game.executeNextTick();
expect(unit.isMarkedForDeletion()).toBe(true);
unit.setOwner(enemyPlayer);
expect(unit.isMarkedForDeletion()).toBe(false);
expect(unit.isActive()).toBe(true);
});
});
});
+111 -11
View File
@@ -2,7 +2,7 @@ import { PlayerInfo, PlayerType } from "../src/core/game/Game";
describe("PlayerInfo", () => {
describe("clan", () => {
test("should extract clan from name when format is [XX]Name", () => {
test("should extract clan from name when format contains [XX]", () => {
const playerInfo = new PlayerInfo(
"[CL]PlayerName",
PlayerType.Human,
@@ -12,7 +12,7 @@ describe("PlayerInfo", () => {
expect(playerInfo.clan).toBe("CL");
});
test("should extract clan from name when format is [XXX]Name", () => {
test("should extract clan from name when format contains [XXX]", () => {
const playerInfo = new PlayerInfo(
"[ABC]PlayerName",
PlayerType.Human,
@@ -22,7 +22,7 @@ describe("PlayerInfo", () => {
expect(playerInfo.clan).toBe("ABC");
});
test("should extract clan from name when format is [XXXX]Name", () => {
test("should extract clan from name when format contains [XXXX]", () => {
const playerInfo = new PlayerInfo(
"[ABCD]PlayerName",
PlayerType.Human,
@@ -32,7 +32,7 @@ describe("PlayerInfo", () => {
expect(playerInfo.clan).toBe("ABCD");
});
test("should extract clan from name when format is [XXXXX]Name", () => {
test("should extract clan from name when format contains [XXXXX]", () => {
const playerInfo = new PlayerInfo(
"[ABCDE]PlayerName",
PlayerType.Human,
@@ -42,27 +42,37 @@ describe("PlayerInfo", () => {
expect(playerInfo.clan).toBe("ABCDE");
});
test("should extract clan from name when format is [xxxxx]Name", () => {
test("should extract uppercase clan from name when format contains [xxxxx]", () => {
const playerInfo = new PlayerInfo(
"[abcde]PlayerName",
PlayerType.Human,
null,
"player_id",
);
expect(playerInfo.clan).toBe("abcde");
expect(playerInfo.clan).toBe("ABCDE");
});
test("should extract clan from name when format is [XxXxX]Name", () => {
test("should extract uppercase clan from name when format contains [XxXxX]", () => {
const playerInfo = new PlayerInfo(
"[AbCdE]PlayerName",
PlayerType.Human,
null,
"player_id",
);
expect(playerInfo.clan).toBe("AbCdE");
expect(playerInfo.clan).toBe("ABCDE");
});
test("should return null when name doesn't start with [", () => {
test("should extract uppercase clan from name when format contains [Xx#xX]", () => {
const playerInfo = new PlayerInfo(
"[Ab1cD]PlayerName",
PlayerType.Human,
null,
"player_id",
);
expect(playerInfo.clan).toBe("AB1CD");
});
test("should return null when name doesn't contain [", () => {
const playerInfo = new PlayerInfo(
"PlayerName",
PlayerType.Human,
@@ -82,7 +92,7 @@ describe("PlayerInfo", () => {
expect(playerInfo.clan).toBeNull();
});
test("should return null when clan tag is not 2-5 uppercase letters", () => {
test("should return null when clan tag is not 2-5 alphanumeric letters", () => {
const playerInfo = new PlayerInfo(
"[A]PlayerName",
PlayerType.Human,
@@ -94,7 +104,7 @@ describe("PlayerInfo", () => {
test("should return null when clan tag contains non alphanumeric characters", () => {
const playerInfo = new PlayerInfo(
"[A1c]PlayerName",
"[A?c]PlayerName",
PlayerType.Human,
null,
"player_id",
@@ -111,5 +121,95 @@ describe("PlayerInfo", () => {
);
expect(playerInfo.clan).toBeNull();
});
test("should extract uppercase clan name from any location in the player name", () => {
const playerInfo = new PlayerInfo(
"Player[aa]Name",
PlayerType.Human,
null,
"player_id",
);
expect(playerInfo.clan).toBe("AA");
});
test("should extract only the first occurrence of a clan name match", () => {
const playerInfo = new PlayerInfo(
"[Ab1cD]Player[aa]Name",
PlayerType.Human,
null,
"player_id",
);
expect(playerInfo.clan).toBe("AB1CD");
});
test("should extract only the first occurrence of a valid clan name match and extract as uppercase", () => {
const playerInfo = new PlayerInfo(
"[Ab1cDEF]Player[aa]Name",
PlayerType.Human,
null,
"player_id",
);
expect(playerInfo.clan).toBe("AA");
});
test("should extract numeric-only clan names", () => {
const playerInfo = new PlayerInfo(
"[012]PlayerName",
PlayerType.Human,
null,
"player_id",
);
expect(playerInfo.clan).toBe("012");
});
test("should extract numeric-only clan names and only the first valid clan name", () => {
const playerInfo = new PlayerInfo(
"[012]Player[aa]Name",
PlayerType.Human,
null,
"player_id",
);
expect(playerInfo.clan).toBe("012");
});
test("should extract numeric-only clan names from anywhere within the name", () => {
const playerInfo = new PlayerInfo(
"Player[012]Name",
PlayerType.Human,
null,
"player_id",
);
expect(playerInfo.clan).toBe("012");
});
test("should extract numeric-only clan names from the end of the name", () => {
const playerInfo = new PlayerInfo(
"PlayerName[012]",
PlayerType.Human,
null,
"player_id",
);
expect(playerInfo.clan).toBe("012");
});
test("should extract uppercase alphanumeric clan names from anywhere within the name", () => {
const playerInfo = new PlayerInfo(
"Player[0a1B2]Name",
PlayerType.Human,
null,
"player_id",
);
expect(playerInfo.clan).toBe("0A1B2");
});
test("should extract uppercase alphanumeric clan names from the end of the name", () => {
const playerInfo = new PlayerInfo(
"PlayerName[0a1B2]",
PlayerType.Human,
null,
"player_id",
);
expect(playerInfo.clan).toBe("0A1B2");
});
});
});
+4
View File
@@ -46,6 +46,10 @@ export class TestConfig extends DefaultConfig {
return 20;
}
deletionMarkDuration(): number {
return 5;
}
defaultSamRange(): number {
return 20;
}
+3
View File
@@ -4,6 +4,9 @@ import { GameMapType } from "../../src/core/game/Game";
import { GameID } from "../../src/core/Schemas";
export class TestServerConfig implements ServerConfig {
enableMatchmaking(): boolean {
throw new Error("Method not implemented.");
}
apiKey(): string {
throw new Error("Method not implemented.");
}