mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-15 09:52:50 +00:00
Merge branch 'main' into local-attack
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -76,6 +76,10 @@ export class LeaderboardModal extends BaseModal {
|
||||
>(${start} - ${end})</span
|
||||
>`;
|
||||
}
|
||||
const refreshTime = html`<span
|
||||
class="text-sm font-normal text-white/40 ml-2 wrap-break-words italic"
|
||||
>(${translateText("leaderboard_modal.refresh_time")})</span
|
||||
>`;
|
||||
|
||||
const content = html`
|
||||
<div
|
||||
@@ -92,6 +96,7 @@ export class LeaderboardModal extends BaseModal {
|
||||
${translateText("leaderboard_modal.title")}
|
||||
</span>
|
||||
${this.activeTab === "clans" ? dateRange : ""}
|
||||
${this.activeTab === "players" ? refreshTime : ""}
|
||||
</div>
|
||||
`,
|
||||
onBack: this.close,
|
||||
|
||||
+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 {
|
||||
@@ -205,8 +200,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),
|
||||
@@ -448,11 +443,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() {
|
||||
|
||||
@@ -99,6 +99,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)) {
|
||||
@@ -247,6 +248,7 @@ export function createRenderer(
|
||||
console.error("spawn timer not found");
|
||||
}
|
||||
spawnTimer.game = game;
|
||||
spawnTimer.eventBus = eventBus;
|
||||
spawnTimer.transformHandler = transformHandler;
|
||||
|
||||
const immunityTimer = document.querySelector(
|
||||
@@ -256,6 +258,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/70 backdrop-blur-xs rounded-tl-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 max-sm:rounded-tr-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({
|
||||
|
||||
+12
-3
@@ -1,6 +1,7 @@
|
||||
import { placeName } from "../client/graphics/NameBoxCalculator";
|
||||
import { getConfig } from "./configuration/ConfigLoader";
|
||||
import { Executor } from "./execution/ExecutionManager";
|
||||
import { RecomputeRailClusterExecution } from "./execution/RecomputeRailClusterExecution";
|
||||
import { WinCheckExecution } from "./execution/WinCheckExecution";
|
||||
import {
|
||||
AllPlayers,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
PlayerInfo,
|
||||
PlayerProfile,
|
||||
PlayerType,
|
||||
UnitType,
|
||||
} from "./game/Game";
|
||||
import { createGame } from "./game/GameImpl";
|
||||
import { TileRef } from "./game/GameMap";
|
||||
@@ -105,13 +107,18 @@ export class GameRunner {
|
||||
this.game.addExecution(...this.execManager.nationExecutions());
|
||||
}
|
||||
this.game.addExecution(new WinCheckExecution());
|
||||
if (!this.game.config().isUnitDisabled(UnitType.Factory)) {
|
||||
this.game.addExecution(
|
||||
new RecomputeRailClusterExecution(this.game.railNetwork()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public addTurn(turn: Turn): void {
|
||||
this.turns.push(turn);
|
||||
}
|
||||
|
||||
public executeNextTick(): boolean {
|
||||
public executeNextTick(pendingTurns?: number): boolean {
|
||||
if (this.isExecuting) {
|
||||
return false;
|
||||
}
|
||||
@@ -175,6 +182,7 @@ export class GameRunner {
|
||||
updates: updates,
|
||||
playerNameViewData: this.playerViewData,
|
||||
tickExecutionDuration: tickExecutionDuration,
|
||||
pendingTurns: pendingTurns ?? 0,
|
||||
});
|
||||
this.isExecuting = false;
|
||||
return true;
|
||||
@@ -188,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 {
|
||||
@@ -312,10 +315,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({
|
||||
@@ -427,7 +429,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,20 @@
|
||||
import { Execution, Game } from "../game/Game";
|
||||
import { RailNetwork } from "../game/RailNetwork";
|
||||
|
||||
export class RecomputeRailClusterExecution implements Execution {
|
||||
constructor(private railNetwork: RailNetwork) {}
|
||||
|
||||
isActive(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
activeDuringSpawnPhase(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
init(mg: Game, ticks: number): void {}
|
||||
|
||||
tick(ticks: number): void {
|
||||
this.railNetwork.recomputeClusters();
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -9,4 +9,5 @@ export interface RailNetwork {
|
||||
findStationsPath(from: TrainStation, to: TrainStation): TrainStation[];
|
||||
stationManager(): StationManager;
|
||||
overlappingRailroads(tile: TileRef): number[];
|
||||
recomputeClusters(): void;
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ export class RailNetworkImpl implements RailNetwork {
|
||||
private gridCellSize: number = 4;
|
||||
private railGrid: RailSpatialGrid;
|
||||
private nextId: number = 0;
|
||||
private dirtyClusters = new Set<Cluster>();
|
||||
|
||||
constructor(
|
||||
private game: Game,
|
||||
@@ -106,26 +107,48 @@ export class RailNetworkImpl implements RailNetwork {
|
||||
}
|
||||
}
|
||||
|
||||
recomputeClusters() {
|
||||
if (this.dirtyClusters.size === 0) return;
|
||||
|
||||
for (const cluster of this.dirtyClusters) {
|
||||
const allOriginalStations = new Set(cluster.stations);
|
||||
while (allOriginalStations.size > 0) {
|
||||
const nextStation = allOriginalStations.values().next().value;
|
||||
const allConnectedStations = this.computeCluster(nextStation);
|
||||
// Filter stations that are connected to the current cluster
|
||||
for (const connectedStation of allConnectedStations) {
|
||||
allOriginalStations.delete(connectedStation);
|
||||
}
|
||||
// Those stations were disconnected: new cluster
|
||||
if (allOriginalStations.size > 0) {
|
||||
const newCluster = new Cluster();
|
||||
// Switching their cluster will automatically remove them from their current cluster
|
||||
newCluster.addStations(allConnectedStations);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.dirtyClusters.clear();
|
||||
}
|
||||
|
||||
removeStation(unit: Unit): void {
|
||||
const station = this._stationManager.findStation(unit);
|
||||
if (!station) return;
|
||||
|
||||
const neighbors = station.neighbors();
|
||||
this.disconnectFromNetwork(station);
|
||||
this._stationManager.removeStation(station);
|
||||
station.unit.setTrainStation(false);
|
||||
|
||||
const cluster = station.getCluster();
|
||||
if (!cluster) return;
|
||||
if (neighbors.length === 1) {
|
||||
cluster.removeStation(station);
|
||||
} else if (neighbors.length > 1) {
|
||||
for (const neighbor of neighbors) {
|
||||
const stations = this.computeCluster(neighbor);
|
||||
const newCluster = new Cluster();
|
||||
newCluster.addStations(stations);
|
||||
}
|
||||
|
||||
cluster.removeStation(station);
|
||||
if (cluster.size() === 0) {
|
||||
this.deleteCluster(cluster);
|
||||
this.dirtyClusters.delete(cluster);
|
||||
return;
|
||||
}
|
||||
station.unit.setTrainStation(false);
|
||||
|
||||
this.dirtyClusters.add(cluster);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -258,10 +281,6 @@ export class RailNetworkImpl implements RailNetwork {
|
||||
this.railGrid.unregister(rail);
|
||||
}
|
||||
station.clearRailroads();
|
||||
const cluster = station.getCluster();
|
||||
if (cluster !== null && cluster.size() === 1) {
|
||||
this.deleteCluster(cluster);
|
||||
}
|
||||
}
|
||||
|
||||
private deleteCluster(cluster: Cluster) {
|
||||
|
||||
@@ -53,7 +53,7 @@ export class TrainStation {
|
||||
id: number = -1; // assigned by StationManager
|
||||
private readonly stopHandlers: Partial<Record<UnitType, TrainStopHandler>> =
|
||||
{};
|
||||
private cluster: Cluster | null;
|
||||
private cluster: Cluster | null = null;
|
||||
private railroads: Set<Railroad> = new Set();
|
||||
// Quick lookup from neighboring station to connecting railroad
|
||||
private railroadByNeighbor: Map<TrainStation, Railroad> = new Map();
|
||||
@@ -129,6 +129,10 @@ export class TrainStation {
|
||||
}
|
||||
|
||||
setCluster(cluster: Cluster | null) {
|
||||
// Properly disconnect cluster if it's already set
|
||||
if (this.cluster !== null) {
|
||||
this.cluster.removeStation(this);
|
||||
}
|
||||
this.cluster = cluster;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user