mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-22 12:26:32 +00:00
Merge branch 'main' into trade
This commit is contained in:
@@ -883,13 +883,6 @@
|
||||
"pattern": {
|
||||
"default": "Default"
|
||||
},
|
||||
"try_me": "Try me!",
|
||||
"trial_remaining": "remaining",
|
||||
"trial_granted": "Skin trial granted!",
|
||||
"trial_cooldown": "Only one trial per 24 hours. Please try again later.",
|
||||
"trial_login_required": "Must be logged in to trial a skin",
|
||||
"reward_countdown": "Reward in {seconds} seconds...",
|
||||
"steam_wishlist_prompt": "Support OpenFront by adding it to your Steam wishlist",
|
||||
"select_skin": "Select Skin",
|
||||
"selected": "selected"
|
||||
},
|
||||
|
||||
@@ -125,31 +125,6 @@ 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();
|
||||
|
||||
|
||||
@@ -385,15 +385,6 @@ export class ClientGameRunner {
|
||||
}
|
||||
});
|
||||
|
||||
const worker = this.worker;
|
||||
const keepWorkerAlive = () => {
|
||||
if (this.isActive) {
|
||||
worker.sendHeartbeat();
|
||||
requestAnimationFrame(keepWorkerAlive);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(keepWorkerAlive);
|
||||
|
||||
const onconnect = () => {
|
||||
console.log("Connected to game server!");
|
||||
this.transport.rejoinGame(this.turnsSeen);
|
||||
|
||||
+4
-29
@@ -85,19 +85,14 @@ 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" | "purchasable_no_trial" | "blocked" | number {
|
||||
): "owned" | "purchasable" | "blocked" {
|
||||
const flares =
|
||||
userMeResponse === false ? [] : (userMeResponse.player.flares ?? []);
|
||||
const expirations: Record<string, number> =
|
||||
userMeResponse === false
|
||||
? {}
|
||||
: (userMeResponse.player.flareExpiration ?? {});
|
||||
if (flares.includes("pattern:*")) {
|
||||
return "owned";
|
||||
}
|
||||
@@ -113,14 +108,6 @@ 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";
|
||||
}
|
||||
|
||||
@@ -139,12 +126,7 @@ export function patternRelationship(
|
||||
return "blocked";
|
||||
}
|
||||
|
||||
// --- 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";
|
||||
}
|
||||
// Patterns is for sale, and it's the right store to show it on.
|
||||
return "purchasable";
|
||||
}
|
||||
|
||||
@@ -162,16 +144,9 @@ export async function getPlayerCosmeticsRefs(): Promise<PlayerCosmeticRefs> {
|
||||
? `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 (!hasWildcard && !flares.includes(flareName)) {
|
||||
pattern = null;
|
||||
}
|
||||
}
|
||||
if (pattern === null) {
|
||||
|
||||
@@ -189,12 +189,6 @@ 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;
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
getPlayerCosmetics,
|
||||
handlePurchase,
|
||||
patternRelationship,
|
||||
TEMP_FLARE_OFFSET,
|
||||
} from "./Cosmetics";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
@@ -124,7 +123,7 @@ export class TerritoryPatternsModal extends BaseModal {
|
||||
? [...(pattern.colorPalettes ?? []), null]
|
||||
: [null];
|
||||
for (const colorPalette of colorPalettes) {
|
||||
let rel: string | number = "owned";
|
||||
let rel = "owned";
|
||||
if (pattern) {
|
||||
rel = patternRelationship(
|
||||
pattern,
|
||||
@@ -136,9 +135,8 @@ export class TerritoryPatternsModal extends BaseModal {
|
||||
if (rel === "blocked") {
|
||||
continue;
|
||||
}
|
||||
const isTrial = typeof rel === "number";
|
||||
if (this.showOnlyOwned) {
|
||||
if (rel !== "owned" && !isTrial) continue;
|
||||
if (rel !== "owned") continue;
|
||||
} else {
|
||||
// Store mode: hide owned items
|
||||
if (rel === "owned") continue;
|
||||
@@ -158,20 +156,7 @@ export class TerritoryPatternsModal extends BaseModal {
|
||||
.colorPalette=${this.cosmetics?.colorPalettes?.[
|
||||
colorPalette?.name ?? ""
|
||||
] ?? null}
|
||||
.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}
|
||||
.requiresPurchase=${rel === "purchasable"}
|
||||
.selected=${isSelected}
|
||||
.onSelect=${(p: PlayerPattern | null) => this.selectPattern(p)}
|
||||
.onPurchase=${(p: Pattern, colorPalette: ColorPalette | null) =>
|
||||
|
||||
+34
-19
@@ -318,50 +318,65 @@ export function formatDebugTranslation(
|
||||
return `${key}::${serializedParams}`;
|
||||
}
|
||||
|
||||
const EMPTY_TRANSLATION_PARAMS: Record<string, string | number> = {};
|
||||
|
||||
function getCachedLangSelector(): LangSelector | null {
|
||||
const self = translateText as any;
|
||||
const cached = self.langSelector as LangSelector | null | undefined;
|
||||
if (cached && cached.isConnected) return cached;
|
||||
|
||||
const found = document.querySelector("lang-selector") as LangSelector | null;
|
||||
self.langSelector = found ?? null;
|
||||
return found;
|
||||
}
|
||||
|
||||
export const translateText = (
|
||||
key: string,
|
||||
params: Record<string, string | number> = {},
|
||||
params?: Record<string, string | number>,
|
||||
): string => {
|
||||
const self = translateText as any;
|
||||
self.formatterCache ??= new Map();
|
||||
self.lastLang ??= null;
|
||||
|
||||
const langSelector = document.querySelector("lang-selector") as LangSelector;
|
||||
const langSelector = getCachedLangSelector();
|
||||
if (!langSelector) {
|
||||
console.warn("LangSelector not found in DOM");
|
||||
return key;
|
||||
}
|
||||
|
||||
const resolvedParams = params ?? EMPTY_TRANSLATION_PARAMS;
|
||||
|
||||
if (langSelector.currentLang === "debug") {
|
||||
return formatDebugTranslation(key, params);
|
||||
return formatDebugTranslation(key, resolvedParams);
|
||||
}
|
||||
|
||||
if (
|
||||
!langSelector.translations ||
|
||||
Object.keys(langSelector.translations).length === 0
|
||||
) {
|
||||
return key;
|
||||
}
|
||||
const translations = langSelector.translations;
|
||||
const defaultTranslations = langSelector.defaultTranslations;
|
||||
if (!translations && !defaultTranslations) return key;
|
||||
|
||||
if (self.lastLang !== langSelector.currentLang) {
|
||||
self.formatterCache.clear();
|
||||
self.lastLang = langSelector.currentLang;
|
||||
}
|
||||
|
||||
let message = langSelector.translations[key];
|
||||
let message = translations?.[key];
|
||||
const hasPrimaryTranslation = message !== undefined;
|
||||
|
||||
if (!message && langSelector.defaultTranslations) {
|
||||
const defaultTranslations = langSelector.defaultTranslations;
|
||||
if (defaultTranslations && defaultTranslations[key]) {
|
||||
message = defaultTranslations[key];
|
||||
}
|
||||
message ??= defaultTranslations?.[key];
|
||||
|
||||
if (message === undefined) return key;
|
||||
|
||||
// Fast path: no params and no ICU placeholders.
|
||||
if (
|
||||
resolvedParams === EMPTY_TRANSLATION_PARAMS &&
|
||||
message.indexOf("{") === -1
|
||||
) {
|
||||
return message;
|
||||
}
|
||||
|
||||
if (!message) return key;
|
||||
|
||||
try {
|
||||
const locale =
|
||||
!langSelector.translations[key] && langSelector.currentLang !== "en"
|
||||
!hasPrimaryTranslation && langSelector.currentLang !== "en"
|
||||
? "en"
|
||||
: langSelector.currentLang;
|
||||
const cacheKey = `${key}:${locale}:${message}`;
|
||||
@@ -372,7 +387,7 @@ export const translateText = (
|
||||
self.formatterCache.set(cacheKey, formatter);
|
||||
}
|
||||
|
||||
return formatter.format(params) as string;
|
||||
return formatter.format(resolvedParams) as string;
|
||||
} catch (e) {
|
||||
console.warn("ICU format error", e);
|
||||
return message;
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { Colord } from "colord";
|
||||
import { base64url } from "jose";
|
||||
import { html, LitElement, TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { customElement, property } 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;
|
||||
@@ -29,64 +26,16 @@ 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)) {
|
||||
@@ -111,104 +60,6 @@ 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) {
|
||||
@@ -286,52 +137,9 @@ export class PatternButton extends LitElement {
|
||||
</div>
|
||||
</button>
|
||||
|
||||
${(this.requiresPurchase || this.trialTimeRemaining > 0) &&
|
||||
this.pattern?.product
|
||||
${this.requiresPurchase && this.pattern?.product
|
||||
? html`
|
||||
<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}
|
||||
<div class="w-full mt-2">
|
||||
<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)]"
|
||||
|
||||
@@ -65,11 +65,27 @@ export class UnitLayer implements Layer {
|
||||
}
|
||||
|
||||
tick() {
|
||||
const unitIds = this.game
|
||||
.updatesSinceLastTick()
|
||||
?.[GameUpdateType.Unit]?.map((unit) => unit.id);
|
||||
const updatedUnitIds =
|
||||
this.game
|
||||
.updatesSinceLastTick()
|
||||
?.[GameUpdateType.Unit]?.map((unit) => unit.id) ?? [];
|
||||
|
||||
this.updateUnitsSprites(unitIds ?? []);
|
||||
const motionPlanUnitIds = this.game.motionPlannedUnitIds();
|
||||
|
||||
if (updatedUnitIds.length === 0) {
|
||||
this.updateUnitsSprites(motionPlanUnitIds);
|
||||
return;
|
||||
}
|
||||
if (motionPlanUnitIds.length === 0) {
|
||||
this.updateUnitsSprites(updatedUnitIds);
|
||||
return;
|
||||
}
|
||||
|
||||
const unitIds = new Set<number>(updatedUnitIds);
|
||||
for (const id of motionPlanUnitIds) {
|
||||
unitIds.add(id);
|
||||
}
|
||||
this.updateUnitsSprites(Array.from(unitIds));
|
||||
}
|
||||
|
||||
init() {
|
||||
|
||||
@@ -201,7 +201,6 @@ 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)}
|
||||
|
||||
@@ -62,8 +62,6 @@ 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({
|
||||
|
||||
@@ -170,10 +170,12 @@ export class GameRunner {
|
||||
}
|
||||
|
||||
const packedTileUpdates = this.game.drainPackedTileUpdates();
|
||||
const packedMotionPlans = this.game.drainPackedMotionPlans();
|
||||
|
||||
this.callBack({
|
||||
tick: this.game.ticks(),
|
||||
packedTileUpdates,
|
||||
...(packedMotionPlans ? { packedMotionPlans } : {}),
|
||||
updates: updates,
|
||||
playerNameViewData: this.playerViewData,
|
||||
tickExecutionDuration: tickExecutionDuration,
|
||||
|
||||
@@ -19,6 +19,8 @@ export class TradeShipExecution implements Execution {
|
||||
private wasCaptured = false;
|
||||
private pathFinder: SteppingPathFinder<TileRef>;
|
||||
private tilesTraveled = 0;
|
||||
private motionPlanId = 1;
|
||||
private motionPlanDst: TileRef | null = null;
|
||||
|
||||
constructor(
|
||||
private origOwner: Player,
|
||||
@@ -93,6 +95,8 @@ export class TradeShipExecution implements Execution {
|
||||
} else {
|
||||
this._dstPort = ports[0];
|
||||
this.tradeShip.setTargetUnit(this._dstPort);
|
||||
// Plan-driven units don't emit per-tick unit updates, so force a sync for the new target.
|
||||
this.tradeShip.touch();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,14 +106,29 @@ export class TradeShipExecution implements Execution {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = this.pathFinder.next(curTile, this._dstPort.tile());
|
||||
const dst = this._dstPort.tile();
|
||||
const result = this.pathFinder.next(curTile, dst);
|
||||
|
||||
switch (result.status) {
|
||||
case PathStatus.PENDING:
|
||||
// Fire unit event to rerender.
|
||||
this.tradeShip.move(curTile);
|
||||
break;
|
||||
case PathStatus.NEXT:
|
||||
if (dst !== this.motionPlanDst) {
|
||||
this.motionPlanId++;
|
||||
const from = result.node;
|
||||
const path = this.pathFinder.findPath(from, dst) ?? [from];
|
||||
if (path.length === 0 || path[0] !== from) {
|
||||
path.unshift(from);
|
||||
}
|
||||
|
||||
this.mg.recordMotionPlan({
|
||||
kind: "grid",
|
||||
unitId: this.tradeShip.id(),
|
||||
planId: this.motionPlanId,
|
||||
startTick: ticks + 1,
|
||||
ticksPerStep: 1,
|
||||
path,
|
||||
});
|
||||
this.motionPlanDst = dst;
|
||||
}
|
||||
// Update safeFromPirates status
|
||||
if (this.mg.isWater(result.node) && this.mg.isShoreline(result.node)) {
|
||||
this.tradeShip.setSafeFromPirates();
|
||||
@@ -119,14 +138,14 @@ export class TradeShipExecution implements Execution {
|
||||
break;
|
||||
case PathStatus.COMPLETE:
|
||||
this.complete();
|
||||
break;
|
||||
return;
|
||||
case PathStatus.NOT_FOUND:
|
||||
console.warn("captured trade ship cannot find route");
|
||||
if (this.tradeShip.isActive()) {
|
||||
this.tradeShip.delete(false);
|
||||
}
|
||||
this.active = false;
|
||||
break;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
UnitType,
|
||||
} from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { MotionPlanRecord } from "../game/MotionPlans";
|
||||
import { RailNetwork } from "../game/RailNetwork";
|
||||
import { getOrientedRailroad, OrientedRailroad } from "../game/Railroad";
|
||||
import { TrainStation } from "../game/TrainStation";
|
||||
@@ -63,6 +64,36 @@ export class TrainExecution implements Execution {
|
||||
return;
|
||||
}
|
||||
this.train = this.createTrainUnits(spawn);
|
||||
|
||||
const carUnitIds = this.cars.map((c) => c.id());
|
||||
const pathTiles: TileRef[] = [];
|
||||
for (let i = 0; i + 1 < this.stations.length; i++) {
|
||||
const segment = getOrientedRailroad(
|
||||
this.stations[i],
|
||||
this.stations[i + 1],
|
||||
);
|
||||
if (!segment) {
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
pathTiles.push(...segment.getTiles());
|
||||
}
|
||||
const startTile = this.train.tile();
|
||||
if (pathTiles.length === 0 || pathTiles[0] !== startTile) {
|
||||
pathTiles.unshift(startTile);
|
||||
}
|
||||
|
||||
const plan: MotionPlanRecord = {
|
||||
kind: "train",
|
||||
engineUnitId: this.train.id(),
|
||||
carUnitIds,
|
||||
planId: 1,
|
||||
startTick: ticks + 1,
|
||||
speed: this.speed,
|
||||
spacing: this.spacing,
|
||||
path: pathTiles,
|
||||
};
|
||||
this.mg.recordMotionPlan(plan);
|
||||
}
|
||||
|
||||
tick(ticks: number): void {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
UnitType,
|
||||
} from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { MotionPlanRecord } from "../game/MotionPlans";
|
||||
import { targetTransportTile } from "../game/TransportShipUtils";
|
||||
import { PathFinding } from "../pathfinding/PathFinder";
|
||||
import { PathStatus, SteppingPathFinder } from "../pathfinding/types";
|
||||
@@ -31,6 +32,8 @@ export class TransportShipExecution implements Execution {
|
||||
private src: TileRef | null;
|
||||
private retreatDst: TileRef | false | null = null;
|
||||
private boat: Unit;
|
||||
private motionPlanId = 1;
|
||||
private motionPlanDst: TileRef | null = null;
|
||||
|
||||
private originalOwner: Player;
|
||||
|
||||
@@ -110,6 +113,22 @@ export class TransportShipExecution implements Execution {
|
||||
targetTile: this.dst,
|
||||
});
|
||||
|
||||
const fullPath = this.pathFinder.findPath(this.src, this.dst) ?? [this.src];
|
||||
if (fullPath.length === 0 || fullPath[0] !== this.src) {
|
||||
fullPath.unshift(this.src);
|
||||
}
|
||||
|
||||
const motionPlan: MotionPlanRecord = {
|
||||
kind: "grid",
|
||||
unitId: this.boat.id(),
|
||||
planId: this.motionPlanId,
|
||||
startTick: ticks + this.ticksPerMove,
|
||||
ticksPerStep: this.ticksPerMove,
|
||||
path: fullPath,
|
||||
};
|
||||
this.mg.recordMotionPlan(motionPlan);
|
||||
this.motionPlanDst = this.dst;
|
||||
|
||||
// Notify the target player about the incoming naval invasion
|
||||
if (this.target.id() !== mg.terraNullius().id()) {
|
||||
mg.displayIncomingUnit(
|
||||
@@ -229,8 +248,6 @@ export class TransportShipExecution implements Execution {
|
||||
case PathStatus.NEXT:
|
||||
this.boat.move(result.node);
|
||||
break;
|
||||
case PathStatus.PENDING:
|
||||
break;
|
||||
case PathStatus.NOT_FOUND: {
|
||||
// TODO: add to poisoned port list
|
||||
const map = this.mg.map();
|
||||
@@ -244,6 +261,26 @@ export class TransportShipExecution implements Execution {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.dst !== null && this.dst !== this.motionPlanDst) {
|
||||
this.motionPlanId++;
|
||||
const fullPath = this.pathFinder.findPath(this.boat.tile(), this.dst) ?? [
|
||||
this.boat.tile(),
|
||||
];
|
||||
if (fullPath.length === 0 || fullPath[0] !== this.boat.tile()) {
|
||||
fullPath.unshift(this.boat.tile());
|
||||
}
|
||||
|
||||
this.mg.recordMotionPlan({
|
||||
kind: "grid",
|
||||
unitId: this.boat.id(),
|
||||
planId: this.motionPlanId,
|
||||
startTick: ticks + this.ticksPerMove,
|
||||
ticksPerStep: this.ticksPerMove,
|
||||
path: fullPath,
|
||||
});
|
||||
this.motionPlanDst = this.dst;
|
||||
}
|
||||
}
|
||||
|
||||
owner(): Player {
|
||||
|
||||
@@ -190,9 +190,6 @@ export class WarshipExecution implements Execution {
|
||||
case PathStatus.NEXT:
|
||||
this.warship.move(result.node);
|
||||
break;
|
||||
case PathStatus.PENDING:
|
||||
this.warship.touch();
|
||||
break;
|
||||
case PathStatus.NOT_FOUND: {
|
||||
console.log(`path not found to target`);
|
||||
break;
|
||||
@@ -221,9 +218,6 @@ export class WarshipExecution implements Execution {
|
||||
case PathStatus.NEXT:
|
||||
this.warship.move(result.node);
|
||||
break;
|
||||
case PathStatus.PENDING:
|
||||
this.warship.touch();
|
||||
return;
|
||||
case PathStatus.NOT_FOUND: {
|
||||
console.log(`path not found to target`);
|
||||
break;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
PlayerUpdate,
|
||||
UnitUpdate,
|
||||
} from "./GameUpdates";
|
||||
import { MotionPlanRecord } from "./MotionPlans";
|
||||
import { RailNetwork } from "./RailNetwork";
|
||||
import { Stats } from "./Stats";
|
||||
import { UnitPredicate } from "./UnitGrid";
|
||||
@@ -777,6 +778,8 @@ export interface Game extends GameMap {
|
||||
inSpawnPhase(): boolean;
|
||||
executeNextTick(): GameUpdates;
|
||||
drainPackedTileUpdates(): Uint32Array;
|
||||
recordMotionPlan(record: MotionPlanRecord): void;
|
||||
drainPackedMotionPlans(): Uint32Array | null;
|
||||
setWinner(winner: Player | Team, allPlayersStats: AllPlayersStats): void;
|
||||
getWinner(): Player | Team | null;
|
||||
config(): Config;
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
} from "./Game";
|
||||
import { GameMap, TileRef } from "./GameMap";
|
||||
import { GameUpdate, GameUpdateType } from "./GameUpdates";
|
||||
import { MotionPlanRecord, packMotionPlans } from "./MotionPlans";
|
||||
import { PlayerImpl } from "./PlayerImpl";
|
||||
import { RailNetwork } from "./RailNetwork";
|
||||
import { createRailNetwork } from "./RailNetworkImpl";
|
||||
@@ -95,6 +96,8 @@ export class GameImpl implements Game {
|
||||
|
||||
private updates: GameUpdates = createGameUpdatesMap();
|
||||
private tileUpdatePairs: number[] = [];
|
||||
private motionPlanRecords: MotionPlanRecord[] = [];
|
||||
private planDrivenUnitIds = new Set<number>();
|
||||
private unitGrid: UnitGrid;
|
||||
|
||||
private playerTeams: Team[] = [];
|
||||
@@ -444,6 +447,46 @@ export class GameImpl implements Game {
|
||||
return packed;
|
||||
}
|
||||
|
||||
recordMotionPlan(record: MotionPlanRecord): void {
|
||||
switch (record.kind) {
|
||||
case "grid":
|
||||
this.planDrivenUnitIds.add(record.unitId);
|
||||
break;
|
||||
case "train":
|
||||
this.planDrivenUnitIds.add(record.engineUnitId);
|
||||
for (const unitId of record.carUnitIds) {
|
||||
this.planDrivenUnitIds.add(unitId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
this.motionPlanRecords.push(record);
|
||||
}
|
||||
|
||||
private isUnitPlanDriven(unitId: number): boolean {
|
||||
return this.planDrivenUnitIds.has(unitId);
|
||||
}
|
||||
|
||||
maybeAddUnitUpdate(unit: Unit): void {
|
||||
if (!this.isUnitPlanDriven(unit.id())) {
|
||||
this.addUpdate(unit.toUpdate());
|
||||
}
|
||||
}
|
||||
|
||||
onUnitMoved(unit: Unit): void {
|
||||
this.updateUnitTile(unit);
|
||||
this.maybeAddUnitUpdate(unit);
|
||||
}
|
||||
|
||||
drainPackedMotionPlans(): Uint32Array | null {
|
||||
const records = this.motionPlanRecords;
|
||||
if (records.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const packed = packMotionPlans(records);
|
||||
records.length = 0;
|
||||
return packed;
|
||||
}
|
||||
|
||||
private hash(): number {
|
||||
let hash = 1;
|
||||
this._players.forEach((p) => {
|
||||
@@ -887,6 +930,7 @@ export class GameImpl implements Game {
|
||||
}
|
||||
removeUnit(u: Unit) {
|
||||
this.unitGrid.removeUnit(u);
|
||||
this.planDrivenUnitIds.delete(u.id());
|
||||
if (u.hasTrainStation()) {
|
||||
this._railNetwork.removeStation(u);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,13 @@ export interface GameUpdateViewData {
|
||||
* state (`uint16`) stored in a `uint32` lane.
|
||||
*/
|
||||
packedTileUpdates: Uint32Array;
|
||||
/**
|
||||
* Optional packed motion plan records.
|
||||
*
|
||||
* When present, this buffer is expected to be transferred worker -> main
|
||||
* (similar to `packedTileUpdates`) to avoid structured-clone copies.
|
||||
*/
|
||||
packedMotionPlans?: Uint32Array;
|
||||
playerNameViewData: Record<string, NameViewData>;
|
||||
tickExecutionDuration?: number;
|
||||
pendingTurns?: number;
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
PlayerUpdate,
|
||||
UnitUpdate,
|
||||
} from "./GameUpdates";
|
||||
import { MotionPlanRecord, unpackMotionPlans } from "./MotionPlans";
|
||||
import { TerrainMapData } from "./TerrainMapLoader";
|
||||
import { TerraNulliusImpl } from "./TerraNulliusImpl";
|
||||
import { UnitGrid, UnitPredicate } from "./UnitGrid";
|
||||
@@ -83,6 +84,17 @@ export class UnitView {
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
applyDerivedPosition(pos: TileRef) {
|
||||
const prev = this.data.pos;
|
||||
this.lastPos.push(pos);
|
||||
this._wasUpdated = true;
|
||||
this.data = {
|
||||
...this.data,
|
||||
lastPos: prev,
|
||||
pos,
|
||||
};
|
||||
}
|
||||
|
||||
id(): number {
|
||||
return this.data.id;
|
||||
}
|
||||
@@ -582,6 +594,20 @@ export class PlayerView {
|
||||
}
|
||||
}
|
||||
|
||||
type TrainPlanState = {
|
||||
planId: number;
|
||||
startTick: number;
|
||||
speed: number;
|
||||
spacing: number;
|
||||
carUnitIds: Uint32Array;
|
||||
path: Uint32Array;
|
||||
cursor: number;
|
||||
usedTilesBuf: Uint32Array;
|
||||
usedHead: number;
|
||||
usedLen: number;
|
||||
lastAdvancedTick: Tick;
|
||||
};
|
||||
|
||||
export class GameView implements GameMap {
|
||||
private lastUpdate: GameUpdateViewData | null;
|
||||
private smallIDToID = new Map<number, PlayerID>();
|
||||
@@ -592,6 +618,17 @@ export class GameView implements GameMap {
|
||||
private _myPlayer: PlayerView | null = null;
|
||||
|
||||
private unitGrid: UnitGrid;
|
||||
private unitMotionPlans = new Map<
|
||||
number,
|
||||
{
|
||||
planId: number;
|
||||
startTick: number;
|
||||
ticksPerStep: number;
|
||||
path: Uint32Array;
|
||||
}
|
||||
>();
|
||||
private trainMotionPlans = new Map<number, TrainPlanState>();
|
||||
private trainUnitToEngine = new Map<number, number>();
|
||||
|
||||
private toDelete = new Set<number>();
|
||||
|
||||
@@ -637,6 +674,51 @@ export class GameView implements GameMap {
|
||||
return this.lastUpdate?.updates ?? null;
|
||||
}
|
||||
|
||||
public motionPlans(): ReadonlyMap<
|
||||
number,
|
||||
{
|
||||
planId: number;
|
||||
startTick: number;
|
||||
ticksPerStep: number;
|
||||
path: Uint32Array;
|
||||
}
|
||||
> {
|
||||
return this.unitMotionPlans;
|
||||
}
|
||||
|
||||
private motionPlannedUnitIdsCache: number[] = [];
|
||||
private motionPlannedUnitIdsDirty = true;
|
||||
|
||||
private markMotionPlannedUnitIdsDirty(): void {
|
||||
this.motionPlannedUnitIdsDirty = true;
|
||||
}
|
||||
|
||||
private rebuildMotionPlannedUnitIdsCacheIfDirty(): void {
|
||||
if (!this.motionPlannedUnitIdsDirty) {
|
||||
return;
|
||||
}
|
||||
this.motionPlannedUnitIdsDirty = false;
|
||||
|
||||
const out = this.motionPlannedUnitIdsCache;
|
||||
out.length = 0;
|
||||
|
||||
for (const unitId of this.unitMotionPlans.keys()) {
|
||||
out.push(unitId);
|
||||
}
|
||||
for (const [engineId, plan] of this.trainMotionPlans) {
|
||||
out.push(engineId);
|
||||
for (let i = 0; i < plan.carUnitIds.length; i++) {
|
||||
const id = plan.carUnitIds[i] >>> 0;
|
||||
if (id !== 0) out.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public motionPlannedUnitIds(): number[] {
|
||||
this.rebuildMotionPlannedUnitIdsCacheIfDirty();
|
||||
return this.motionPlannedUnitIdsCache;
|
||||
}
|
||||
|
||||
public isCatchingUp(): boolean {
|
||||
return (this.lastUpdate?.pendingTurns ?? 0) > 1;
|
||||
}
|
||||
@@ -656,6 +738,11 @@ export class GameView implements GameMap {
|
||||
this.updatedTiles.push(tile);
|
||||
}
|
||||
|
||||
if (gu.packedMotionPlans) {
|
||||
const records = unpackMotionPlans(gu.packedMotionPlans);
|
||||
this.applyMotionPlanRecords(records);
|
||||
}
|
||||
|
||||
if (gu.updates === null) {
|
||||
throw new Error("lastUpdate.updates not initialized");
|
||||
}
|
||||
@@ -704,8 +791,244 @@ export class GameView implements GameMap {
|
||||
if (!unit.isActive()) {
|
||||
// Wait until next tick to delete the unit.
|
||||
this.toDelete.add(unit.id());
|
||||
if (this.unitMotionPlans.delete(unit.id())) {
|
||||
this.markMotionPlannedUnitIdsDirty();
|
||||
}
|
||||
this.clearTrainPlanForUnit(unit.id());
|
||||
}
|
||||
});
|
||||
|
||||
this.advanceMotionPlannedUnits(gu.tick);
|
||||
this.rebuildMotionPlannedUnitIdsCacheIfDirty();
|
||||
}
|
||||
|
||||
private advanceMotionPlannedUnits(currentTick: Tick): void {
|
||||
for (const [unitId, plan] of this.unitMotionPlans) {
|
||||
const unit = this._units.get(unitId);
|
||||
if (!unit || !unit.isActive()) {
|
||||
if (this.unitMotionPlans.delete(unitId)) {
|
||||
this.markMotionPlannedUnitIdsDirty();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const oldTile = unit.tile();
|
||||
const dt = currentTick - plan.startTick;
|
||||
const stepIndex =
|
||||
dt <= 0 ? 0 : Math.floor(dt / Math.max(1, plan.ticksPerStep));
|
||||
const lastIndex = plan.path.length - 1;
|
||||
const idx = Math.max(0, Math.min(lastIndex, stepIndex));
|
||||
const newTile = plan.path[idx] as TileRef;
|
||||
|
||||
if (newTile !== oldTile) {
|
||||
unit.applyDerivedPosition(newTile);
|
||||
this.unitGrid.updateUnitCell(unit);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Once a plan is past its final step, `newTile` remains clamped to the last path tile.
|
||||
// Drop finished plans to avoid repeatedly marking static units as updated each tick.
|
||||
if (dt > 0 && stepIndex >= lastIndex) {
|
||||
if (this.unitMotionPlans.delete(unitId)) {
|
||||
this.markMotionPlannedUnitIdsDirty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.advanceTrainMotionPlannedUnits(currentTick);
|
||||
}
|
||||
|
||||
private clearTrainPlanForUnit(unitId: number): void {
|
||||
const engineId =
|
||||
this.trainUnitToEngine.get(unitId) ??
|
||||
(this.trainMotionPlans.has(unitId) ? unitId : null);
|
||||
if (engineId === null) {
|
||||
return;
|
||||
}
|
||||
const plan = this.trainMotionPlans.get(engineId);
|
||||
if (!plan) {
|
||||
this.trainUnitToEngine.delete(unitId);
|
||||
return;
|
||||
}
|
||||
if (this.trainMotionPlans.delete(engineId)) {
|
||||
this.markMotionPlannedUnitIdsDirty();
|
||||
}
|
||||
this.trainUnitToEngine.delete(engineId);
|
||||
for (let i = 0; i < plan.carUnitIds.length; i++) {
|
||||
const id = plan.carUnitIds[i] >>> 0;
|
||||
if (id !== 0) this.trainUnitToEngine.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
private advanceTrainMotionPlannedUnits(currentTick: Tick): void {
|
||||
const staleEngineIds: number[] = [];
|
||||
for (const [engineId, plan] of this.trainMotionPlans) {
|
||||
const engine = this._units.get(engineId);
|
||||
if (!engine || !engine.isActive()) {
|
||||
staleEngineIds.push(engineId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const steps = currentTick - plan.lastAdvancedTick;
|
||||
if (steps <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const path = plan.path;
|
||||
const lastIndex = path.length - 1;
|
||||
const cap = plan.usedTilesBuf.length;
|
||||
|
||||
const pushUsed = (tile: TileRef) => {
|
||||
if (cap === 0) return;
|
||||
if (plan.usedLen < cap) {
|
||||
const idx = (plan.usedHead + plan.usedLen) % cap;
|
||||
plan.usedTilesBuf[idx] = tile >>> 0;
|
||||
plan.usedLen++;
|
||||
} else {
|
||||
plan.usedTilesBuf[plan.usedHead] = tile >>> 0;
|
||||
plan.usedHead = (plan.usedHead + 1) % cap;
|
||||
plan.usedLen = cap;
|
||||
}
|
||||
};
|
||||
|
||||
const usedGet = (index: number): TileRef | null => {
|
||||
if (index < 0 || index >= plan.usedLen || cap === 0) return null;
|
||||
const idx = (plan.usedHead + index) % cap;
|
||||
return plan.usedTilesBuf[idx] as TileRef;
|
||||
};
|
||||
|
||||
let didMove = false;
|
||||
for (let step = 0; step < steps; step++) {
|
||||
const cursor = plan.cursor;
|
||||
if (cursor >= lastIndex) {
|
||||
break;
|
||||
}
|
||||
for (let i = 0; i < plan.speed && cursor + i < path.length; i++) {
|
||||
pushUsed(path[cursor + i] as TileRef);
|
||||
}
|
||||
|
||||
plan.cursor = Math.min(lastIndex, cursor + plan.speed);
|
||||
|
||||
for (let i = plan.carUnitIds.length - 1; i >= 0; --i) {
|
||||
const carId = plan.carUnitIds[i] >>> 0;
|
||||
if (carId === 0) continue;
|
||||
const car = this._units.get(carId);
|
||||
if (!car || !car.isActive()) {
|
||||
continue;
|
||||
}
|
||||
const carTileIndex = (i + 1) * plan.spacing + 2;
|
||||
const tile = usedGet(carTileIndex);
|
||||
if (tile !== null) {
|
||||
const oldTile = car.tile();
|
||||
if (tile !== oldTile) {
|
||||
car.applyDerivedPosition(tile);
|
||||
this.unitGrid.updateUnitCell(car);
|
||||
didMove = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newEngineTile = path[plan.cursor] as TileRef;
|
||||
const oldEngineTile = engine.tile();
|
||||
if (newEngineTile !== oldEngineTile) {
|
||||
engine.applyDerivedPosition(newEngineTile);
|
||||
this.unitGrid.updateUnitCell(engine);
|
||||
didMove = true;
|
||||
}
|
||||
}
|
||||
|
||||
plan.lastAdvancedTick = currentTick;
|
||||
|
||||
// Preserve the final-step redraw (plan remains for the tick where motion ends),
|
||||
// then clear once the train has settled and no longer moves.
|
||||
// Note: trains are currently deleted at the end of TrainExecution, and the ensuing
|
||||
// `Unit` update (isActive=false) also clears any associated motion plan records.
|
||||
// This expiry is defensive to avoid keeping stale plans around if that behavior changes.
|
||||
if (!didMove && plan.cursor >= lastIndex) {
|
||||
staleEngineIds.push(engineId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const engineId of staleEngineIds) {
|
||||
this.clearTrainPlanForUnit(engineId);
|
||||
}
|
||||
}
|
||||
|
||||
private applyMotionPlanRecords(records: readonly MotionPlanRecord[]): void {
|
||||
for (const record of records) {
|
||||
switch (record.kind) {
|
||||
case "grid": {
|
||||
if (record.ticksPerStep < 1 || record.path.length < 1) {
|
||||
break;
|
||||
}
|
||||
const existing = this.unitMotionPlans.get(record.unitId);
|
||||
if (existing && record.planId <= existing.planId) {
|
||||
break;
|
||||
}
|
||||
|
||||
const path =
|
||||
record.path instanceof Uint32Array
|
||||
? record.path
|
||||
: Uint32Array.from(record.path);
|
||||
|
||||
this.unitMotionPlans.set(record.unitId, {
|
||||
planId: record.planId,
|
||||
startTick: record.startTick,
|
||||
ticksPerStep: record.ticksPerStep,
|
||||
path,
|
||||
});
|
||||
this.markMotionPlannedUnitIdsDirty();
|
||||
break;
|
||||
}
|
||||
case "train": {
|
||||
if (record.speed < 1 || record.path.length < 1) {
|
||||
break;
|
||||
}
|
||||
const existing = this.trainMotionPlans.get(record.engineUnitId);
|
||||
if (existing && record.planId <= existing.planId) {
|
||||
break;
|
||||
}
|
||||
if (existing) {
|
||||
this.clearTrainPlanForUnit(record.engineUnitId);
|
||||
}
|
||||
|
||||
const carUnitIds =
|
||||
record.carUnitIds instanceof Uint32Array
|
||||
? record.carUnitIds
|
||||
: Uint32Array.from(record.carUnitIds);
|
||||
const path =
|
||||
record.path instanceof Uint32Array
|
||||
? record.path
|
||||
: Uint32Array.from(record.path);
|
||||
|
||||
const usedCap = carUnitIds.length * record.spacing + 3;
|
||||
const usedTilesBuf = new Uint32Array(Math.max(0, usedCap));
|
||||
|
||||
this.trainMotionPlans.set(record.engineUnitId, {
|
||||
planId: record.planId,
|
||||
startTick: record.startTick,
|
||||
speed: record.speed,
|
||||
spacing: record.spacing,
|
||||
carUnitIds,
|
||||
path,
|
||||
cursor: 0,
|
||||
usedTilesBuf,
|
||||
usedHead: 0,
|
||||
usedLen: 0,
|
||||
lastAdvancedTick: record.startTick,
|
||||
});
|
||||
this.markMotionPlannedUnitIdsDirty();
|
||||
|
||||
this.trainUnitToEngine.set(record.engineUnitId, record.engineUnitId);
|
||||
for (let i = 0; i < carUnitIds.length; i++) {
|
||||
const carId = carUnitIds[i] >>> 0;
|
||||
if (carId !== 0)
|
||||
this.trainUnitToEngine.set(carId, record.engineUnitId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recentlyUpdatedTiles(): TileRef[] {
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { TileRef } from "./GameMap";
|
||||
|
||||
export enum PackedMotionPlanKind {
|
||||
GridPathSet = 1,
|
||||
TrainRailPathSet = 2,
|
||||
}
|
||||
|
||||
export interface GridPathPlan {
|
||||
kind: "grid";
|
||||
unitId: number;
|
||||
planId: number;
|
||||
startTick: number;
|
||||
ticksPerStep: number;
|
||||
/**
|
||||
* TileRef path where `path[0]` is the unit tile at `startTick`.
|
||||
*/
|
||||
path: readonly TileRef[] | Uint32Array;
|
||||
}
|
||||
|
||||
export interface TrainRailPathPlan {
|
||||
kind: "train";
|
||||
engineUnitId: number;
|
||||
/**
|
||||
* TrainExecution `cars[]` order (tail engine + carriages).
|
||||
*/
|
||||
carUnitIds: readonly number[] | Uint32Array;
|
||||
planId: number;
|
||||
startTick: number;
|
||||
speed: number;
|
||||
spacing: number;
|
||||
/**
|
||||
* Concatenated rail tile path across all segments, without de-duplicating at stations.
|
||||
*/
|
||||
path: readonly TileRef[] | Uint32Array;
|
||||
}
|
||||
|
||||
export type MotionPlanRecord = GridPathPlan | TrainRailPathPlan;
|
||||
|
||||
export function packMotionPlans(
|
||||
records: readonly MotionPlanRecord[],
|
||||
): Uint32Array {
|
||||
let totalWords = 1;
|
||||
for (const record of records) {
|
||||
switch (record.kind) {
|
||||
case "grid": {
|
||||
const pathLen = (record.path.length >>> 0) as number;
|
||||
totalWords += 2 + 5 + pathLen;
|
||||
break;
|
||||
}
|
||||
case "train": {
|
||||
const carCount = (record.carUnitIds.length >>> 0) as number;
|
||||
const pathLen = (record.path.length >>> 0) as number;
|
||||
totalWords += 2 + 7 + carCount + pathLen;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const out = new Uint32Array(totalWords);
|
||||
out[0] = records.length >>> 0;
|
||||
|
||||
let offset = 1;
|
||||
for (const record of records) {
|
||||
switch (record.kind) {
|
||||
case "grid": {
|
||||
const path = record.path as ArrayLike<number>;
|
||||
const pathLen = path.length >>> 0;
|
||||
const wordCount = 2 + 5 + pathLen;
|
||||
|
||||
out[offset++] = PackedMotionPlanKind.GridPathSet;
|
||||
out[offset++] = wordCount >>> 0;
|
||||
out[offset++] = record.unitId >>> 0;
|
||||
out[offset++] = record.planId >>> 0;
|
||||
out[offset++] = record.startTick >>> 0;
|
||||
out[offset++] = record.ticksPerStep >>> 0;
|
||||
out[offset++] = pathLen >>> 0;
|
||||
|
||||
for (let i = 0; i < pathLen; i++) {
|
||||
out[offset++] = path[i] >>> 0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "train": {
|
||||
const carUnitIds = record.carUnitIds as ArrayLike<number>;
|
||||
const carCount = carUnitIds.length >>> 0;
|
||||
|
||||
const path = record.path as ArrayLike<number>;
|
||||
const pathLen = path.length >>> 0;
|
||||
|
||||
const wordCount = 2 + 7 + carCount + pathLen;
|
||||
out[offset++] = PackedMotionPlanKind.TrainRailPathSet;
|
||||
out[offset++] = wordCount >>> 0;
|
||||
out[offset++] = record.engineUnitId >>> 0;
|
||||
out[offset++] = record.planId >>> 0;
|
||||
out[offset++] = record.startTick >>> 0;
|
||||
out[offset++] = record.speed >>> 0;
|
||||
out[offset++] = record.spacing >>> 0;
|
||||
out[offset++] = carCount >>> 0;
|
||||
out[offset++] = pathLen >>> 0;
|
||||
|
||||
for (let i = 0; i < carCount; i++) {
|
||||
out[offset++] = carUnitIds[i] >>> 0;
|
||||
}
|
||||
for (let i = 0; i < pathLen; i++) {
|
||||
out[offset++] = path[i] >>> 0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (offset !== out.length) {
|
||||
throw new Error(
|
||||
`packMotionPlans size mismatch: wrote ${offset}, expected ${out.length}`,
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function unpackMotionPlans(packed: Uint32Array): MotionPlanRecord[] {
|
||||
if (packed.length < 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const recordCount = packed[0] >>> 0;
|
||||
const records: MotionPlanRecord[] = [];
|
||||
let offset = 1;
|
||||
|
||||
for (let i = 0; i < recordCount && offset + 1 < packed.length; i++) {
|
||||
const kind = packed[offset] >>> 0;
|
||||
const wordCount = packed[offset + 1] >>> 0;
|
||||
|
||||
if (wordCount < 2 || offset + wordCount > packed.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
switch (kind) {
|
||||
case PackedMotionPlanKind.GridPathSet: {
|
||||
if (wordCount < 2 + 5) {
|
||||
break;
|
||||
}
|
||||
const unitId = packed[offset + 2] >>> 0;
|
||||
const planId = packed[offset + 3] >>> 0;
|
||||
const startTick = packed[offset + 4] >>> 0;
|
||||
const ticksPerStep = packed[offset + 5] >>> 0;
|
||||
const pathLen = packed[offset + 6] >>> 0;
|
||||
|
||||
const expectedWordCount = 2 + 5 + pathLen;
|
||||
if (expectedWordCount !== wordCount) {
|
||||
break;
|
||||
}
|
||||
|
||||
const pathStart = offset + 7;
|
||||
const pathEnd = pathStart + pathLen;
|
||||
const path = packed.slice(pathStart, pathEnd);
|
||||
|
||||
records.push({
|
||||
kind: "grid",
|
||||
unitId,
|
||||
planId,
|
||||
startTick,
|
||||
ticksPerStep,
|
||||
path,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case PackedMotionPlanKind.TrainRailPathSet: {
|
||||
if (wordCount < 2 + 7) {
|
||||
break;
|
||||
}
|
||||
const engineUnitId = packed[offset + 2] >>> 0;
|
||||
const planId = packed[offset + 3] >>> 0;
|
||||
const startTick = packed[offset + 4] >>> 0;
|
||||
const speed = packed[offset + 5] >>> 0;
|
||||
const spacing = packed[offset + 6] >>> 0;
|
||||
const carCount = packed[offset + 7] >>> 0;
|
||||
const pathLen = packed[offset + 8] >>> 0;
|
||||
|
||||
const expectedWordCount = 2 + 7 + carCount + pathLen;
|
||||
if (expectedWordCount !== wordCount) {
|
||||
break;
|
||||
}
|
||||
|
||||
const carStart = offset + 9;
|
||||
const carEnd = carStart + carCount;
|
||||
const pathStart = carEnd;
|
||||
const pathEnd = pathStart + pathLen;
|
||||
const carUnitIds = packed.slice(carStart, carEnd);
|
||||
const path = packed.slice(pathStart, pathEnd);
|
||||
|
||||
records.push({
|
||||
kind: "train",
|
||||
engineUnitId,
|
||||
carUnitIds,
|
||||
planId,
|
||||
startTick,
|
||||
speed,
|
||||
spacing,
|
||||
path,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// Unknown kind: skip.
|
||||
break;
|
||||
}
|
||||
|
||||
offset += wordCount;
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
@@ -159,8 +159,7 @@ export class UnitImpl implements Unit {
|
||||
}
|
||||
this._lastTile = this._tile;
|
||||
this._tile = tile;
|
||||
this.mg.updateUnitTile(this);
|
||||
this.mg.addUpdate(this.toUpdate());
|
||||
this.mg.onUnitMoved(this);
|
||||
}
|
||||
|
||||
setTroops(troops: number): void {
|
||||
@@ -336,7 +335,10 @@ export class UnitImpl implements Unit {
|
||||
if (this.type() !== UnitType.TransportShip) {
|
||||
throw new Error(`Cannot retreat ${this.type()}`);
|
||||
}
|
||||
this._retreating = true;
|
||||
if (!this._retreating) {
|
||||
this._retreating = true;
|
||||
this.mg.addUpdate(this.toUpdate());
|
||||
}
|
||||
}
|
||||
|
||||
isUnderConstruction(): boolean {
|
||||
|
||||
@@ -4,14 +4,12 @@
|
||||
*/
|
||||
|
||||
export enum PathStatus {
|
||||
NEXT,
|
||||
PENDING,
|
||||
COMPLETE,
|
||||
NOT_FOUND,
|
||||
NEXT = 0,
|
||||
COMPLETE = 2,
|
||||
NOT_FOUND = 3,
|
||||
}
|
||||
|
||||
export type PathResult<T> =
|
||||
| { status: PathStatus.PENDING }
|
||||
| { status: PathStatus.NEXT; node: T }
|
||||
| { status: PathStatus.COMPLETE; node: T }
|
||||
| { status: PathStatus.NOT_FOUND };
|
||||
|
||||
@@ -16,26 +16,110 @@ import {
|
||||
const ctx: Worker = self as any;
|
||||
let gameRunner: Promise<GameRunner> | null = null;
|
||||
const mapLoader = new FetchGameMapLoader(`/maps`, version);
|
||||
const MAX_TICKS_PER_HEARTBEAT = 4;
|
||||
// Yield threshold; not a backlog cap. Used to avoid monopolizing the worker task
|
||||
// and flooding the main thread with messages during catch-up.
|
||||
const MAX_TICKS_BEFORE_YIELD = 4;
|
||||
|
||||
function gameUpdate(gu: GameUpdateViewData | ErrorUpdate) {
|
||||
// skip if ErrorUpdate
|
||||
if (!("updates" in gu)) {
|
||||
let drainScheduled = false;
|
||||
let draining = false;
|
||||
let drainRequested = false;
|
||||
|
||||
function scheduleDrain(): void {
|
||||
drainRequested = true;
|
||||
if (drainScheduled || draining) {
|
||||
return;
|
||||
}
|
||||
sendMessage({
|
||||
type: "game_update",
|
||||
gameUpdate: gu,
|
||||
});
|
||||
drainScheduled = true;
|
||||
setTimeout(() => {
|
||||
void drain().catch((e) => {
|
||||
console.error("Worker drain failed:", e);
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
|
||||
async function drain(): Promise<void> {
|
||||
drainScheduled = false;
|
||||
if (draining) {
|
||||
return;
|
||||
}
|
||||
if (!gameRunner) {
|
||||
return;
|
||||
}
|
||||
|
||||
draining = true;
|
||||
drainRequested = false;
|
||||
let shouldContinue = false;
|
||||
try {
|
||||
const gr = await gameRunner;
|
||||
if (!gr) {
|
||||
return;
|
||||
}
|
||||
|
||||
const batch: GameUpdateViewData[] = [];
|
||||
const onTickUpdate = (gu: GameUpdateViewData | ErrorUpdate) => {
|
||||
if (!("updates" in gu)) {
|
||||
return;
|
||||
}
|
||||
batch.push(gu);
|
||||
};
|
||||
|
||||
// Temporarily route tick callbacks into this drain's batch.
|
||||
tickUpdateSink = onTickUpdate;
|
||||
|
||||
let ticksRun = 0;
|
||||
while (ticksRun < MAX_TICKS_BEFORE_YIELD && gr.pendingTurns() > 0) {
|
||||
const ok = gr.executeNextTick(gr.pendingTurns());
|
||||
if (!ok) {
|
||||
break;
|
||||
}
|
||||
ticksRun++;
|
||||
}
|
||||
|
||||
tickUpdateSink = null;
|
||||
|
||||
sendGameUpdateBatch(batch);
|
||||
|
||||
shouldContinue = gr.pendingTurns() > 0;
|
||||
} finally {
|
||||
tickUpdateSink = null;
|
||||
draining = false;
|
||||
}
|
||||
|
||||
if (shouldContinue || drainRequested) {
|
||||
scheduleDrain();
|
||||
}
|
||||
}
|
||||
|
||||
let tickUpdateSink: ((gu: GameUpdateViewData | ErrorUpdate) => void) | null =
|
||||
null;
|
||||
|
||||
function gameUpdate(gu: GameUpdateViewData | ErrorUpdate) {
|
||||
tickUpdateSink?.(gu);
|
||||
}
|
||||
|
||||
function sendGameUpdateBatch(gameUpdates: GameUpdateViewData[]): void {
|
||||
if (gameUpdates.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const transfers: Transferable[] = [];
|
||||
for (const gu of gameUpdates) {
|
||||
transfers.push(gu.packedTileUpdates.buffer);
|
||||
if (gu.packedMotionPlans) {
|
||||
transfers.push(gu.packedMotionPlans.buffer);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.postMessage(
|
||||
{
|
||||
type: "game_update_batch",
|
||||
gameUpdates,
|
||||
} as WorkerMessage,
|
||||
transfers,
|
||||
);
|
||||
}
|
||||
|
||||
function sendMessage(message: WorkerMessage) {
|
||||
if (message.type === "game_update") {
|
||||
// Transfer the packed tile updates buffer to avoid structured-clone copies and
|
||||
// reduce worker-side memory churn during long runs / catch-up.
|
||||
ctx.postMessage(message, [message.gameUpdate.packedTileUpdates.buffer]);
|
||||
return;
|
||||
}
|
||||
ctx.postMessage(message);
|
||||
}
|
||||
|
||||
@@ -43,20 +127,6 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
|
||||
const message = e.data;
|
||||
|
||||
switch (message.type) {
|
||||
case "heartbeat": {
|
||||
const gr = await gameRunner;
|
||||
if (!gr) {
|
||||
break;
|
||||
}
|
||||
const pendingTurns = gr.pendingTurns();
|
||||
const ticksToRun = Math.min(pendingTurns, MAX_TICKS_PER_HEARTBEAT);
|
||||
for (let i = 0; i < ticksToRun; i++) {
|
||||
if (!gr.executeNextTick(gr.pendingTurns())) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "init":
|
||||
try {
|
||||
gameRunner = createGameRunner(
|
||||
@@ -84,7 +154,8 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
|
||||
|
||||
try {
|
||||
const gr = await gameRunner;
|
||||
await gr.addTurn(message.turn);
|
||||
gr.addTurn(message.turn);
|
||||
scheduleDrain();
|
||||
} catch (error) {
|
||||
console.error("Failed to process turn:", error);
|
||||
throw error;
|
||||
|
||||
@@ -45,6 +45,13 @@ export class WorkerClient {
|
||||
this.gameUpdateCallback(message.gameUpdate);
|
||||
}
|
||||
break;
|
||||
case "game_update_batch":
|
||||
if (this.gameUpdateCallback && message.gameUpdates) {
|
||||
for (const gu of message.gameUpdates) {
|
||||
this.gameUpdateCallback(gu);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "initialized":
|
||||
default:
|
||||
@@ -103,12 +110,6 @@ export class WorkerClient {
|
||||
});
|
||||
}
|
||||
|
||||
sendHeartbeat() {
|
||||
this.worker.postMessage({
|
||||
type: "heartbeat",
|
||||
});
|
||||
}
|
||||
|
||||
playerProfile(playerID: number): Promise<PlayerProfile> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.isInitialized) {
|
||||
|
||||
@@ -10,11 +10,11 @@ import { GameUpdateViewData } from "../game/GameUpdates";
|
||||
import { ClientID, GameStartInfo, Turn } from "../Schemas";
|
||||
|
||||
export type WorkerMessageType =
|
||||
| "heartbeat"
|
||||
| "init"
|
||||
| "initialized"
|
||||
| "turn"
|
||||
| "game_update"
|
||||
| "game_update_batch"
|
||||
| "player_actions"
|
||||
| "player_actions_result"
|
||||
| "player_profile"
|
||||
@@ -32,10 +32,6 @@ interface BaseWorkerMessage {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface HeartbeatMessage extends BaseWorkerMessage {
|
||||
type: "heartbeat";
|
||||
}
|
||||
|
||||
// Messages from main thread to worker
|
||||
export interface InitMessage extends BaseWorkerMessage {
|
||||
type: "init";
|
||||
@@ -58,6 +54,11 @@ export interface GameUpdateMessage extends BaseWorkerMessage {
|
||||
gameUpdate: GameUpdateViewData;
|
||||
}
|
||||
|
||||
export interface GameUpdateBatchMessage extends BaseWorkerMessage {
|
||||
type: "game_update_batch";
|
||||
gameUpdates: GameUpdateViewData[];
|
||||
}
|
||||
|
||||
export interface PlayerActionsMessage extends BaseWorkerMessage {
|
||||
type: "player_actions";
|
||||
playerID: PlayerID;
|
||||
@@ -116,7 +117,6 @@ export interface TransportShipSpawnResultMessage extends BaseWorkerMessage {
|
||||
|
||||
// Union types for type safety
|
||||
export type MainThreadMessage =
|
||||
| HeartbeatMessage
|
||||
| InitMessage
|
||||
| TurnMessage
|
||||
| PlayerActionsMessage
|
||||
@@ -129,6 +129,7 @@ export type MainThreadMessage =
|
||||
export type WorkerMessage =
|
||||
| InitializedMessage
|
||||
| GameUpdateMessage
|
||||
| GameUpdateBatchMessage
|
||||
| PlayerActionsResultMessage
|
||||
| PlayerProfileResultMessage
|
||||
| PlayerBorderTilesResultMessage
|
||||
|
||||
+6
-6
@@ -33,11 +33,11 @@ fi
|
||||
echo "Tunnel created with ID: ${TUNNEL_ID}"
|
||||
|
||||
# Configure the tunnel with hostname
|
||||
echo "Configuring tunnel to point to ${SUBDOMAIN}.${DOMAIN}..."
|
||||
echo "Configuring tunnel to point to tunnel-${SUBDOMAIN}.${DOMAIN}..."
|
||||
curl -s -X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/cfd_tunnel/${TUNNEL_ID}/configurations" \
|
||||
-H "Authorization: Bearer ${CF_API_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "{\"config\":{\"ingress\":[{\"hostname\":\"${SUBDOMAIN}.${DOMAIN}\",\"service\":\"http://localhost:80\"},{\"service\":\"http_status:404\"}]}}"
|
||||
--data "{\"config\":{\"ingress\":[{\"hostname\":\"tunnel-${SUBDOMAIN}.${DOMAIN}\",\"service\":\"http://localhost:80\"},{\"service\":\"http_status:404\"}]}}"
|
||||
|
||||
# Update DNS record to point to the new tunnel
|
||||
echo "Updating DNS record to point to the new tunnel..."
|
||||
@@ -55,7 +55,7 @@ if [ -z "$ZONE_ID" ] || [ "$ZONE_ID" == "null" ]; then
|
||||
fi
|
||||
|
||||
# Check for existing record
|
||||
EXISTING_RECORDS=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records?name=${SUBDOMAIN}.${DOMAIN}" \
|
||||
EXISTING_RECORDS=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records?name=tunnel-${SUBDOMAIN}.${DOMAIN}" \
|
||||
-H "Authorization: Bearer ${CF_API_TOKEN}" \
|
||||
-H "Content-Type: application/json")
|
||||
|
||||
@@ -68,18 +68,18 @@ if [ -z "$RECORD_ID" ] || [ "$RECORD_ID" == "null" ]; then
|
||||
DNS_RESPONSE=$(curl -s -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records" \
|
||||
-H "Authorization: Bearer ${CF_API_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "{\"type\":\"CNAME\",\"name\":\"${SUBDOMAIN}\",\"content\":\"${TUNNEL_ID}.cfargotunnel.com\",\"ttl\":1,\"proxied\":true}")
|
||||
--data "{\"type\":\"CNAME\",\"name\":\"tunnel-${SUBDOMAIN}.${DOMAIN}\",\"content\":\"${TUNNEL_ID}.cfargotunnel.com\",\"ttl\":1,\"proxied\":true}")
|
||||
else
|
||||
# Update existing record
|
||||
echo "Updating existing DNS record..."
|
||||
DNS_RESPONSE=$(curl -s -X PUT "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records/${RECORD_ID}" \
|
||||
-H "Authorization: Bearer ${CF_API_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "{\"type\":\"CNAME\",\"name\":\"${SUBDOMAIN}\",\"content\":\"${TUNNEL_ID}.cfargotunnel.com\",\"ttl\":1,\"proxied\":true}")
|
||||
--data "{\"type\":\"CNAME\",\"name\":\"tunnel-${SUBDOMAIN}.${DOMAIN}\",\"content\":\"${TUNNEL_ID}.cfargotunnel.com\",\"ttl\":1,\"proxied\":true}")
|
||||
fi
|
||||
|
||||
# Log the tunnel information
|
||||
echo "Tunnel configuration is set up! Site will be available at: https://${SUBDOMAIN}.${DOMAIN}"
|
||||
echo "Tunnel configuration is set up! Site will be available at: https://tunnel-${SUBDOMAIN}.${DOMAIN}"
|
||||
|
||||
# Export the tunnel token for supervisord
|
||||
export CLOUDFLARE_TUNNEL_TOKEN=${TUNNEL_TOKEN}
|
||||
|
||||
@@ -74,9 +74,11 @@ describe("TradeShipExecution", () => {
|
||||
tradeShip = {
|
||||
isActive: vi.fn(() => true),
|
||||
owner: vi.fn(() => origOwner),
|
||||
id: vi.fn(() => 123),
|
||||
move: vi.fn(),
|
||||
setTargetUnit: vi.fn(),
|
||||
setSafeFromPirates: vi.fn(),
|
||||
touch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
tile: vi.fn(() => 2001),
|
||||
} as any;
|
||||
@@ -85,6 +87,7 @@ describe("TradeShipExecution", () => {
|
||||
tradeShipExecution.init(game, 0);
|
||||
tradeShipExecution["pathFinder"] = {
|
||||
next: vi.fn(() => ({ status: PathStatus.NEXT, node: 2001 })),
|
||||
findPath: vi.fn((from: number) => [from]),
|
||||
} as any;
|
||||
tradeShipExecution["tradeShip"] = tradeShip;
|
||||
});
|
||||
@@ -116,6 +119,7 @@ describe("TradeShipExecution", () => {
|
||||
it("should complete trade and award gold", () => {
|
||||
tradeShipExecution["pathFinder"] = {
|
||||
next: vi.fn(() => ({ status: PathStatus.COMPLETE, node: 2001 })),
|
||||
findPath: vi.fn((from: number) => [from]),
|
||||
} as any;
|
||||
tradeShipExecution.tick(1);
|
||||
expect(tradeShip.delete).toHaveBeenCalledWith(false);
|
||||
|
||||
@@ -59,6 +59,9 @@ fi
|
||||
|
||||
echo "Starting new container for ${HOST} environment..."
|
||||
|
||||
# Ensure the traefik network exists
|
||||
docker network create web 2> /dev/null || true
|
||||
|
||||
# Remove any existing volume for this container if it exists
|
||||
docker volume rm "cloudflared-${CONTAINER_NAME}" 2> /dev/null || true
|
||||
|
||||
@@ -67,6 +70,11 @@ docker run -d \
|
||||
--env-file "$ENV_FILE" \
|
||||
--name "${CONTAINER_NAME}" \
|
||||
-v "cloudflared-${CONTAINER_NAME}:/etc/cloudflared" \
|
||||
--network web \
|
||||
--label "traefik.enable=true" \
|
||||
--label "traefik.http.routers.${CONTAINER_NAME}.rule=Host(\`${SUBDOMAIN}.${DOMAIN}\`)" \
|
||||
--label "traefik.http.routers.${CONTAINER_NAME}.entrypoints=web" \
|
||||
--label "traefik.http.services.${CONTAINER_NAME}.loadbalancer.server.port=80" \
|
||||
"${GHCR_IMAGE}"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
|
||||
Reference in New Issue
Block a user