Merge branch 'main' into fix-sam-bug

This commit is contained in:
Abdallah Bahrawi
2026-02-16 21:58:54 +02:00
committed by GitHub
84 changed files with 1626 additions and 688 deletions
+5
View File
@@ -44,6 +44,11 @@ Licensed under [Open Data Commons Open Database License (ODbL)](https://opendata
Copernicus Global Digital Elevation Models distributed by OpenTopography.
Copyright © opentopography.org. All Rights Reserved. [Terms of Use](https://opentopography.org/usageterms)
### Hawaii Relief Map
[USA Hawaii relief location map](https://commons.wikimedia.org/wiki/File:USA_Hawaii_relief_location_map.svg) by NordNordWest
Licensed under [CC BY-SA 3.0 DE](https://creativecommons.org/licenses/by-sa/3.0/de/deed.en)
## Icons
### [The Noun Project](https://thenounproject.com/)
+4 -2
View File
@@ -249,7 +249,7 @@
<control-panel></control-panel>
</div>
<div
class="order-1 sm:order-none w-full sm:w-1/2 min-[1200px]:w-auto min-[1200px]:fixed min-[1200px]:right-0 min-[1200px]:bottom-0 flex flex-col sm:items-end pointer-events-none"
class="order-1 sm:order-none w-full sm:flex-1 min-[1200px]:w-auto min-[1200px]:fixed min-[1200px]:right-0 min-[1200px]:bottom-0 flex flex-col sm:items-end pointer-events-none"
>
<chat-display></chat-display>
<events-display></events-display>
@@ -262,7 +262,9 @@
<win-modal></win-modal>
<game-starting-modal></game-starting-modal>
<unit-display></unit-display>
<div class="flex flex-col items-end fixed top-4 right-4 z-1000 gap-2">
<div
class="flex flex-col items-end fixed top-0 right-0 min-[1200px]:top-4 min-[1200px]:right-4 z-1000 gap-2"
>
<game-right-sidebar></game-right-sidebar>
<replay-panel></replay-panel>
</div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

@@ -0,0 +1,50 @@
{
"name": "Hawaii",
"nations": [
{
"coordinates": [283, 281],
"name": "Niihau",
"flag": "us"
},
{
"coordinates": [613, 189],
"name": "Kauai",
"flag": "us"
},
{
"coordinates": [1424, 525],
"name": "Oahu",
"flag": "us"
},
{
"coordinates": [1930, 708],
"name": "Molokai",
"flag": "us"
},
{
"coordinates": [1977, 874],
"name": "Lanai",
"flag": "us"
},
{
"coordinates": [2145, 1036],
"name": "Kahoolawe",
"flag": "us"
},
{
"coordinates": [2287, 900],
"name": "Maui",
"flag": "us"
},
{
"coordinates": [2550, 1550],
"name": "Kona",
"flag": "us"
},
{
"coordinates": [2900, 1500],
"name": "Hilo",
"flag": "us"
}
]
}
+1
View File
@@ -69,6 +69,7 @@ var maps = []struct {
{Name: "amazonriver"},
{Name: "yenisei"},
{Name: "tradersdream"},
{Name: "hawaii"},
{Name: "big_plains", IsTest: true},
{Name: "half_land_half_ocean", IsTest: true},
{Name: "ocean_and_land", IsTest: true},
+3 -24
View File
@@ -1232,7 +1232,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -1276,7 +1275,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -2210,7 +2208,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -4604,7 +4601,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.32.tgz",
"integrity": "sha512-3jigKqgSjsH6gYZv2nEsqdXfZqIFGAV36XYYjf9KGZ3PSG+IhLecqPnI310RvjutyMwifE2hhhNEklOUrvx/wA==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -4770,7 +4766,6 @@
"integrity": "sha512-4O3idHxhyzjClSMJ0a29AcoK0+YwnEqzI6oz3vlRf3xw0zbzt15MzXwItOlnr5nIth6zlY2RENLsOPvhyrKAQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.34.1",
"@typescript-eslint/types": "8.34.1",
@@ -5250,7 +5245,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5650,7 +5644,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001718",
"electron-to-chromium": "^1.5.160",
@@ -5804,7 +5797,6 @@
"integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"assertion-error": "^2.0.1",
"check-error": "^2.1.1",
@@ -6726,7 +6718,6 @@
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"dev": true,
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -7296,7 +7287,6 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8627,7 +8617,6 @@
"integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@acemir/cssom": "^0.9.28",
"@asamuzakjp/dom-selector": "^6.7.6",
@@ -10223,7 +10212,6 @@
"integrity": "sha512-dyuThzncsgEgJZnvd/A/5x6IkUERbK+phXqUQrI+0C6WE+8xqGH5VChRTLecemhgZF0kQ+gZOM3tJTX9937xpg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@pixi/colord": "^2.9.6",
"@types/css-font-loading-module": "^0.0.12",
@@ -10268,7 +10256,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -10371,7 +10358,6 @@
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -10488,9 +10474,9 @@
}
},
"node_modules/qs": {
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
@@ -11194,7 +11180,6 @@
"integrity": "sha512-Z0NVCW45W8Mg5oC/27/+fCqIHFnW8kpkFOq0j9XJIev4Ld0mKmERaZv5DMLAb9fGCevjKwaEeIQz5+MBXfZcDw==",
"dev": true,
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"@sinonjs/commons": "^3.0.1",
"@sinonjs/fake-timers": "^15.1.0",
@@ -11616,7 +11601,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -11859,7 +11843,6 @@
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.3.tgz",
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -11928,7 +11911,6 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -12075,7 +12057,6 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -12825,7 +12806,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -12839,7 +12819,6 @@
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.0.16",
"@vitest/mocker": "4.0.16",
+1 -1
View File
@@ -12,7 +12,7 @@
"docs:map-generator": "cd map-generator && go doc -cmd -u -all",
"tunnel": "npm run build-prod && npm run start:server",
"test": "vitest run && vitest run tests/server",
"perf": "npx tsx tests/perf/*.ts",
"perf": "npx tsx tests/perf/run-all.ts",
"test:coverage": "vitest run --coverage",
"format": "prettier --ignore-unknown --write .",
"format:map-generator": "cd map-generator && go fmt .",
+5
View File
@@ -1533,6 +1533,11 @@
"continent": "Europe",
"name": "Norway"
},
{
"code": "1_Occitania",
"continent": "Europe",
"name": "Occitania"
},
{
"code": "Ohio",
"continent": "North America",
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 21 KiB

+12 -2
View File
@@ -328,7 +328,8 @@
"didier": "Didier",
"didierfrance": "Didier (France)",
"amazonriver": "Amazon River",
"tradersdream": "Traders Dream"
"tradersdream": "Traders Dream",
"hawaii": "Hawaii"
},
"map_categories": {
"featured": "Featured",
@@ -713,6 +714,7 @@
"show_units": "Show Units"
},
"events_display": {
"events": "Events",
"retreating": "retreating",
"alliance_request_status": "{name} {status} your alliance request",
"alliance_accepted": "accepted",
@@ -847,7 +849,8 @@
"random_spawn": "Random spawn is enabled. Selecting starting location for you...",
"singleplayer_game_paused": "Game paused",
"multiplayer_game_paused": "Game paused by Lobby Creator",
"pvp_immunity_active": "PVP immunity active for {seconds}s"
"pvp_immunity_active": "PVP immunity active for {seconds}s",
"catching_up": "Catching up..."
},
"territory_patterns": {
"title": "Skins",
@@ -859,6 +862,13 @@
"pattern": {
"default": "Default"
},
"try_me": "Try me!",
"trial_remaining": "remaining",
"trial_granted": "Skin trial granted!",
"trial_cooldown": "Only one trial per 24 hours. Please try again later.",
"trial_login_required": "Must be logged in to trial a skin",
"reward_countdown": "Reward in {seconds} seconds...",
"steam_wishlist_prompt": "Support OpenFront by adding it to your Steam wishlist",
"select_skin": "Select Skin",
"selected": "selected"
},
+65
View File
@@ -0,0 +1,65 @@
{
"map": {
"height": 2076,
"num_land_tiles": 408264,
"width": 3200
},
"map16x": {
"height": 519,
"num_land_tiles": 24703,
"width": 800
},
"map4x": {
"height": 1038,
"num_land_tiles": 100951,
"width": 1600
},
"name": "Hawaii",
"nations": [
{
"coordinates": [283, 281],
"flag": "us",
"name": "Niihau"
},
{
"coordinates": [613, 189],
"flag": "us",
"name": "Kauai"
},
{
"coordinates": [1424, 525],
"flag": "us",
"name": "Oahu"
},
{
"coordinates": [1930, 708],
"flag": "us",
"name": "Molokai"
},
{
"coordinates": [1977, 874],
"flag": "us",
"name": "Lanai"
},
{
"coordinates": [2145, 1036],
"flag": "us",
"name": "Kahoolawe"
},
{
"coordinates": [2287, 900],
"flag": "us",
"name": "Maui"
},
{
"coordinates": [2550, 1550],
"flag": "us",
"name": "Kona"
},
{
"coordinates": [2900, 1500],
"flag": "us",
"name": "Hilo"
}
]
}
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: 4.9 KiB

+25
View File
@@ -125,6 +125,31 @@ export async function createCheckoutSession(
}
}
export async function grantTemporaryFlare(flare: string): Promise<boolean> {
try {
const response = await fetch(`${getApiBase()}/flares_granted/temporary`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: await getAuthHeader(),
},
body: JSON.stringify({ flare }),
});
if (!response.ok) {
console.error(
"grantTemporaryFlare: request failed",
response.status,
response.statusText,
);
return false;
}
return true;
} catch (e) {
console.error("grantTemporaryFlare: request failed", e);
return false;
}
}
export function getApiBase() {
const domainname = getAudience();
+13
View File
@@ -10,6 +10,7 @@ export type UserAuth = { jwt: string; claims: TokenPayload } | false;
const PERSISTENT_ID_KEY = "player_persistent_id";
let __jwt: string | null = null;
let __refreshPromise: Promise<void> | null = null;
export function discordLogin() {
const redirectUri = encodeURIComponent(window.location.href);
@@ -138,6 +139,18 @@ export async function userAuth(
}
async function refreshJwt(): Promise<void> {
if (__refreshPromise) {
return __refreshPromise;
}
__refreshPromise = doRefreshJwt();
try {
await __refreshPromise;
} finally {
__refreshPromise = null;
}
}
async function doRefreshJwt(): Promise<void> {
try {
console.log("Refreshing jwt");
const response = await fetch(getApiBase() + "/auth/refresh", {
+98 -3
View File
@@ -5,7 +5,15 @@ import {
CosmeticsSchema,
Pattern,
} from "../core/CosmeticSchemas";
import { createCheckoutSession, getApiBase } from "./Api";
import {
PlayerCosmeticRefs,
PlayerCosmetics,
PlayerPattern,
} from "../core/Schemas";
import { UserSettings } from "../core/game/UserSettings";
import { createCheckoutSession, getApiBase, getUserMe } from "./Api";
export const TEMP_FLARE_OFFSET = 1 * 60 * 1000; // 1 minute
export async function handlePurchase(
pattern: Pattern,
@@ -77,14 +85,19 @@ export async function getCosmeticsHash(): Promise<string | null> {
return __cosmeticsHash;
}
// When a number is returned it signifies when the pattern expires.
export function patternRelationship(
pattern: Pattern,
colorPalette: { name: string; isArchived?: boolean } | null,
userMeResponse: UserMeResponse | false,
affiliateCode: string | null,
): "owned" | "purchasable" | "blocked" {
): "owned" | "purchasable" | "purchasable_no_trial" | "blocked" | number {
const flares =
userMeResponse === false ? [] : (userMeResponse.player.flares ?? []);
const expirations: Record<string, number> =
userMeResponse === false
? {}
: (userMeResponse.player.flareExpiration ?? {});
if (flares.includes("pattern:*")) {
return "owned";
}
@@ -100,6 +113,14 @@ export function patternRelationship(
const requiredFlare = `pattern:${pattern.name}:${colorPalette.name}`;
if (flares.includes(requiredFlare)) {
const expiresAt = expirations[requiredFlare];
if (expiresAt) {
if (expiresAt - Date.now() <= TEMP_FLARE_OFFSET) {
// Already expired or about to expire so just show it as purchasable.
return "purchasable";
}
return expiresAt;
}
return "owned";
}
@@ -118,6 +139,80 @@ export function patternRelationship(
return "blocked";
}
// Patterns is for sale, and it's the right store to show it on.
// --- Patterns is for sale, and it's the right store to show it on. ---
if (pattern.name === "custom") {
// Don't allow trying a custom pattern.
return "purchasable_no_trial";
}
return "purchasable";
}
export async function getPlayerCosmeticsRefs(): Promise<PlayerCosmeticRefs> {
const userSettings = new UserSettings();
const cosmetics = await fetchCosmetics();
let pattern: PlayerPattern | null =
userSettings.getSelectedPatternName(cosmetics);
if (pattern) {
const userMe = await getUserMe();
if (userMe) {
const flareName =
pattern.colorPalette?.name === undefined
? `pattern:${pattern.name}`
: `pattern:${pattern.name}:${pattern.colorPalette.name}`;
const flares = userMe.player.flares ?? [];
const expirations = userMe.player.flareExpiration ?? {};
const hasWildcard = flares.includes("pattern:*");
if (!hasWildcard) {
if (!flares.includes(flareName)) {
pattern = null;
} else if (expirations[flareName]) {
if (expirations[flareName]! - Date.now() <= TEMP_FLARE_OFFSET) {
pattern = null;
}
}
}
}
if (pattern === null) {
userSettings.setSelectedPatternName(undefined);
}
}
return {
flag: userSettings.getFlag(),
color: userSettings.getSelectedColor() ?? undefined,
patternName: pattern?.name ?? undefined,
patternColorPaletteName: pattern?.colorPalette?.name ?? undefined,
};
}
export async function getPlayerCosmetics(): Promise<PlayerCosmetics> {
const refs = await getPlayerCosmeticsRefs();
const cosmetics = await fetchCosmetics();
const result: PlayerCosmetics = {};
if (refs.flag) {
result.flag = refs.flag;
}
if (refs.color) {
result.color = { color: refs.color };
}
if (refs.patternName && cosmetics) {
const pattern = cosmetics.patterns[refs.patternName];
if (pattern) {
result.pattern = {
name: refs.patternName,
patternData: pattern.pattern,
colorPalette: refs.patternColorPaletteName
? cosmetics.colorPalettes?.[refs.patternColorPaletteName]
: undefined,
};
}
}
return result;
}
+8 -14
View File
@@ -10,7 +10,7 @@ import "./AccountModal";
import { getUserMe } from "./Api";
import { userAuth } from "./Auth";
import { joinLobby } from "./ClientGameRunner";
import { fetchCosmetics } from "./Cosmetics";
import { getPlayerCosmeticsRefs } from "./Cosmetics";
import { crazyGamesSDK } from "./CrazyGamesSDK";
import "./FlagInput";
import { FlagInput } from "./FlagInput";
@@ -182,6 +182,12 @@ declare global {
onPlayerReady: (() => void) | null;
addUnits: (units: Array<{ type: string }>) => Promise<void>;
displayUnits: () => void;
// Rewarded video ad methods
manuallyCreateRewardUi?: (options: {
skipConfirmation?: boolean;
watchAdId?: string;
closeId?: string;
}) => Promise<void> | void;
};
Bolt: {
on: (unitType: string, event: string, callback: () => void) => void;
@@ -798,24 +804,12 @@ class Client {
this.updateJoinUrlForShare(lobby.gameID, config);
}
const pattern = this.userSettings.getSelectedPatternName(
await fetchCosmetics(),
);
this.gameStop = joinLobby(
this.eventBus,
{
gameID: lobby.gameID,
serverConfig: config,
cosmetics: {
color: this.userSettings.getSelectedColor() ?? undefined,
patternName: pattern?.name ?? undefined,
patternColorPaletteName: pattern?.colorPalette?.name ?? undefined,
flag:
this.flagInput === null || this.flagInput.getCurrentFlag() === "xx"
? ""
: this.flagInput.getCurrentFlag(),
},
cosmetics: await getPlayerCosmeticsRefs(),
turnstileToken: await this.getTurnstileToken(lobby),
playerName:
this.usernameInput?.getCurrentUsername() ?? genAnonUsername(),
+30
View File
@@ -0,0 +1,30 @@
const GITHUB_PR_URL_REGEX =
/(?<!\()\bhttps:\/\/github\.com\/openfrontio\/OpenFrontIO\/pull\/(\d+)\b/g;
const GITHUB_COMPARE_URL_REGEX =
/(?<!\()\bhttps:\/\/github\.com\/openfrontio\/OpenFrontIO\/compare\/([\w.-]+)\b/g;
const GITHUB_MENTION_REGEX =
/(^|[^\w/[`])@([a-z\d](?:[a-z\d-]{0,37}[a-z\d])?)(?![\w-])/gim;
export function normalizeNewsMarkdown(markdown: string): string {
return (
markdown
// Convert bold header lines (e.g. "**Title**") into real Markdown headers.
// Exclude lines starting with - or * to avoid converting bullet points.
.replace(/^([^\-*\s].*?) \*\*(.+?)\*\*$/gm, "## $1 $2")
.replace(
GITHUB_PR_URL_REGEX,
(_match, prNumber) =>
`[#${prNumber}](https://github.com/openfrontio/OpenFrontIO/pull/${prNumber})`,
)
.replace(
GITHUB_COMPARE_URL_REGEX,
(_match, comparison) =>
`[${comparison}](https://github.com/openfrontio/OpenFrontIO/compare/${comparison})`,
)
.replace(
GITHUB_MENTION_REGEX,
(_match, prefix, username) =>
`${prefix}[@${username}](https://github.com/${username})`,
)
);
}
+4 -17
View File
@@ -6,6 +6,7 @@ import { translateText } from "../client/Utils";
import "./components/baseComponents/Modal";
import { BaseModal } from "./components/BaseModal";
import { modalHeader } from "./components/ui/ModalHeader";
import { normalizeNewsMarkdown } from "./NewsMarkdown";
import changelog from "/changelog.md?url";
import megaphone from "/images/Megaphone.svg?url";
@@ -67,23 +68,9 @@ export class NewsModal extends BaseModal {
this.initialized = true;
fetch(`${changelog}?v=${encodeURIComponent(version.trim())}`)
.then((response) => (response.ok ? response.text() : "Failed to load"))
.then((markdown) =>
markdown
// Convert bold header lines (e.g. "**Title**") into real Markdown headers
// Exclude lines starting with - or * to avoid converting bullet points
.replace(/^([^\-*\s].*?) \*\*(.+?)\*\*$/gm, "## $1 $2")
.replace(
/(?<!\()\bhttps:\/\/github\.com\/openfrontio\/OpenFrontIO\/pull\/(\d+)\b/g,
(_match, prNumber) =>
`[#${prNumber}](https://github.com/openfrontio/OpenFrontIO/pull/${prNumber})`,
)
.replace(
/(?<!\()\bhttps:\/\/github\.com\/openfrontio\/OpenFrontIO\/compare\/([\w.-]+)\b/g,
(_match, comparison) =>
`[${comparison}](https://github.com/openfrontio/OpenFrontIO/compare/${comparison})`,
),
)
.then((markdown) => (this.markdown = markdown));
.then((markdown) => normalizeNewsMarkdown(markdown))
.then((markdown) => (this.markdown = markdown))
.catch(() => (this.markdown = "Failed to load"));
}
}
}
+8 -21
View File
@@ -1,10 +1,8 @@
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { Cosmetics } from "../core/CosmeticSchemas";
import { UserSettings } from "../core/game/UserSettings";
import { PlayerPattern } from "../core/Schemas";
import { renderPatternPreview } from "./components/PatternButton";
import { fetchCosmetics } from "./Cosmetics";
import { getPlayerCosmetics } from "./Cosmetics";
import { crazyGamesSDK } from "./CrazyGamesSDK";
import { translateText } from "./Utils";
@@ -17,24 +15,14 @@ export class PatternInput extends LitElement {
@property({ type: Boolean, attribute: "show-select-label" })
public showSelectLabel: boolean = false;
private userSettings = new UserSettings();
private cosmetics: Cosmetics | null = null;
private _abortController: AbortController | null = null;
private _onPatternSelected = () => {
this.updateFromSettings();
private _onPatternSelected = async () => {
const cosmetics = await getPlayerCosmetics();
this.selectedColor = cosmetics.color?.color ?? null;
this.pattern = cosmetics.pattern ?? null;
};
private updateFromSettings() {
this.selectedColor = this.userSettings.getSelectedColor() ?? null;
if (this.cosmetics) {
this.pattern = this.userSettings.getSelectedPatternName(this.cosmetics);
} else {
this.pattern = null;
}
}
private onInputClick(e: Event) {
e.preventDefault();
e.stopPropagation();
@@ -50,10 +38,9 @@ export class PatternInput extends LitElement {
super.connectedCallback();
this._abortController = new AbortController();
this.isLoading = true;
const cosmetics = await fetchCosmetics();
if (!this.isConnected) return;
this.cosmetics = cosmetics;
this.updateFromSettings();
const cosmetics = await getPlayerCosmetics();
this.selectedColor = cosmetics.color?.color ?? null;
this.pattern = cosmetics.pattern ?? null;
if (!this.isConnected) return;
this.isLoading = false;
window.addEventListener("pattern-selected", this._onPatternSelected, {
+3 -2
View File
@@ -49,7 +49,8 @@ export class PublicLobby extends LitElement {
if (this.publicGames) {
this.serverTimeOffset = this.publicGames.serverTime - Date.now();
}
this.publicGames.games.forEach((l) => {
// TODO: thihs is just a temporary scaffolding until PR #3191 is merged.
this.publicGames.games["ffa"]?.forEach((l) => {
if (!this.lobbyIDToStart.has(l.gameID)) {
// Convert server's startsAt to client time by subtracting offset
const startsAt = l.startsAt ?? Date.now();
@@ -77,7 +78,7 @@ export class PublicLobby extends LitElement {
render() {
if (!this.publicGames) return html``;
const lobby = this.publicGames.games[0];
const lobby = this.publicGames.games["ffa"]?.[0];
if (!lobby?.gameConfig) return html``;
const start = this.lobbyIDToStart.get(lobby.gameID) ?? 0;
+179
View File
@@ -0,0 +1,179 @@
let rewardedUnitRegistered = false;
let rewardedAdReady = false;
// Listen for when rewarded ad becomes available
if (typeof window !== "undefined") {
window.addEventListener("rewardedAdVideoRewardReady", () => {
console.log("[RewardedVideoPromo] Rewarded ad is ready");
rewardedAdReady = true;
});
}
const AD_READY_TIMEOUT_MS = 3000;
function ensureRewardedUnitRegistered(): Promise<void> {
console.log("[ensureRewardedUnitRegistered] Called", {
rewardedUnitRegistered,
rewardedAdReady,
hasSpaAddAds: !!window.ramp?.spaAddAds,
});
return new Promise((resolve, reject) => {
// Check for real SDK (not just stub from index.html)
if (!window.ramp?.spaAddAds) {
console.log(
"[ensureRewardedUnitRegistered] Rejecting: spaAddAds not available",
);
reject(new Error("Ramp SDK not available"));
return;
}
// If already registered and ready, resolve immediately
if (rewardedUnitRegistered && rewardedAdReady) {
console.log(
"[ensureRewardedUnitRegistered] Already registered and ready",
);
resolve();
return;
}
// Register the unit if not already registered
if (!rewardedUnitRegistered) {
try {
window.ramp.spaAddAds([{ type: "rewarded_ad_video", selectorId: "" }]);
rewardedUnitRegistered = true;
console.log("[RewardedVideoPromo] Rewarded unit registered");
} catch (e) {
reject(e);
return;
}
}
// If ad is already ready, resolve
if (rewardedAdReady) {
console.log("[ensureRewardedUnitRegistered] Ad already ready");
resolve();
return;
}
// Wait for the rewardedAdVideoRewardReady event or no-fill event
console.log("[ensureRewardedUnitRegistered] Waiting for ad to be ready...");
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const cleanup = () => {
if (timeoutId) clearTimeout(timeoutId);
window.removeEventListener("rewardedAdVideoRewardReady", onReady);
window.removeEventListener("rewardedVideoNoFill", onNoFill);
window.removeEventListener("rewardedAdNoFill", onNoFill);
window.removeEventListener("pwNoFillEvent", onNoFill);
};
const onReady = () => {
console.log("[ensureRewardedUnitRegistered] Ad is now ready");
cleanup();
resolve();
};
const onNoFill = () => {
console.log("[ensureRewardedUnitRegistered] No fill event received");
cleanup();
reject(new Error("No rewarded ad available"));
};
timeoutId = setTimeout(() => {
cleanup();
console.log("[ensureRewardedUnitRegistered] Timeout waiting for ad");
reject(new Error("Ad timeout"));
}, AD_READY_TIMEOUT_MS);
window.addEventListener("rewardedAdVideoRewardReady", onReady);
window.addEventListener("rewardedVideoNoFill", onNoFill);
window.addEventListener("rewardedAdNoFill", onNoFill);
window.addEventListener("pwNoFillEvent", onNoFill);
});
}
export function showRewardedAd(): Promise<void> {
console.log("[showRewardedAd] Called", {
rewardedUnitRegistered,
});
return new Promise((resolve, reject) => {
console.log("[showRewardedAd] Calling ensureRewardedUnitRegistered...");
ensureRewardedUnitRegistered()
.then(() => {
console.log("[showRewardedAd] ensureRewardedUnitRegistered resolved");
if (!window.ramp?.manuallyCreateRewardUi) {
reject(new Error("Ramp SDK manuallyCreateRewardUi not available"));
return;
}
// Set up event listeners before triggering the ad
const cleanup = () => {
window.removeEventListener(
"rewardedAdRewardGranted",
onRewardGranted,
);
window.removeEventListener("rewardedAdCompleted", onCompleted);
window.removeEventListener("rewardedCloseButtonTriggered", onClosed);
window.removeEventListener("rejectAdCloseCta", onRejected);
// Destroy old unit and reset state so next ad attempt will re-register
try {
window.ramp?.destroyUnits?.("rewarded_ad_video");
} catch (e) {
console.error("[showRewardedAd] Failed to destroy unit:", e);
}
rewardedUnitRegistered = false;
rewardedAdReady = false;
};
const onRewardGranted = () => {
console.log("[showRewardedAd] Reward granted");
cleanup();
resolve();
};
const onCompleted = () => {
console.log("[showRewardedAd] Ad completed without reward");
// Don't resolve here - wait for rewardedAdRewardGranted
};
const onClosed = () => {
console.log("[showRewardedAd] User closed ad early");
cleanup();
reject(new Error("User closed ad early"));
};
const onRejected = () => {
console.log("[showRewardedAd] User rejected ad");
cleanup();
reject(new Error("User rejected ad"));
};
window.addEventListener("rewardedAdRewardGranted", onRewardGranted);
window.addEventListener("rewardedAdCompleted", onCompleted);
window.addEventListener("rewardedCloseButtonTriggered", onClosed);
window.addEventListener("rejectAdCloseCta", onRejected);
// Trigger the ad
const result = window.ramp.manuallyCreateRewardUi({
skipConfirmation: true,
});
// If it returns a promise that rejects, handle that too
if (result && typeof result.then === "function") {
result.catch((error: unknown) => {
cleanup();
reject(error);
});
}
})
.catch((err) => {
console.log(
"[showRewardedAd] ensureRewardedUnitRegistered rejected:",
err,
);
reject(err);
});
});
}
+2 -25
View File
@@ -11,7 +11,6 @@ import {
HumansVsNations,
UnitType,
} from "../core/game/Game";
import { UserSettings } from "../core/game/UserSettings";
import { TeamCountConfig } from "../core/Schemas";
import { generateID } from "../core/Util";
import { hasLinkedAccount } from "./Api";
@@ -21,9 +20,8 @@ import { BaseModal } from "./components/BaseModal";
import "./components/GameConfigSettings";
import "./components/ToggleInputCard";
import { modalHeader } from "./components/ui/ModalHeader";
import { fetchCosmetics } from "./Cosmetics";
import { getPlayerCosmetics } from "./Cosmetics";
import { crazyGamesSDK } from "./CrazyGamesSDK";
import { FlagInput } from "./FlagInput";
import { JoinLobbyEvent } from "./Main";
import { UsernameInput } from "./UsernameInput";
import {
@@ -90,8 +88,6 @@ export class SinglePlayerModal extends BaseModal {
...DEFAULT_OPTIONS.disabledUnits,
];
private userSettings: UserSettings = new UserSettings();
connectedCallback() {
super.connectedCallback();
document.addEventListener(
@@ -624,18 +620,6 @@ export class SinglePlayerModal extends BaseModal {
console.warn("Username input element not found");
}
const flagInput = document.querySelector("flag-input") as FlagInput;
if (!flagInput) {
console.warn("Flag input element not found");
}
const cosmetics = await fetchCosmetics();
let selectedPattern = this.userSettings.getSelectedPatternName(cosmetics);
selectedPattern ??= cosmetics
? (this.userSettings.getDevOnlyPattern() ?? null)
: null;
const selectedColor = this.userSettings.getSelectedColor();
await crazyGamesSDK.requestMidgameAd();
this.dispatchEvent(
@@ -648,14 +632,7 @@ export class SinglePlayerModal extends BaseModal {
{
clientID,
username: usernameInput.getCurrentUsername(),
cosmetics: {
flag:
flagInput.getCurrentFlag() === "xx"
? ""
: flagInput.getCurrentFlag(),
pattern: selectedPattern ?? undefined,
color: selectedColor ? { color: selectedColor } : undefined,
},
cosmetics: await getPlayerCosmetics(),
},
],
config: {
+26 -18
View File
@@ -12,8 +12,10 @@ import "./components/PatternButton";
import { modalHeader } from "./components/ui/ModalHeader";
import {
fetchCosmetics,
getPlayerCosmetics,
handlePurchase,
patternRelationship,
TEMP_FLARE_OFFSET,
} from "./Cosmetics";
import { translateText } from "./Utils";
@@ -37,8 +39,8 @@ export class TerritoryPatternsModal extends BaseModal {
private userMeResponse: UserMeResponse | false = false;
private _onPatternSelected = () => {
this.updateFromSettings();
private _onPatternSelected = async () => {
await this.updateFromSettings();
this.refresh();
};
@@ -62,24 +64,16 @@ export class TerritoryPatternsModal extends BaseModal {
window.removeEventListener("pattern-selected", this._onPatternSelected);
}
private updateFromSettings() {
this.selectedPattern =
this.cosmetics !== null
? this.userSettings.getSelectedPatternName(this.cosmetics)
: null;
this.selectedColor = this.userSettings.getSelectedColor() ?? null;
private async updateFromSettings() {
const cosmetics = await getPlayerCosmetics();
this.selectedPattern = cosmetics.pattern ?? null;
this.selectedColor = cosmetics.color?.color ?? null;
}
async onUserMe(userMeResponse: UserMeResponse | false) {
if (!hasLinkedAccount(userMeResponse)) {
this.userSettings.setSelectedPatternName(undefined);
this.userSettings.setSelectedColor(undefined);
this.selectedPattern = null;
this.selectedColor = null;
}
this.userMeResponse = userMeResponse;
this.cosmetics = await fetchCosmetics();
this.updateFromSettings();
await this.updateFromSettings();
this.refresh();
}
@@ -130,7 +124,7 @@ export class TerritoryPatternsModal extends BaseModal {
? [...(pattern.colorPalettes ?? []), null]
: [null];
for (const colorPalette of colorPalettes) {
let rel = "owned";
let rel: string | number = "owned";
if (pattern) {
rel = patternRelationship(
pattern,
@@ -142,8 +136,9 @@ export class TerritoryPatternsModal extends BaseModal {
if (rel === "blocked") {
continue;
}
const isTrial = typeof rel === "number";
if (this.showOnlyOwned) {
if (rel !== "owned") continue;
if (rel !== "owned" && !isTrial) continue;
} else {
// Store mode: hide owned items
if (rel === "owned") continue;
@@ -163,7 +158,20 @@ export class TerritoryPatternsModal extends BaseModal {
.colorPalette=${this.cosmetics?.colorPalettes?.[
colorPalette?.name ?? ""
] ?? null}
.requiresPurchase=${rel === "purchasable"}
.requiresPurchase=${rel === "purchasable" ||
rel === "purchasable_no_trial"}
.allowTrial=${rel === "purchasable"}
.hasLinkedAccount=${hasLinkedAccount(this.userMeResponse)}
.trialCooldown=${this.userMeResponse !== false &&
this.userMeResponse.player.tempFlaresCooldown}
.trialTimeRemaining=${isTrial
? Math.max(
0,
Math.floor(
((rel as number) - TEMP_FLARE_OFFSET - Date.now()) / 1000,
),
)
: 0}
.selected=${isSelected}
.onSelect=${(p: PlayerPattern | null) => this.selectPattern(p)}
.onPurchase=${(p: Pattern, colorPalette: ColorPalette | null) =>
+6 -12
View File
@@ -55,13 +55,8 @@ export class SendUpgradeStructureIntentEvent implements GameEvent {
) {}
}
export class SendAllianceReplyIntentEvent implements GameEvent {
constructor(
// The original alliance requestor
public readonly requestor: PlayerView,
public readonly recipient: PlayerView,
public readonly accepted: boolean,
) {}
export class SendAllianceRejectIntentEvent implements GameEvent {
constructor(public readonly requestor: PlayerView) {}
}
export class SendAllianceExtensionIntentEvent implements GameEvent {
@@ -204,8 +199,8 @@ export class Transport {
this.eventBus.on(SendAllianceRequestIntentEvent, (e) =>
this.onSendAllianceRequest(e),
);
this.eventBus.on(SendAllianceReplyIntentEvent, (e) =>
this.onAllianceRequestReplyUIEvent(e),
this.eventBus.on(SendAllianceRejectIntentEvent, (e) =>
this.onAllianceRejectUIEvent(e),
);
this.eventBus.on(SendAllianceExtensionIntentEvent, (e) =>
this.onSendAllianceExtensionIntent(e),
@@ -447,11 +442,10 @@ export class Transport {
});
}
private onAllianceRequestReplyUIEvent(event: SendAllianceReplyIntentEvent) {
private onAllianceRejectUIEvent(event: SendAllianceRejectIntentEvent) {
this.sendIntent({
type: "allianceRequestReply",
type: "allianceReject",
requestor: event.requestor.id(),
accept: event.accepted,
});
}
+195 -3
View File
@@ -1,14 +1,17 @@
import { Colord } from "colord";
import { base64url } from "jose";
import { html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { customElement, property, state } from "lit/decorators.js";
import {
ColorPalette,
DefaultPattern,
Pattern,
} from "../../core/CosmeticSchemas";
import { UserSettings } from "../../core/game/UserSettings";
import { PatternDecoder } from "../../core/PatternDecoder";
import { PlayerPattern } from "../../core/Schemas";
import { grantTemporaryFlare } from "../Api";
import { showRewardedAd } from "../RewardedVideoPromo";
import { translateText } from "../Utils";
export const BUTTON_WIDTH = 150;
@@ -26,16 +29,64 @@ export class PatternButton extends LitElement {
@property({ type: Boolean })
requiresPurchase: boolean = false;
@property({ type: Number })
trialTimeRemaining: number = 0;
@property({ type: Boolean })
allowTrial: boolean = true;
@property({ type: Boolean })
trialCooldown: boolean = false;
@property({ type: Boolean })
hasLinkedAccount: boolean = false;
@property({ type: Function })
onSelect?: (pattern: PlayerPattern | null) => void;
@property({ type: Function })
onPurchase?: (pattern: Pattern, colorPalette: ColorPalette | null) => void;
private _countdownInterval: ReturnType<typeof setInterval> | null = null;
@state()
private _adLoading: boolean = false;
createRenderRoot() {
return this;
}
updated(changedProperties: Map<string, unknown>) {
if (changedProperties.has("trialTimeRemaining")) {
this.setupCountdown();
}
}
disconnectedCallback() {
super.disconnectedCallback();
this.clearCountdown();
}
private setupCountdown() {
this.clearCountdown();
if (this.trialTimeRemaining > 0) {
this._countdownInterval = setInterval(() => {
this.trialTimeRemaining--;
if (this.trialTimeRemaining <= 0) {
this.trialTimeRemaining = 0;
this.clearCountdown();
}
}, 1000);
}
}
private clearCountdown() {
if (this._countdownInterval !== null) {
clearInterval(this._countdownInterval);
this._countdownInterval = null;
}
}
private translateCosmetic(prefix: string, patternName: string): string {
const translation = translateText(`${prefix}.${patternName}`);
if (translation.startsWith(prefix)) {
@@ -60,6 +111,104 @@ export class PatternButton extends LitElement {
} satisfies PlayerPattern);
}
private async grantTrial() {
const flare =
this.colorPalette?.name === undefined
? `pattern:${this.pattern!.name}`
: `pattern:${this.pattern!.name}:${this.colorPalette.name}`;
await grantTemporaryFlare(flare);
new UserSettings().setSelectedPatternName(flare);
alert(translateText("territory_patterns.trial_granted"));
window.location.reload();
}
private showSteamModal(): Promise<void> {
return new Promise((resolve) => {
const overlay = document.createElement("div");
overlay.className =
"fixed inset-0 bg-black/80 flex items-center justify-center z-[9999]";
let secondsLeft = 10;
const updateContent = () => {
overlay.innerHTML = `
<div class="bg-slate-900 border border-white/20 rounded-xl p-8 max-w-md text-center">
<h2 class="text-2xl font-bold text-white mb-4">Wishlist on Steam!</h2>
<p class="text-white/70 mb-6">${translateText("territory_patterns.steam_wishlist_prompt")}</p>
<a
href="https://store.steampowered.com/app/3560670"
target="_blank"
rel="noopener noreferrer"
class="inline-block px-6 py-3 bg-[#1b2838] hover:bg-[#2a475e] text-white font-bold rounded-lg mb-6 transition-colors"
>
<span class="flex items-center gap-2">
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.979 0C5.678 0 .511 4.86.022 11.037l6.432 2.658a3.387 3.387 0 0 1 1.912-.59c.064 0 .128.003.191.006l2.866-4.158v-.058c0-2.495 2.03-4.524 4.524-4.524 2.494 0 4.524 2.031 4.524 4.527s-2.03 4.525-4.524 4.525h-.105l-4.091 2.921c0 .054.003.108.003.163 0 1.871-1.523 3.393-3.394 3.393-1.646 0-3.02-1.179-3.33-2.74L.453 15.406C1.727 20.279 6.228 24 11.979 24 18.627 24 24 18.627 24 12S18.627 0 11.979 0zM7.54 18.21l-1.473-.61c.262.543.714.999 1.314 1.25 1.297.539 2.793-.076 3.332-1.375.263-.63.264-1.319.005-1.949s-.75-1.121-1.377-1.383c-.624-.26-1.29-.249-1.878-.03l1.523.63c.956.4 1.409 1.5 1.009 2.455-.397.957-1.497 1.41-2.454 1.012zm11.415-9.303a3.015 3.015 0 0 0-3.015-3.015 3.015 3.015 0 1 0 3.015 3.015zm-5.273-.005c0-1.248 1.013-2.26 2.262-2.26a2.26 2.26 0 1 1 0 4.52 2.261 2.261 0 0 1-2.262-2.26z"/>
</svg>
Wishlist on Steam
</span>
</a>
<div class="text-white/50 text-sm">
${translateText("territory_patterns.reward_countdown", { seconds: secondsLeft.toString() })}
</div>
</div>
`;
};
updateContent();
document.body.appendChild(overlay);
const interval = setInterval(() => {
secondsLeft--;
if (secondsLeft <= 0) {
clearInterval(interval);
overlay.remove();
resolve();
} else {
updateContent();
}
}, 1000);
});
}
private async handleTryMe(e: Event) {
e.stopPropagation();
if (this.pattern === null || this._adLoading) return;
if (!this.hasLinkedAccount) {
alert(translateText("territory_patterns.trial_login_required"));
return;
}
if (this.trialCooldown) {
alert(translateText("territory_patterns.trial_cooldown"));
return;
}
console.log("[PatternButton] handleTryMe called");
this._adLoading = true;
try {
console.log("[PatternButton] Calling showRewardedAd...");
await showRewardedAd();
console.log("[PatternButton] showRewardedAd resolved");
await this.grantTrial();
} catch (error) {
console.error("[PatternButton] Rewarded ad failed:", error);
// Show Steam wishlist modal with countdown
await this.showSteamModal();
await this.grantTrial();
} finally {
this._adLoading = false;
}
}
private formatTimeRemaining(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
private handlePurchase(e: Event) {
e.stopPropagation();
if (this.pattern?.product) {
@@ -137,9 +286,52 @@ export class PatternButton extends LitElement {
</div>
</button>
${this.requiresPurchase && this.pattern?.product
${(this.requiresPurchase || this.trialTimeRemaining > 0) &&
this.pattern?.product
? html`
<div class="w-full mt-2">
<div class="w-full mt-2 flex flex-col gap-2">
${this.trialTimeRemaining > 0
? html`
<div
class="w-full px-4 py-2 bg-yellow-500/20 text-yellow-400 border border-yellow-500/30 rounded-lg text-xs font-bold uppercase tracking-wider text-center"
>
${this.formatTimeRemaining(this.trialTimeRemaining)}
${translateText("territory_patterns.trial_remaining")}
</div>
`
: this.allowTrial
? html`
<button
class="w-full px-4 py-2 bg-blue-500/20 text-blue-400 border border-blue-500/30 rounded-lg text-xs font-bold uppercase tracking-wider cursor-pointer transition-all duration-200
hover:bg-blue-500/30 hover:shadow-[0_0_15px_rgba(59,130,246,0.2)] flex items-center justify-center gap-2"
@click=${this.handleTryMe}
?disabled=${this._adLoading}
>
${this._adLoading
? html`<svg
class="animate-spin h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>`
: translateText("territory_patterns.try_me")}
</button>
`
: null}
<button
class="w-full px-4 py-2 bg-green-500/20 text-green-400 border border-green-500/30 rounded-lg text-xs font-bold uppercase tracking-wider cursor-pointer transition-all duration-200
hover:bg-green-500/30 hover:shadow-[0_0_15px_rgba(74,222,128,0.2)]"
+1 -1
View File
@@ -12,7 +12,7 @@ const CARD_LABEL_CLASS =
"text-xs uppercase font-bold tracking-wider leading-tight break-words hyphens-auto";
function cardClass(active: boolean, extra = ""): string {
return `w-full rounded-xl border cursor-pointer transition-all duration-200 active:scale-95 ${extra} ${active ? ACTIVE_CARD : INACTIVE_CARD}`;
return `w-full h-full rounded-xl border cursor-pointer transition-all duration-200 active:scale-95 ${extra} ${active ? ACTIVE_CARD : INACTIVE_CARD}`;
}
@customElement("toggle-input-card")
+19 -1
View File
@@ -15,6 +15,8 @@ export class MapDisplay extends LitElement {
@state() private mapName: string | null = null;
@state() private isLoading = true;
@state() private hasNations = true;
private observer: IntersectionObserver | null = null;
private dataLoaded = false;
createRenderRoot() {
return this;
@@ -22,7 +24,23 @@ export class MapDisplay extends LitElement {
connectedCallback() {
super.connectedCallback();
this.loadMapData();
this.observer = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting) && !this.dataLoaded) {
this.dataLoaded = true;
this.loadMapData();
this.observer?.disconnect();
}
},
{ rootMargin: "200px" },
);
this.observer.observe(this);
}
disconnectedCallback() {
this.observer?.disconnect();
this.observer = null;
super.disconnectedCallback();
}
private async loadMapData() {
+3
View File
@@ -98,6 +98,7 @@ export function createRenderer(
console.error("GameLeftSidebar element not found in the DOM");
}
gameLeftSidebar.game = game;
gameLeftSidebar.eventBus = eventBus;
const teamStats = document.querySelector("team-stats") as TeamStats;
if (!teamStats || !(teamStats instanceof TeamStats)) {
@@ -246,6 +247,7 @@ export function createRenderer(
console.error("spawn timer not found");
}
spawnTimer.game = game;
spawnTimer.eventBus = eventBus;
spawnTimer.transformHandler = transformHandler;
const immunityTimer = document.querySelector(
@@ -255,6 +257,7 @@ export function createRenderer(
console.error("immunity timer not found");
}
immunityTimer.game = game;
immunityTimer.eventBus = eventBus;
const inGameHeaderAd = document.querySelector(
"in-game-header-ad",
+16 -14
View File
@@ -221,7 +221,7 @@ export class AttacksDisplay extends LitElement implements Layer {
return this.incomingAttacks.map(
(attack) => html`
<div
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs rounded px-1.5 py-0.5 overflow-hidden"
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs min-[1200px]:rounded-lg sm:rounded-r-lg px-1.5 py-0.5 overflow-hidden"
>
${this.renderButton({
content: html`<img
@@ -232,7 +232,7 @@ export class AttacksDisplay extends LitElement implements Layer {
<span class="inline-block min-w-[3rem] text-right"
>${renderTroops(attack.troops)}</span
>
<span class="truncate"
<span class="truncate ml-1"
>${(
this.game.playerBySmallID(attack.attackerID) as PlayerView
)?.name()}</span
@@ -254,7 +254,7 @@ export class AttacksDisplay extends LitElement implements Layer {
/>`,
onClick: () => this.handleRetaliate(attack),
className:
"ml-auto inline-flex items-center justify-center cursor-pointer bg-red-900/50 hover:bg-red-800/70 rounded px-1.5 py-1 border border-red-700/50",
"ml-auto inline-flex items-center justify-center cursor-pointer bg-red-900/50 hover:bg-red-800/70 rounded-lg px-1.5 py-1 border border-red-700/50",
translate: false,
})
: ""}
@@ -269,7 +269,7 @@ export class AttacksDisplay extends LitElement implements Layer {
return this.outgoingAttacks.map(
(attack) => html`
<div
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs rounded px-1.5 py-0.5 overflow-hidden"
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs min-[1200px]:rounded-lg sm:rounded-r-lg px-1.5 py-0.5 overflow-hidden"
>
${this.renderButton({
content: html`<img
@@ -280,7 +280,7 @@ export class AttacksDisplay extends LitElement implements Layer {
<span class="inline-block min-w-[3rem] text-right"
>${renderTroops(attack.troops)}</span
>
<span class="truncate"
<span class="truncate ml-1"
>${(
this.game.playerBySmallID(attack.targetID) as PlayerView
)?.name()}</span
@@ -297,7 +297,7 @@ export class AttacksDisplay extends LitElement implements Layer {
className: "ml-auto text-left shrink-0",
disabled: attack.retreating,
})
: html`<span class="ml-auto shrink-0 text-blue-400"
: html`<span class="ml-auto truncate text-blue-400"
>(${translateText("events_display.retreating")}...)</span
>`}
</div>
@@ -311,7 +311,7 @@ export class AttacksDisplay extends LitElement implements Layer {
return this.outgoingLandAttacks.map(
(landAttack) => html`
<div
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs rounded px-1.5 py-0.5 overflow-hidden"
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs min-[1200px]:rounded-lg sm:rounded-r-lg px-1.5 py-0.5 overflow-hidden"
>
${this.renderButton({
content: html`<img
@@ -334,7 +334,7 @@ export class AttacksDisplay extends LitElement implements Layer {
className: "ml-auto text-left shrink-0",
disabled: landAttack.retreating,
})
: html`<span class="ml-auto shrink-0 text-blue-400"
: html`<span class="ml-auto truncate text-blue-400"
>(${translateText("events_display.retreating")}...)</span
>`}
</div>
@@ -367,14 +367,14 @@ export class AttacksDisplay extends LitElement implements Layer {
return this.outgoingBoats.map(
(boat) => html`
<div
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs rounded px-1.5 py-0.5 overflow-hidden"
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs min-[1200px]:rounded-lg sm:rounded-r-lg px-1.5 py-0.5 overflow-hidden"
>
${this.renderButton({
content: html`${this.renderBoatIcon(boat)}
<span class="inline-block min-w-[3rem] text-right"
>${renderTroops(boat.troops())}</span
>
<span class="truncate text-xs"
<span class="truncate text-xs ml-1"
>${this.getBoatTargetName(boat)}</span
>`,
onClick: () => this.eventBus.emit(new GoToUnitEvent(boat)),
@@ -389,7 +389,7 @@ export class AttacksDisplay extends LitElement implements Layer {
className: "ml-auto text-left shrink-0",
disabled: boat.retreating(),
})
: html`<span class="ml-auto shrink-0 text-blue-400"
: html`<span class="ml-auto truncate text-blue-400"
>(${translateText("events_display.retreating")}...)</span
>`}
</div>
@@ -403,14 +403,16 @@ export class AttacksDisplay extends LitElement implements Layer {
return this.incomingBoats.map(
(boat) => html`
<div
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs rounded px-1.5 py-0.5 overflow-hidden"
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs min-[1200px]:rounded-lg sm:rounded-r-lg px-1.5 py-0.5 overflow-hidden"
>
${this.renderButton({
content: html`${this.renderBoatIcon(boat)}
<span class="inline-block min-w-[3rem] text-right"
>${renderTroops(boat.troops())}</span
>
<span class="truncate text-xs">${boat.owner()?.name()}</span>`,
<span class="truncate text-xs ml-1"
>${boat.owner()?.name()}</span
>`,
onClick: () => this.eventBus.emit(new GoToUnitEvent(boat)),
className:
"text-left text-red-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
@@ -439,7 +441,7 @@ export class AttacksDisplay extends LitElement implements Layer {
return html`
<div
class="w-full mb-1 pointer-events-auto grid grid-cols-2 lg:grid-cols-1 gap-1 text-white text-sm lg:text-base"
class="w-full mb-1 mt-1 sm:mt-0 pointer-events-auto grid grid-cols-2 sm:grid-cols-1 gap-1 text-white text-sm lg:text-base"
>
${this.renderOutgoingAttacks()} ${this.renderOutgoingLandAttacks()}
${this.renderBoats()} ${this.renderIncomingAttacks()}
+24 -26
View File
@@ -260,8 +260,8 @@ export class ControlPanel extends LitElement implements Layer {
render() {
return html`
<div
class="pointer-events-auto ${this._isVisible
? "relative z-[60] w-full lg:max-w-[400px] text-sm lg:text-base bg-gray-800/70 p-1.5 pr-2 lg:p-5 shadow-lg lg:rounded-tr-xl min-[1200px]:rounded-xl backdrop-blur-sm"
class="relative pointer-events-auto ${this._isVisible
? "relative z-[60] w-full lg:max-w-[400px] text-sm lg:text-base bg-gray-800/70 p-1.5 pr-2 lg:p-5 shadow-lg sm:rounded-tr-lg min-[1200px]:rounded-lg backdrop-blur-xs"
: "hidden"}"
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
>
@@ -309,7 +309,7 @@ export class ControlPanel extends LitElement implements Layer {
</div>
</div>
<!-- Small red vertical bar indicator -->
<div class="relative shrink-0">
<div class="shrink-0">
<div
class="w-1.5 h-8 bg-white/20 rounded-full relative overflow-hidden"
>
@@ -318,32 +318,30 @@ export class ControlPanel extends LitElement implements Layer {
style="height: ${this.attackRatio * 100}%"
></div>
</div>
${this._touchDragging
? html`
<div
class="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 flex flex-col items-center pointer-events-auto z-[10000] bg-gray-800/80 backdrop-blur-sm rounded-lg p-2 w-12"
style="height: 50vh;"
@touchstart=${(e: TouchEvent) => this.handleBarTouch(e)}
>
<span
class="text-red-400 text-sm font-bold mb-1"
translate="no"
>${(this.attackRatio * 100).toFixed(0)}%</span
>
<div
class="attack-drag-bar flex-1 w-3 bg-white/20 rounded-full relative overflow-hidden"
>
<div
class="absolute bottom-0 w-full bg-red-500 rounded-full"
style="height: ${this.attackRatio * 100}%"
></div>
</div>
</div>
`
: ""}
</div>
</div>
</div>
${this._touchDragging
? html`
<div
class="absolute bottom-full right-0 flex flex-col items-center pointer-events-auto z-[10000] bg-gray-800/80 backdrop-blur-sm rounded-l-lg sm:rounded-lg p-2 w-12"
style="height: 50vh;"
@touchstart=${(e: TouchEvent) => this.handleBarTouch(e)}
>
<span class="text-red-400 text-sm font-bold mb-1" translate="no"
>${(this.attackRatio * 100).toFixed(0)}%</span
>
<div
class="attack-drag-bar flex-1 w-3 bg-white/20 rounded-full relative overflow-hidden"
>
<div
class="absolute bottom-0 w-full bg-red-500 rounded-full"
style="height: ${this.attackRatio * 100}%"
></div>
</div>
</div>
`
: ""}
<!-- Attack ratio bar (desktop, always visible) -->
<div class="hidden lg:block mt-2">
<div
+18 -17
View File
@@ -24,7 +24,8 @@ import {
} from "../../../core/game/GameUpdates";
import {
SendAllianceExtensionIntentEvent,
SendAllianceReplyIntentEvent,
SendAllianceRejectIntentEvent,
SendAllianceRequestIntentEvent,
} from "../../Transport";
import { Layer } from "./Layer";
@@ -468,16 +469,14 @@ export class EventsDisplay extends LitElement implements Layer {
className: "btn",
action: () =>
this.eventBus.emit(
new SendAllianceReplyIntentEvent(requestor, recipient, true),
new SendAllianceRequestIntentEvent(recipient, requestor),
),
},
{
text: translateText("events_display.reject_alliance"),
className: "btn-info",
action: () =>
this.eventBus.emit(
new SendAllianceReplyIntentEvent(requestor, recipient, false),
),
this.eventBus.emit(new SendAllianceRejectIntentEvent(requestor)),
},
],
highlight: true,
@@ -788,17 +787,19 @@ export class EventsDisplay extends LitElement implements Layer {
>
${this.renderButton({
content: html`
Events
<span
class="${this.newEvents
? ""
: "hidden"} inline-block px-2 bg-red-500 rounded-lg text-sm"
>${this.newEvents}</span
>
<span class="flex items-center gap-2">
${translateText("events_display.events")}
${this.newEvents > 0
? html`<span
class="inline-block px-2 bg-red-500 rounded-lg text-sm"
>${this.newEvents}</span
>`
: ""}
</span>
`,
onClick: this.toggleHidden,
className:
"text-white cursor-pointer pointer-events-auto w-fit p-2 lg:p-3 rounded-lg bg-gray-800/70 backdrop-blur-sm",
"text-white cursor-pointer pointer-events-auto w-fit p-2 lg:p-3 min-[1200px]:rounded-lg sm:rounded-tl-lg bg-gray-800/70 backdrop-blur-xs",
})}
</div>
`
@@ -809,9 +810,9 @@ export class EventsDisplay extends LitElement implements Layer {
>
<!-- Button Bar -->
<div
class="w-full p-2 lg:p-3 bg-gray-800/70 min-[1200px]:rounded-t-lg lg:rounded-tl-lg"
class="w-full p-2 lg:p-3 bg-gray-800/70 min-[1200px]:rounded-t-lg sm:rounded-tl-lg"
>
<div class="flex justify-between items-center">
<div class="flex justify-between items-center gap-3">
<div class="flex gap-4">
${this.renderToggleButton(
swordIcon,
@@ -857,7 +858,7 @@ export class EventsDisplay extends LitElement implements Layer {
>
<div>
<table
class="w-full max-h-none border-collapse text-white shadow-lg lg:text-base text-md md:text-xs pointer-events-auto"
class="w-full max-h-none border-collapse text-white shadow-lg text-xs lg:text-sm pointer-events-auto"
>
<tbody>
${filteredEvents.map(
@@ -896,7 +897,7 @@ export class EventsDisplay extends LitElement implements Layer {
${event.buttons.map(
(btn) => html`
<button
class="inline-block px-3 py-1 text-white rounded-sm text-md md:text-sm cursor-pointer transition-colors duration-300
class="inline-block px-3 py-1 text-white rounded-sm text-xs lg:text-sm cursor-pointer transition-colors duration-300
${btn.className.includes("btn-info")
? "bg-blue-500 hover:bg-blue-600"
: btn.className.includes(
+22 -3
View File
@@ -1,10 +1,13 @@
import { Colord } from "colord";
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { GameMode } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { translateText } from "../../Utils";
import { ImmunityBarVisibleEvent } from "./ImmunityTimer";
import { Layer } from "./Layer";
import { SpawnBarVisibleEvent } from "./SpawnTimer";
import leaderboardRegularIcon from "/images/LeaderboardIconRegularWhite.svg?url";
import leaderboardSolidIcon from "/images/LeaderboardIconSolidWhite.svg?url";
import teamRegularIcon from "/images/TeamIconRegularWhite.svg?url";
@@ -22,9 +25,14 @@ export class GameLeftSidebar extends LitElement implements Layer {
private isPlayerTeamLabelVisible = false;
@state()
private playerTeam: string | null = null;
@state()
private spawnBarVisible = false;
@state()
private immunityBarVisible = false;
private playerColor: Colord = new Colord("#FFFFFF");
public game: GameView;
public eventBus: EventBus;
private _shownOnInit = false;
createRenderRoot() {
@@ -33,6 +41,12 @@ export class GameLeftSidebar extends LitElement implements Layer {
init() {
this.isVisible = true;
this.eventBus.on(SpawnBarVisibleEvent, (e) => {
this.spawnBarVisible = e.visible;
});
this.eventBus.on(ImmunityBarVisibleEvent, (e) => {
this.immunityBarVisible = e.visible;
});
if (this.isTeamGame) {
this.isPlayerTeamLabelVisible = true;
}
@@ -68,6 +82,10 @@ export class GameLeftSidebar extends LitElement implements Layer {
}
}
private get barOffset(): number {
return (this.spawnBarVisible ? 7 : 0) + (this.immunityBarVisible ? 7 : 0);
}
private toggleLeaderboard(): void {
this.isLeaderboardShow = !this.isLeaderboardShow;
}
@@ -90,9 +108,10 @@ export class GameLeftSidebar extends LitElement implements Layer {
render() {
return html`
<aside
class=${`fixed top-4 left-4 z-1000 flex flex-col max-h-[calc(100vh-80px)] overflow-y-auto p-2 bg-slate-800/40 backdrop-blur-xs shadow-xs rounded-lg transition-transform duration-300 ease-out transform ${
class=${`fixed top-0 min-[1200px]:top-4 left-0 min-[1200px]:left-4 z-1000 flex flex-col max-h-[calc(100vh-80px)] overflow-y-auto p-2 bg-gray-800/70 backdrop-blur-xs shadow-xs min-[1200px]:rounded-lg rounded-br-lg ${this.isLeaderboardShow || this.isTeamLeaderboardShow ? "max-[400px]:w-full max-[400px]:rounded-none" : ""} transition-all duration-300 ease-out transform ${
this.isVisible ? "translate-x-0" : "hidden"
}`}
style="margin-top: ${this.barOffset}px;"
>
<div class="flex items-center gap-4 xl:gap-6 text-white">
<div
@@ -152,7 +171,7 @@ export class GameLeftSidebar extends LitElement implements Layer {
${this.isPlayerTeamLabelVisible
? html`
<div
class="flex items-center w-full text-white"
class="flex items-center w-full text-white mt-2"
@contextmenu=${(e: Event) => e.preventDefault()}
>
${translateText("help_modal.ui_your_team")}
@@ -166,7 +185,7 @@ export class GameLeftSidebar extends LitElement implements Layer {
`
: null}
<div
class=${`block lg:flex flex-wrap ${this.isLeaderboardShow && this.isTeamLeaderboardShow ? "gap-2" : ""}`}
class=${`block lg:flex flex-wrap overflow-x-auto min-w-0 w-full ${this.isLeaderboardShow && this.isTeamLeaderboardShow ? "gap-2" : ""}`}
>
<leader-board .visible=${this.isLeaderboardShow}></leader-board>
<team-stats
+23 -1
View File
@@ -6,9 +6,11 @@ import { GameView } from "../../../core/game/GameView";
import { crazyGamesSDK } from "../../CrazyGamesSDK";
import { PauseGameIntentEvent, SendWinnerEvent } from "../../Transport";
import { translateText } from "../../Utils";
import { ImmunityBarVisibleEvent } from "./ImmunityTimer";
import { Layer } from "./Layer";
import { ShowReplayPanelEvent } from "./ReplayPanel";
import { ShowSettingsModalEvent } from "./SettingsModal";
import { SpawnBarVisibleEvent } from "./SpawnTimer";
import exitIcon from "/images/ExitIconWhite.svg?url";
import FastForwardIconSolid from "/images/FastForwardIconSolidWhite.svg?url";
import pauseIcon from "/images/PauseIconWhite.svg?url";
@@ -37,6 +39,8 @@ export class GameRightSidebar extends LitElement implements Layer {
private hasWinner = false;
private isLobbyCreator = false;
private spawnBarVisible = false;
private immunityBarVisible = false;
createRenderRoot() {
return this;
@@ -49,6 +53,15 @@ export class GameRightSidebar extends LitElement implements Layer {
this._isVisible = true;
this.game.inSpawnPhase();
this.eventBus.on(SpawnBarVisibleEvent, (e) => {
this.spawnBarVisible = e.visible;
this.updateParentOffset();
});
this.eventBus.on(ImmunityBarVisibleEvent, (e) => {
this.immunityBarVisible = e.visible;
this.updateParentOffset();
});
this.eventBus.on(SendWinnerEvent, () => {
this.hasWinner = true;
this.requestUpdate();
@@ -91,6 +104,15 @@ export class GameRightSidebar extends LitElement implements Layer {
}
}
private updateParentOffset(): void {
const offset =
(this.spawnBarVisible ? 7 : 0) + (this.immunityBarVisible ? 7 : 0);
const parent = this.parentElement as HTMLElement;
if (parent) {
parent.style.marginTop = `${offset}px`;
}
}
private secondsToHms = (d: number): string => {
const pad = (n: number) => (n < 10 ? `0${n}` : n);
@@ -153,7 +175,7 @@ export class GameRightSidebar extends LitElement implements Layer {
return html`
<aside
class=${`w-fit flex flex-row items-center gap-3 py-2 px-3 bg-gray-800/70 backdrop-blur-xs shadow-xs rounded-lg transition-transform duration-300 ease-out transform text-white ${
class=${`w-fit flex flex-row items-center gap-3 py-2 px-3 bg-gray-800/70 backdrop-blur-xs shadow-xs min-[1200px]:rounded-lg rounded-bl-lg transition-transform duration-300 ease-out transform text-white ${
this._isVisible ? "translate-x-0" : "translate-x-full"
}`}
@contextmenu=${(e: Event) => e.preventDefault()}
+28 -4
View File
@@ -19,6 +19,12 @@ export class HeadsUpMessage extends LitElement implements Layer {
@state()
private isImmunityActive = false;
@state()
private isCatchingUp = false;
private catchingUpTicks = 0;
private static readonly CATCHING_UP_SHOW_THRESHOLD = 10;
@state()
private toastMessage: string | import("lit").TemplateResult | null = null;
@state()
@@ -92,12 +98,30 @@ export class HeadsUpMessage extends LitElement implements Layer {
this.game.isSpawnImmunityActive() &&
ticksSinceSpawnEnd < showImmunityHudDuration;
const currentlyCatchingUp =
!this.game.config().isReplay() && this.game.isCatchingUp();
if (currentlyCatchingUp) {
this.catchingUpTicks++;
} else {
this.catchingUpTicks = 0;
}
this.isCatchingUp =
this.catchingUpTicks >= HeadsUpMessage.CATCHING_UP_SHOW_THRESHOLD;
this.isVisible =
this.game.inSpawnPhase() || this.isPaused || this.isImmunityActive;
this.game.inSpawnPhase() ||
this.isPaused ||
this.isImmunityActive ||
this.isCatchingUp;
this.requestUpdate();
}
private getMessage(): string {
if (this.isCatchingUp) {
return translateText("heads_up_message.catching_up");
}
if (this.isPaused) {
if (this.game.config().gameConfig().gameType === GameType.Singleplayer) {
return translateText("heads_up_message.singleplayer_game_paused");
@@ -146,10 +170,10 @@ export class HeadsUpMessage extends LitElement implements Layer {
? html`
<div
class="fixed top-[15%] left-1/2 -translate-x-1/2 z-[11000]
inline-flex items-center justify-center h-8 lg:h-10
inline-flex items-center justify-center min-h-8 lg:min-h-10
w-fit max-w-[90vw]
bg-gray-900/60 rounded-md lg:rounded-lg
backdrop-blur-md text-white text-md lg:text-xl px-3 lg:px-4
bg-gray-800/70 rounded-md lg:rounded-lg
backdrop-blur-xs text-white text-md lg:text-xl px-3 lg:px-4 py-1
text-center break-words"
style="word-wrap: break-word; hyphens: auto;"
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
+31 -16
View File
@@ -1,14 +1,21 @@
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import { EventBus, GameEvent } from "../../../core/EventBus";
import { GameMode } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { Layer } from "./Layer";
export class ImmunityBarVisibleEvent implements GameEvent {
constructor(public readonly visible: boolean) {}
}
@customElement("immunity-timer")
export class ImmunityTimer extends LitElement implements Layer {
public game: GameView;
public eventBus: EventBus;
private isVisible = false;
private _barVisible = false;
private isActive = false;
private progressRatio = 0;
@@ -46,24 +53,24 @@ export class ImmunityTimer extends LitElement implements Layer {
this.game.inSpawnPhase()
) {
this.setInactive();
return;
} else {
const immunityEnd = spawnPhaseTurns + immunityDuration;
const ticks = this.game.ticks();
if (ticks >= immunityEnd || ticks < spawnPhaseTurns) {
this.setInactive();
} else {
const elapsedTicks = Math.max(0, ticks - spawnPhaseTurns);
this.progressRatio = Math.min(
1,
Math.max(0, elapsedTicks / immunityDuration),
);
this.isActive = true;
this.requestUpdate();
}
}
const immunityEnd = spawnPhaseTurns + immunityDuration;
const ticks = this.game.ticks();
if (ticks >= immunityEnd || ticks < spawnPhaseTurns) {
this.setInactive();
return;
}
const elapsedTicks = Math.max(0, ticks - spawnPhaseTurns);
this.progressRatio = Math.min(
1,
Math.max(0, elapsedTicks / immunityDuration),
);
this.isActive = true;
this.requestUpdate();
this.emitBarVisibility();
}
private setInactive() {
@@ -73,6 +80,14 @@ export class ImmunityTimer extends LitElement implements Layer {
}
}
private emitBarVisibility() {
const nowVisible = this.isVisible && this.isActive;
if (nowVisible !== this._barVisible) {
this._barVisible = nowVisible;
this.eventBus?.emit(new ImmunityBarVisibleEvent(this._barVisible));
}
}
shouldTransform(): boolean {
return false;
}
+40 -11
View File
@@ -55,6 +55,12 @@ export class Leaderboard extends LitElement implements Layer {
init() {}
willUpdate(changed: Map<string, unknown>) {
if (changed.has("visible") && this.visible) {
this.updateLeaderboard();
}
}
getTickIntervalMs() {
return 1000;
}
@@ -184,10 +190,10 @@ export class Leaderboard extends LitElement implements Layer {
@contextmenu=${(e: Event) => e.preventDefault()}
>
<div
class="grid bg-gray-800/70 w-full text-xs md:text-xs lg:text-sm"
style="grid-template-columns: 30px 100px 70px 55px 105px;"
class="grid bg-gray-800/85 w-full text-xs md:text-xs lg:text-sm rounded-lg overflow-hidden"
style="grid-template-columns: minmax(24px, 30px) minmax(60px, 100px) minmax(45px, 70px) minmax(40px, 55px) minmax(55px, 105px);"
>
<div class="contents font-bold bg-gray-700/50">
<div class="contents font-bold bg-gray-700/60">
<div class="py-1 md:py-2 text-center border-b border-slate-500">
#
</div>
@@ -234,28 +240,51 @@ export class Leaderboard extends LitElement implements Layer {
${repeat(
this.players,
(p) => p.player.id(),
(player) => html`
(player, index) => html`
<div
class="contents hover:bg-slate-600/60 ${player.isOnSameTeam
? "font-bold"
: ""} cursor-pointer"
@click=${() => this.handleRowClickPlayer(player.player)}
>
<div class="py-1 md:py-2 text-center border-b border-slate-500">
<div
class="py-1 md:py-2 text-center ${index <
this.players.length - 1
? "border-b border-slate-500"
: ""}"
>
${player.position}
</div>
<div
class="py-1 md:py-2 text-center border-b border-slate-500 truncate"
class="py-1 md:py-2 text-center ${index <
this.players.length - 1
? "border-b border-slate-500"
: ""} truncate"
>
${player.name}
</div>
<div class="py-1 md:py-2 text-center border-b border-slate-500">
<div
class="py-1 md:py-2 text-center ${index <
this.players.length - 1
? "border-b border-slate-500"
: ""}"
>
${player.score}
</div>
<div class="py-1 md:py-2 text-center border-b border-slate-500">
<div
class="py-1 md:py-2 text-center ${index <
this.players.length - 1
? "border-b border-slate-500"
: ""}"
>
${player.gold}
</div>
<div class="py-1 md:py-2 text-center border-b border-slate-500">
<div
class="py-1 md:py-2 text-center ${index <
this.players.length - 1
? "border-b border-slate-500"
: ""}"
>
${player.maxTroops}
</div>
</div>
@@ -265,9 +294,9 @@ export class Leaderboard extends LitElement implements Layer {
</div>
<button
class="mt-1 px-1.5 pb-0.5 md:px-2 text-xs md:text-xs lg:text-sm
class="mt-2 p-0.5 px-1.5 md:px-2 text-xs md:text-xs lg:text-sm
border rounded-md border-slate-500 transition-colors
text-white mx-auto block hover:bg-white/10"
text-white mx-auto block hover:bg-white/10 bg-gray-700/50"
@click=${() => {
this.showTopFive = !this.showTopFive;
this.updateLeaderboard();
@@ -138,7 +138,7 @@ export class NukeTrajectoryPreviewLayer implements Layer {
// Get buildable units to find spawn tile (expensive call - only on tick when tile changes)
player
.actions(targetTile)
.actions(targetTile, [ghostStructure])
.then((actions) => {
// Ignore stale results if target changed
if (this.lastTargetTile !== targetTile) {
@@ -22,8 +22,10 @@ import {
} from "../../Utils";
import { getFirstPlacePlayer, getPlayerIcons } from "../PlayerIcons";
import { TransformHandler } from "../TransformHandler";
import { ImmunityBarVisibleEvent } from "./ImmunityTimer";
import { Layer } from "./Layer";
import { CloseRadialMenuEvent } from "./RadialMenu";
import { SpawnBarVisibleEvent } from "./SpawnTimer";
import allianceIcon from "/images/AllianceIcon.svg?url";
import warshipIcon from "/images/BattleshipIconWhite.svg?url";
import cityIcon from "/images/CityIconWhite.svg?url";
@@ -77,8 +79,17 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
@state()
private _isInfoVisible: boolean = false;
@state()
private spawnBarVisible = false;
@state()
private immunityBarVisible = false;
private _isActive = false;
private get barOffset(): number {
return (this.spawnBarVisible ? 7 : 0) + (this.immunityBarVisible ? 7 : 0);
}
private lastMouseUpdate = 0;
init() {
@@ -89,6 +100,12 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
this.maybeShow(e.x, e.y),
);
this.eventBus.on(CloseRadialMenuEvent, () => this.hide());
this.eventBus.on(SpawnBarVisibleEvent, (e) => {
this.spawnBarVisible = e.visible;
});
this.eventBus.on(ImmunityBarVisibleEvent, (e) => {
this.immunityBarVisible = e.visible;
});
this._isActive = true;
}
@@ -155,6 +172,24 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
this.requestUpdate();
}
private getPlayerNameColor(
player: PlayerView,
myPlayer: PlayerView | null | undefined,
isFriendly: boolean,
): string {
if (isFriendly) return "text-green-500";
if (
myPlayer &&
myPlayer !== player &&
player.type() === PlayerType.Nation
) {
const relation =
this.playerProfile?.relations[myPlayer.smallID()] ?? Relation.Neutral;
return this.getRelationClass(relation);
}
return "text-white";
}
private getRelationClass(relation: Relation): string {
switch (relation) {
case Relation.Hostile:
@@ -255,7 +290,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
.find((alliance) => alliance.other === player.id());
if (alliance !== undefined) {
allianceHtml = html` <div
class="flex flex-col items-center ml-auto mr-0 text-sm font-bold leading-tight"
class="flex items-center ml-auto mr-0 gap-1 text-sm font-bold leading-tight"
>
<img src=${allianceIcon} width="20" height="20" />
${this.allianceExpirationText(alliance)}
@@ -293,9 +328,11 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
<!-- Right: Player identity + Units below -->
<div class="flex flex-col justify-between self-stretch">
<div
class="flex items-center gap-2 font-bold text-sm lg:text-lg ${isFriendly
? "text-green-500"
: "text-white"}"
class="flex items-center gap-2 font-bold text-sm lg:text-lg ${this.getPlayerNameColor(
player,
myPlayer,
isFriendly ?? false,
)}"
>
${player.cosmetics.flag
? player.cosmetics.flag!.startsWith("!")
@@ -452,11 +489,12 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
return html`
<div
class="fixed top-0 lg:top-4 left-0 right-0 sm:left-1/2 sm:right-auto sm:-translate-x-1/2 z-[1001]"
class="fixed top-0 min-[1200px]:top-4 left-0 right-0 sm:left-1/2 sm:right-auto sm:-translate-x-1/2 z-[1001]"
style="margin-top: ${this.barOffset}px;"
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
>
<div
class="bg-gray-800/70 backdrop-blur-xs shadow-xs lg:rounded-lg shadow-lg transition-all duration-300 text-white text-lg lg:text-base w-full sm:w-auto sm:min-w-[400px] overflow-hidden ${containerClasses}"
class="bg-gray-800/70 backdrop-blur-xs shadow-xs min-[1200px]:rounded-lg sm:rounded-b-lg shadow-lg transition-all duration-300 text-white text-lg lg:text-base w-full sm:w-auto sm:min-w-[400px] overflow-hidden ${containerClasses}"
>
${this.player !== null ? this.renderPlayerInfo(this.player) : ""}
${this.unit !== null ? this.renderUnitInfo(this.unit) : ""}
+1 -1
View File
@@ -68,7 +68,7 @@ export class ReplayPanel extends LitElement implements Layer {
return html`
<div
class="p-2 bg-gray-800/70 backdrop-blur-xs shadow-xs rounded-lg"
class="p-2 bg-gray-800/70 backdrop-blur-xs shadow-xs min-[1200px]:rounded-lg rounded-l-lg"
@contextmenu=${(e: Event) => e.preventDefault()}
>
<label class="block mb-2 text-white" translate="no">
+39 -30
View File
@@ -1,16 +1,23 @@
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import { EventBus, GameEvent } from "../../../core/EventBus";
import { GameMode, Team } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
export class SpawnBarVisibleEvent implements GameEvent {
constructor(public readonly visible: boolean) {}
}
@customElement("spawn-timer")
export class SpawnTimer extends LitElement implements Layer {
public game: GameView;
public eventBus: EventBus;
public transformHandler: TransformHandler;
private ratios = [0];
private _barVisible = false;
private colors = ["rgba(0, 128, 255, 0.7)", "rgba(0, 0, 0, 0.5)"];
private isVisible = false;
@@ -37,39 +44,41 @@ export class SpawnTimer extends LitElement implements Layer {
this.game.ticks() / this.game.config().numSpawnPhaseTurns(),
];
this.colors = ["rgba(0, 128, 255, 0.7)"];
this.requestUpdate();
return;
} else {
this.ratios = [];
this.colors = [];
if (this.game.config().gameConfig().gameMode === GameMode.Team) {
const teamTiles: Map<Team, number> = new Map();
for (const player of this.game.players()) {
const team = player.team();
if (team === null) continue;
const tiles = teamTiles.get(team) ?? 0;
teamTiles.set(team, tiles + player.numTilesOwned());
}
const theme = this.game.config().theme();
const total = sumIterator(teamTiles.values());
if (total > 0) {
for (const [team, count] of teamTiles) {
const ratio = count / total;
this.ratios.push(ratio);
this.colors.push(theme.teamColor(team).toRgbString());
}
}
}
}
this.ratios = [];
this.colors = [];
if (this.game.config().gameConfig().gameMode !== GameMode.Team) {
this.requestUpdate();
return;
}
const teamTiles: Map<Team, number> = new Map();
for (const player of this.game.players()) {
const team = player.team();
if (team === null) throw new Error("Team is null");
const tiles = teamTiles.get(team) ?? 0;
teamTiles.set(team, tiles + player.numTilesOwned());
}
const theme = this.game.config().theme();
const total = sumIterator(teamTiles.values());
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();
this.emitBarVisibility();
}
private emitBarVisibility() {
const nowVisible = this.isVisible && this.ratios.length > 0;
if (nowVisible !== this._barVisible) {
this._barVisible = nowVisible;
this.eventBus?.emit(new SpawnBarVisibleEvent(this._barVisible));
}
}
shouldTransform(): boolean {
@@ -4,7 +4,7 @@ import { crazyGamesSDK } from "src/client/CrazyGamesSDK";
import { getGamesPlayed } from "src/client/Utils";
import { GameType } from "src/core/game/Game";
import { GameView } from "../../../core/game/GameView";
import "../../components/VideoReward";
import "../../components/VideoPromo";
import { Layer } from "./Layer";
@customElement("spawn-video-ad")
@@ -285,7 +285,7 @@ export class StructureIconsLayer implements Layer {
this.game
?.myPlayer()
?.actions(tileRef)
?.actions(tileRef, [this.ghostUnit?.buildableUnit.type])
.then((actions) => {
if (this.potentialUpgrade) {
this.potentialUpgrade.iconContainer.filters = [];
+2 -2
View File
@@ -132,7 +132,7 @@ export class TeamStats extends LitElement implements Layer {
return html`
<div
class="max-h-[30vh] overflow-y-auto grid bg-slate-800/70 w-full text-white text-xs md:text-sm mt-2"
class="max-h-[30vh] overflow-x-hidden overflow-y-auto grid bg-slate-800/85 w-full text-white text-xs md:text-sm mt-2 rounded-lg"
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
>
<div
@@ -140,7 +140,7 @@ export class TeamStats extends LitElement implements Layer {
style="--cols:${this.showUnits ? 5 : 4};"
>
<!-- Header -->
<div class="contents font-bold bg-slate-700/50">
<div class="contents font-bold bg-slate-700/60">
<div class="p-1.5 md:p-2.5 text-center border-b border-slate-500">
${translateText("leaderboard.team")}
</div>
+15 -12
View File
@@ -22,6 +22,19 @@ import portIcon from "/images/PortIcon.svg?url";
import samLauncherIcon from "/images/SamLauncherIconWhite.svg?url";
import defensePostIcon from "/images/ShieldIconWhite.svg?url";
const BUILDABLE_UNITS: UnitType[] = [
UnitType.City,
UnitType.Factory,
UnitType.Port,
UnitType.DefensePost,
UnitType.MissileSilo,
UnitType.SAMLauncher,
UnitType.Warship,
UnitType.AtomBomb,
UnitType.HydrogenBomb,
UnitType.MIRV,
];
@customElement("unit-display")
export class UnitDisplay extends LitElement implements Layer {
public game: GameView;
@@ -55,17 +68,7 @@ export class UnitDisplay extends LitElement implements Layer {
}
}
this.allDisabled =
config.isUnitDisabled(UnitType.City) &&
config.isUnitDisabled(UnitType.Factory) &&
config.isUnitDisabled(UnitType.Port) &&
config.isUnitDisabled(UnitType.DefensePost) &&
config.isUnitDisabled(UnitType.MissileSilo) &&
config.isUnitDisabled(UnitType.SAMLauncher) &&
config.isUnitDisabled(UnitType.Warship) &&
config.isUnitDisabled(UnitType.AtomBomb) &&
config.isUnitDisabled(UnitType.HydrogenBomb) &&
config.isUnitDisabled(UnitType.MIRV);
this.allDisabled = BUILDABLE_UNITS.every((u) => config.isUnitDisabled(u));
this.requestUpdate();
}
@@ -101,7 +104,7 @@ export class UnitDisplay extends LitElement implements Layer {
tick() {
const player = this.game?.myPlayer();
player?.actions().then((actions) => {
player?.actions(undefined, BUILDABLE_UNITS).then((actions) => {
this.playerActions = actions;
});
if (!player) return;
+2 -1
View File
@@ -93,7 +93,7 @@ export class WinModal extends LitElement implements Layer {
@click=${this.hide}
class="flex-1 px-3 py-3 text-base cursor-pointer bg-blue-500/60 text-white border-0 rounded-sm transition-all duration-200 hover:bg-blue-500/80 hover:-translate-y-px active:translate-y-px"
>
${this.game.myPlayer()?.isAlive()
${this.game?.myPlayer()?.isAlive()
? translateText("win_modal.keep")
: translateText("win_modal.spectate")}
</button>
@@ -218,6 +218,7 @@ export class WinModal extends LitElement implements Layer {
.pattern=${pattern}
.colorPalette=${colorPalette}
.requiresPurchase=${true}
.allowTrial=${false}
.onSelect=${(p: Pattern | null) => {}}
.onPurchase=${(p: Pattern, colorPalette: ColorPalette | null) =>
handlePurchase(p, colorPalette)}
+2
View File
@@ -56,6 +56,8 @@ export const UserMeResponseSchema = z.object({
publicId: z.string(),
roles: z.string().array().optional(),
flares: z.string().array().optional(),
flareExpiration: z.record(z.string(), z.number()).optional(),
tempFlaresCooldown: z.boolean(),
achievements: z
.array(
z.object({
+5 -3
View File
@@ -118,7 +118,7 @@ export class GameRunner {
this.turns.push(turn);
}
public executeNextTick(): boolean {
public executeNextTick(pendingTurns?: number): boolean {
if (this.isExecuting) {
return false;
}
@@ -182,6 +182,7 @@ export class GameRunner {
updates: updates,
playerNameViewData: this.playerViewData,
tickExecutionDuration: tickExecutionDuration,
pendingTurns: pendingTurns ?? 0,
});
this.isExecuting = false;
return true;
@@ -195,13 +196,14 @@ export class GameRunner {
playerID: PlayerID,
x?: number,
y?: number,
units?: UnitType[],
): PlayerActions {
const player = this.game.player(playerID);
const tile =
x !== undefined && y !== undefined ? this.game.ref(x, y) : null;
const actions = {
canAttack: tile !== null && player.canAttack(tile),
buildableUnits: player.buildableUnits(tile),
canAttack: tile !== null && units === undefined && player.canAttack(tile),
buildableUnits: player.buildableUnits(tile, units),
canSendEmojiAllPlayers: player.canSendEmoji(AllPlayers),
canEmbargoAll: player.canEmbargoAll(),
} as PlayerActions;
+12 -10
View File
@@ -34,7 +34,7 @@ export type Intent =
| BoatAttackIntent
| CancelBoatIntent
| AllianceRequestIntent
| AllianceRequestReplyIntent
| AllianceRejectIntent
| AllianceExtensionIntent
| BreakAllianceIntent
| TargetPlayerIntent
@@ -60,9 +60,7 @@ 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<
typeof AllianceRequestReplyIntentSchema
>;
export type AllianceRejectIntent = z.infer<typeof AllianceRejectIntentSchema>;
export type BreakAllianceIntent = z.infer<typeof BreakAllianceIntentSchema>;
export type TargetPlayerIntent = z.infer<typeof TargetPlayerIntentSchema>;
export type EmojiIntent = z.infer<typeof EmojiIntentSchema>;
@@ -139,6 +137,9 @@ export type GameStartInfo = z.infer<typeof GameStartInfoSchema>;
export type GameInfo = z.infer<typeof GameInfoSchema>;
export type PublicGames = z.infer<typeof PublicGamesSchema>;
export type PublicGameInfo = z.infer<typeof PublicGameInfoSchema>;
export type PublicGameType = z.infer<typeof PublicGameTypeSchema>;
export const PublicGameTypeSchema = z.enum(["ffa", "team", "special"]);
const ClientInfoSchema = z.object({
clientID: z.string(),
@@ -152,6 +153,7 @@ export const GameInfoSchema = z.object({
startsAt: z.number().optional(),
serverTime: z.number(),
gameConfig: z.lazy(() => GameConfigSchema).optional(),
publicGameType: PublicGameTypeSchema.optional(),
});
export const PublicGameInfoSchema = z.object({
@@ -159,11 +161,12 @@ export const PublicGameInfoSchema = z.object({
numClients: z.number(),
startsAt: z.number(),
gameConfig: z.lazy(() => GameConfigSchema).optional(),
publicGameType: PublicGameTypeSchema,
});
export const PublicGamesSchema = z.object({
serverTime: z.number(),
games: PublicGameInfoSchema.array(),
games: z.record(PublicGameTypeSchema, z.array(PublicGameInfoSchema)),
});
export class LobbyInfoEvent implements GameEvent {
@@ -311,10 +314,9 @@ export const AllianceRequestIntentSchema = z.object({
recipient: ID,
});
export const AllianceRequestReplyIntentSchema = z.object({
type: z.literal("allianceRequestReply"),
requestor: ID, // The one who made the original alliance request
accept: z.boolean(),
export const AllianceRejectIntentSchema = z.object({
type: z.literal("allianceReject"),
requestor: ID,
});
export const BreakAllianceIntentSchema = z.object({
@@ -426,7 +428,7 @@ const IntentSchema = z.discriminatedUnion("type", [
BoatAttackIntentSchema,
CancelBoatIntentSchema,
AllianceRequestIntentSchema,
AllianceRequestReplyIntentSchema,
AllianceRejectIntentSchema,
BreakAllianceIntentSchema,
TargetPlayerIntentSchema,
EmojiIntentSchema,
+3 -7
View File
@@ -3,8 +3,8 @@ import { PseudoRandom } from "../PseudoRandom";
import { ClientID, GameID, StampedIntent, Turn } from "../Schemas";
import { simpleHash } from "../Util";
import { AllianceExtensionExecution } from "./alliance/AllianceExtensionExecution";
import { AllianceRejectExecution } from "./alliance/AllianceRejectExecution";
import { AllianceRequestExecution } from "./alliance/AllianceRequestExecution";
import { AllianceRequestReplyExecution } from "./alliance/AllianceRequestReplyExecution";
import { BreakAllianceExecution } from "./alliance/BreakAllianceExecution";
import { AttackExecution } from "./AttackExecution";
import { BoatRetreatExecution } from "./BoatRetreatExecution";
@@ -75,12 +75,8 @@ export class Executor {
return new TransportShipExecution(player, intent.dst, intent.troops);
case "allianceRequest":
return new AllianceRequestExecution(player, intent.recipient);
case "allianceRequestReply":
return new AllianceRequestReplyExecution(
intent.requestor,
player,
intent.accept,
);
case "allianceReject":
return new AllianceRejectExecution(intent.requestor, player);
case "breakAlliance":
return new BreakAllianceExecution(player, intent.recipient);
case "targetPlayer":
+1
View File
@@ -63,6 +63,7 @@ export class MirvExecution implements Execution {
}
if (this.targetPlayer !== this.player) {
this.targetPlayer.updateRelation(this.player, -100);
this.player.updateRelation(this.targetPlayer, -100);
}
}
}
@@ -0,0 +1,49 @@
import { Execution, Game, Player, PlayerID } from "../../game/Game";
export class AllianceRejectExecution implements Execution {
private active = true;
constructor(
private requestorID: PlayerID,
private recipient: Player,
) {}
init(mg: Game, ticks: number): void {
if (!mg.hasPlayer(this.requestorID)) {
console.warn(
`[AllianceRejectExecution] Requestor ${this.requestorID} not found`,
);
this.active = false;
return;
}
const requestor = mg.player(this.requestorID);
if (requestor.isFriendly(this.recipient)) {
console.warn(
`[AllianceRejectExecution] Player ${this.requestorID} cannot reject alliance with ${this.recipient.id}, already allied`,
);
} else {
const request = requestor
.outgoingAllianceRequests()
.find((ar) => ar.recipient() === this.recipient);
if (request === undefined) {
console.warn(
`[AllianceRejectExecution] Player ${this.requestorID} cannot reject alliance with ${this.recipient.id}, no alliance request found`,
);
} else {
request.reject();
}
}
this.active = false;
}
tick(ticks: number): void {}
isActive(): boolean {
return this.active;
}
activeDuringSpawnPhase(): boolean {
return false;
}
}
@@ -2,8 +2,10 @@ import {
AllianceRequest,
Execution,
Game,
MessageType,
Player,
PlayerID,
UnitType,
} from "../../game/Game";
export class AllianceRequestExecution implements Execution {
@@ -39,6 +41,19 @@ export class AllianceRequestExecution implements Execution {
// then accept it instead of creating a new one.
this.active = false;
incoming.accept();
// Update player relations
this.requestor.updateRelation(recipient, 100);
recipient.updateRelation(this.requestor, 100);
// Automatically remove embargoes only if they were automatically created
if (this.requestor.hasEmbargoAgainst(recipient))
this.requestor.endTemporaryEmbargo(recipient);
if (recipient.hasEmbargoAgainst(this.requestor))
recipient.endTemporaryEmbargo(this.requestor);
// Cancel incoming nukes between players
this.cancelNukesBetweenAlliedPlayers(recipient);
} else {
this.req = this.requestor.createAllianceRequest(recipient);
}
@@ -69,4 +84,51 @@ export class AllianceRequestExecution implements Execution {
activeDuringSpawnPhase(): boolean {
return false;
}
cancelNukesBetweenAlliedPlayers(recipient: Player): void {
const neutralized = new Map<Player, number>();
const players = [this.requestor, recipient];
for (const launcher of players) {
for (const unit of launcher.units(
UnitType.AtomBomb,
UnitType.HydrogenBomb,
)) {
if (!unit.isActive() || unit.reachedTarget()) continue;
const targetTile = unit.targetTile();
if (!targetTile) continue;
const targetOwner = this.mg.owner(targetTile);
if (!targetOwner.isPlayer()) continue;
const other = launcher === this.requestor ? recipient : this.requestor;
if (targetOwner !== other) continue;
unit.delete(false);
neutralized.set(launcher, (neutralized.get(launcher) ?? 0) + 1);
}
}
for (const [launcher, count] of neutralized) {
const other = launcher === this.requestor ? recipient : this.requestor;
this.mg.displayMessage(
"events_display.alliance_nukes_destroyed_outgoing",
MessageType.ALLIANCE_ACCEPTED,
launcher.id(),
undefined,
{ name: other.displayName(), count },
);
this.mg.displayMessage(
"events_display.alliance_nukes_destroyed_incoming",
MessageType.ALLIANCE_ACCEPTED,
other.id(),
undefined,
{ name: launcher.displayName(), count },
);
}
}
}
@@ -1,117 +0,0 @@
import {
Execution,
Game,
MessageType,
Player,
PlayerID,
UnitType,
} from "../../game/Game";
export class AllianceRequestReplyExecution implements Execution {
private active = true;
private requestor: Player | null = null;
constructor(
private requestorID: PlayerID,
private recipient: Player,
private accept: boolean,
) {}
private cancelNukesBetweenAlliedPlayers(
mg: Game,
p1: Player,
p2: Player,
): void {
const neutralized = new Map<Player, number>();
const players = [p1, p2];
for (const launcher of players) {
for (const unit of launcher.units(
UnitType.AtomBomb,
UnitType.HydrogenBomb,
)) {
if (!unit.isActive() || unit.reachedTarget()) continue;
const targetTile = unit.targetTile();
if (!targetTile) continue;
const targetOwner = mg.owner(targetTile);
if (!targetOwner.isPlayer()) continue;
const other = launcher === p1 ? p2 : p1;
if (targetOwner !== other) continue;
unit.delete(false);
neutralized.set(launcher, (neutralized.get(launcher) ?? 0) + 1);
}
}
for (const [launcher, count] of neutralized) {
const other = launcher === p1 ? p2 : p1;
mg.displayMessage(
"events_display.alliance_nukes_destroyed_outgoing",
MessageType.ALLIANCE_ACCEPTED,
launcher.id(),
undefined,
{ name: other.displayName(), count },
);
mg.displayMessage(
"events_display.alliance_nukes_destroyed_incoming",
MessageType.ALLIANCE_ACCEPTED,
other.id(),
undefined,
{ name: launcher.displayName(), count },
);
}
}
init(mg: Game, ticks: number): void {
if (!mg.hasPlayer(this.requestorID)) {
console.warn(
`AllianceRequestReplyExecution requester ${this.requestorID} not found`,
);
this.active = false;
return;
}
this.requestor = mg.player(this.requestorID);
if (this.requestor.isFriendly(this.recipient)) {
console.warn("already allied");
} else {
const request = this.requestor
.outgoingAllianceRequests()
.find((ar) => ar.recipient() === this.recipient);
if (request === undefined) {
console.warn("no alliance request found");
} else {
if (this.accept) {
request.accept();
this.requestor.updateRelation(this.recipient, 100);
this.recipient.updateRelation(this.requestor, 100);
this.cancelNukesBetweenAlliedPlayers(
mg,
this.requestor,
this.recipient,
);
} else {
request.reject();
}
}
}
this.active = false;
}
tick(ticks: number): void {}
isActive(): boolean {
return this.active;
}
activeDuringSpawnPhase(): boolean {
return false;
}
}
@@ -87,7 +87,7 @@ export class NationAllianceBehavior {
}
return false;
}
// Reject if otherPlayer has allied with 50% or more of all players (Hard and Impossible only)
// Reject if otherPlayer has allied with a lot of players (Hard and Impossible only)
// To make sure there are enough non-friendly players in the game to stop the crown with nukes
if (this.hasTooManyAlliances(otherPlayer)) {
return false;
@@ -148,7 +148,7 @@ export class NationAllianceBehavior {
.filter((p) => p.type() !== PlayerType.Bot).length;
const otherPlayerAlliances = otherPlayer.alliances().length;
if (difficulty !== Difficulty.Hard) {
if (difficulty === Difficulty.Hard) {
return otherPlayerAlliances >= totalPlayers * 0.5;
} else {
return otherPlayerAlliances >= totalPlayers * 0.25;
@@ -4,7 +4,9 @@ import {
Game,
Gold,
Player,
PlayerID,
PlayerType,
Tick,
UnitType,
} from "../../game/Game";
import { TileRef } from "../../game/GameMap";
@@ -18,7 +20,15 @@ import {
respondToMIRV,
} from "./NationEmojiBehavior";
// 30 seconds at 10 ticks/second
const MIRV_COOLDOWN_TICKS = 300;
export class NationMIRVBehavior {
// Shared across all NationMIRVBehavior instances.
// Tracks the last tick a MIRV was sent at each player, so multiple nations don't pile-on the same target.
// Especially important for games with very high starting gold settings.
private static recentMirvTargets = new Map<PlayerID, Tick>();
constructor(
private random: PseudoRandom,
private game: Game,
@@ -119,19 +129,19 @@ export class NationMIRVBehavior {
}
const inboundMIRVSender = this.selectCounterMirvTarget();
if (inboundMIRVSender) {
if (inboundMIRVSender && !this.wasRecentlyMirved(inboundMIRVSender)) {
this.maybeSendMIRV(inboundMIRVSender);
return true;
}
const victoryDenialTarget = this.selectVictoryDenialTarget();
if (victoryDenialTarget) {
if (victoryDenialTarget && !this.wasRecentlyMirved(victoryDenialTarget)) {
this.maybeSendMIRV(victoryDenialTarget);
return true;
}
const steamrollStopTarget = this.selectSteamrollStopTarget();
if (steamrollStopTarget) {
if (steamrollStopTarget && !this.wasRecentlyMirved(steamrollStopTarget)) {
this.maybeSendMIRV(steamrollStopTarget);
return true;
}
@@ -223,6 +233,17 @@ export class NationMIRVBehavior {
return null;
}
// MIRV Cooldown Methods
private wasRecentlyMirved(target: Player): boolean {
const lastTick = NationMIRVBehavior.recentMirvTargets.get(target.id());
if (lastTick === undefined) return false;
return this.game.ticks() - lastTick < MIRV_COOLDOWN_TICKS;
}
private recordMirvHit(target: Player): void {
NationMIRVBehavior.recentMirvTargets.set(target.id(), this.game.ticks());
}
// MIRV Helper Methods
private getValidMirvTargetPlayers(): Player[] {
if (this.player === null) throw new Error("not initialized");
@@ -261,6 +282,7 @@ export class NationMIRVBehavior {
const centerTile = this.calculateTerritoryCenter(enemy);
if (centerTile && this.player.canBuild(UnitType.MIRV, centerTile)) {
this.game.addExecution(new MirvExecution(this.player, centerTile));
this.recordMirvHit(enemy);
this.emojiBehavior.sendEmoji(AllPlayers, EMOJI_NUKE);
respondToMIRV(this.game, this.random, enemy);
}
@@ -659,8 +659,8 @@ export class NationNukeBehavior {
this.recentlySentNukes.push([tick, tile, nukeType]);
if (nukeType === UnitType.AtomBomb) {
this.atomBombsLaunched++;
// Increase perceived cost by 35% each time to simulate saving up for a MIRV (higher than hydro to make atom bombs less attractive for the lategame)
this.atomBombPerceivedCost = (this.atomBombPerceivedCost * 135n) / 100n;
// Increase perceived cost by 50% each time to simulate saving up for a MIRV (higher than hydro to make atom bombs less attractive for the lategame)
this.atomBombPerceivedCost = (this.atomBombPerceivedCost * 150n) / 100n;
} else if (nukeType === UnitType.HydrogenBomb) {
this.hydrogenBombsLaunched++;
// Increase perceived cost by 25% each time to simulate saving up for a MIRV
+39 -4
View File
@@ -3,6 +3,7 @@ import {
Game,
GameMode,
HumansVsNations,
isStructureType,
Player,
PlayerID,
PlayerType,
@@ -199,6 +200,13 @@ export class AiAttackBehavior {
borderingFriends: Player[],
borderingEnemies: Player[],
) {
// In games with high starting gold, nations will quickly build a lot of cities
// This causes them to expand slowly (cities increase max troops), and bots will steal their structures
// In this case: Attack bots before ratio checks
if (this.hasNeighboringBotWithStructures()) {
if (this.attackBots()) return;
}
// Save up troops until we reach the reserve ratio
if (!this.hasReserveRatioTroops()) return;
@@ -345,6 +353,18 @@ export class AiAttackBehavior {
}
}
private hasNeighboringBotWithStructures(): boolean {
return this.player
.neighbors()
.some(
(n) =>
n.isPlayer() &&
n.type() === PlayerType.Bot &&
!this.player.isFriendly(n) &&
n.units().some((u) => isStructureType(u.type())),
);
}
private hasReserveRatioTroops(): boolean {
const maxTroops = this.game.config().maxTroops(this.player);
const ratio = this.player.troops() / maxTroops;
@@ -380,6 +400,7 @@ export class AiAttackBehavior {
// Sort neighboring bots by density (troops / tiles) and attempt to attack many of them (Parallel attacks)
// sendAttack will do nothing if we don't have enough reserve troops left
// Bots that own structures are prioritized as targets (they might have stolen our structures and they will delete them!)
private attackBots(): boolean {
const bots = this.player
.neighbors()
@@ -397,7 +418,16 @@ export class AiAttackBehavior {
this.botAttackTroopsSent = 0;
const density = (p: Player) => p.troops() / p.numTilesOwned();
const sortedBots = bots.slice().sort((a, b) => density(a) - density(b));
const ownsStructures = (p: Player) =>
p.units().some((u) => isStructureType(u.type()));
const sortedBots = bots.slice().sort((a, b) => {
const aHasStructures = ownsStructures(a);
const bHasStructures = ownsStructures(b);
if (aHasStructures !== bHasStructures) {
return aHasStructures ? -1 : 1;
}
return density(a) - density(b);
});
const reducedBots = sortedBots.slice(0, this.getBotAttackMaxParallelism());
for (const bot of reducedBots) {
@@ -700,9 +730,14 @@ export class AiAttackBehavior {
private sendLandAttack(target: Player | TerraNullius) {
const maxTroops = this.game.config().maxTroops(this.player);
const reserveRatio = target.isPlayer()
? this.reserveRatio
: this.expandRatio;
const botWithStructures =
target.isPlayer() &&
target.type() === PlayerType.Bot &&
target.units().some((u) => isStructureType(u.type()));
// Use the expand ratio when attacking a bot that owns structures — we need to
// recapture those structures ASAP, even before reaching the normal reserve.
const useReserve = target.isPlayer() && !botWithStructures;
const reserveRatio = useReserve ? this.reserveRatio : this.expandRatio;
const targetTroops = maxTroops * reserveRatio;
let troops;
+3 -1
View File
@@ -120,6 +120,7 @@ export enum GameMapType {
AmazonRiver = "Amazon River",
Yenisei = "Yenisei",
TradersDream = "Traders Dream",
Hawaii = "Hawaii",
}
export type GameMapName = keyof typeof GameMapType;
@@ -168,6 +169,7 @@ export const mapCategories: Record<string, GameMapType[]> = {
GameMapType.StraitOfHormuz,
GameMapType.AmazonRiver,
GameMapType.Yenisei,
GameMapType.Hawaii,
],
fantasy: [
GameMapType.Pangaea,
@@ -625,7 +627,7 @@ export interface Player {
unitCount(type: UnitType): number;
unitsConstructed(type: UnitType): number;
unitsOwned(type: UnitType): number;
buildableUnits(tile: TileRef | null): BuildableUnit[];
buildableUnits(tile: TileRef | null, units?: UnitType[]): BuildableUnit[];
canBuild(type: UnitType, targetTile: TileRef): TileRef | false;
buildUnit<T extends UnitType>(
type: T,
-6
View File
@@ -332,12 +332,6 @@ export class GameImpl implements Game {
request,
);
// Automatically remove embargoes only if they were automatically created
if (requestor.hasEmbargoAgainst(recipient))
requestor.endTemporaryEmbargo(recipient);
if (recipient.hasEmbargoAgainst(requestor))
recipient.endTemporaryEmbargo(requestor);
this.addUpdate({
type: GameUpdateType.AllianceRequestReply,
request: request.toUpdate(),
+1
View File
@@ -20,6 +20,7 @@ export interface GameUpdateViewData {
packedTileUpdates: BigUint64Array;
playerNameViewData: Record<string, NameViewData>;
tickExecutionDuration?: number;
pendingTurns?: number;
}
export interface ErrorUpdate {
+6 -1
View File
@@ -403,11 +403,12 @@ export class PlayerView {
return { hasEmbargo, hasFriendly };
}
async actions(tile?: TileRef): Promise<PlayerActions> {
async actions(tile?: TileRef, units?: UnitType[]): Promise<PlayerActions> {
return this.game.worker.playerInteraction(
this.id(),
tile && this.game.x(tile),
tile && this.game.y(tile),
units,
);
}
@@ -636,6 +637,10 @@ export class GameView implements GameMap {
return this.lastUpdate?.updates ?? null;
}
public isCatchingUp(): boolean {
return (this.lastUpdate?.pendingTurns ?? 0) > 1;
}
public update(gu: GameUpdateViewData) {
this.toDelete.forEach((id) => this._units.delete(id));
this.toDelete.clear();
+34 -24
View File
@@ -22,6 +22,7 @@ import {
EmojiMessage,
GameMode,
Gold,
isStructureType,
MessageType,
MutableAlliance,
Player,
@@ -960,31 +961,40 @@ export class PlayerImpl implements Player {
this.recordUnitConstructed(unit.type());
}
public buildableUnits(tile: TileRef | null): BuildableUnit[] {
const validTiles = tile !== null ? this.validStructureSpawnTiles(tile) : [];
return Object.values(UnitType).map((u) => {
let canUpgrade: number | false = false;
let canBuild: TileRef | false = false;
if (!this.mg.inSpawnPhase()) {
const existingUnit = tile !== null && this.findUnitToUpgrade(u, tile);
if (existingUnit !== false) {
canUpgrade = existingUnit.id();
public buildableUnits(
tile: TileRef | null,
units?: UnitType[],
): BuildableUnit[] {
const validTiles =
tile !== null &&
(units === undefined || units.some((u) => isStructureType(u)))
? this.validStructureSpawnTiles(tile)
: [];
return Object.values(UnitType)
.filter((u) => units === undefined || units.includes(u))
.map((u) => {
let canUpgrade: number | false = false;
let canBuild: TileRef | false = false;
if (!this.mg.inSpawnPhase()) {
const existingUnit = tile !== null && this.findUnitToUpgrade(u, tile);
if (existingUnit !== false) {
canUpgrade = existingUnit.id();
}
if (tile !== null) {
canBuild = this.canBuild(u, tile, validTiles);
}
}
if (tile !== null) {
canBuild = this.canBuild(u, tile, validTiles);
}
}
return {
type: u,
canBuild,
canUpgrade,
cost: this.mg.config().unitInfo(u).cost(this.mg, this),
overlappingRailroads:
canBuild !== false
? this.mg.railNetwork().overlappingRailroads(canBuild)
: [],
} as BuildableUnit;
});
return {
type: u,
canBuild,
canUpgrade,
cost: this.mg.config().unitInfo(u).cost(this.mg, this),
overlappingRailroads:
canBuild !== false
? this.mg.railNetwork().overlappingRailroads(canBuild)
: [],
} as BuildableUnit;
});
}
canBuild(
+6
View File
@@ -192,6 +192,12 @@ export class UserSettings {
}
}
getFlag(): string | undefined {
const flag = localStorage.getItem("flag");
if (!flag || flag === "xx") return undefined;
return flag;
}
backgroundMusicVolume(): number {
return this.getFloat("settings.backgroundMusicVolume", 0);
}
+4 -2
View File
@@ -42,9 +42,10 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
if (!gr) {
break;
}
const ticksToRun = Math.min(gr.pendingTurns(), MAX_TICKS_PER_HEARTBEAT);
const pendingTurns = gr.pendingTurns();
const ticksToRun = Math.min(pendingTurns, MAX_TICKS_PER_HEARTBEAT);
for (let i = 0; i < ticksToRun; i++) {
if (!gr.executeNextTick()) {
if (!gr.executeNextTick(gr.pendingTurns())) {
break;
}
}
@@ -94,6 +95,7 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
message.playerID,
message.x,
message.y,
message.units,
);
sendMessage({
type: "player_actions_result",
+6 -3
View File
@@ -4,6 +4,7 @@ import {
PlayerBorderTiles,
PlayerID,
PlayerProfile,
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
@@ -164,6 +165,7 @@ export class WorkerClient {
playerID: PlayerID,
x?: number,
y?: number,
units?: UnitType[],
): Promise<PlayerActions> {
return new Promise((resolve, reject) => {
if (!this.isInitialized) {
@@ -185,9 +187,10 @@ export class WorkerClient {
this.worker.postMessage({
type: "player_actions",
id: messageId,
playerID: playerID,
x: x,
y: y,
playerID,
x,
y,
units,
});
});
}
+2
View File
@@ -3,6 +3,7 @@ import {
PlayerBorderTiles,
PlayerID,
PlayerProfile,
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { GameUpdateViewData } from "../game/GameUpdates";
@@ -62,6 +63,7 @@ export interface PlayerActionsMessage extends BaseWorkerMessage {
playerID: PlayerID;
x?: number;
y?: number;
units?: UnitType[];
}
export interface PlayerActionsResultMessage extends BaseWorkerMessage {
+3 -1
View File
@@ -8,7 +8,7 @@ import {
GameMode,
GameType,
} from "../core/game/Game";
import { GameConfig, GameID } from "../core/Schemas";
import { GameConfig, GameID, PublicGameType } from "../core/Schemas";
import { Client } from "./Client";
import { GamePhase, GameServer } from "./GameServer";
@@ -57,6 +57,7 @@ export class GameManager {
gameConfig: GameConfig | undefined,
creatorPersistentID?: string,
startsAt?: number,
publicGameType?: PublicGameType,
) {
const game = new GameServer(
id,
@@ -83,6 +84,7 @@ export class GameManager {
},
creatorPersistentID,
startsAt,
publicGameType,
);
this.games.set(id, game);
return game;
+3
View File
@@ -13,6 +13,7 @@ import {
GameStartInfo,
GameStartInfoSchema,
PlayerRecord,
PublicGameType,
ServerDesyncSchema,
ServerErrorMessage,
ServerLobbyInfoMessage,
@@ -90,6 +91,7 @@ export class GameServer {
public gameConfig: GameConfig,
private creatorPersistentID?: string,
private startsAt?: number,
private publicGameType?: PublicGameType,
) {
this.log = log_.child({ gameID: id });
}
@@ -824,6 +826,7 @@ export class GameServer {
gameConfig: this.gameConfig,
startsAt: this.startsAt,
serverTime: Date.now(),
publicGameType: this.publicGameType,
};
}
+2
View File
@@ -3,6 +3,7 @@ import {
GameConfigSchema,
PublicGameInfoSchema,
PublicGamesSchema,
PublicGameTypeSchema,
} from "../core/Schemas";
export type WorkerLobbyList = z.infer<typeof WorkerLobbyListSchema>;
@@ -48,6 +49,7 @@ const MasterCreateGameSchema = z.object({
gameID: z.string(),
gameConfig: GameConfigSchema,
startsAt: z.number(),
publicGameType: PublicGameTypeSchema,
});
export const MasterMessageSchema = z.discriminatedUnion("type", [
+113 -93
View File
@@ -13,7 +13,7 @@ import {
Trios,
} from "../core/game/Game";
import { PseudoRandom } from "../core/PseudoRandom";
import { GameConfig, TeamCountConfig } from "../core/Schemas";
import { GameConfig, PublicGameType, TeamCountConfig } from "../core/Schemas";
import { logger } from "./Logger";
import { getMapLandTiles } from "./MapLandTiles";
@@ -68,13 +68,9 @@ const frequency: Partial<Record<GameMapName, number>> = {
TheBox: 3,
Yenisei: 6,
TradersDream: 4,
Hawaii: 4,
};
interface MapWithMode {
map: GameMapType;
mode: GameMode;
}
const TEAM_WEIGHTS: { config: TeamCountConfig; weight: number }[] = [
{ config: 2, weight: 10 },
{ config: 3, weight: 10 },
@@ -89,12 +85,23 @@ const TEAM_WEIGHTS: { config: TeamCountConfig; weight: number }[] = [
];
export class MapPlaylist {
private mapsPlaylist: MapWithMode[] = [];
private playlists: Record<PublicGameType, GameMapType[]> = {
ffa: [],
special: [],
team: [],
};
constructor(private disableTeams: boolean = false) {}
constructor() {}
public async gameConfig(): Promise<GameConfig> {
const { map, mode } = this.getNextMap();
public async gameConfig(type: PublicGameType): Promise<GameConfig> {
if (type === "special") {
return this.getSpecialConfig();
}
// TODO: consider moving modifier to special lobby.
const mode = type === "ffa" ? GameMode.FFA : GameMode.Team;
const map = this.getNextMap(type);
const playerTeams =
mode === GameMode.Team ? this.getTeamCount() : undefined;
@@ -165,52 +172,127 @@ export class MapPlaylist {
} satisfies GameConfig;
}
private getSpecialConfig(): GameConfig {
// TODO: create better special configs.
const map = this.getNextMap("special");
return {
donateGold: true,
donateTroops: true,
gameMap: map,
maxPlayers: 2,
gameType: GameType.Public,
gameMapSize: GameMapSize.Normal,
difficulty: Difficulty.Easy,
rankedType: RankedType.OneVOne,
infiniteGold: false,
infiniteTroops: false,
instantBuild: false,
randomSpawn: false,
disableNations: true,
gameMode: GameMode.Team,
playerTeams: HumansVsNations,
bots: 100,
spawnImmunityDuration: 5 * 10,
disabledUnits: [],
} satisfies GameConfig;
}
public get1v1Config(): GameConfig {
const maps = [
GameMapType.Iceland,
GameMapType.Australia, // 40%
GameMapType.Australia,
GameMapType.Australia,
GameMapType.Australia,
GameMapType.Pangaea,
GameMapType.Italia,
GameMapType.FalklandIslands,
GameMapType.Sierpinski,
GameMapType.Iceland, // 20%
GameMapType.Asia, // 20%
GameMapType.EuropeClassic, // 20%
];
const isCompact = Math.random() < 0.5;
return {
donateGold: false,
donateTroops: false,
gameMap: maps[Math.floor(Math.random() * maps.length)],
maxPlayers: 2,
gameType: GameType.Public,
gameMapSize: GameMapSize.Compact,
difficulty: Difficulty.Easy,
gameMapSize: isCompact ? GameMapSize.Compact : GameMapSize.Normal,
difficulty: Difficulty.Medium, // Doesn't matter, nations are disabled
rankedType: RankedType.OneVOne,
infiniteGold: false,
infiniteTroops: false,
maxTimerValue: 10, // 10 minutes
maxTimerValue: isCompact ? 10 : 15,
instantBuild: false,
randomSpawn: false,
disableNations: true,
gameMode: GameMode.FFA,
bots: 100,
bots: isCompact ? 100 : 400,
spawnImmunityDuration: 30 * 10,
disabledUnits: [],
} satisfies GameConfig;
}
private getNextMap(): MapWithMode {
if (this.mapsPlaylist.length === 0) {
const numAttempts = 10000;
for (let i = 0; i < numAttempts; i++) {
if (this.shuffleMapsPlaylist()) {
log.info(`Generated map playlist in ${i} attempts`);
return this.mapsPlaylist.shift()!;
private getNextMap(type: PublicGameType): GameMapType {
const playlist = this.playlists[type];
if (playlist.length === 0) {
playlist.push(...this.generateNewPlaylist());
}
return playlist.shift()!;
}
private generateNewPlaylist(): GameMapType[] {
const maps = this.buildMapsList();
const rand = new PseudoRandom(Date.now());
const shuffledSource = rand.shuffleArray([...maps]);
const playlist: GameMapType[] = [];
const numAttempts = 10000;
for (let attempt = 0; attempt < numAttempts; attempt++) {
playlist.length = 0;
const source = [...shuffledSource];
let success = true;
while (source.length > 0) {
if (!this.addNextMapNonConsecutive(playlist, source)) {
success = false;
break;
}
}
log.error("Failed to generate a valid map playlist");
if (success) {
log.info(`Generated map playlist in ${attempt} attempts`);
return playlist;
}
}
// Even if it failed, playlist will be partially populated.
return this.mapsPlaylist.shift()!;
log.warn(
`Failed to generate non-consecutive playlist after ${numAttempts} attempts, falling back to shuffle`,
);
return rand.shuffleArray([...maps]);
}
private addNextMapNonConsecutive(
playlist: GameMapType[],
source: GameMapType[],
): boolean {
const nonConsecutiveNum = 5;
const lastMaps = playlist.slice(-nonConsecutiveNum);
for (let i = 0; i < source.length; i++) {
const map = source[i];
if (!lastMaps.includes(map)) {
source.splice(i, 1);
playlist.push(map);
return true;
}
}
return false;
}
private buildMapsList(): GameMapType[] {
const maps: GameMapType[] = [];
(Object.keys(GameMapType) as GameMapName[]).forEach((key) => {
for (let i = 0; i < (frequency[key] ?? 0); i++) {
maps.push(GameMapType[key]);
}
});
return maps;
}
private getTeamCount(): TeamCountConfig {
@@ -321,66 +403,4 @@ export class MapPlaylist {
roundToNearest5(limitedBase * 0.5),
];
}
private shuffleMapsPlaylist(): boolean {
const maps: GameMapType[] = [];
(Object.keys(GameMapType) as GameMapName[]).forEach((key) => {
for (let i = 0; i < (frequency[key] ?? 0); i++) {
maps.push(GameMapType[key]);
}
});
const rand = new PseudoRandom(Date.now());
const ffa1: GameMapType[] = rand.shuffleArray([...maps]);
const team1: GameMapType[] = rand.shuffleArray([...maps]);
const ffa2: GameMapType[] = rand.shuffleArray([...maps]);
const team2: GameMapType[] = rand.shuffleArray([...maps]);
const ffa3: GameMapType[] = rand.shuffleArray([...maps]);
this.mapsPlaylist = [];
for (let i = 0; i < maps.length; i++) {
if (!this.addNextMap(this.mapsPlaylist, ffa1, GameMode.FFA)) {
return false;
}
if (!this.disableTeams) {
if (!this.addNextMap(this.mapsPlaylist, team1, GameMode.Team)) {
return false;
}
}
if (!this.addNextMap(this.mapsPlaylist, ffa2, GameMode.FFA)) {
return false;
}
if (!this.disableTeams) {
if (!this.addNextMap(this.mapsPlaylist, team2, GameMode.Team)) {
return false;
}
}
if (!this.addNextMap(this.mapsPlaylist, ffa3, GameMode.FFA)) {
return false;
}
}
return true;
}
private addNextMap(
playlist: MapWithMode[],
nextEls: GameMapType[],
mode: GameMode,
): boolean {
const nonConsecutiveNum = 5;
const lastEls = playlist
.slice(playlist.length - nonConsecutiveNum)
.map((m) => m.map);
for (let i = 0; i < nextEls.length; i++) {
const next = nextEls[i];
if (lastEls.includes(next)) {
continue;
}
nextEls.splice(i, 1);
playlist.push({ map: next, mode: mode });
return true;
}
return false;
}
}
+59 -39
View File
@@ -1,7 +1,7 @@
import { Worker } from "cluster";
import winston from "winston";
import { ServerConfig } from "../core/configuration/Config";
import { PublicGameInfo } from "../core/Schemas";
import { PublicGameInfo, PublicGameType } from "../core/Schemas";
import { generateID } from "../core/Util";
import {
MasterCreateGame,
@@ -72,11 +72,24 @@ export class MasterLobbyService {
}
}
private getAllLobbies(): PublicGameInfo[] {
const lobbies = Array.from(this.workerLobbies.values())
.flat()
.sort((a, b) => a.startsAt! - b.startsAt);
return lobbies;
private getAllLobbies(): Record<PublicGameType, PublicGameInfo[]> {
const lobbies = Array.from(this.workerLobbies.values()).flat();
const result: Record<PublicGameType, PublicGameInfo[]> = {
ffa: [],
team: [],
special: [],
};
for (const lobby of lobbies) {
result[lobby.publicGameType].push(lobby);
}
for (const type of Object.keys(result) as PublicGameType[]) {
result[type].sort((a, b) => a.startsAt - b.startsAt);
}
return result;
}
private broadcastLobbies() {
@@ -97,39 +110,46 @@ export class MasterLobbyService {
}
private async maybeScheduleLobby() {
const lobbies = this.getAllLobbies();
if (lobbies.length >= 2) {
return;
const lobbiesByType = this.getAllLobbies();
for (const type of Object.keys(lobbiesByType) as PublicGameType[]) {
const lobbies = lobbiesByType[type];
if (lobbies.length >= 2) {
continue;
}
const lastStart = lobbies.reduce(
(max, pb) => Math.max(max, pb.startsAt),
Date.now(),
);
const gameID = generateID();
const workerId = this.config.workerIndex(gameID);
const gameConfig = await this.playlist.gameConfig(type);
const worker = this.workers.get(workerId);
if (!worker) {
this.log.error(`Worker ${workerId} not found`);
continue;
}
worker.send(
{
type: "createGame",
gameID,
gameConfig,
startsAt: lastStart + this.config.gameCreationRate(),
publicGameType: type,
} satisfies MasterCreateGame,
(e) => {
if (e) {
this.log.error("Failed to schedule lobby on worker:", e);
}
},
);
this.log.info(
`Scheduled public game ${gameID} (${type}) on worker ${workerId}`,
);
}
const lastStart = lobbies.reduce(
(max, pb) => Math.max(max, pb.startsAt),
Date.now(),
);
const gameID = generateID();
const workerId = this.config.workerIndex(gameID);
const gameConfig = await this.playlist.gameConfig();
const worker = this.workers.get(workerId);
if (!worker) {
this.log.error(`Worker ${workerId} not found`);
return;
}
worker.send(
{
type: "createGame",
gameID,
gameConfig,
startsAt: lastStart + this.config.gameCreationRate(),
} satisfies MasterCreateGame,
(e) => {
if (e) {
this.log.error("Failed to schedule lobby on worker:", e);
}
},
);
this.log.info(`Scheduled public game ${gameID} on worker ${workerId}`);
}
}
+1 -1
View File
@@ -36,7 +36,7 @@ const config = getServerConfigFromServer();
const workerId = parseInt(process.env.WORKER_ID ?? "0");
const log = logger.child({ comp: `w_${workerId}` });
const playlist = new MapPlaylist(true);
const playlist = new MapPlaylist();
// Worker setup
export async function startWorker() {
+2
View File
@@ -52,6 +52,7 @@ export class WorkerLobbyService {
msg.gameConfig,
undefined,
msg.startsAt,
msg.publicGameType,
);
break;
}
@@ -73,6 +74,7 @@ export class WorkerLobbyService {
numClients: gi.clients?.length ?? 0,
startsAt: gi.startsAt!,
gameConfig: gi.gameConfig,
publicGameType: gi.publicGameType!,
} satisfies PublicGameInfo;
});
process.send?.({ type: "lobbyList", lobbies } satisfies WorkerLobbyList);
+14 -19
View File
@@ -1,4 +1,4 @@
import { AllianceRequestReplyExecution } from "src/core/execution/alliance/AllianceRequestReplyExecution";
import { AllianceRequestExecution } from "src/core/execution/alliance/AllianceRequestExecution";
import { GameUpdateType } from "src/core/game/GameUpdates";
import { NukeExecution } from "../src/core/execution/NukeExecution";
import {
@@ -69,12 +69,10 @@ describe("Alliance acceptance immediately destroys in-flight nukes", () => {
expect(player2.isAlliedWith(player1)).toBe(false);
expect(player1.isFriendly(player2)).toBe(false);
player2.createAllianceRequest(player1);
game.addExecution(
new AllianceRequestReplyExecution(player2.id(), player1, true),
);
game.executeNextTick();
game.addExecution(new AllianceRequestExecution(player1, player2.id()));
game.executeNextTick(); // creates request
game.addExecution(new AllianceRequestExecution(player2, player1.id()));
game.executeNextTick(); // counter-request auto-accepts
expect(player2.isAlliedWith(player1)).toBe(true);
expect(player1.isFriendly(player2)).toBe(true);
@@ -100,12 +98,11 @@ describe("Alliance acceptance immediately destroys in-flight nukes", () => {
expect(player2.isAlliedWith(player1)).toBe(false);
expect(player1.isFriendly(player2)).toBe(false);
player1.createAllianceRequest(player2);
game.addExecution(
new AllianceRequestReplyExecution(player1.id(), player2, true),
);
game.executeNextTick();
// Both requests added in same tick so the nuke tick can't revoke the first
// before the counter-request sees it.
game.addExecution(new AllianceRequestExecution(player1, player2.id()));
game.addExecution(new AllianceRequestExecution(player2, player1.id()));
game.executeNextTick(); // both init: first creates request, second auto-accepts
expect(player2.isAlliedWith(player1)).toBe(true);
expect(player1.isFriendly(player2)).toBe(true);
@@ -137,12 +134,10 @@ describe("Alliance acceptance immediately destroys in-flight nukes", () => {
expect(player2.isAlliedWith(player1)).toBe(false);
expect(player1.isFriendly(player2)).toBe(false);
player2.createAllianceRequest(player1);
game.addExecution(
new AllianceRequestReplyExecution(player2.id(), player1, true),
);
const updates = game.executeNextTick();
game.addExecution(new AllianceRequestExecution(player1, player2.id()));
game.executeNextTick(); // creates request
game.addExecution(new AllianceRequestExecution(player2, player1.id()));
const updates = game.executeNextTick(); // counter-request auto-accepts
expect(player2.isAlliedWith(player1)).toBe(true);
expect(player1.isFriendly(player2)).toBe(true);
+3 -10
View File
@@ -1,5 +1,4 @@
import { AllianceRequestExecution } from "../src/core/execution/alliance/AllianceRequestExecution";
import { AllianceRequestReplyExecution } from "../src/core/execution/alliance/AllianceRequestReplyExecution";
import { DonateGoldExecution } from "../src/core/execution/DonateGoldExecution";
import { Game, Player, PlayerType } from "../src/core/game/Game";
import { playerInfo, setup } from "./util/Setup";
@@ -44,9 +43,7 @@ describe("Alliance Donation", () => {
game.addExecution(new AllianceRequestExecution(player1, player2.id()));
game.executeNextTick();
game.addExecution(
new AllianceRequestReplyExecution(player1.id(), player2, true),
);
game.addExecution(new AllianceRequestExecution(player2, player1.id()));
game.executeNextTick();
expect(player1.isAlliedWith(player2)).toBeTruthy();
@@ -65,9 +62,7 @@ describe("Alliance Donation", () => {
game.addExecution(new AllianceRequestExecution(player1, player2.id()));
game.executeNextTick();
game.addExecution(
new AllianceRequestReplyExecution(player1.id(), player2, true),
);
game.addExecution(new AllianceRequestExecution(player2, player1.id()));
game.executeNextTick();
expect(player1.isAlliedWith(player2)).toBeTruthy();
@@ -121,9 +116,7 @@ describe("Alliance Donation", () => {
game.executeNextTick();
const goldBefore = player2.gold();
game.addExecution(
new AllianceRequestReplyExecution(player1.id(), player2, true),
);
game.addExecution(new AllianceRequestExecution(player2, player1.id()));
game.addExecution(new DonateGoldExecution(player1, player2.id(), 100));
game.executeNextTick();
+6 -16
View File
@@ -1,6 +1,5 @@
import { AllianceExtensionExecution } from "../src/core/execution/alliance/AllianceExtensionExecution";
import { AllianceRequestExecution } from "../src/core/execution/alliance/AllianceRequestExecution";
import { AllianceRequestReplyExecution } from "../src/core/execution/alliance/AllianceRequestReplyExecution";
import { Game, MessageType, Player, PlayerType } from "../src/core/game/Game";
import { playerInfo, setup } from "./util/Setup";
@@ -36,17 +35,14 @@ describe("AllianceExtensionExecution", () => {
test("Successfully extends existing alliance between Humans", () => {
vi.spyOn(player1, "canSendAllianceRequest").mockReturnValue(true);
vi.spyOn(player2, "canSendAllianceRequest").mockReturnValue(true);
vi.spyOn(player2, "isAlive").mockReturnValue(true);
vi.spyOn(player1, "isAlive").mockReturnValue(true);
game.addExecution(new AllianceRequestExecution(player1, player2.id()));
game.executeNextTick();
game.executeNextTick();
game.addExecution(
new AllianceRequestReplyExecution(player1.id(), player2, true),
);
game.executeNextTick();
game.addExecution(new AllianceRequestExecution(player2, player1.id()));
game.executeNextTick();
expect(player1.allianceWith(player2)).toBeTruthy();
@@ -83,17 +79,14 @@ describe("AllianceExtensionExecution", () => {
test("Successfully extends existing alliance between Human and non-Human", () => {
vi.spyOn(player1, "canSendAllianceRequest").mockReturnValue(true);
vi.spyOn(player3, "canSendAllianceRequest").mockReturnValue(true);
vi.spyOn(player3, "isAlive").mockReturnValue(true);
vi.spyOn(player1, "isAlive").mockReturnValue(true);
game.addExecution(new AllianceRequestExecution(player1, player3.id()));
game.executeNextTick();
game.executeNextTick();
game.addExecution(
new AllianceRequestReplyExecution(player1.id(), player3, true),
);
game.executeNextTick();
game.addExecution(new AllianceRequestExecution(player3, player1.id()));
game.executeNextTick();
expect(player1.allianceWith(player3)).toBeTruthy();
@@ -121,18 +114,15 @@ describe("AllianceExtensionExecution", () => {
test("Sends message to other player when one player requests renewal", () => {
vi.spyOn(player1, "canSendAllianceRequest").mockReturnValue(true);
vi.spyOn(player2, "canSendAllianceRequest").mockReturnValue(true);
vi.spyOn(player2, "isAlive").mockReturnValue(true);
vi.spyOn(player1, "isAlive").mockReturnValue(true);
// Create alliance between player1 and player2
game.addExecution(new AllianceRequestExecution(player1, player2.id()));
game.executeNextTick();
game.executeNextTick();
game.addExecution(
new AllianceRequestReplyExecution(player1.id(), player2, true),
);
game.executeNextTick();
game.addExecution(new AllianceRequestExecution(player2, player1.id()));
game.executeNextTick();
expect(player1.allianceWith(player2)).toBeTruthy();
+14 -16
View File
@@ -1,5 +1,5 @@
import { AllianceRejectExecution } from "../src/core/execution/alliance/AllianceRejectExecution";
import { AllianceRequestExecution } from "../src/core/execution/alliance/AllianceRequestExecution";
import { AllianceRequestReplyExecution } from "../src/core/execution/alliance/AllianceRequestReplyExecution";
import { NukeExecution } from "../src/core/execution/NukeExecution";
import { Game, Player, PlayerType, UnitType } from "../src/core/game/Game";
import { playerInfo, setup } from "./util/Setup";
@@ -36,21 +36,7 @@ describe("AllianceRequestExecution", () => {
}
});
test("Can create alliance by replying", () => {
game.addExecution(new AllianceRequestExecution(player1, player2.id()));
game.executeNextTick();
game.addExecution(
new AllianceRequestReplyExecution(player1.id(), player2, true),
);
game.executeNextTick();
game.executeNextTick();
expect(player1.isAlliedWith(player2)).toBeTruthy();
expect(player2.isAlliedWith(player1)).toBeTruthy();
});
test("Can create alliance by sending alliance request back", () => {
test("Can create alliance by counter-request", () => {
game.addExecution(new AllianceRequestExecution(player1, player2.id()));
game.executeNextTick();
@@ -61,6 +47,18 @@ describe("AllianceRequestExecution", () => {
expect(player2.isAlliedWith(player1)).toBeTruthy();
});
test("Can reject alliance request", () => {
game.addExecution(new AllianceRequestExecution(player1, player2.id()));
game.executeNextTick();
game.addExecution(new AllianceRejectExecution(player1.id(), player2));
game.executeNextTick();
expect(player1.isAlliedWith(player2)).toBeFalsy();
expect(player2.isAlliedWith(player1)).toBeFalsy();
expect(player1.outgoingAllianceRequests().length).toBe(0);
});
test("Alliance request expires", () => {
game.config().allianceRequestDuration = () => 5;
game.addExecution(new AllianceRequestExecution(player1, player2.id()));
+49
View File
@@ -0,0 +1,49 @@
import { normalizeNewsMarkdown } from "../../src/client/NewsMarkdown";
describe("normalizeNewsMarkdown", () => {
it("converts openfront pull request URLs to short markdown links", () => {
const input =
"Fix attack logic in https://github.com/openfrontio/OpenFrontIO/pull/1234";
const result = normalizeNewsMarkdown(input);
expect(result).toContain(
"[#1234](https://github.com/openfrontio/OpenFrontIO/pull/1234)",
);
});
it("converts openfront compare URLs to markdown links", () => {
const input =
"Full Changelog: https://github.com/openfrontio/OpenFrontIO/compare/v1.0.0...v1.1.0";
const result = normalizeNewsMarkdown(input);
expect(result).toContain(
"[v1.0.0...v1.1.0](https://github.com/openfrontio/OpenFrontIO/compare/v1.0.0...v1.1.0)",
);
});
it("converts github @mentions to profile links", () => {
const input = "- Feature by @evanpelle in release notes";
const result = normalizeNewsMarkdown(input);
expect(result).toContain("[@evanpelle](https://github.com/evanpelle)");
});
it("does not convert existing markdown-linked mentions", () => {
const input = "Credit [@evanpelle](https://github.com/evanpelle)";
const result = normalizeNewsMarkdown(input);
expect(result).toBe(input);
});
it("does not convert email addresses", () => {
const input = "Contact support@openfront.io for help";
const result = normalizeNewsMarkdown(input);
expect(result).toBe(input);
});
});
+4 -15
View File
@@ -3,7 +3,6 @@ import { AttackExecution } from "../../../src/core/execution/AttackExecution";
import { SpawnExecution } from "../../../src/core/execution/SpawnExecution";
//import { TransportShipExecution } from "../../../src/core/execution/TransportShipExecution";
import { AllianceRequestExecution } from "../../../src/core/execution/alliance/AllianceRequestExecution";
import { AllianceRequestReplyExecution } from "../../../src/core/execution/alliance/AllianceRequestReplyExecution";
import {
Game,
Player,
@@ -68,16 +67,11 @@ describe("GameImpl", () => {
test("Don't become traitor when betraying inactive player", async () => {
vi.spyOn(attacker, "canSendAllianceRequest").mockReturnValue(true);
vi.spyOn(defender, "canSendAllianceRequest").mockReturnValue(true);
game.addExecution(new AllianceRequestExecution(attacker, defender.id()));
game.executeNextTick();
game.executeNextTick();
game.addExecution(
new AllianceRequestReplyExecution(attacker.id(), defender, true),
);
game.executeNextTick();
game.addExecution(new AllianceRequestExecution(defender, attacker.id()));
game.executeNextTick();
expect(attacker.allianceWith(defender)).toBeTruthy();
@@ -107,16 +101,11 @@ describe("GameImpl", () => {
test("Do become traitor when betraying active player", async () => {
vi.spyOn(attacker, "canSendAllianceRequest").mockReturnValue(true);
vi.spyOn(defender, "canSendAllianceRequest").mockReturnValue(true);
game.addExecution(new AllianceRequestExecution(attacker, defender.id()));
game.executeNextTick();
game.executeNextTick();
game.addExecution(
new AllianceRequestReplyExecution(attacker.id(), defender, true),
);
game.executeNextTick();
game.addExecution(new AllianceRequestExecution(defender, attacker.id()));
game.executeNextTick();
expect(attacker.allianceWith(defender)).toBeTruthy();
+9
View File
@@ -0,0 +1,9 @@
import { execSync } from "child_process";
import { globSync } from "glob";
// "perf": "npx tsx tests/perf/*.ts" doesn't work on Windows
const files = globSync("tests/perf/*.ts").filter((f) => !f.includes("run-all"));
for (const file of files) {
console.log(`\nRunning ${file}...`);
execSync(`tsx "${file}"`, { stdio: "inherit" });
}