mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 13:50:43 +00:00
Merge branch 'main' into fix-sam-bug
This commit is contained in:
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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},
|
||||
|
||||
Generated
+3
-24
@@ -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
@@ -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 .",
|
||||
|
||||
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 |
@@ -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();
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) : ""}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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,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":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface GameUpdateViewData {
|
||||
packedTileUpdates: BigUint64Array;
|
||||
playerNameViewData: Record<string, NameViewData>;
|
||||
tickExecutionDuration?: number;
|
||||
pendingTurns?: number;
|
||||
}
|
||||
|
||||
export interface ErrorUpdate {
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
Reference in New Issue
Block a user