Merge branch 'main' into local-attack

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