mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-29 13:32:12 +00:00
Merge branch 'main' into local-attack
This commit is contained in:
@@ -2,16 +2,12 @@ import { decodeJwt } from "jose";
|
||||
import { z } from "zod";
|
||||
import { TokenPayload, TokenPayloadSchema } from "../core/ApiSchemas";
|
||||
import { base64urlToUuid } from "../core/Base64";
|
||||
import { ID } from "../core/Schemas";
|
||||
import { generateID } from "../core/Util";
|
||||
import { getApiBase, getAudience } from "./Api";
|
||||
import { generateCryptoRandomUUID } from "./Utils";
|
||||
|
||||
export type UserAuth = { jwt: string; claims: TokenPayload } | false;
|
||||
|
||||
const PERSISTENT_ID_KEY = "player_persistent_id";
|
||||
const CLIENT_ID_KEY = "client_join_id";
|
||||
const CLIENT_GAME_ID_KEY = "client_join_game_id";
|
||||
|
||||
let __jwt: string | null = null;
|
||||
|
||||
@@ -213,22 +209,6 @@ export function getPersistentID(): string {
|
||||
return base64urlToUuid(sub);
|
||||
}
|
||||
|
||||
export function getClientIDForGame(gameID: string): string {
|
||||
const storedGameID = sessionStorage.getItem(CLIENT_GAME_ID_KEY);
|
||||
const storedClientID = sessionStorage.getItem(CLIENT_ID_KEY);
|
||||
if (
|
||||
storedGameID === gameID &&
|
||||
storedClientID &&
|
||||
ID.safeParse(storedClientID).success
|
||||
) {
|
||||
return storedClientID;
|
||||
}
|
||||
const newID = generateID();
|
||||
sessionStorage.setItem(CLIENT_GAME_ID_KEY, gameID);
|
||||
sessionStorage.setItem(CLIENT_ID_KEY, newID);
|
||||
return newID;
|
||||
}
|
||||
|
||||
// WARNING: DO NOT EXPOSE THIS ID
|
||||
function getPersistentIDFromLocalStorage(): string {
|
||||
// Try to get existing localStorage
|
||||
|
||||
@@ -57,7 +57,6 @@ export interface LobbyConfig {
|
||||
serverConfig: ServerConfig;
|
||||
cosmetics: PlayerCosmeticRefs;
|
||||
playerName: string;
|
||||
clientID: ClientID;
|
||||
gameID: GameID;
|
||||
turnstileToken: string | null;
|
||||
// GameStartInfo only exists when playing a singleplayer game.
|
||||
@@ -72,9 +71,10 @@ export function joinLobby(
|
||||
onPrestart: () => void,
|
||||
onJoin: () => void,
|
||||
): (force?: boolean) => boolean {
|
||||
console.log(
|
||||
`joining lobby: gameID: ${lobbyConfig.gameID}, clientID: ${lobbyConfig.clientID}`,
|
||||
);
|
||||
// Mutable clientID state — assigned by server (multiplayer) or derived from gameStartInfo (singleplayer)
|
||||
let clientID: ClientID | undefined;
|
||||
|
||||
console.log(`joining lobby: gameID: ${lobbyConfig.gameID}`);
|
||||
|
||||
const userSettings: UserSettings = new UserSettings();
|
||||
startGame(lobbyConfig.gameID, lobbyConfig.gameStartInfo?.config ?? {});
|
||||
@@ -83,23 +83,18 @@ export function joinLobby(
|
||||
|
||||
let currentGameRunner: ClientGameRunner | null = null;
|
||||
|
||||
let hasJoined = false;
|
||||
|
||||
const onconnect = () => {
|
||||
if (hasJoined) {
|
||||
console.log("rejoining game");
|
||||
transport.rejoinGame(0);
|
||||
} else {
|
||||
hasJoined = true;
|
||||
console.log(`Joining game lobby ${lobbyConfig.gameID}`);
|
||||
transport.joinGame();
|
||||
}
|
||||
// Always send join - server will detect reconnection via persistentID
|
||||
console.log(`Joining game lobby ${lobbyConfig.gameID}`);
|
||||
transport.joinGame();
|
||||
};
|
||||
let terrainLoad: Promise<TerrainMapData> | null = null;
|
||||
|
||||
const onmessage = (message: ServerMessage) => {
|
||||
if (message.type === "lobby_info") {
|
||||
eventBus.emit(new LobbyInfoEvent(message.lobby));
|
||||
// Server tells us our assigned clientID
|
||||
clientID = message.myClientID;
|
||||
eventBus.emit(new LobbyInfoEvent(message.lobby, message.myClientID));
|
||||
return;
|
||||
}
|
||||
if (message.type === "prestart") {
|
||||
@@ -119,11 +114,14 @@ export function joinLobby(
|
||||
console.log(
|
||||
`lobby: game started: ${JSON.stringify(message, replacer, 2)}`,
|
||||
);
|
||||
// Server tells us our assigned clientID (also sent on start for late joins)
|
||||
clientID = message.myClientID;
|
||||
onJoin();
|
||||
// For multiplayer games, GameStartInfo is not known until game starts.
|
||||
lobbyConfig.gameStartInfo = message.gameStartInfo;
|
||||
createClientGame(
|
||||
lobbyConfig,
|
||||
clientID,
|
||||
eventBus,
|
||||
transport,
|
||||
userSettings,
|
||||
@@ -149,7 +147,7 @@ export function joinLobby(
|
||||
e.message,
|
||||
e.stack,
|
||||
lobbyConfig.gameID,
|
||||
lobbyConfig.clientID,
|
||||
clientID,
|
||||
true,
|
||||
false,
|
||||
"error_modal.connection_error",
|
||||
@@ -170,7 +168,7 @@ export function joinLobby(
|
||||
message.error,
|
||||
message.message,
|
||||
lobbyConfig.gameID,
|
||||
lobbyConfig.clientID,
|
||||
clientID,
|
||||
true,
|
||||
false,
|
||||
"error_modal.connection_error",
|
||||
@@ -197,6 +195,7 @@ export function joinLobby(
|
||||
|
||||
async function createClientGame(
|
||||
lobbyConfig: LobbyConfig,
|
||||
clientID: ClientID,
|
||||
eventBus: EventBus,
|
||||
transport: Transport,
|
||||
userSettings: UserSettings,
|
||||
@@ -222,16 +221,14 @@ async function createClientGame(
|
||||
mapLoader,
|
||||
);
|
||||
}
|
||||
const worker = new WorkerClient(
|
||||
lobbyConfig.gameStartInfo,
|
||||
lobbyConfig.clientID,
|
||||
);
|
||||
const worker = new WorkerClient(lobbyConfig.gameStartInfo, clientID);
|
||||
await worker.initialize();
|
||||
const gameView = new GameView(
|
||||
worker,
|
||||
config,
|
||||
gameMap,
|
||||
lobbyConfig.clientID,
|
||||
clientID,
|
||||
lobbyConfig.playerName,
|
||||
lobbyConfig.gameStartInfo.gameID,
|
||||
lobbyConfig.gameStartInfo.players,
|
||||
);
|
||||
@@ -245,6 +242,7 @@ async function createClientGame(
|
||||
|
||||
return new ClientGameRunner(
|
||||
lobbyConfig,
|
||||
clientID,
|
||||
eventBus,
|
||||
gameRenderer,
|
||||
new InputHandler(gameRenderer.uiState, canvas, eventBus),
|
||||
@@ -270,6 +268,7 @@ export class ClientGameRunner {
|
||||
|
||||
constructor(
|
||||
private lobby: LobbyConfig,
|
||||
private clientID: ClientID,
|
||||
private eventBus: EventBus,
|
||||
private renderer: GameRenderer,
|
||||
private input: InputHandler,
|
||||
@@ -303,8 +302,8 @@ export class ClientGameRunner {
|
||||
{
|
||||
persistentID: getPersistentID(),
|
||||
username: this.lobby.playerName,
|
||||
clientID: this.lobby.clientID,
|
||||
stats: update.allPlayersStats[this.lobby.clientID],
|
||||
clientID: this.clientID,
|
||||
stats: update.allPlayersStats[this.clientID],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -361,7 +360,7 @@ export class ClientGameRunner {
|
||||
gu.errMsg,
|
||||
gu.stack ?? "missing",
|
||||
this.lobby.gameStartInfo.gameID,
|
||||
this.lobby.clientID,
|
||||
this.clientID,
|
||||
);
|
||||
console.error(gu.stack);
|
||||
this.stop();
|
||||
@@ -423,7 +422,7 @@ export class ClientGameRunner {
|
||||
"spawn_failed",
|
||||
translateText("error_modal.spawn_failed.description"),
|
||||
this.lobby.gameID,
|
||||
this.lobby.clientID,
|
||||
this.clientID,
|
||||
true,
|
||||
false,
|
||||
translateText("error_modal.spawn_failed.title"),
|
||||
@@ -460,7 +459,7 @@ export class ClientGameRunner {
|
||||
`desync from server: ${JSON.stringify(message)}`,
|
||||
"",
|
||||
this.lobby.gameStartInfo.gameID,
|
||||
this.lobby.clientID,
|
||||
this.clientID,
|
||||
true,
|
||||
false,
|
||||
"error_modal.desync_notice",
|
||||
@@ -471,7 +470,7 @@ export class ClientGameRunner {
|
||||
message.error,
|
||||
message.message,
|
||||
this.lobby.gameID,
|
||||
this.lobby.clientID,
|
||||
this.clientID,
|
||||
true,
|
||||
false,
|
||||
"error_modal.connection_error",
|
||||
@@ -555,7 +554,7 @@ export class ClientGameRunner {
|
||||
return;
|
||||
}
|
||||
if (this.myPlayer === null) {
|
||||
const myPlayer = this.gameView.playerByClientID(this.lobby.clientID);
|
||||
const myPlayer = this.gameView.playerByClientID(this.clientID);
|
||||
if (myPlayer === null) return;
|
||||
this.myPlayer = myPlayer;
|
||||
}
|
||||
@@ -585,7 +584,7 @@ export class ClientGameRunner {
|
||||
const tile = this.gameView.ref(cell.x, cell.y);
|
||||
|
||||
if (this.myPlayer === null) {
|
||||
const myPlayer = this.gameView.playerByClientID(this.lobby.clientID);
|
||||
const myPlayer = this.gameView.playerByClientID(this.clientID);
|
||||
if (myPlayer === null) return;
|
||||
this.myPlayer = myPlayer;
|
||||
}
|
||||
@@ -646,7 +645,7 @@ export class ClientGameRunner {
|
||||
}
|
||||
|
||||
if (this.myPlayer === null) {
|
||||
const myPlayer = this.gameView.playerByClientID(this.lobby.clientID);
|
||||
const myPlayer = this.gameView.playerByClientID(this.clientID);
|
||||
if (myPlayer === null) return;
|
||||
this.myPlayer = myPlayer;
|
||||
}
|
||||
@@ -665,7 +664,7 @@ export class ClientGameRunner {
|
||||
}
|
||||
|
||||
if (this.myPlayer === null) {
|
||||
const myPlayer = this.gameView.playerByClientID(this.lobby.clientID);
|
||||
const myPlayer = this.gameView.playerByClientID(this.clientID);
|
||||
if (myPlayer === null) return;
|
||||
this.myPlayer = myPlayer;
|
||||
}
|
||||
@@ -779,7 +778,7 @@ function showErrorModal(
|
||||
error: string,
|
||||
message: string | undefined,
|
||||
gameID: GameID,
|
||||
clientID: ClientID,
|
||||
clientID: ClientID | undefined,
|
||||
closable = false,
|
||||
showDiscord = true,
|
||||
heading = "error_modal.crashed",
|
||||
|
||||
+14
-5
@@ -13,7 +13,6 @@ export class GutterAds extends LitElement {
|
||||
private rightAdType: string = "standard_iab_rght1";
|
||||
private leftContainerId: string = "gutter-ad-container-left";
|
||||
private rightContainerId: string = "gutter-ad-container-right";
|
||||
private margin: string = "10px";
|
||||
|
||||
// Override createRenderRoot to disable shadow DOM
|
||||
createRenderRoot() {
|
||||
@@ -50,6 +49,16 @@ export class GutterAds extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
try {
|
||||
window.ramp.destroyUnits(this.leftAdType);
|
||||
window.ramp.destroyUnits(this.rightAdType);
|
||||
console.log("successfully destroyed gutter ads");
|
||||
} catch (e) {
|
||||
console.error("error destroying gutter ads", e);
|
||||
}
|
||||
}
|
||||
|
||||
private loadAds(): void {
|
||||
console.log("loading ramp ads");
|
||||
// Ensure the container elements exist before loading ads
|
||||
@@ -111,8 +120,8 @@ export class GutterAds extends LitElement {
|
||||
return html`
|
||||
<!-- Left Gutter Ad -->
|
||||
<div
|
||||
class="hidden xl:flex fixed left-0 top-1/2 transform -translate-y-1/2 w-[160px] min-h-[600px] z-[100] pointer-events-auto items-center justify-center"
|
||||
style="margin-left: ${this.margin};"
|
||||
class="hidden xl:flex fixed transform -translate-y-1/2 w-[160px] min-h-[600px] z-[100] pointer-events-auto items-center justify-center"
|
||||
style="left: calc(50% - 10cm - 230px); top: calc(50% + 10px);"
|
||||
>
|
||||
<div
|
||||
id="${this.leftContainerId}"
|
||||
@@ -122,8 +131,8 @@ export class GutterAds extends LitElement {
|
||||
|
||||
<!-- Right Gutter Ad -->
|
||||
<div
|
||||
class="hidden xl:flex fixed right-0 top-1/2 transform -translate-y-1/2 w-[160px] min-h-[600px] z-[100] pointer-events-auto items-center justify-center"
|
||||
style="margin-right: ${this.margin};"
|
||||
class="hidden xl:flex fixed transform -translate-y-1/2 w-[160px] min-h-[600px] z-[100] pointer-events-auto items-center justify-center"
|
||||
style="left: calc(50% + 10cm + 70px); top: calc(50% + 10px);"
|
||||
>
|
||||
<div
|
||||
id="${this.rightContainerId}"
|
||||
|
||||
@@ -154,7 +154,7 @@ export class HelpModal extends BaseModal {
|
||||
<iframe
|
||||
id="tutorial-video-iframe"
|
||||
class="absolute top-0 left-0 w-full h-full"
|
||||
src="${TUTORIAL_VIDEO_URL}"
|
||||
src="${this.isModalOpen ? TUTORIAL_VIDEO_URL : ""}"
|
||||
title="${translateText("help_modal.video_tutorial_title")}"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { TemplateResult, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { translateText } from "../client/Utils";
|
||||
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
|
||||
import { EventBus } from "../core/EventBus";
|
||||
import {
|
||||
Difficulty,
|
||||
Duos,
|
||||
@@ -17,11 +18,12 @@ import {
|
||||
ClientInfo,
|
||||
GameConfig,
|
||||
GameInfo,
|
||||
LobbyInfoEvent,
|
||||
TeamCountConfig,
|
||||
isValidGameID,
|
||||
} from "../core/Schemas";
|
||||
import { generateID } from "../core/Util";
|
||||
import { getClientIDForGame } from "./Auth";
|
||||
import { getPlayToken } from "./Auth";
|
||||
import "./components/baseComponents/Modal";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/CopyButton";
|
||||
@@ -74,6 +76,8 @@ export class HostLobbyModal extends BaseModal {
|
||||
@state() private lobbyCreatorClientID: string = "";
|
||||
@state() private nationCount: number = 0;
|
||||
|
||||
@property({ attribute: false }) eventBus: EventBus | null = null;
|
||||
|
||||
private playersInterval: NodeJS.Timeout | null = null;
|
||||
// Add a new timer for debouncing bot changes
|
||||
private botsUpdateTimer: number | null = null;
|
||||
@@ -81,6 +85,14 @@ export class HostLobbyModal extends BaseModal {
|
||||
|
||||
private leaveLobbyOnClose = true;
|
||||
|
||||
private readonly handleLobbyInfo = (event: LobbyInfoEvent) => {
|
||||
const lobby = event.lobby;
|
||||
this.lobbyCreatorClientID = lobby.lobbyCreatorClientID ?? "";
|
||||
if (lobby.clients) {
|
||||
this.clients = lobby.clients;
|
||||
}
|
||||
};
|
||||
|
||||
private renderOptionToggle(
|
||||
labelKey: string,
|
||||
checked: boolean,
|
||||
@@ -137,6 +149,21 @@ export class HostLobbyModal extends BaseModal {
|
||||
}
|
||||
}
|
||||
|
||||
private startLobbyUpdates() {
|
||||
this.stopLobbyUpdates();
|
||||
if (!this.eventBus) {
|
||||
console.warn(
|
||||
"HostLobbyModal: eventBus not set, cannot subscribe to lobby updates",
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.eventBus.on(LobbyInfoEvent, this.handleLobbyInfo);
|
||||
}
|
||||
|
||||
private stopLobbyUpdates() {
|
||||
this.eventBus?.off(LobbyInfoEvent, this.handleLobbyInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
const maxTimerHandlers = this.createToggleHandlers(
|
||||
() => this.maxTimer,
|
||||
@@ -636,10 +663,13 @@ export class HostLobbyModal extends BaseModal {
|
||||
}
|
||||
|
||||
protected onOpen(): void {
|
||||
this.startLobbyUpdates();
|
||||
this.lobbyId = generateID();
|
||||
this.lobbyCreatorClientID = getClientIDForGame(this.lobbyId);
|
||||
// Note: clientID will be assigned by server when we join the lobby
|
||||
// lobbyCreatorClientID stays empty until then
|
||||
|
||||
createLobby(this.lobbyCreatorClientID, this.lobbyId)
|
||||
// Pass auth token for creator identification (server extracts persistentID from it)
|
||||
createLobby(this.lobbyId)
|
||||
.then(async (lobby) => {
|
||||
this.lobbyId = lobby.gameID;
|
||||
if (!isValidGameID(this.lobbyId)) {
|
||||
@@ -654,7 +684,6 @@ export class HostLobbyModal extends BaseModal {
|
||||
new CustomEvent("join-lobby", {
|
||||
detail: {
|
||||
gameID: this.lobbyId,
|
||||
clientID: this.lobbyCreatorClientID,
|
||||
source: "host",
|
||||
} as JoinLobbyEvent,
|
||||
bubbles: true,
|
||||
@@ -720,6 +749,7 @@ export class HostLobbyModal extends BaseModal {
|
||||
|
||||
protected onClose(): void {
|
||||
console.log("Closing host lobby modal");
|
||||
this.stopLobbyUpdates();
|
||||
if (this.leaveLobbyOnClose) {
|
||||
this.leaveLobby();
|
||||
this.updateHistory("/"); // Reset URL to base
|
||||
@@ -1083,20 +1113,20 @@ export class HostLobbyModal extends BaseModal {
|
||||
}
|
||||
}
|
||||
|
||||
async function createLobby(
|
||||
creatorClientID: string,
|
||||
gameID: string,
|
||||
): Promise<GameInfo> {
|
||||
async function createLobby(gameID: string): Promise<GameInfo> {
|
||||
const config = await getServerConfigFromClient();
|
||||
// Send JWT token for creator identification - server extracts persistentID from it
|
||||
// persistentID should never be exposed to other clients
|
||||
const token = await getPlayToken();
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/${config.workerPath(gameID)}/api/create_game/${gameID}?creatorClientID=${encodeURIComponent(creatorClientID)}`,
|
||||
`/${config.workerPath(gameID)}/api/create_game/${gameID}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
// body: JSON.stringify(data), // Include this if you need to send data
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
HumansVsNations,
|
||||
} from "../core/game/Game";
|
||||
import { getApiBase } from "./Api";
|
||||
import { getClientIDForGame } from "./Auth";
|
||||
import { crazyGamesSDK } from "./CrazyGamesSDK";
|
||||
import { JoinLobbyEvent } from "./Main";
|
||||
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
|
||||
@@ -61,9 +60,7 @@ export class JoinLobbyModal extends BaseModal {
|
||||
|
||||
private readonly handleLobbyInfo = (event: LobbyInfoEvent) => {
|
||||
const lobby = event.lobby;
|
||||
if (!this.currentLobbyId || lobby.gameID !== this.currentLobbyId) {
|
||||
return;
|
||||
}
|
||||
this.currentClientID = event.myClientID;
|
||||
// Only stop showing spinner when we have player info
|
||||
if (this.isConnecting && lobby.clients) {
|
||||
this.isConnecting = false;
|
||||
@@ -335,7 +332,6 @@ export class JoinLobbyModal extends BaseModal {
|
||||
new CustomEvent("join-lobby", {
|
||||
detail: {
|
||||
gameID: lobbyId,
|
||||
clientID: this.currentClientID,
|
||||
source: "public",
|
||||
} as JoinLobbyEvent,
|
||||
bubbles: true,
|
||||
@@ -346,7 +342,8 @@ export class JoinLobbyModal extends BaseModal {
|
||||
|
||||
private startTrackingLobby(lobbyId: string, lobbyInfo?: GameInfo) {
|
||||
this.currentLobbyId = lobbyId;
|
||||
this.currentClientID = getClientIDForGame(lobbyId);
|
||||
// clientID will be assigned by server via lobby_info message
|
||||
this.currentClientID = "";
|
||||
this.gameConfig = null;
|
||||
this.players = [];
|
||||
this.nationCount = 0;
|
||||
@@ -545,9 +542,7 @@ export class JoinLobbyModal extends BaseModal {
|
||||
}
|
||||
}
|
||||
|
||||
this.lobbyCreatorClientID = this.isPrivateLobby()
|
||||
? (lobby.clients?.[0]?.clientID ?? null)
|
||||
: null;
|
||||
this.lobbyCreatorClientID = lobby.lobbyCreatorClientID ?? null;
|
||||
}
|
||||
|
||||
private startLobbyUpdates() {
|
||||
@@ -776,7 +771,6 @@ export class JoinLobbyModal extends BaseModal {
|
||||
new CustomEvent("join-lobby", {
|
||||
detail: {
|
||||
gameID: lobbyId,
|
||||
clientID: this.currentClientID,
|
||||
source: "private",
|
||||
} as JoinLobbyEvent,
|
||||
bubbles: true,
|
||||
@@ -835,7 +829,6 @@ export class JoinLobbyModal extends BaseModal {
|
||||
detail: {
|
||||
gameID: lobbyId,
|
||||
gameRecord: parsed.data,
|
||||
clientID: this.currentClientID,
|
||||
source: "private",
|
||||
} as JoinLobbyEvent,
|
||||
bubbles: true,
|
||||
|
||||
@@ -2,13 +2,14 @@ import { z } from "zod";
|
||||
import { EventBus } from "../core/EventBus";
|
||||
import {
|
||||
AllPlayersStats,
|
||||
ClientID,
|
||||
ClientMessage,
|
||||
ClientSendWinnerMessage,
|
||||
Intent,
|
||||
PartialGameRecordSchema,
|
||||
PlayerRecord,
|
||||
ServerMessage,
|
||||
ServerStartGameMessage,
|
||||
StampedIntent,
|
||||
Turn,
|
||||
} from "../core/Schemas";
|
||||
import {
|
||||
@@ -34,12 +35,13 @@ export class LocalServer {
|
||||
|
||||
private turns: Turn[] = [];
|
||||
|
||||
private intents: Intent[] = [];
|
||||
private intents: StampedIntent[] = [];
|
||||
private startedAt: number;
|
||||
|
||||
private paused = false;
|
||||
private replaySpeedMultiplier = defaultReplaySpeedMultiplier;
|
||||
|
||||
private clientID: ClientID | undefined;
|
||||
private winner: ClientSendWinnerMessage | null = null;
|
||||
private allPlayersStats: AllPlayersStats = {};
|
||||
|
||||
@@ -102,34 +104,48 @@ export class LocalServer {
|
||||
if (this.lobbyConfig.gameStartInfo === undefined) {
|
||||
throw new Error("missing gameStartInfo");
|
||||
}
|
||||
this.clientID = this.lobbyConfig.gameStartInfo.players[0]?.clientID;
|
||||
if (!this.clientID) {
|
||||
throw new Error("missing clientID");
|
||||
}
|
||||
this.clientMessage({
|
||||
type: "start",
|
||||
gameStartInfo: this.lobbyConfig.gameStartInfo,
|
||||
turns: [],
|
||||
lobbyCreatedAt: this.lobbyConfig.gameStartInfo.lobbyCreatedAt,
|
||||
myClientID: this.clientID,
|
||||
} satisfies ServerStartGameMessage);
|
||||
}
|
||||
|
||||
onMessage(clientMsg: ClientMessage) {
|
||||
if (clientMsg.type === "rejoin") {
|
||||
if (!this.clientID) {
|
||||
throw new Error("missing clientID");
|
||||
}
|
||||
this.clientMessage({
|
||||
type: "start",
|
||||
gameStartInfo: this.lobbyConfig.gameStartInfo!,
|
||||
turns: this.turns,
|
||||
lobbyCreatedAt: this.lobbyConfig.gameStartInfo!.lobbyCreatedAt,
|
||||
myClientID: this.clientID,
|
||||
} satisfies ServerStartGameMessage);
|
||||
}
|
||||
if (clientMsg.type === "intent") {
|
||||
if (clientMsg.intent.type === "toggle_pause") {
|
||||
if (clientMsg.intent.paused) {
|
||||
// Server stamps clientID - client doesn't send it
|
||||
const stampedIntent = {
|
||||
...clientMsg.intent,
|
||||
clientID: this.clientID!,
|
||||
};
|
||||
if (stampedIntent.type === "toggle_pause") {
|
||||
if (stampedIntent.paused) {
|
||||
// Pausing: add intent and end turn before pause takes effect
|
||||
this.intents.push(clientMsg.intent);
|
||||
this.intents.push(stampedIntent);
|
||||
this.endTurn();
|
||||
this.paused = true;
|
||||
} else {
|
||||
// Unpausing: clear pause flag before adding intent so next turn can execute
|
||||
this.paused = false;
|
||||
this.intents.push(clientMsg.intent);
|
||||
this.intents.push(stampedIntent);
|
||||
this.endTurn();
|
||||
}
|
||||
return;
|
||||
@@ -139,7 +155,7 @@ export class LocalServer {
|
||||
return;
|
||||
}
|
||||
|
||||
this.intents.push(clientMsg.intent);
|
||||
this.intents.push(stampedIntent);
|
||||
}
|
||||
if (clientMsg.type === "hash") {
|
||||
if (!this.lobbyConfig.gameRecord) {
|
||||
@@ -224,8 +240,8 @@ export class LocalServer {
|
||||
{
|
||||
persistentID: getPersistentID(),
|
||||
username: this.lobbyConfig.playerName,
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
stats: this.allPlayersStats[this.lobbyConfig.clientID],
|
||||
clientID: this.clientID!,
|
||||
stats: this.allPlayersStats[this.clientID!],
|
||||
cosmetics: this.lobbyConfig.gameStartInfo?.players[0].cosmetics,
|
||||
clanTag: getClanTag(this.lobbyConfig.playerName) ?? undefined,
|
||||
},
|
||||
|
||||
+3
-2
@@ -210,7 +210,6 @@ declare global {
|
||||
}
|
||||
|
||||
export interface JoinLobbyEvent {
|
||||
clientID: string;
|
||||
// Multiplayer games only have gameID, gameConfig is not known until game starts.
|
||||
gameID: string;
|
||||
// GameConfig only exists when playing a singleplayer game.
|
||||
@@ -504,6 +503,8 @@ class Client {
|
||||
) as HostPrivateLobbyModal;
|
||||
if (!this.hostModal || !(this.hostModal instanceof HostPrivateLobbyModal)) {
|
||||
console.warn("Host private lobby modal element not found");
|
||||
} else {
|
||||
this.hostModal.eventBus = this.eventBus;
|
||||
}
|
||||
const hostLobbyButton = document.getElementById("host-lobby-button");
|
||||
if (hostLobbyButton === null) throw new Error("Missing host-lobby-button");
|
||||
@@ -818,7 +819,6 @@ class Client {
|
||||
turnstileToken: await this.getTurnstileToken(lobby),
|
||||
playerName:
|
||||
this.usernameInput?.getCurrentUsername() ?? genAnonUsername(),
|
||||
clientID: lobby.clientID,
|
||||
gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info,
|
||||
gameRecord: lobby.gameRecord,
|
||||
},
|
||||
@@ -850,6 +850,7 @@ class Client {
|
||||
"token-login",
|
||||
"matchmaking-modal",
|
||||
"lang-selector",
|
||||
"gutter-ads",
|
||||
].forEach((tag) => {
|
||||
const modal = document.querySelector(tag) as HTMLElement & {
|
||||
close?: () => void;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { customElement, query, state } from "lit/decorators.js";
|
||||
import { UserMeResponse } from "../core/ApiSchemas";
|
||||
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
|
||||
import { getUserMe, hasLinkedAccount } from "./Api";
|
||||
import { getClientIDForGame, getPlayToken } from "./Auth";
|
||||
import { getPlayToken } from "./Auth";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/Difficulties";
|
||||
import "./components/PatternButton";
|
||||
@@ -230,7 +230,6 @@ export class MatchmakingModal extends BaseModal {
|
||||
new CustomEvent("join-lobby", {
|
||||
detail: {
|
||||
gameID: this.gameID,
|
||||
clientID: getClientIDForGame(this.gameID),
|
||||
source: "matchmaking",
|
||||
} as JoinLobbyEvent,
|
||||
bubbles: true,
|
||||
|
||||
@@ -912,7 +912,6 @@ export class SinglePlayerModal extends BaseModal {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("join-lobby", {
|
||||
detail: {
|
||||
clientID: clientID,
|
||||
gameID: gameID,
|
||||
gameStartInfo: {
|
||||
gameID: gameID,
|
||||
|
||||
+2
-25
@@ -403,7 +403,7 @@ export class Transport {
|
||||
this.sendMsg({
|
||||
type: "join",
|
||||
gameID: this.lobbyConfig.gameID,
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
// Note: clientID is not sent - server assigns it based on persistentID
|
||||
username: this.lobbyConfig.playerName,
|
||||
cosmetics: this.lobbyConfig.cosmetics,
|
||||
turnstileToken: this.lobbyConfig.turnstileToken,
|
||||
@@ -415,7 +415,7 @@ export class Transport {
|
||||
this.sendMsg({
|
||||
type: "rejoin",
|
||||
gameID: this.lobbyConfig.gameID,
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
// Note: clientID is not sent - server looks it up from persistentID in token
|
||||
lastTurn: lastTurn,
|
||||
token: await getPlayToken(),
|
||||
} satisfies ClientRejoinMessage);
|
||||
@@ -444,7 +444,6 @@ export class Transport {
|
||||
private onSendAllianceRequest(event: SendAllianceRequestIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "allianceRequest",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
recipient: event.recipient.id(),
|
||||
});
|
||||
}
|
||||
@@ -452,7 +451,6 @@ export class Transport {
|
||||
private onAllianceRequestReplyUIEvent(event: SendAllianceReplyIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "allianceRequestReply",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
requestor: event.requestor.id(),
|
||||
accept: event.accepted,
|
||||
});
|
||||
@@ -461,7 +459,6 @@ export class Transport {
|
||||
private onBreakAllianceRequestUIEvent(event: SendBreakAllianceIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "breakAlliance",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
recipient: event.recipient.id(),
|
||||
});
|
||||
}
|
||||
@@ -471,7 +468,6 @@ export class Transport {
|
||||
) {
|
||||
this.sendIntent({
|
||||
type: "allianceExtension",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
recipient: event.recipient.id(),
|
||||
});
|
||||
}
|
||||
@@ -479,7 +475,6 @@ export class Transport {
|
||||
private onSendSpawnIntentEvent(event: SendSpawnIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "spawn",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
tile: event.tile,
|
||||
});
|
||||
}
|
||||
@@ -487,7 +482,6 @@ export class Transport {
|
||||
private onSendAttackIntent(event: SendAttackIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "attack",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
targetID: event.targetID,
|
||||
troops: event.troops,
|
||||
sourceTile: event.sourceTile,
|
||||
@@ -497,7 +491,6 @@ export class Transport {
|
||||
private onSendBoatAttackIntent(event: SendBoatAttackIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "boat",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
troops: event.troops,
|
||||
dst: event.dst,
|
||||
});
|
||||
@@ -507,7 +500,6 @@ export class Transport {
|
||||
this.sendIntent({
|
||||
type: "upgrade_structure",
|
||||
unit: event.unitType,
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
unitId: event.unitId,
|
||||
});
|
||||
}
|
||||
@@ -515,7 +507,6 @@ export class Transport {
|
||||
private onSendTargetPlayerIntent(event: SendTargetPlayerIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "targetPlayer",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
target: event.targetID,
|
||||
});
|
||||
}
|
||||
@@ -523,7 +514,6 @@ export class Transport {
|
||||
private onSendEmojiIntent(event: SendEmojiIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "emoji",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
recipient:
|
||||
event.recipient === AllPlayers ? AllPlayers : event.recipient.id(),
|
||||
emoji: event.emoji,
|
||||
@@ -533,7 +523,6 @@ export class Transport {
|
||||
private onSendDonateGoldIntent(event: SendDonateGoldIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "donate_gold",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
recipient: event.recipient.id(),
|
||||
gold: event.gold ? Number(event.gold) : null,
|
||||
});
|
||||
@@ -542,7 +531,6 @@ export class Transport {
|
||||
private onSendDonateTroopIntent(event: SendDonateTroopsIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "donate_troops",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
recipient: event.recipient.id(),
|
||||
troops: event.troops,
|
||||
});
|
||||
@@ -551,7 +539,6 @@ export class Transport {
|
||||
private onSendQuickChatIntent(event: SendQuickChatEvent) {
|
||||
this.sendIntent({
|
||||
type: "quick_chat",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
recipient: event.recipient.id(),
|
||||
quickChatKey: event.quickChatKey,
|
||||
target: event.target,
|
||||
@@ -561,7 +548,6 @@ export class Transport {
|
||||
private onSendEmbargoIntent(event: SendEmbargoIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "embargo",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
targetID: event.target.id(),
|
||||
action: event.action,
|
||||
});
|
||||
@@ -570,7 +556,6 @@ export class Transport {
|
||||
private onSendEmbargoAllIntent(event: SendEmbargoAllIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "embargo_all",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
action: event.action,
|
||||
});
|
||||
}
|
||||
@@ -578,7 +563,6 @@ export class Transport {
|
||||
private onBuildUnitIntent(event: BuildUnitIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "build_unit",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
unit: event.unit,
|
||||
tile: event.tile,
|
||||
rocketDirectionUp: event.rocketDirectionUp,
|
||||
@@ -588,7 +572,6 @@ export class Transport {
|
||||
private onPauseGameIntent(event: PauseGameIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "toggle_pause",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
paused: event.paused,
|
||||
});
|
||||
}
|
||||
@@ -628,7 +611,6 @@ export class Transport {
|
||||
private onCancelAttackIntentEvent(event: CancelAttackIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "cancel_attack",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
attackID: event.attackID,
|
||||
});
|
||||
}
|
||||
@@ -636,7 +618,6 @@ export class Transport {
|
||||
private onCancelBoatIntentEvent(event: CancelBoatIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "cancel_boat",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
unitID: event.unitID,
|
||||
});
|
||||
}
|
||||
@@ -644,7 +625,6 @@ export class Transport {
|
||||
private onMoveWarshipEvent(event: MoveWarshipIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "move_warship",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
unitId: event.unitId,
|
||||
tile: event.tile,
|
||||
});
|
||||
@@ -653,7 +633,6 @@ export class Transport {
|
||||
private onSendDeleteUnitIntent(event: SendDeleteUnitIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "delete_unit",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
unitId: event.unitId,
|
||||
});
|
||||
}
|
||||
@@ -661,7 +640,6 @@ export class Transport {
|
||||
private onSendKickPlayerIntent(event: SendKickPlayerIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "kick_player",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
target: event.target,
|
||||
});
|
||||
}
|
||||
@@ -669,7 +647,6 @@ export class Transport {
|
||||
private onSendUpdateGameConfigIntent(event: SendUpdateGameConfigIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "update_game_config",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
config: event.config,
|
||||
});
|
||||
}
|
||||
|
||||
+26
-2
@@ -44,12 +44,36 @@ export function getGameModeLabel(gameConfig: GameConfig): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Numeric team count
|
||||
// Numeric team count (e.g. "5 teams of 20")
|
||||
const teamCount =
|
||||
typeof playerTeams === "number"
|
||||
? playerTeams
|
||||
: getTeamCount(playerTeams, maxPlayers ?? 0);
|
||||
return translateText("public_lobby.teams", { num: teamCount });
|
||||
const teamSize =
|
||||
teamCount > 0 ? Math.floor((maxPlayers ?? 0) / teamCount) : 0;
|
||||
|
||||
// If the computed team size matches a named format, use that label instead
|
||||
const namedTeamType =
|
||||
teamSize === 2
|
||||
? Duos
|
||||
: teamSize === 3
|
||||
? Trios
|
||||
: teamSize === 4
|
||||
? Quads
|
||||
: null;
|
||||
if (namedTeamType) {
|
||||
const teamKey = `public_lobby.teams_${namedTeamType}`;
|
||||
const translated = translateText(teamKey, { team_count: teamCount });
|
||||
if (translated !== teamKey) {
|
||||
return translated;
|
||||
}
|
||||
}
|
||||
|
||||
const teamsLabel = translateText("public_lobby.teams", { num: teamCount });
|
||||
if (teamSize > 0) {
|
||||
return `${teamsLabel} ${translateText("public_lobby.players_per_team", { num: teamSize })}`;
|
||||
}
|
||||
return teamsLabel;
|
||||
}
|
||||
|
||||
function getTeamCount(
|
||||
|
||||
@@ -7,6 +7,7 @@ import { FrameProfiler } from "./FrameProfiler";
|
||||
import { TransformHandler } from "./TransformHandler";
|
||||
import { UIState } from "./UIState";
|
||||
import { AlertFrame } from "./layers/AlertFrame";
|
||||
import { AttacksDisplay } from "./layers/AttacksDisplay";
|
||||
import { BuildMenu } from "./layers/BuildMenu";
|
||||
import { ChatDisplay } from "./layers/ChatDisplay";
|
||||
import { ChatModal } from "./layers/ChatModal";
|
||||
@@ -124,6 +125,16 @@ export function createRenderer(
|
||||
eventsDisplay.game = game;
|
||||
eventsDisplay.uiState = uiState;
|
||||
|
||||
const attacksDisplay = document.querySelector(
|
||||
"attacks-display",
|
||||
) as AttacksDisplay;
|
||||
if (!(attacksDisplay instanceof AttacksDisplay)) {
|
||||
console.error("attacks display not found");
|
||||
}
|
||||
attacksDisplay.eventBus = eventBus;
|
||||
attacksDisplay.game = game;
|
||||
attacksDisplay.uiState = uiState;
|
||||
|
||||
const chatDisplay = document.querySelector("chat-display") as ChatDisplay;
|
||||
if (!(chatDisplay instanceof ChatDisplay)) {
|
||||
console.error("chat display not found");
|
||||
@@ -266,17 +277,18 @@ export function createRenderer(
|
||||
const layers: Layer[] = [
|
||||
new TerrainLayer(game, transformHandler),
|
||||
new TerritoryLayer(game, eventBus, transformHandler, userSettings),
|
||||
new RailroadLayer(game, eventBus, transformHandler),
|
||||
new RailroadLayer(game, eventBus, transformHandler, uiState),
|
||||
structureLayer,
|
||||
samRadiusLayer,
|
||||
new UnitLayer(game, eventBus, transformHandler),
|
||||
new FxLayer(game, transformHandler),
|
||||
new FxLayer(game, eventBus, transformHandler),
|
||||
new UILayer(game, eventBus, transformHandler),
|
||||
new NukeTrajectoryPreviewLayer(game, eventBus, transformHandler, uiState),
|
||||
new StructureIconsLayer(game, eventBus, uiState, transformHandler),
|
||||
new DynamicUILayer(game, transformHandler, eventBus),
|
||||
new NameLayer(game, transformHandler, eventBus),
|
||||
eventsDisplay,
|
||||
attacksDisplay,
|
||||
chatDisplay,
|
||||
buildMenu,
|
||||
new MainRadialMenu(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { UnitType } from "../../core/game/Game";
|
||||
export interface UIState {
|
||||
attackRatio: number;
|
||||
ghostStructure: UnitType | null;
|
||||
overlappingRailroads: number[];
|
||||
rocketDirectionUp: boolean;
|
||||
localAttackHeld: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { MessageType, PlayerType, UnitType } from "../../../core/game/Game";
|
||||
import {
|
||||
AttackUpdate,
|
||||
GameUpdateType,
|
||||
UnitIncomingUpdate,
|
||||
} from "../../../core/game/GameUpdates";
|
||||
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
|
||||
import {
|
||||
CancelAttackIntentEvent,
|
||||
CancelBoatIntentEvent,
|
||||
SendAttackIntentEvent,
|
||||
} from "../../Transport";
|
||||
import { renderTroops, translateText } from "../../Utils";
|
||||
import { getColoredSprite } from "../SpriteLoader";
|
||||
import { UIState } from "../UIState";
|
||||
import { Layer } from "./Layer";
|
||||
import {
|
||||
GoToPlayerEvent,
|
||||
GoToPositionEvent,
|
||||
GoToUnitEvent,
|
||||
} from "./Leaderboard";
|
||||
import swordIcon from "/images/SwordIcon.svg?url";
|
||||
|
||||
@customElement("attacks-display")
|
||||
export class AttacksDisplay extends LitElement implements Layer {
|
||||
public eventBus: EventBus;
|
||||
public game: GameView;
|
||||
public uiState: UIState;
|
||||
|
||||
private active: boolean = false;
|
||||
private incomingBoatIDs: Set<number> = new Set();
|
||||
private spriteDataURLCache: Map<string, string> = new Map();
|
||||
@state() private _isVisible: boolean = false;
|
||||
@state() private incomingAttacks: AttackUpdate[] = [];
|
||||
@state() private outgoingAttacks: AttackUpdate[] = [];
|
||||
@state() private outgoingLandAttacks: AttackUpdate[] = [];
|
||||
@state() private outgoingBoats: UnitView[] = [];
|
||||
@state() private incomingBoats: UnitView[] = [];
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
init() {}
|
||||
|
||||
tick() {
|
||||
this.active = true;
|
||||
|
||||
if (!this._isVisible && !this.game.inSpawnPhase()) {
|
||||
this._isVisible = true;
|
||||
}
|
||||
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer || !myPlayer.isAlive()) {
|
||||
if (this._isVisible) {
|
||||
this._isVisible = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Track incoming boat unit IDs from UnitIncoming events
|
||||
const updates = this.game.updatesSinceLastTick();
|
||||
if (updates) {
|
||||
for (const event of updates[
|
||||
GameUpdateType.UnitIncoming
|
||||
] as UnitIncomingUpdate[]) {
|
||||
if (
|
||||
event.playerID === myPlayer.smallID() &&
|
||||
event.messageType === MessageType.NAVAL_INVASION_INBOUND
|
||||
) {
|
||||
this.incomingBoatIDs.add(event.unitID);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve incoming boats from tracked IDs, remove inactive ones
|
||||
const resolvedIncomingBoats: UnitView[] = [];
|
||||
for (const unitID of this.incomingBoatIDs) {
|
||||
const unit = this.game.unit(unitID);
|
||||
if (unit && unit.isActive() && unit.type() === UnitType.TransportShip) {
|
||||
resolvedIncomingBoats.push(unit);
|
||||
} else {
|
||||
this.incomingBoatIDs.delete(unitID);
|
||||
}
|
||||
}
|
||||
this.incomingBoats = resolvedIncomingBoats;
|
||||
|
||||
this.incomingAttacks = myPlayer.incomingAttacks().filter((a) => {
|
||||
const t = (this.game.playerBySmallID(a.attackerID) as PlayerView).type();
|
||||
return t !== PlayerType.Bot;
|
||||
});
|
||||
|
||||
this.outgoingAttacks = myPlayer
|
||||
.outgoingAttacks()
|
||||
.filter((a) => a.targetID !== 0);
|
||||
|
||||
this.outgoingLandAttacks = myPlayer
|
||||
.outgoingAttacks()
|
||||
.filter((a) => a.targetID === 0);
|
||||
|
||||
this.outgoingBoats = myPlayer
|
||||
.units()
|
||||
.filter((u) => u.type() === UnitType.TransportShip);
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
shouldTransform(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
renderLayer(): void {}
|
||||
|
||||
private renderButton(options: {
|
||||
content: any;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
translate?: boolean;
|
||||
hidden?: boolean;
|
||||
}) {
|
||||
const {
|
||||
content,
|
||||
onClick,
|
||||
className = "",
|
||||
disabled = false,
|
||||
translate = true,
|
||||
hidden = false,
|
||||
} = options;
|
||||
|
||||
if (hidden) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html`
|
||||
<button
|
||||
class="${className}"
|
||||
@click=${onClick}
|
||||
?disabled=${disabled}
|
||||
?translate=${translate}
|
||||
>
|
||||
${content}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
private emitCancelAttackIntent(id: string) {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer) return;
|
||||
this.eventBus.emit(new CancelAttackIntentEvent(id));
|
||||
}
|
||||
|
||||
private emitBoatCancelIntent(id: number) {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer) return;
|
||||
this.eventBus.emit(new CancelBoatIntentEvent(id));
|
||||
}
|
||||
|
||||
private emitGoToPlayerEvent(attackerID: number) {
|
||||
const attacker = this.game.playerBySmallID(attackerID) as PlayerView;
|
||||
this.eventBus.emit(new GoToPlayerEvent(attacker));
|
||||
}
|
||||
|
||||
private getBoatSpriteDataURL(unit: UnitView): string {
|
||||
const owner = unit.owner();
|
||||
const key = `boat-${owner.id()}`;
|
||||
const cached = this.spriteDataURLCache.get(key);
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const canvas = getColoredSprite(unit, this.game.config().theme());
|
||||
const dataURL = canvas.toDataURL();
|
||||
this.spriteDataURLCache.set(key, dataURL);
|
||||
return dataURL;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private async attackWarningOnClick(attack: AttackUpdate) {
|
||||
const playerView = this.game.playerBySmallID(attack.attackerID);
|
||||
if (playerView !== undefined) {
|
||||
if (playerView instanceof PlayerView) {
|
||||
const averagePosition = await playerView.attackAveragePosition(
|
||||
attack.attackerID,
|
||||
attack.id,
|
||||
);
|
||||
|
||||
if (averagePosition === null) {
|
||||
this.emitGoToPlayerEvent(attack.attackerID);
|
||||
} else {
|
||||
this.eventBus.emit(
|
||||
new GoToPositionEvent(averagePosition.x, averagePosition.y),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.emitGoToPlayerEvent(attack.attackerID);
|
||||
}
|
||||
}
|
||||
|
||||
private handleRetaliate(attack: AttackUpdate) {
|
||||
const attacker = this.game.playerBySmallID(attack.attackerID) as PlayerView;
|
||||
if (!attacker) return;
|
||||
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer) return;
|
||||
|
||||
const counterTroops = Math.min(
|
||||
attack.troops,
|
||||
this.uiState.attackRatio * myPlayer.troops(),
|
||||
);
|
||||
this.eventBus.emit(new SendAttackIntentEvent(attacker.id(), counterTroops));
|
||||
}
|
||||
|
||||
private renderIncomingAttacks() {
|
||||
if (this.incomingAttacks.length === 0) return html``;
|
||||
|
||||
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"
|
||||
>
|
||||
${this.renderButton({
|
||||
content: html`<img
|
||||
src="${swordIcon}"
|
||||
class="h-4 w-4 inline-block"
|
||||
style="filter: brightness(0) saturate(100%) invert(27%) sepia(91%) saturate(4551%) hue-rotate(348deg) brightness(89%) contrast(97%)"
|
||||
/>
|
||||
<span class="inline-block min-w-[3rem] text-right"
|
||||
>${renderTroops(attack.troops)}</span
|
||||
>
|
||||
<span class="truncate"
|
||||
>${(
|
||||
this.game.playerBySmallID(attack.attackerID) as PlayerView
|
||||
)?.name()}</span
|
||||
>
|
||||
${attack.retreating
|
||||
? `(${translateText("events_display.retreating")}...)`
|
||||
: ""} `,
|
||||
onClick: () => this.attackWarningOnClick(attack),
|
||||
className:
|
||||
"text-left text-red-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
|
||||
translate: false,
|
||||
})}
|
||||
${!attack.retreating
|
||||
? this.renderButton({
|
||||
content: html`<img
|
||||
src="${swordIcon}"
|
||||
class="h-4 w-4"
|
||||
style="filter: brightness(0) saturate(100%) invert(27%) sepia(91%) saturate(4551%) hue-rotate(348deg) brightness(89%) contrast(97%)"
|
||||
/>`,
|
||||
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",
|
||||
translate: false,
|
||||
})
|
||||
: ""}
|
||||
</div>
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
private renderOutgoingAttacks() {
|
||||
if (this.outgoingAttacks.length === 0) return html``;
|
||||
|
||||
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"
|
||||
>
|
||||
${this.renderButton({
|
||||
content: html`<img
|
||||
src="${swordIcon}"
|
||||
class="h-4 w-4 inline-block"
|
||||
style="filter: invert(1)"
|
||||
/>
|
||||
<span class="inline-block min-w-[3rem] text-right"
|
||||
>${renderTroops(attack.troops)}</span
|
||||
>
|
||||
<span class="truncate"
|
||||
>${(
|
||||
this.game.playerBySmallID(attack.targetID) as PlayerView
|
||||
)?.name()}</span
|
||||
> `,
|
||||
onClick: async () => this.attackWarningOnClick(attack),
|
||||
className:
|
||||
"text-left text-blue-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
|
||||
translate: false,
|
||||
})}
|
||||
${!attack.retreating
|
||||
? this.renderButton({
|
||||
content: "❌",
|
||||
onClick: () => this.emitCancelAttackIntent(attack.id),
|
||||
className: "ml-auto text-left shrink-0",
|
||||
disabled: attack.retreating,
|
||||
})
|
||||
: html`<span class="ml-auto shrink-0 text-blue-400"
|
||||
>(${translateText("events_display.retreating")}...)</span
|
||||
>`}
|
||||
</div>
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
private renderOutgoingLandAttacks() {
|
||||
if (this.outgoingLandAttacks.length === 0) return html``;
|
||||
|
||||
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"
|
||||
>
|
||||
${this.renderButton({
|
||||
content: html`<img
|
||||
src="${swordIcon}"
|
||||
class="h-4 w-4 inline-block"
|
||||
style="filter: invert(1)"
|
||||
/>
|
||||
<span class="inline-block min-w-[3rem] text-right"
|
||||
>${renderTroops(landAttack.troops)}</span
|
||||
>
|
||||
${translateText("help_modal.ui_wilderness")}`,
|
||||
className:
|
||||
"text-left text-gray-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
|
||||
translate: false,
|
||||
})}
|
||||
${!landAttack.retreating
|
||||
? this.renderButton({
|
||||
content: "❌",
|
||||
onClick: () => this.emitCancelAttackIntent(landAttack.id),
|
||||
className: "ml-auto text-left shrink-0",
|
||||
disabled: landAttack.retreating,
|
||||
})
|
||||
: html`<span class="ml-auto shrink-0 text-blue-400"
|
||||
>(${translateText("events_display.retreating")}...)</span
|
||||
>`}
|
||||
</div>
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
private getBoatTargetName(boat: UnitView): string {
|
||||
const target = boat.targetTile();
|
||||
if (target === undefined) return "";
|
||||
const ownerID = this.game.ownerID(target);
|
||||
if (ownerID === 0) return "";
|
||||
const player = this.game.playerBySmallID(ownerID) as PlayerView;
|
||||
return player?.name() ?? "";
|
||||
}
|
||||
|
||||
private renderBoatIcon(boat: UnitView) {
|
||||
const dataURL = this.getBoatSpriteDataURL(boat);
|
||||
if (!dataURL) return html``;
|
||||
return html`<img
|
||||
src="${dataURL}"
|
||||
class="h-5 w-5 inline-block"
|
||||
style="image-rendering: pixelated"
|
||||
/>`;
|
||||
}
|
||||
|
||||
private renderBoats() {
|
||||
if (this.outgoingBoats.length === 0) return html``;
|
||||
|
||||
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"
|
||||
>
|
||||
${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"
|
||||
>${this.getBoatTargetName(boat)}</span
|
||||
>`,
|
||||
onClick: () => this.eventBus.emit(new GoToUnitEvent(boat)),
|
||||
className:
|
||||
"text-left text-blue-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
|
||||
translate: false,
|
||||
})}
|
||||
${!boat.retreating()
|
||||
? this.renderButton({
|
||||
content: "❌",
|
||||
onClick: () => this.emitBoatCancelIntent(boat.id()),
|
||||
className: "ml-auto text-left shrink-0",
|
||||
disabled: boat.retreating(),
|
||||
})
|
||||
: html`<span class="ml-auto shrink-0 text-blue-400"
|
||||
>(${translateText("events_display.retreating")}...)</span
|
||||
>`}
|
||||
</div>
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
private renderIncomingBoats() {
|
||||
if (this.incomingBoats.length === 0) return html``;
|
||||
|
||||
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"
|
||||
>
|
||||
${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>`,
|
||||
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",
|
||||
translate: false,
|
||||
})}
|
||||
</div>
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.active || !this._isVisible) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
const hasAnything =
|
||||
this.outgoingAttacks.length > 0 ||
|
||||
this.outgoingLandAttacks.length > 0 ||
|
||||
this.outgoingBoats.length > 0 ||
|
||||
this.incomingAttacks.length > 0 ||
|
||||
this.incomingBoats.length > 0;
|
||||
|
||||
if (!hasAnything) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
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"
|
||||
>
|
||||
${this.renderOutgoingAttacks()} ${this.renderOutgoingLandAttacks()}
|
||||
${this.renderBoats()} ${this.renderIncomingAttacks()}
|
||||
${this.renderIncomingBoats()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -261,7 +261,7 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
return html`
|
||||
<div
|
||||
class="pointer-events-auto ${this._isVisible
|
||||
? "relative z-[60] w-full max-lg:landscape:fixed max-lg:landscape:bottom-0 max-lg:landscape:left-0 max-lg:landscape:w-1/2 max-lg:landscape:z-50 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"
|
||||
? "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"
|
||||
: "hidden"}"
|
||||
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
|
||||
>
|
||||
|
||||
@@ -8,15 +8,12 @@ import {
|
||||
getMessageCategory,
|
||||
MessageCategory,
|
||||
MessageType,
|
||||
PlayerType,
|
||||
Tick,
|
||||
UnitType,
|
||||
} from "../../../core/game/Game";
|
||||
import {
|
||||
AllianceExpiredUpdate,
|
||||
AllianceRequestReplyUpdate,
|
||||
AllianceRequestUpdate,
|
||||
AttackUpdate,
|
||||
BrokeAllianceUpdate,
|
||||
DisplayChatMessageUpdate,
|
||||
DisplayMessageUpdate,
|
||||
@@ -26,22 +23,15 @@ import {
|
||||
UnitIncomingUpdate,
|
||||
} from "../../../core/game/GameUpdates";
|
||||
import {
|
||||
CancelAttackIntentEvent,
|
||||
CancelBoatIntentEvent,
|
||||
SendAllianceExtensionIntentEvent,
|
||||
SendAllianceReplyIntentEvent,
|
||||
SendAttackIntentEvent,
|
||||
} from "../../Transport";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
|
||||
import { onlyImages } from "../../../core/Util";
|
||||
import { renderNumber, renderTroops } from "../../Utils";
|
||||
import {
|
||||
GoToPlayerEvent,
|
||||
GoToPositionEvent,
|
||||
GoToUnitEvent,
|
||||
} from "./Leaderboard";
|
||||
import { renderNumber } from "../../Utils";
|
||||
import { GoToPlayerEvent, GoToUnitEvent } from "./Leaderboard";
|
||||
|
||||
import { getMessageTypeClasses, translateText } from "../../Utils";
|
||||
import { UIState } from "../UIState";
|
||||
@@ -84,10 +74,6 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
|
||||
// allianceID -> last checked at tick
|
||||
private alliancesCheckedAt = new Map<number, Tick>();
|
||||
@state() private incomingAttacks: AttackUpdate[] = [];
|
||||
@state() private outgoingAttacks: AttackUpdate[] = [];
|
||||
@state() private outgoingLandAttacks: AttackUpdate[] = [];
|
||||
@state() private outgoingBoats: UnitView[] = [];
|
||||
@state() private _hidden: boolean = false;
|
||||
@state() private _isVisible: boolean = false;
|
||||
@state() private newEvents: number = 0;
|
||||
@@ -194,9 +180,6 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
constructor() {
|
||||
super();
|
||||
this.events = [];
|
||||
this.incomingAttacks = [];
|
||||
this.outgoingAttacks = [];
|
||||
this.outgoingBoats = [];
|
||||
}
|
||||
|
||||
init() {}
|
||||
@@ -254,24 +237,6 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
// Update attacks
|
||||
this.incomingAttacks = myPlayer.incomingAttacks().filter((a) => {
|
||||
const t = (this.game.playerBySmallID(a.attackerID) as PlayerView).type();
|
||||
return t !== PlayerType.Bot;
|
||||
});
|
||||
|
||||
this.outgoingAttacks = myPlayer
|
||||
.outgoingAttacks()
|
||||
.filter((a) => a.targetID !== 0);
|
||||
|
||||
this.outgoingLandAttacks = myPlayer
|
||||
.outgoingAttacks()
|
||||
.filter((a) => a.targetID === 0);
|
||||
|
||||
this.outgoingBoats = myPlayer
|
||||
.units()
|
||||
.filter((u) => u.type() === UnitType.TransportShip);
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
@@ -664,28 +629,12 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
});
|
||||
}
|
||||
|
||||
emitCancelAttackIntent(id: string) {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer) return;
|
||||
this.eventBus.emit(new CancelAttackIntentEvent(id));
|
||||
}
|
||||
|
||||
emitBoatCancelIntent(id: number) {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer) return;
|
||||
this.eventBus.emit(new CancelBoatIntentEvent(id));
|
||||
}
|
||||
|
||||
emitGoToPlayerEvent(attackerID: number) {
|
||||
const attacker = this.game.playerBySmallID(attackerID) as PlayerView;
|
||||
if (!attacker) return;
|
||||
this.eventBus.emit(new GoToPlayerEvent(attacker));
|
||||
}
|
||||
|
||||
emitGoToPositionEvent(x: number, y: number) {
|
||||
this.eventBus.emit(new GoToPositionEvent(x, y));
|
||||
}
|
||||
|
||||
emitGoToUnitEvent(unit: UnitView) {
|
||||
this.eventBus.emit(new GoToUnitEvent(unit));
|
||||
}
|
||||
@@ -753,196 +702,6 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
: event.description;
|
||||
}
|
||||
|
||||
private async attackWarningOnClick(attack: AttackUpdate) {
|
||||
const playerView = this.game.playerBySmallID(attack.attackerID);
|
||||
if (playerView !== undefined) {
|
||||
if (playerView instanceof PlayerView) {
|
||||
const averagePosition = await playerView.attackAveragePosition(
|
||||
attack.attackerID,
|
||||
attack.id,
|
||||
);
|
||||
|
||||
if (averagePosition === null) {
|
||||
this.emitGoToPlayerEvent(attack.attackerID);
|
||||
} else {
|
||||
this.emitGoToPositionEvent(averagePosition.x, averagePosition.y);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.emitGoToPlayerEvent(attack.attackerID);
|
||||
}
|
||||
}
|
||||
|
||||
private handleRetaliate(attack: AttackUpdate) {
|
||||
const attacker = this.game.playerBySmallID(attack.attackerID) as PlayerView;
|
||||
if (!attacker) return;
|
||||
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer) return;
|
||||
|
||||
const counterTroops = Math.min(
|
||||
attack.troops,
|
||||
this.uiState.attackRatio * myPlayer.troops(),
|
||||
);
|
||||
this.eventBus.emit(new SendAttackIntentEvent(attacker.id(), counterTroops));
|
||||
}
|
||||
|
||||
private renderIncomingAttacks() {
|
||||
return html`
|
||||
${this.incomingAttacks.length > 0
|
||||
? html`
|
||||
<div class="flex flex-wrap gap-y-1 gap-x-2">
|
||||
${this.incomingAttacks.map(
|
||||
(attack) => html`
|
||||
<div class="inline-flex items-center gap-1">
|
||||
${this.renderButton({
|
||||
content: html`
|
||||
${renderTroops(attack.troops)}
|
||||
${(
|
||||
this.game.playerBySmallID(
|
||||
attack.attackerID,
|
||||
) as PlayerView
|
||||
)?.name()}
|
||||
${attack.retreating
|
||||
? `(${translateText("events_display.retreating")}...)`
|
||||
: ""}
|
||||
`,
|
||||
onClick: () => this.attackWarningOnClick(attack),
|
||||
className: "text-left text-red-400",
|
||||
translate: false,
|
||||
})}
|
||||
${!attack.retreating
|
||||
? this.renderButton({
|
||||
content: translateText("events_display.retaliate"),
|
||||
onClick: () => this.handleRetaliate(attack),
|
||||
className:
|
||||
"inline-block px-3 py-1 text-white rounded-sm text-md md:text-sm cursor-pointer transition-colors duration-300 bg-red-600 hover:bg-red-700",
|
||||
translate: true,
|
||||
})
|
||||
: ""}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderOutgoingAttacks() {
|
||||
return html`
|
||||
${this.outgoingAttacks.length > 0
|
||||
? html`
|
||||
<div class="flex flex-wrap gap-y-1 gap-x-2">
|
||||
${this.outgoingAttacks.map(
|
||||
(attack) => html`
|
||||
<div class="inline-flex items-center gap-1">
|
||||
${this.renderButton({
|
||||
content: html`
|
||||
${renderTroops(attack.troops)}
|
||||
${(
|
||||
this.game.playerBySmallID(
|
||||
attack.targetID,
|
||||
) as PlayerView
|
||||
)?.name()}
|
||||
`,
|
||||
onClick: async () => this.attackWarningOnClick(attack),
|
||||
className: "text-left text-blue-400",
|
||||
translate: false,
|
||||
})}
|
||||
${!attack.retreating
|
||||
? this.renderButton({
|
||||
content: "❌",
|
||||
onClick: () => this.emitCancelAttackIntent(attack.id),
|
||||
className: "text-left shrink-0",
|
||||
disabled: attack.retreating,
|
||||
})
|
||||
: html`<span class="shrink-0 text-blue-400"
|
||||
>(${translateText(
|
||||
"events_display.retreating",
|
||||
)}...)</span
|
||||
>`}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderOutgoingLandAttacks() {
|
||||
return html`
|
||||
${this.outgoingLandAttacks.length > 0
|
||||
? html`
|
||||
<div class="flex flex-wrap gap-y-1 gap-x-2">
|
||||
${this.outgoingLandAttacks.map(
|
||||
(landAttack) => html`
|
||||
<div class="inline-flex items-center gap-1">
|
||||
${this.renderButton({
|
||||
content: html`${renderTroops(landAttack.troops)}
|
||||
${translateText("help_modal.ui_wilderness")}`,
|
||||
className: "text-left text-gray-400",
|
||||
translate: false,
|
||||
})}
|
||||
${!landAttack.retreating
|
||||
? this.renderButton({
|
||||
content: "❌",
|
||||
onClick: () =>
|
||||
this.emitCancelAttackIntent(landAttack.id),
|
||||
className: "text-left shrink-0",
|
||||
disabled: landAttack.retreating,
|
||||
})
|
||||
: html`<span class="shrink-0 text-blue-400"
|
||||
>(${translateText(
|
||||
"events_display.retreating",
|
||||
)}...)</span
|
||||
>`}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderBoats() {
|
||||
return html`
|
||||
${this.outgoingBoats.length > 0
|
||||
? html`
|
||||
<div class="flex flex-wrap gap-y-1 gap-x-2">
|
||||
${this.outgoingBoats.map(
|
||||
(boat) => html`
|
||||
<div class="inline-flex items-center gap-1">
|
||||
${this.renderButton({
|
||||
content: html`${translateText("events_display.boat")}:
|
||||
${renderTroops(boat.troops())}`,
|
||||
onClick: () => this.emitGoToUnitEvent(boat),
|
||||
className: "text-left text-blue-400",
|
||||
translate: false,
|
||||
})}
|
||||
${!boat.retreating()
|
||||
? this.renderButton({
|
||||
content: "❌",
|
||||
onClick: () => this.emitBoatCancelIntent(boat.id()),
|
||||
className: "text-left shrink-0",
|
||||
disabled: boat.retreating(),
|
||||
})
|
||||
: html`<span class="shrink-0 text-blue-400"
|
||||
>(${translateText(
|
||||
"events_display.retreating",
|
||||
)}...)</span
|
||||
>`}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderBetrayalDebuffTimer() {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer || !myPlayer.isTraitor()) {
|
||||
@@ -1161,17 +920,6 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
</tr>
|
||||
`,
|
||||
)}
|
||||
<!--- Incoming attacks row -->
|
||||
${this.incomingAttacks.length > 0
|
||||
? html`
|
||||
<tr class="lg:px-2 lg:py-1 p-1">
|
||||
<td class="lg:px-2 lg:py-1 p-1 text-left">
|
||||
${this.renderIncomingAttacks()}
|
||||
</td>
|
||||
</tr>
|
||||
`
|
||||
: ""}
|
||||
|
||||
<!--- Betrayal debuff timer row -->
|
||||
${(() => {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
@@ -1190,45 +938,8 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
`
|
||||
: ""}
|
||||
|
||||
<!--- Outgoing attacks row -->
|
||||
${this.outgoingAttacks.length > 0
|
||||
? html`
|
||||
<tr class="lg:px-2 lg:py-1 p-1">
|
||||
<td class="lg:px-2 lg:py-1 p-1 text-left">
|
||||
${this.renderOutgoingAttacks()}
|
||||
</td>
|
||||
</tr>
|
||||
`
|
||||
: ""}
|
||||
|
||||
<!--- Outgoing land attacks row -->
|
||||
${this.outgoingLandAttacks.length > 0
|
||||
? html`
|
||||
<tr class="lg:px-2 lg:py-1 p-1">
|
||||
<td class="lg:px-2 lg:py-1 p-1 text-left">
|
||||
${this.renderOutgoingLandAttacks()}
|
||||
</td>
|
||||
</tr>
|
||||
`
|
||||
: ""}
|
||||
|
||||
<!--- Boats row -->
|
||||
${this.outgoingBoats.length > 0
|
||||
? html`
|
||||
<tr class="lg:px-2 lg:py-1 p-1">
|
||||
<td class="lg:px-2 lg:py-1 p-1 text-left">
|
||||
${this.renderBoats()}
|
||||
</td>
|
||||
</tr>
|
||||
`
|
||||
: ""}
|
||||
|
||||
<!--- Empty row when no events or attacks -->
|
||||
<!--- Empty row when no events -->
|
||||
${filteredEvents.length === 0 &&
|
||||
this.incomingAttacks.length === 0 &&
|
||||
this.outgoingAttacks.length === 0 &&
|
||||
this.outgoingLandAttacks.length === 0 &&
|
||||
this.outgoingBoats.length === 0 &&
|
||||
!(() => {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
return (
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { Theme } from "../../../core/configuration/Config";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { UnitType } from "../../../core/game/Game";
|
||||
import {
|
||||
ConquestUpdate,
|
||||
GameUpdateType,
|
||||
RailroadUpdate,
|
||||
} from "../../../core/game/GameUpdates";
|
||||
import { TileRef } from "../../../core/game/GameMap";
|
||||
import { ConquestUpdate, GameUpdateType } from "../../../core/game/GameUpdates";
|
||||
import { GameView, UnitView } from "../../../core/game/GameView";
|
||||
import SoundManager, { SoundEffect } from "../../sound/SoundManager";
|
||||
import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader";
|
||||
@@ -15,6 +13,7 @@ import { SpriteFx } from "../fx/SpriteFx";
|
||||
import { UnitExplosionFx } from "../fx/UnitExplosionFx";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { Layer } from "./Layer";
|
||||
import { RailTileChangedEvent } from "./RailroadLayer";
|
||||
export class FxLayer implements Layer {
|
||||
private canvas: HTMLCanvasElement;
|
||||
private context: CanvasRenderingContext2D;
|
||||
@@ -30,6 +29,7 @@ export class FxLayer implements Layer {
|
||||
|
||||
constructor(
|
||||
private game: GameView,
|
||||
private eventBus: EventBus,
|
||||
private transformHandler: TransformHandler,
|
||||
) {
|
||||
this.theme = this.game.config().theme();
|
||||
@@ -50,12 +50,6 @@ export class FxLayer implements Layer {
|
||||
if (unitView === undefined) return;
|
||||
this.onUnitEvent(unitView);
|
||||
});
|
||||
this.game
|
||||
.updatesSinceLastTick()
|
||||
?.[GameUpdateType.RailroadEvent]?.forEach((update) => {
|
||||
if (update === undefined) return;
|
||||
this.onRailroadEvent(update);
|
||||
});
|
||||
this.game
|
||||
.updatesSinceLastTick()
|
||||
?.[GameUpdateType.ConquestEvent]?.forEach((update) => {
|
||||
@@ -129,22 +123,19 @@ export class FxLayer implements Layer {
|
||||
}
|
||||
}
|
||||
|
||||
onRailroadEvent(railroad: RailroadUpdate) {
|
||||
const railTiles = railroad.railTiles;
|
||||
for (const rail of railTiles) {
|
||||
// No need for pseudorandom, this is fx
|
||||
const chanceFx = Math.floor(Math.random() * 3);
|
||||
if (chanceFx === 0) {
|
||||
const x = this.game.x(rail.tile);
|
||||
const y = this.game.y(rail.tile);
|
||||
const animation = new SpriteFx(
|
||||
this.animatedSpriteLoader,
|
||||
x,
|
||||
y,
|
||||
FxType.Dust,
|
||||
);
|
||||
this.allFx.push(animation);
|
||||
}
|
||||
onRailroadEvent(tile: TileRef) {
|
||||
// No need for pseudorandom, this is fx
|
||||
const chanceFx = Math.floor(Math.random() * 3);
|
||||
if (chanceFx === 0) {
|
||||
const x = this.game.x(tile);
|
||||
const y = this.game.y(tile);
|
||||
const animation = new SpriteFx(
|
||||
this.animatedSpriteLoader,
|
||||
x,
|
||||
y,
|
||||
FxType.Dust,
|
||||
);
|
||||
this.allFx.push(animation);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,6 +231,10 @@ export class FxLayer implements Layer {
|
||||
|
||||
async init() {
|
||||
this.redraw();
|
||||
|
||||
this.eventBus.on(RailTileChangedEvent, (e) => {
|
||||
this.onRailroadEvent(e.tile);
|
||||
});
|
||||
try {
|
||||
this.animatedSpriteLoader.loadAllAnimatedSpriteImages();
|
||||
console.log("FX sprites loaded successfully");
|
||||
|
||||
@@ -3,7 +3,7 @@ import { customElement } from "lit/decorators.js";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
const AD_SHOW_TICKS = 2 * 60 * 10; // 2 minutes
|
||||
const AD_SHOW_TICKS = 10 * 60 * 10; // 2 minutes
|
||||
const HEADER_AD_TYPE = "standard_iab_head1";
|
||||
const HEADER_AD_CONTAINER_ID = "header-ad-container";
|
||||
const TWO_XL_BREAKPOINT = 1536;
|
||||
@@ -72,6 +72,12 @@ export class InGameHeaderAd extends LitElement implements Layer {
|
||||
private hideHeaderAd(): void {
|
||||
this.shouldShow = false;
|
||||
this.adLoaded = false;
|
||||
try {
|
||||
window.ramp.destroyUnits(HEADER_AD_TYPE);
|
||||
console.log("successfully destroyed in game header ad");
|
||||
} catch (e) {
|
||||
console.error("error destroying in game header ad", e);
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,16 @@ import {
|
||||
} from "./RadialMenuElements";
|
||||
import backIcon from "/images/BackIconWhite.svg?url";
|
||||
|
||||
function resolveColor(
|
||||
item: MenuElement,
|
||||
params: MenuElementParams | null,
|
||||
): string | undefined {
|
||||
if (typeof item.color === "function") {
|
||||
return params ? item.color(params) : undefined;
|
||||
}
|
||||
return item.color;
|
||||
}
|
||||
|
||||
export class CloseRadialMenuEvent implements GameEvent {
|
||||
constructor() {}
|
||||
}
|
||||
@@ -322,7 +332,7 @@ export class RadialMenu implements Layer {
|
||||
const disabled = this.params === null || d.data.disabled(this.params);
|
||||
const color = disabled
|
||||
? this.config.disabledColor
|
||||
: (d.data.color ?? "#333333");
|
||||
: (resolveColor(d.data, this.params) ?? "#333333");
|
||||
const opacity = disabled ? 0.5 : 0.7;
|
||||
|
||||
if (d.data.id === this.selectedItemId && this.currentLevel > level) {
|
||||
@@ -365,7 +375,7 @@ export class RadialMenu implements Layer {
|
||||
const color =
|
||||
this.params === null || d.data.disabled(this.params)
|
||||
? this.config.disabledColor
|
||||
: (d.data.color ?? "#333333");
|
||||
: (resolveColor(d.data, this.params) ?? "#333333");
|
||||
path.attr("fill", color);
|
||||
}
|
||||
});
|
||||
@@ -431,7 +441,7 @@ export class RadialMenu implements Layer {
|
||||
path.attr("stroke-width", "2");
|
||||
const color = disabled
|
||||
? this.config.disabledColor
|
||||
: (d.data.color ?? "#333333");
|
||||
: (resolveColor(d.data, this.params) ?? "#333333");
|
||||
const opacity = disabled ? 0.5 : 0.7;
|
||||
path.attr(
|
||||
"fill",
|
||||
@@ -848,10 +858,7 @@ export class RadialMenu implements Layer {
|
||||
|
||||
public disableAllButtons() {
|
||||
this.updateCenterButtonState("default");
|
||||
|
||||
for (const item of this.currentMenuItems) {
|
||||
item.color = this.config.disabledColor;
|
||||
}
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
public updateCenterButtonState(state: CenterButtonState) {
|
||||
@@ -1043,7 +1050,7 @@ export class RadialMenu implements Layer {
|
||||
const disabled = this.isItemDisabled(item);
|
||||
const color = disabled
|
||||
? this.config.disabledColor
|
||||
: (item.color ?? "#333333");
|
||||
: (resolveColor(item, this.params) ?? "#333333");
|
||||
const opacity = disabled ? 0.5 : 0.7;
|
||||
|
||||
// Update path appearance
|
||||
|
||||
@@ -47,7 +47,7 @@ export interface MenuElement {
|
||||
id: string;
|
||||
name: string;
|
||||
displayed?: boolean | ((params: MenuElementParams) => boolean);
|
||||
color?: string;
|
||||
color?: string | ((params: MenuElementParams) => string);
|
||||
icon?: string;
|
||||
text?: string;
|
||||
fontSize?: string;
|
||||
@@ -77,6 +77,7 @@ export const COLORS = {
|
||||
boat: "#3f6ab1",
|
||||
ally: "#53ac75",
|
||||
breakAlly: "#c74848",
|
||||
breakAllyNoDebuff: "#d4882b",
|
||||
delete: "#ff0000",
|
||||
info: "#64748B",
|
||||
target: "#ff0000",
|
||||
@@ -217,7 +218,10 @@ const allyBreakElement: MenuElement = {
|
||||
!params.playerActions?.interaction?.canBreakAlliance,
|
||||
displayed: (params: MenuElementParams) =>
|
||||
!!params.playerActions?.interaction?.canBreakAlliance,
|
||||
color: COLORS.breakAlly,
|
||||
color: (params: MenuElementParams) =>
|
||||
params.selected?.isTraitor() || params.selected?.isDisconnected()
|
||||
? COLORS.breakAllyNoDebuff
|
||||
: COLORS.breakAlly,
|
||||
icon: traitorIcon,
|
||||
action: (params: MenuElementParams) => {
|
||||
params.playerActionHandler.handleBreakAlliance(
|
||||
@@ -630,6 +634,7 @@ export const rootMenuElement: MenuElement = {
|
||||
color: COLORS.info,
|
||||
subMenu: (params: MenuElementParams) => {
|
||||
const isAllied = params.selected?.isAlliedWith(params.myPlayer);
|
||||
const isDisconnected = isDisconnectedTarget(params);
|
||||
|
||||
const tileOwner = params.game.owner(params.tile);
|
||||
const isOwnTerritory =
|
||||
@@ -641,9 +646,9 @@ export const rootMenuElement: MenuElement = {
|
||||
...(isOwnTerritory
|
||||
? [deleteUnitElement, allyRequestElement, buildMenuElement]
|
||||
: [
|
||||
isAllied ? allyBreakElement : boatMenuElement,
|
||||
isAllied && !isDisconnected ? allyBreakElement : boatMenuElement,
|
||||
allyRequestElement,
|
||||
isFriendlyTarget(params) && !isDisconnectedTarget(params)
|
||||
isFriendlyTarget(params) && !isDisconnected
|
||||
? donateGoldRadialElement
|
||||
: attackMenuElement,
|
||||
]),
|
||||
|
||||
@@ -1,33 +1,49 @@
|
||||
import { colord } from "colord";
|
||||
import { Theme } from "../../../core/configuration/Config";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { PlayerID } from "../../../core/game/Game";
|
||||
import { EventBus, GameEvent } from "../../../core/EventBus";
|
||||
import { PlayerID, UnitType } from "../../../core/game/Game";
|
||||
import { TileRef } from "../../../core/game/GameMap";
|
||||
import {
|
||||
GameUpdateType,
|
||||
RailroadUpdate,
|
||||
RailTile,
|
||||
RailType,
|
||||
RailroadConstructionUpdate,
|
||||
RailroadDestructionUpdate,
|
||||
RailroadSnapUpdate,
|
||||
} from "../../../core/game/GameUpdates";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { AlternateViewEvent } from "../../InputHandler";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { UIState } from "../UIState";
|
||||
import { Layer } from "./Layer";
|
||||
import { getBridgeRects, getRailroadRects } from "./RailroadSprites";
|
||||
import {
|
||||
computeRailTiles,
|
||||
RailroadView,
|
||||
RailTile,
|
||||
RailType,
|
||||
} from "./RailroadView";
|
||||
|
||||
type RailRef = {
|
||||
tile: RailTile;
|
||||
numOccurence: number;
|
||||
lastOwnerId: PlayerID | null;
|
||||
};
|
||||
const SNAPPABLE_STRUCTURES: UnitType[] = [
|
||||
UnitType.Port,
|
||||
UnitType.City,
|
||||
UnitType.Factory,
|
||||
];
|
||||
export class RailTileChangedEvent implements GameEvent {
|
||||
constructor(public tile: TileRef) {}
|
||||
}
|
||||
|
||||
export class RailroadLayer implements Layer {
|
||||
private canvas: HTMLCanvasElement;
|
||||
private context: CanvasRenderingContext2D;
|
||||
private theme: Theme;
|
||||
private alternativeView = false;
|
||||
// Save the number of railroads per tiles. Delete when it reaches 0
|
||||
private existingRailroads = new Map<TileRef, RailRef>();
|
||||
private railroads = new Map<number, RailroadView>();
|
||||
// Railroads under construction
|
||||
private pendingRailroads = new Set<number>();
|
||||
private nextRailIndexToCheck = 0;
|
||||
private railTileList: TileRef[] = [];
|
||||
private railTileIndex = new Map<TileRef, number>();
|
||||
@@ -38,20 +54,52 @@ export class RailroadLayer implements Layer {
|
||||
private game: GameView,
|
||||
private eventBus: EventBus,
|
||||
private transformHandler: TransformHandler,
|
||||
) {
|
||||
this.theme = game.config().theme();
|
||||
}
|
||||
private uiState: UIState,
|
||||
) {}
|
||||
|
||||
shouldTransform(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
tick() {
|
||||
this.updatePendingRailroads();
|
||||
const updates = this.game.updatesSinceLastTick();
|
||||
const railUpdates =
|
||||
updates !== null ? updates[GameUpdateType.RailroadEvent] : [];
|
||||
for (const rail of railUpdates) {
|
||||
this.handleRailroadRendering(rail);
|
||||
if (!updates) return;
|
||||
// The event has to be handled in this specific order: construction / snap / destruction
|
||||
// Otherwise some ID may not be available yet/anymore
|
||||
updates[GameUpdateType.RailroadConstructionEvent]?.forEach((update) => {
|
||||
if (update === undefined) return;
|
||||
this.onRailroadConstruction(update);
|
||||
});
|
||||
updates[GameUpdateType.RailroadSnapEvent]?.forEach((update) => {
|
||||
if (update === undefined) return;
|
||||
this.onRailroadSnapEvent(update);
|
||||
});
|
||||
updates[GameUpdateType.RailroadDestructionEvent]?.forEach((update) => {
|
||||
if (update === undefined) return;
|
||||
this.onRailroadDestruction(update);
|
||||
});
|
||||
}
|
||||
|
||||
updatePendingRailroads() {
|
||||
for (const id of this.pendingRailroads) {
|
||||
const pending = this.railroads.get(id);
|
||||
if (pending === undefined) {
|
||||
// Rail deleted or snapped before the end of the animation
|
||||
this.pendingRailroads.delete(id);
|
||||
continue;
|
||||
}
|
||||
const newTiles = pending.tick();
|
||||
if (newTiles.length === 0) {
|
||||
// Animation complete
|
||||
this.pendingRailroads.delete(id);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const railTile of newTiles) {
|
||||
this.paintRailTile(railTile);
|
||||
this.eventBus.emit(new RailTileChangedEvent(railTile.tile));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +168,32 @@ export class RailroadLayer implements Layer {
|
||||
}
|
||||
}
|
||||
|
||||
private highlightOverlappingRailroads(context: CanvasRenderingContext2D) {
|
||||
if (
|
||||
this.uiState.ghostStructure === null ||
|
||||
!SNAPPABLE_STRUCTURES.includes(this.uiState.ghostStructure)
|
||||
)
|
||||
return;
|
||||
if (
|
||||
this.uiState.overlappingRailroads === undefined ||
|
||||
this.uiState.overlappingRailroads.length === 0
|
||||
)
|
||||
return;
|
||||
const offsetX = -this.game.width() / 2;
|
||||
const offsetY = -this.game.height() / 2;
|
||||
context.fillStyle = "rgba(0, 255, 0, 0.4)";
|
||||
for (const id of this.uiState.overlappingRailroads) {
|
||||
const rail = this.railroads.get(id);
|
||||
if (rail) {
|
||||
for (const railTile of rail.drawnTiles()) {
|
||||
const x = this.game.x(railTile.tile);
|
||||
const y = this.game.y(railTile.tile);
|
||||
context.fillRect(x + offsetX - 1, y + offsetY - 1, 2.5, 2.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderLayer(context: CanvasRenderingContext2D) {
|
||||
const scale = this.transformHandler.scale;
|
||||
if (scale <= 1) {
|
||||
@@ -154,6 +228,7 @@ export class RailroadLayer implements Layer {
|
||||
|
||||
context.save();
|
||||
context.globalAlpha = alpha;
|
||||
this.highlightOverlappingRailroads(context);
|
||||
context.drawImage(
|
||||
this.canvas,
|
||||
srcX,
|
||||
@@ -168,55 +243,115 @@ export class RailroadLayer implements Layer {
|
||||
context.restore();
|
||||
}
|
||||
|
||||
private handleRailroadRendering(railUpdate: RailroadUpdate) {
|
||||
for (const railRoad of railUpdate.railTiles) {
|
||||
if (railUpdate.isActive) {
|
||||
this.paintRailroad(railRoad);
|
||||
} else {
|
||||
this.clearRailroad(railRoad);
|
||||
}
|
||||
private onRailroadSnapEvent(update: RailroadSnapUpdate) {
|
||||
const original = this.railroads.get(update.originalId);
|
||||
if (!original) {
|
||||
console.warn("Could not snap railroad: ", update.originalId);
|
||||
return;
|
||||
}
|
||||
if (!original.isComplete()) {
|
||||
// The animation is not complete but we don't want to compute where the animation should resume
|
||||
// Just draw every remaining rails at once
|
||||
this.drawRemainingTiles(original);
|
||||
}
|
||||
|
||||
// No need to compute the directions here, the rails are already painted
|
||||
const directions1: RailTile[] = update.tiles1.map((tile) => ({
|
||||
tile,
|
||||
type: RailType.HORIZONTAL,
|
||||
}));
|
||||
const directions2: RailTile[] = update.tiles2.map((tile) => ({
|
||||
tile,
|
||||
type: RailType.HORIZONTAL,
|
||||
}));
|
||||
// The rails are already painted, consider them complete
|
||||
this.railroads.set(
|
||||
update.newId1,
|
||||
new RailroadView(update.newId1, directions1, true),
|
||||
);
|
||||
this.railroads.set(
|
||||
update.newId2,
|
||||
new RailroadView(update.newId2, directions2, true),
|
||||
);
|
||||
|
||||
this.railroads.delete(update.originalId);
|
||||
}
|
||||
|
||||
private paintRailroad(railRoad: RailTile) {
|
||||
const currentOwner = this.game.owner(railRoad.tile)?.id() ?? null;
|
||||
const railTile = this.existingRailroads.get(railRoad.tile);
|
||||
private drawRemainingTiles(railroad: RailroadView) {
|
||||
for (const tile of railroad.remainingTiles()) {
|
||||
this.paintRail(tile);
|
||||
}
|
||||
this.pendingRailroads.delete(railroad.id);
|
||||
}
|
||||
|
||||
if (railTile) {
|
||||
railTile.numOccurence++;
|
||||
railTile.tile = railRoad;
|
||||
railTile.lastOwnerId = currentOwner;
|
||||
private onRailroadConstruction(railUpdate: RailroadConstructionUpdate) {
|
||||
const railTiles = computeRailTiles(this.game, railUpdate.tiles);
|
||||
const rail = new RailroadView(railUpdate.id, railTiles);
|
||||
this.addRailroad(rail);
|
||||
}
|
||||
|
||||
private onRailroadDestruction(railUpdate: RailroadDestructionUpdate) {
|
||||
const railroad = this.railroads.get(railUpdate.id);
|
||||
if (!railroad) {
|
||||
console.warn("Can't remove unexisting railroad: ", railUpdate.id);
|
||||
return;
|
||||
}
|
||||
this.removeRailroad(railroad);
|
||||
}
|
||||
|
||||
private addRailroad(railroad: RailroadView) {
|
||||
this.railroads.set(railroad.id, railroad);
|
||||
this.pendingRailroads.add(railroad.id);
|
||||
}
|
||||
|
||||
private removeRailroad(railroad: RailroadView) {
|
||||
this.pendingRailroads.delete(railroad.id);
|
||||
for (const railTile of railroad.drawnTiles()) {
|
||||
this.clearRailroad(railTile.tile);
|
||||
this.eventBus.emit(new RailTileChangedEvent(railTile.tile));
|
||||
}
|
||||
this.railroads.delete(railroad.id);
|
||||
}
|
||||
|
||||
private paintRailTile(railTile: RailTile) {
|
||||
const currentOwner = this.game.owner(railTile.tile)?.id() ?? null;
|
||||
const railRef = this.existingRailroads.get(railTile.tile);
|
||||
|
||||
if (railRef) {
|
||||
railRef.numOccurence++;
|
||||
railRef.tile = railTile;
|
||||
railRef.lastOwnerId = currentOwner;
|
||||
} else {
|
||||
this.existingRailroads.set(railRoad.tile, {
|
||||
tile: railRoad,
|
||||
this.existingRailroads.set(railTile.tile, {
|
||||
tile: railTile,
|
||||
numOccurence: 1,
|
||||
lastOwnerId: currentOwner,
|
||||
});
|
||||
this.railTileIndex.set(railRoad.tile, this.railTileList.length);
|
||||
this.railTileList.push(railRoad.tile);
|
||||
this.paintRail(railRoad);
|
||||
this.railTileIndex.set(railTile.tile, this.railTileList.length);
|
||||
this.railTileList.push(railTile.tile);
|
||||
this.paintRail(railTile);
|
||||
}
|
||||
}
|
||||
|
||||
private clearRailroad(railRoad: RailTile) {
|
||||
const ref = this.existingRailroads.get(railRoad.tile);
|
||||
private clearRailroad(railroad: TileRef) {
|
||||
const ref = this.existingRailroads.get(railroad);
|
||||
if (ref) ref.numOccurence--;
|
||||
|
||||
if (!ref || ref.numOccurence <= 0) {
|
||||
this.existingRailroads.delete(railRoad.tile);
|
||||
this.removeRailTile(railRoad.tile);
|
||||
this.existingRailroads.delete(railroad);
|
||||
this.removeRailTile(railroad);
|
||||
if (this.context === undefined) throw new Error("Not initialized");
|
||||
if (this.game.isWater(railRoad.tile)) {
|
||||
if (this.game.isWater(railroad)) {
|
||||
this.context.clearRect(
|
||||
this.game.x(railRoad.tile) * 2 - 2,
|
||||
this.game.y(railRoad.tile) * 2 - 2,
|
||||
this.game.x(railroad) * 2 - 2,
|
||||
this.game.y(railroad) * 2 - 2,
|
||||
5,
|
||||
6,
|
||||
);
|
||||
} else {
|
||||
this.context.clearRect(
|
||||
this.game.x(railRoad.tile) * 2 - 1,
|
||||
this.game.y(railRoad.tile) * 2 - 1,
|
||||
this.game.x(railroad) * 2 - 1,
|
||||
this.game.y(railroad) * 2 - 1,
|
||||
3,
|
||||
3,
|
||||
);
|
||||
@@ -242,15 +377,15 @@ export class RailroadLayer implements Layer {
|
||||
}
|
||||
}
|
||||
|
||||
paintRail(railRoad: RailTile) {
|
||||
paintRail(railTile: RailTile) {
|
||||
if (this.context === undefined) throw new Error("Not initialized");
|
||||
const { tile } = railRoad;
|
||||
const { railType } = railRoad;
|
||||
const { tile } = railTile;
|
||||
const { type } = railTile;
|
||||
const x = this.game.x(tile);
|
||||
const y = this.game.y(tile);
|
||||
// If rail tile is over water, paint a bridge underlay first
|
||||
if (this.game.isWater(tile)) {
|
||||
this.paintBridge(this.context, x, y, railType);
|
||||
this.paintBridge(this.context, x, y, type);
|
||||
}
|
||||
const owner = this.game.owner(tile);
|
||||
const recipient = owner.isPlayer() ? owner : null;
|
||||
@@ -263,7 +398,7 @@ export class RailroadLayer implements Layer {
|
||||
}
|
||||
|
||||
this.context.fillStyle = color.toRgbString();
|
||||
this.paintRailRects(this.context, x, y, railType);
|
||||
this.paintRailRects(this.context, x, y, type);
|
||||
}
|
||||
|
||||
private paintRailRects(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RailType } from "../../../core/game/GameUpdates";
|
||||
import { RailType } from "./RailroadView";
|
||||
|
||||
const railTypeToFunctionMap: Record<RailType, () => number[][]> = {
|
||||
[RailType.TOP_RIGHT]: topRightRailroadCornerRects,
|
||||
@@ -40,9 +40,9 @@ function horizontalRailroadRects(): number[][] {
|
||||
function verticalRailroadRects(): number[][] {
|
||||
// x/y/w/h
|
||||
const rects = [
|
||||
[-1, -2, 1, 2],
|
||||
[1, -2, 1, 2],
|
||||
[0, -1, 1, 1],
|
||||
[-1, -1, 1, 2],
|
||||
[1, -1, 1, 2],
|
||||
[0, 0, 1, 1],
|
||||
];
|
||||
return rects;
|
||||
}
|
||||
@@ -50,9 +50,9 @@ function verticalRailroadRects(): number[][] {
|
||||
function topRightRailroadCornerRects(): number[][] {
|
||||
// x/y/w/h
|
||||
const rects = [
|
||||
[-1, -2, 1, 2],
|
||||
[-1, -1, 1, 1],
|
||||
[0, -1, 1, 2],
|
||||
[1, -2, 1, 4],
|
||||
[1, -1, 1, 3],
|
||||
];
|
||||
return rects;
|
||||
}
|
||||
@@ -60,9 +60,9 @@ function topRightRailroadCornerRects(): number[][] {
|
||||
function topLeftRailroadCornerRects(): number[][] {
|
||||
// x/y/w/h
|
||||
const rects = [
|
||||
[-1, -2, 1, 4],
|
||||
[-1, -1, 1, 3],
|
||||
[0, -1, 1, 2],
|
||||
[1, -2, 1, 2],
|
||||
[1, -1, 1, 1],
|
||||
];
|
||||
return rects;
|
||||
}
|
||||
@@ -70,9 +70,9 @@ function topLeftRailroadCornerRects(): number[][] {
|
||||
function bottomRightRailroadCornerRects(): number[][] {
|
||||
// x/y/w/h
|
||||
const rects = [
|
||||
[-1, 1, 1, 2],
|
||||
[-1, 1, 1, 1],
|
||||
[0, 0, 1, 2],
|
||||
[1, -1, 1, 4],
|
||||
[1, -1, 1, 3],
|
||||
];
|
||||
return rects;
|
||||
}
|
||||
@@ -80,9 +80,9 @@ function bottomRightRailroadCornerRects(): number[][] {
|
||||
function bottomLeftRailroadCornerRects(): number[][] {
|
||||
// x/y/w/h
|
||||
const rects = [
|
||||
[-1, -1, 1, 4],
|
||||
[-1, -1, 1, 3],
|
||||
[0, 0, 1, 2],
|
||||
[1, 1, 1, 2],
|
||||
[1, 1, 1, 1],
|
||||
];
|
||||
return rects;
|
||||
}
|
||||
@@ -109,8 +109,8 @@ function horizontalBridge(): number[][] {
|
||||
function verticalBridge(): number[][] {
|
||||
// x/y/w/h
|
||||
return [
|
||||
[-2, -2, 1, 3],
|
||||
[2, -2, 1, 3],
|
||||
[-2, -1, 1, 3],
|
||||
[2, -1, 1, 3],
|
||||
];
|
||||
}
|
||||
// ⌞
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { TileRef } from "../../../core/game/GameMap";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
|
||||
export enum RailType {
|
||||
VERTICAL,
|
||||
HORIZONTAL,
|
||||
TOP_LEFT,
|
||||
TOP_RIGHT,
|
||||
BOTTOM_LEFT,
|
||||
BOTTOM_RIGHT,
|
||||
}
|
||||
|
||||
export type RailTile = {
|
||||
tile: TileRef;
|
||||
type: RailType;
|
||||
};
|
||||
|
||||
export function computeRailTiles(game: GameView, tiles: TileRef[]): RailTile[] {
|
||||
if (tiles.length === 0) return [];
|
||||
if (tiles.length === 1) {
|
||||
return [{ tile: tiles[0], type: RailType.VERTICAL }];
|
||||
}
|
||||
const railTypes: RailTile[] = [];
|
||||
// Inverse direction computation for the first tile
|
||||
railTypes.push({
|
||||
tile: tiles[0],
|
||||
type: computeExtremityDirection(game, tiles[0], tiles[1]),
|
||||
});
|
||||
for (let i = 1; i < tiles.length - 1; i++) {
|
||||
const direction = computeDirection(
|
||||
game,
|
||||
tiles[i - 1],
|
||||
tiles[i],
|
||||
tiles[i + 1],
|
||||
);
|
||||
railTypes.push({ tile: tiles[i], type: direction });
|
||||
}
|
||||
railTypes.push({
|
||||
tile: tiles[tiles.length - 1],
|
||||
type: computeExtremityDirection(
|
||||
game,
|
||||
tiles[tiles.length - 1],
|
||||
tiles[tiles.length - 2],
|
||||
),
|
||||
});
|
||||
return railTypes;
|
||||
}
|
||||
|
||||
function computeExtremityDirection(
|
||||
game: GameView,
|
||||
tile: TileRef,
|
||||
next: TileRef,
|
||||
): RailType {
|
||||
const x = game.x(tile);
|
||||
const y = game.y(tile);
|
||||
const nextX = game.x(next);
|
||||
const nextY = game.y(next);
|
||||
|
||||
const dx = nextX - x;
|
||||
const dy = nextY - y;
|
||||
|
||||
if (dx === 0 && dy === 0) return RailType.VERTICAL; // No movement
|
||||
|
||||
if (dx === 0) {
|
||||
return RailType.VERTICAL;
|
||||
} else if (dy === 0) {
|
||||
return RailType.HORIZONTAL;
|
||||
}
|
||||
return RailType.VERTICAL;
|
||||
}
|
||||
|
||||
export function computeDirection(
|
||||
game: GameView,
|
||||
prev: TileRef,
|
||||
current: TileRef,
|
||||
next: TileRef,
|
||||
): RailType {
|
||||
const x1 = game.x(prev);
|
||||
const y1 = game.y(prev);
|
||||
const x2 = game.x(current);
|
||||
const y2 = game.y(current);
|
||||
const x3 = game.x(next);
|
||||
const y3 = game.y(next);
|
||||
|
||||
const dx1 = x2 - x1;
|
||||
const dy1 = y2 - y1;
|
||||
const dx2 = x3 - x2;
|
||||
const dy2 = y3 - y2;
|
||||
|
||||
// Straight line
|
||||
if (dx1 === dx2 && dy1 === dy2) {
|
||||
if (dx1 !== 0) return RailType.HORIZONTAL;
|
||||
if (dy1 !== 0) return RailType.VERTICAL;
|
||||
}
|
||||
|
||||
// Turn (corner) cases
|
||||
if ((dx1 === 0 && dx2 !== 0) || (dx1 !== 0 && dx2 === 0)) {
|
||||
// Now figure out which type of corner
|
||||
if (dx1 === 0 && dx2 === 1 && dy1 === -1) return RailType.BOTTOM_RIGHT;
|
||||
if (dx1 === 0 && dx2 === -1 && dy1 === -1) return RailType.BOTTOM_LEFT;
|
||||
if (dx1 === 0 && dx2 === 1 && dy1 === 1) return RailType.TOP_RIGHT;
|
||||
if (dx1 === 0 && dx2 === -1 && dy1 === 1) return RailType.TOP_LEFT;
|
||||
|
||||
if (dx1 === 1 && dx2 === 0 && dy2 === -1) return RailType.TOP_LEFT;
|
||||
if (dx1 === -1 && dx2 === 0 && dy2 === -1) return RailType.TOP_RIGHT;
|
||||
if (dx1 === 1 && dx2 === 0 && dy2 === 1) return RailType.BOTTOM_LEFT;
|
||||
if (dx1 === -1 && dx2 === 0 && dy2 === 1) return RailType.BOTTOM_RIGHT;
|
||||
}
|
||||
console.warn(`Invalid rail segment: ${dx1}:${dy1}, ${dx2}:${dy2}`);
|
||||
return RailType.VERTICAL;
|
||||
}
|
||||
|
||||
/**
|
||||
* A list of tile that can be incrementally painted each tick
|
||||
*/
|
||||
export class RailroadView {
|
||||
private headIndex: number = 0;
|
||||
private tailIndex: number;
|
||||
private increment: number = 3;
|
||||
constructor(
|
||||
public id: number,
|
||||
private railTiles: RailTile[],
|
||||
complete: boolean = false,
|
||||
) {
|
||||
// If the railroad is considered complete, no drawing or animation is required
|
||||
this.tailIndex = complete ? 0 : railTiles.length;
|
||||
}
|
||||
|
||||
isComplete(): boolean {
|
||||
return this.headIndex >= this.tailIndex;
|
||||
}
|
||||
|
||||
tiles(): RailTile[] {
|
||||
return this.railTiles;
|
||||
}
|
||||
|
||||
remainingTiles(): RailTile[] {
|
||||
if (this.isComplete()) {
|
||||
// Animation complete, no tiles need to be painted
|
||||
return [];
|
||||
}
|
||||
return this.railTiles.slice(this.headIndex, this.tailIndex);
|
||||
}
|
||||
|
||||
drawnTiles(): RailTile[] {
|
||||
if (this.isComplete()) {
|
||||
// Animation complete, every tiles have been painted
|
||||
return this.tiles();
|
||||
}
|
||||
let drawnTiles = this.railTiles.slice(0, this.headIndex);
|
||||
drawnTiles = drawnTiles.concat(this.railTiles.slice(this.tailIndex));
|
||||
return drawnTiles;
|
||||
}
|
||||
|
||||
tick(): RailTile[] {
|
||||
if (this.isComplete()) return [];
|
||||
let updatedRailTiles: RailTile[];
|
||||
// Check if remaining tiles can be done all at once
|
||||
if (this.tailIndex - this.headIndex <= 2 * this.increment) {
|
||||
updatedRailTiles = this.railTiles.slice(this.headIndex, this.tailIndex);
|
||||
} else {
|
||||
updatedRailTiles = [
|
||||
...this.railTiles.slice(
|
||||
this.headIndex,
|
||||
this.headIndex + this.increment,
|
||||
),
|
||||
...this.railTiles.slice(
|
||||
this.tailIndex - this.increment,
|
||||
this.tailIndex,
|
||||
),
|
||||
];
|
||||
}
|
||||
this.headIndex = Math.min(this.headIndex + this.increment, this.tailIndex);
|
||||
this.tailIndex = Math.max(this.tailIndex - this.increment, this.headIndex);
|
||||
return updatedRailTiles;
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,7 @@ export class SpawnVideoAd extends LitElement implements Layer {
|
||||
window.innerWidth < 768 ||
|
||||
crazyGamesSDK.isOnCrazyGames() ||
|
||||
this.game.config().gameConfig().gameType === GameType.Singleplayer ||
|
||||
getGamesPlayed() < 3 || // Don't show to new players
|
||||
getGamesPlayed() % 3 !== 0 // Only show 1 in 3 times
|
||||
getGamesPlayed() < 3 // Don't show to new players
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -333,10 +333,15 @@ export class StructureIconsLayer implements Layer {
|
||||
new OutlineFilter({ thickness: 2, color: "rgba(0, 255, 0, 1)" }),
|
||||
];
|
||||
}
|
||||
// No overlapping when a structure is upgradable
|
||||
this.uiState.overlappingRailroads = [];
|
||||
} else if (unit.canBuild === false) {
|
||||
this.ghostUnit.container.filters = [
|
||||
new OutlineFilter({ thickness: 2, color: "rgba(255, 0, 0, 1)" }),
|
||||
];
|
||||
this.uiState.overlappingRailroads = [];
|
||||
} else {
|
||||
this.uiState.overlappingRailroads = unit.overlappingRailroads;
|
||||
}
|
||||
|
||||
const scale = this.transformHandler.scale;
|
||||
@@ -450,7 +455,13 @@ export class StructureIconsLayer implements Layer {
|
||||
priceGroup: ghost.priceGroup,
|
||||
priceBox: ghost.priceBox,
|
||||
range: null,
|
||||
buildableUnit: { type, canBuild: false, canUpgrade: false, cost: 0n },
|
||||
buildableUnit: {
|
||||
type,
|
||||
canBuild: false,
|
||||
canUpgrade: false,
|
||||
cost: 0n,
|
||||
overlappingRailroads: [],
|
||||
},
|
||||
};
|
||||
const showPrice = this.game.config().userSettings().cursorCostLabel();
|
||||
this.updateGhostPrice(0, showPrice);
|
||||
|
||||
@@ -30,7 +30,6 @@ import { loadTerrainMap as loadGameMap } from "./game/TerrainMapLoader";
|
||||
import { PseudoRandom } from "./PseudoRandom";
|
||||
import { ClientID, GameStartInfo, Turn } from "./Schemas";
|
||||
import { simpleHash } from "./Util";
|
||||
import { censorNameWithClanTag } from "./validations/username";
|
||||
|
||||
export async function createGameRunner(
|
||||
gameStart: GameStartInfo,
|
||||
@@ -48,7 +47,7 @@ export async function createGameRunner(
|
||||
|
||||
const humans = gameStart.players.map((p) => {
|
||||
return new PlayerInfo(
|
||||
p.clientID === clientID ? p.username : censorNameWithClanTag(p.username),
|
||||
p.username,
|
||||
PlayerType.Human,
|
||||
p.clientID,
|
||||
random.nextID(),
|
||||
|
||||
+42
-33
@@ -148,6 +148,7 @@ const ClientInfoSchema = z.object({
|
||||
export const GameInfoSchema = z.object({
|
||||
gameID: z.string(),
|
||||
clients: z.array(ClientInfoSchema).optional(),
|
||||
lobbyCreatorClientID: z.string().optional(),
|
||||
startsAt: z.number().optional(),
|
||||
serverTime: z.number(),
|
||||
gameConfig: z.lazy(() => GameConfigSchema).optional(),
|
||||
@@ -166,7 +167,10 @@ export const PublicGamesSchema = z.object({
|
||||
});
|
||||
|
||||
export class LobbyInfoEvent implements GameEvent {
|
||||
constructor(public lobby: GameInfo) {}
|
||||
constructor(
|
||||
public lobby: GameInfo,
|
||||
public myClientID: ClientID,
|
||||
) {}
|
||||
}
|
||||
|
||||
export interface ClientInfo {
|
||||
@@ -280,140 +284,137 @@ export const QuickChatKeySchema = z.enum(
|
||||
// Intents
|
||||
//
|
||||
|
||||
const BaseIntentSchema = z.object({
|
||||
clientID: ID,
|
||||
});
|
||||
|
||||
export const AllianceExtensionIntentSchema = BaseIntentSchema.extend({
|
||||
export const AllianceExtensionIntentSchema = z.object({
|
||||
type: z.literal("allianceExtension"),
|
||||
recipient: ID,
|
||||
});
|
||||
|
||||
export const AttackIntentSchema = BaseIntentSchema.extend({
|
||||
export const AttackIntentSchema = z.object({
|
||||
type: z.literal("attack"),
|
||||
targetID: ID.nullable(),
|
||||
troops: z.number().nonnegative().nullable(),
|
||||
sourceTile: z.number().nullable().optional(),
|
||||
});
|
||||
|
||||
export const SpawnIntentSchema = BaseIntentSchema.extend({
|
||||
export const SpawnIntentSchema = z.object({
|
||||
type: z.literal("spawn"),
|
||||
tile: z.number(),
|
||||
});
|
||||
|
||||
export const BoatAttackIntentSchema = BaseIntentSchema.extend({
|
||||
export const BoatAttackIntentSchema = z.object({
|
||||
type: z.literal("boat"),
|
||||
troops: z.number().nonnegative(),
|
||||
dst: z.number(),
|
||||
});
|
||||
|
||||
export const AllianceRequestIntentSchema = BaseIntentSchema.extend({
|
||||
export const AllianceRequestIntentSchema = z.object({
|
||||
type: z.literal("allianceRequest"),
|
||||
recipient: ID,
|
||||
});
|
||||
|
||||
export const AllianceRequestReplyIntentSchema = BaseIntentSchema.extend({
|
||||
export const AllianceRequestReplyIntentSchema = z.object({
|
||||
type: z.literal("allianceRequestReply"),
|
||||
requestor: ID, // The one who made the original alliance request
|
||||
accept: z.boolean(),
|
||||
});
|
||||
|
||||
export const BreakAllianceIntentSchema = BaseIntentSchema.extend({
|
||||
export const BreakAllianceIntentSchema = z.object({
|
||||
type: z.literal("breakAlliance"),
|
||||
recipient: ID,
|
||||
});
|
||||
|
||||
export const TargetPlayerIntentSchema = BaseIntentSchema.extend({
|
||||
export const TargetPlayerIntentSchema = z.object({
|
||||
type: z.literal("targetPlayer"),
|
||||
target: ID,
|
||||
});
|
||||
|
||||
export const EmojiIntentSchema = BaseIntentSchema.extend({
|
||||
export const EmojiIntentSchema = z.object({
|
||||
type: z.literal("emoji"),
|
||||
recipient: z.union([ID, z.literal(AllPlayers)]),
|
||||
emoji: EmojiSchema,
|
||||
});
|
||||
|
||||
export const EmbargoIntentSchema = BaseIntentSchema.extend({
|
||||
export const EmbargoIntentSchema = z.object({
|
||||
type: z.literal("embargo"),
|
||||
targetID: ID,
|
||||
action: z.union([z.literal("start"), z.literal("stop")]),
|
||||
});
|
||||
|
||||
export const EmbargoAllIntentSchema = BaseIntentSchema.extend({
|
||||
export const EmbargoAllIntentSchema = z.object({
|
||||
type: z.literal("embargo_all"),
|
||||
action: z.union([z.literal("start"), z.literal("stop")]),
|
||||
});
|
||||
|
||||
export const DonateGoldIntentSchema = BaseIntentSchema.extend({
|
||||
export const DonateGoldIntentSchema = z.object({
|
||||
type: z.literal("donate_gold"),
|
||||
recipient: ID,
|
||||
gold: z.number().nonnegative().nullable(),
|
||||
});
|
||||
|
||||
export const DonateTroopIntentSchema = BaseIntentSchema.extend({
|
||||
export const DonateTroopIntentSchema = z.object({
|
||||
type: z.literal("donate_troops"),
|
||||
recipient: ID,
|
||||
troops: z.number().nonnegative().nullable(),
|
||||
});
|
||||
|
||||
export const BuildUnitIntentSchema = BaseIntentSchema.extend({
|
||||
export const BuildUnitIntentSchema = z.object({
|
||||
type: z.literal("build_unit"),
|
||||
unit: z.enum(UnitType),
|
||||
tile: z.number(),
|
||||
rocketDirectionUp: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const UpgradeStructureIntentSchema = BaseIntentSchema.extend({
|
||||
export const UpgradeStructureIntentSchema = z.object({
|
||||
type: z.literal("upgrade_structure"),
|
||||
unit: z.enum(UnitType),
|
||||
unitId: z.number(),
|
||||
});
|
||||
|
||||
export const CancelAttackIntentSchema = BaseIntentSchema.extend({
|
||||
export const CancelAttackIntentSchema = z.object({
|
||||
type: z.literal("cancel_attack"),
|
||||
attackID: z.string(),
|
||||
});
|
||||
|
||||
export const CancelBoatIntentSchema = BaseIntentSchema.extend({
|
||||
export const CancelBoatIntentSchema = z.object({
|
||||
type: z.literal("cancel_boat"),
|
||||
unitID: z.number(),
|
||||
});
|
||||
|
||||
export const MoveWarshipIntentSchema = BaseIntentSchema.extend({
|
||||
export const MoveWarshipIntentSchema = z.object({
|
||||
type: z.literal("move_warship"),
|
||||
unitId: z.number(),
|
||||
tile: z.number(),
|
||||
});
|
||||
|
||||
export const DeleteUnitIntentSchema = BaseIntentSchema.extend({
|
||||
export const DeleteUnitIntentSchema = z.object({
|
||||
type: z.literal("delete_unit"),
|
||||
unitId: z.number(),
|
||||
});
|
||||
|
||||
export const QuickChatIntentSchema = BaseIntentSchema.extend({
|
||||
export const QuickChatIntentSchema = z.object({
|
||||
type: z.literal("quick_chat"),
|
||||
recipient: ID,
|
||||
quickChatKey: QuickChatKeySchema,
|
||||
target: ID.optional(),
|
||||
});
|
||||
|
||||
export const MarkDisconnectedIntentSchema = BaseIntentSchema.extend({
|
||||
export const MarkDisconnectedIntentSchema = z.object({
|
||||
type: z.literal("mark_disconnected"),
|
||||
clientID: ID,
|
||||
isDisconnected: z.boolean(),
|
||||
});
|
||||
|
||||
export const KickPlayerIntentSchema = BaseIntentSchema.extend({
|
||||
export const KickPlayerIntentSchema = z.object({
|
||||
type: z.literal("kick_player"),
|
||||
target: ID,
|
||||
});
|
||||
|
||||
export const TogglePauseIntentSchema = BaseIntentSchema.extend({
|
||||
export const TogglePauseIntentSchema = z.object({
|
||||
type: z.literal("toggle_pause"),
|
||||
paused: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const UpdateGameConfigIntentSchema = BaseIntentSchema.extend({
|
||||
export const UpdateGameConfigIntentSchema = z.object({
|
||||
type: z.literal("update_game_config"),
|
||||
config: GameConfigSchema.partial(),
|
||||
});
|
||||
@@ -445,13 +446,17 @@ const IntentSchema = z.discriminatedUnion("type", [
|
||||
UpdateGameConfigIntentSchema,
|
||||
]);
|
||||
|
||||
// StampedIntent = Intent with server-stamped clientID (used in turns and execution)
|
||||
export const StampedIntentSchema = IntentSchema.and(z.object({ clientID: ID }));
|
||||
export type StampedIntent = Intent & { clientID: ClientID };
|
||||
|
||||
//
|
||||
// Server utility types
|
||||
//
|
||||
|
||||
export const TurnSchema = z.object({
|
||||
turnNumber: z.number(),
|
||||
intents: IntentSchema.array(),
|
||||
intents: StampedIntentSchema.array(),
|
||||
// The hash of the game state at the end of the turn.
|
||||
hash: z.number().nullable().optional(),
|
||||
});
|
||||
@@ -540,6 +545,8 @@ export const ServerStartGameMessageSchema = z.object({
|
||||
turns: TurnSchema.array(),
|
||||
gameStartInfo: GameStartInfoSchema,
|
||||
lobbyCreatedAt: z.number(),
|
||||
// The clientID assigned to this connection by the server
|
||||
myClientID: ID,
|
||||
});
|
||||
|
||||
export const ServerDesyncSchema = z.object({
|
||||
@@ -560,6 +567,8 @@ export const ServerErrorSchema = z.object({
|
||||
export const ServerLobbyInfoMessageSchema = z.object({
|
||||
type: z.literal("lobby_info"),
|
||||
lobby: GameInfoSchema,
|
||||
// The clientID assigned to this connection by the server
|
||||
myClientID: ID,
|
||||
});
|
||||
|
||||
export const ServerMessageSchema = z.discriminatedUnion("type", [
|
||||
@@ -604,10 +613,10 @@ export const ClientIntentMessageSchema = z.object({
|
||||
});
|
||||
|
||||
// WARNING: never send this message to clients.
|
||||
// Note: clientID is NOT included - server assigns it based on persistentID from token
|
||||
export const ClientJoinMessageSchema = z.object({
|
||||
type: z.literal("join"),
|
||||
clientID: ID,
|
||||
token: TokenSchema, // WARNING: PII
|
||||
token: TokenSchema, // WARNING: PII - server extracts persistentID from this
|
||||
gameID: ID,
|
||||
username: UsernameSchema,
|
||||
// Server replaces the refs with the actual cosmetic data.
|
||||
@@ -618,7 +627,7 @@ export const ClientJoinMessageSchema = z.object({
|
||||
export const ClientRejoinMessageSchema = z.object({
|
||||
type: z.literal("rejoin"),
|
||||
gameID: ID,
|
||||
clientID: ID,
|
||||
// Note: clientID is NOT sent - server looks it up from persistentID in token
|
||||
lastTurn: z.number(),
|
||||
token: TokenSchema,
|
||||
});
|
||||
|
||||
@@ -24,20 +24,27 @@ export const greenTeamColors: Colord[] = generateTeamColors(green);
|
||||
export const botTeamColors: Colord[] = [botColor];
|
||||
|
||||
function generateTeamColors(baseColor: Colord): Colord[] {
|
||||
const hsl = baseColor.toHsl();
|
||||
const lch = baseColor.toLch();
|
||||
const colorCount = 64;
|
||||
const goldenAngle = 137.508;
|
||||
|
||||
return Array.from({ length: colorCount }, (_, index) => {
|
||||
const progression = index / (colorCount - 1);
|
||||
if (index === 0) return baseColor;
|
||||
|
||||
const saturation = hsl.s * (1.0 - 0.3 * progression);
|
||||
const lightness = Math.min(100, hsl.l + progression * 30);
|
||||
// Spread hues evenly across ±12° band using golden angle within that range
|
||||
const hueShift = ((index * goldenAngle) % 24) - 12;
|
||||
const h = (lch.h + hueShift + 360) % 360;
|
||||
|
||||
return colord({
|
||||
h: hsl.h,
|
||||
s: saturation,
|
||||
l: lightness,
|
||||
});
|
||||
// Chroma oscillates ±10% around the base to add variety without washing out
|
||||
const chromaFactor = 1.0 + 0.1 * Math.sin(index * 0.7);
|
||||
const c = Math.max(10, Math.min(130, lch.c * chromaFactor));
|
||||
|
||||
// Lightness alternates above/below the base using golden angle spacing
|
||||
// Tighter range (±18) keeps teammates recognizable as the same team
|
||||
const lightOffset = 18 * Math.sin(index * goldenAngle * (Math.PI / 180));
|
||||
const l = Math.max(25, Math.min(80, lch.l + lightOffset));
|
||||
|
||||
return colord({ l, c, h });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Execution, Game } from "../game/Game";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { ClientID, GameID, Intent, Turn } from "../Schemas";
|
||||
import { ClientID, GameID, StampedIntent, Turn } from "../Schemas";
|
||||
import { simpleHash } from "../Util";
|
||||
import { AllianceExtensionExecution } from "./alliance/AllianceExtensionExecution";
|
||||
import { AllianceRequestExecution } from "./alliance/AllianceRequestExecution";
|
||||
@@ -46,7 +46,7 @@ export class Executor {
|
||||
return turn.intents.map((i) => this.createExec(i));
|
||||
}
|
||||
|
||||
createExec(intent: Intent): Execution {
|
||||
createExec(intent: StampedIntent): Execution {
|
||||
const player = this.mg.playerByClientID(intent.clientID);
|
||||
if (!player) {
|
||||
console.warn(`player with clientID ${intent.clientID} not found`);
|
||||
|
||||
@@ -2,39 +2,36 @@ import {
|
||||
Difficulty,
|
||||
Execution,
|
||||
Game,
|
||||
GameMode,
|
||||
Gold,
|
||||
Nation,
|
||||
Player,
|
||||
PlayerID,
|
||||
Relation,
|
||||
TerrainType,
|
||||
UnitType,
|
||||
} from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { GameID } from "../Schemas";
|
||||
import { assertNever, simpleHash } from "../Util";
|
||||
import { ConstructionExecution } from "./ConstructionExecution";
|
||||
import { NationAllianceBehavior } from "./nation/NationAllianceBehavior";
|
||||
import { NationEmojiBehavior } from "./nation/NationEmojiBehavior";
|
||||
import { NationMIRVBehavior } from "./nation/NationMIRVBehavior";
|
||||
import { NationNukeBehavior } from "./nation/NationNukeBehavior";
|
||||
import { randTerritoryTileArray } from "./nation/NationUtils";
|
||||
import { NationStructureBehavior } from "./nation/NationStructureBehavior";
|
||||
import { NationWarshipBehavior } from "./nation/NationWarshipBehavior";
|
||||
import { structureSpawnTileValue } from "./nation/structureSpawnTileValue";
|
||||
import { SpawnExecution } from "./SpawnExecution";
|
||||
import { AiAttackBehavior } from "./utils/AiAttackBehavior";
|
||||
|
||||
export class NationExecution implements Execution {
|
||||
private active = true;
|
||||
private random: PseudoRandom;
|
||||
private emojiBehavior: NationEmojiBehavior | null = null;
|
||||
private mirvBehavior: NationMIRVBehavior | null = null;
|
||||
private attackBehavior: AiAttackBehavior | null = null;
|
||||
private allianceBehavior: NationAllianceBehavior | null = null;
|
||||
private warshipBehavior: NationWarshipBehavior | null = null;
|
||||
private nukeBehavior: NationNukeBehavior | null = null;
|
||||
private behaviorsInitialized = false;
|
||||
private emojiBehavior!: NationEmojiBehavior;
|
||||
private mirvBehavior!: NationMIRVBehavior;
|
||||
private attackBehavior!: AiAttackBehavior;
|
||||
private allianceBehavior!: NationAllianceBehavior;
|
||||
private warshipBehavior!: NationWarshipBehavior;
|
||||
private nukeBehavior!: NationNukeBehavior;
|
||||
private structureBehavior!: NationStructureBehavior;
|
||||
private mg: Game;
|
||||
private player: Player | null = null;
|
||||
|
||||
@@ -89,7 +86,7 @@ export class NationExecution implements Execution {
|
||||
tick(ticks: number) {
|
||||
// Ship tracking
|
||||
if (
|
||||
this.warshipBehavior !== null &&
|
||||
this.behaviorsInitialized &&
|
||||
this.player !== null &&
|
||||
this.player.isAlive() &&
|
||||
this.mg.config().gameConfig().difficulty !== Difficulty.Easy
|
||||
@@ -98,6 +95,24 @@ export class NationExecution implements Execution {
|
||||
}
|
||||
|
||||
if (ticks % this.attackRate !== this.attackTick) {
|
||||
// Call handleStructures twice between regular attack ticks (at 1/3 and 2/3 of the interval)
|
||||
// Otherwise it is possible that we earn more gold than we can spend
|
||||
// The alternative is placing multiple structures in handleStructures, but that causes problems
|
||||
if (
|
||||
this.behaviorsInitialized &&
|
||||
this.player !== null &&
|
||||
this.player.isAlive()
|
||||
) {
|
||||
const offset = ticks % this.attackRate;
|
||||
const oneThird =
|
||||
(this.attackTick + Math.floor(this.attackRate / 3)) % this.attackRate;
|
||||
const twoThirds =
|
||||
(this.attackTick + Math.floor((this.attackRate * 2) / 3)) %
|
||||
this.attackRate;
|
||||
if (offset === oneThird || offset === twoThirds) {
|
||||
this.structureBehavior.handleStructures();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -133,56 +148,8 @@ export class NationExecution implements Execution {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this.emojiBehavior === null ||
|
||||
this.mirvBehavior === null ||
|
||||
this.attackBehavior === null ||
|
||||
this.allianceBehavior === null ||
|
||||
this.warshipBehavior === null ||
|
||||
this.nukeBehavior === null
|
||||
) {
|
||||
this.emojiBehavior = new NationEmojiBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
);
|
||||
this.mirvBehavior = new NationMIRVBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
this.emojiBehavior,
|
||||
);
|
||||
this.allianceBehavior = new NationAllianceBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
this.emojiBehavior,
|
||||
);
|
||||
this.warshipBehavior = new NationWarshipBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
this.emojiBehavior,
|
||||
);
|
||||
this.attackBehavior = new AiAttackBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
this.triggerRatio,
|
||||
this.reserveRatio,
|
||||
this.expandRatio,
|
||||
this.allianceBehavior,
|
||||
this.emojiBehavior,
|
||||
);
|
||||
this.nukeBehavior = new NationNukeBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
this.attackBehavior,
|
||||
this.emojiBehavior,
|
||||
);
|
||||
|
||||
// Send an attack on the first tick
|
||||
if (!this.behaviorsInitialized) {
|
||||
this.initializeBehaviors();
|
||||
this.attackBehavior.forceSendAttack(this.mg.terraNullius());
|
||||
return;
|
||||
}
|
||||
@@ -192,13 +159,65 @@ export class NationExecution implements Execution {
|
||||
this.allianceBehavior.handleAllianceRequests();
|
||||
this.allianceBehavior.handleAllianceExtensionRequests();
|
||||
this.mirvBehavior.considerMIRV();
|
||||
this.handleUnits();
|
||||
this.structureBehavior.handleStructures();
|
||||
this.warshipBehavior.maybeSpawnWarship();
|
||||
this.handleEmbargoesToHostileNations();
|
||||
this.attackBehavior.maybeAttack();
|
||||
this.warshipBehavior.counterWarshipInfestation();
|
||||
this.nukeBehavior.maybeSendNuke();
|
||||
}
|
||||
|
||||
private initializeBehaviors(): void {
|
||||
if (this.player === null) throw new Error("Player not initialized");
|
||||
|
||||
this.emojiBehavior = new NationEmojiBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
);
|
||||
this.mirvBehavior = new NationMIRVBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
this.emojiBehavior,
|
||||
);
|
||||
this.allianceBehavior = new NationAllianceBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
this.emojiBehavior,
|
||||
);
|
||||
this.warshipBehavior = new NationWarshipBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
this.emojiBehavior,
|
||||
);
|
||||
this.attackBehavior = new AiAttackBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
this.triggerRatio,
|
||||
this.reserveRatio,
|
||||
this.expandRatio,
|
||||
this.allianceBehavior,
|
||||
this.emojiBehavior,
|
||||
);
|
||||
this.nukeBehavior = new NationNukeBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
this.attackBehavior,
|
||||
this.emojiBehavior,
|
||||
);
|
||||
this.structureBehavior = new NationStructureBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
);
|
||||
this.behaviorsInitialized = true;
|
||||
}
|
||||
|
||||
private randomSpawnLand(): TileRef | null {
|
||||
if (this.nation.spawnCell === undefined) throw new Error("not initialized");
|
||||
|
||||
@@ -249,102 +268,6 @@ export class NationExecution implements Execution {
|
||||
});
|
||||
}
|
||||
|
||||
private handleUnits() {
|
||||
if (this.warshipBehavior === null) throw new Error("not initialized");
|
||||
const hasCoastalTiles = this.hasCoastalTiles();
|
||||
const isTeamGame = this.mg.config().gameConfig().gameMode === GameMode.Team;
|
||||
return (
|
||||
this.maybeSpawnStructure(UnitType.City, (num) => num) ||
|
||||
this.maybeSpawnStructure(UnitType.Port, (num) => num) ||
|
||||
this.warshipBehavior.maybeSpawnWarship() ||
|
||||
this.maybeSpawnStructure(UnitType.Factory, (num) =>
|
||||
hasCoastalTiles ? num * 3 : num,
|
||||
) ||
|
||||
this.maybeSpawnStructure(UnitType.DefensePost, (num) => (num + 2) ** 2) ||
|
||||
this.maybeSpawnStructure(UnitType.SAMLauncher, (num) =>
|
||||
isTeamGame ? num : num ** 2,
|
||||
) ||
|
||||
this.maybeSpawnStructure(UnitType.MissileSilo, (num) => num ** 2)
|
||||
);
|
||||
}
|
||||
|
||||
private hasCoastalTiles(): boolean {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
for (const tile of this.player.borderTiles()) {
|
||||
if (this.mg.isOceanShore(tile)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private maybeSpawnStructure(
|
||||
type: UnitType,
|
||||
multiplier: (num: number) => number,
|
||||
) {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
const owned = this.player.unitsOwned(type);
|
||||
const perceivedCostMultiplier = multiplier(owned + 1);
|
||||
const realCost = this.cost(type);
|
||||
const perceivedCost = realCost * BigInt(perceivedCostMultiplier);
|
||||
if (this.player.gold() < perceivedCost) {
|
||||
return false;
|
||||
}
|
||||
const tile = this.structureSpawnTile(type);
|
||||
if (tile === null) {
|
||||
return false;
|
||||
}
|
||||
const canBuild = this.player.canBuild(type, tile);
|
||||
if (canBuild === false) {
|
||||
return false;
|
||||
}
|
||||
this.mg.addExecution(new ConstructionExecution(this.player, type, tile));
|
||||
return true;
|
||||
}
|
||||
|
||||
private structureSpawnTile(type: UnitType): TileRef | null {
|
||||
if (this.mg === undefined) throw new Error("Not initialized");
|
||||
if (this.player === null) throw new Error("Not initialized");
|
||||
const tiles =
|
||||
type === UnitType.Port
|
||||
? this.randCoastalTileArray(25)
|
||||
: randTerritoryTileArray(this.random, this.mg, this.player, 25);
|
||||
if (tiles.length === 0) return null;
|
||||
const valueFunction = structureSpawnTileValue(this.mg, this.player, type);
|
||||
if (valueFunction === null) return null;
|
||||
let bestTile: TileRef | null = null;
|
||||
let bestValue = 0;
|
||||
for (const t of tiles) {
|
||||
const v = valueFunction(t);
|
||||
if (v <= bestValue && bestTile !== null) continue;
|
||||
if (!this.player.canBuild(type, t)) continue;
|
||||
// Found a better tile
|
||||
bestTile = t;
|
||||
bestValue = v;
|
||||
}
|
||||
return bestTile;
|
||||
}
|
||||
|
||||
private randCoastalTileArray(numTiles: number): TileRef[] {
|
||||
const tiles = Array.from(this.player!.borderTiles()).filter((t) =>
|
||||
this.mg.isOceanShore(t),
|
||||
);
|
||||
return Array.from(this.arraySampler(tiles, numTiles));
|
||||
}
|
||||
|
||||
private *arraySampler<T>(a: T[], sampleSize: number): Generator<T> {
|
||||
if (a.length <= sampleSize) {
|
||||
// Return all elements
|
||||
yield* a;
|
||||
} else {
|
||||
// Sample `sampleSize` elements
|
||||
const remaining = new Set<T>(a);
|
||||
while (sampleSize--) {
|
||||
const t = this.random.randFromSet(remaining);
|
||||
remaining.delete(t);
|
||||
yield t;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleEmbargoesToHostileNations() {
|
||||
const player = this.player;
|
||||
if (player === null) return;
|
||||
@@ -375,11 +298,6 @@ export class NationExecution implements Execution {
|
||||
});
|
||||
}
|
||||
|
||||
private cost(type: UnitType): Gold {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
return this.mg.unitInfo(type).cost(this.mg, this.player);
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
@@ -310,7 +310,8 @@ export class NukeExecution implements Execution {
|
||||
unit.type() !== UnitType.AtomBomb &&
|
||||
unit.type() !== UnitType.HydrogenBomb &&
|
||||
unit.type() !== UnitType.MIRVWarhead &&
|
||||
unit.type() !== UnitType.MIRV
|
||||
unit.type() !== UnitType.MIRV &&
|
||||
unit.type() !== UnitType.SAMMissile
|
||||
) {
|
||||
if (this.mg.euclideanDistSquared(this.dst, unit.tile()) < outer2) {
|
||||
unit.delete(true, this.player);
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
import { Execution, Game } from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { GameUpdateType, RailTile, RailType } from "../game/GameUpdates";
|
||||
import { Railroad } from "../game/Railroad";
|
||||
|
||||
export class RailroadExecution implements Execution {
|
||||
private mg: Game;
|
||||
private active: boolean = true;
|
||||
private headIndex: number = 0;
|
||||
private tailIndex: number = 0;
|
||||
private increment: number = 3;
|
||||
private railTiles: RailTile[] = [];
|
||||
constructor(private railRoad: Railroad) {
|
||||
this.tailIndex = railRoad.tiles.length;
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
this.mg = mg;
|
||||
const tiles = this.railRoad.tiles;
|
||||
// Inverse direction computation for the first tile
|
||||
this.railTiles.push({
|
||||
tile: tiles[0],
|
||||
railType:
|
||||
tiles.length > 0
|
||||
? this.computeExtremityDirection(tiles[0], tiles[1])
|
||||
: RailType.VERTICAL,
|
||||
});
|
||||
for (let i = 1; i < tiles.length - 1; i++) {
|
||||
const direction = this.computeDirection(
|
||||
tiles[i - 1],
|
||||
tiles[i],
|
||||
tiles[i + 1],
|
||||
);
|
||||
this.railTiles.push({ tile: tiles[i], railType: direction });
|
||||
}
|
||||
this.railTiles.push({
|
||||
tile: tiles[tiles.length - 1],
|
||||
railType:
|
||||
tiles.length > 0
|
||||
? this.computeExtremityDirection(
|
||||
tiles[tiles.length - 1],
|
||||
tiles[tiles.length - 2],
|
||||
)
|
||||
: RailType.VERTICAL,
|
||||
});
|
||||
}
|
||||
|
||||
private computeExtremityDirection(tile: TileRef, next: TileRef): RailType {
|
||||
const x = this.mg.x(tile);
|
||||
const y = this.mg.y(tile);
|
||||
const nextX = this.mg.x(next);
|
||||
const nextY = this.mg.y(next);
|
||||
|
||||
const dx = nextX - x;
|
||||
const dy = nextY - y;
|
||||
|
||||
if (dx === 0 && dy === 0) return RailType.VERTICAL; // No movement
|
||||
|
||||
if (dx === 0) {
|
||||
return RailType.VERTICAL;
|
||||
} else if (dy === 0) {
|
||||
return RailType.HORIZONTAL;
|
||||
}
|
||||
return RailType.VERTICAL;
|
||||
}
|
||||
|
||||
private computeDirection(
|
||||
prev: TileRef,
|
||||
current: TileRef,
|
||||
next: TileRef,
|
||||
): RailType {
|
||||
if (this.mg === null) {
|
||||
throw new Error("Not initialized");
|
||||
}
|
||||
const x1 = this.mg.x(prev);
|
||||
const y1 = this.mg.y(prev);
|
||||
const x2 = this.mg.x(current);
|
||||
const y2 = this.mg.y(current);
|
||||
const x3 = this.mg.x(next);
|
||||
const y3 = this.mg.y(next);
|
||||
|
||||
const dx1 = x2 - x1;
|
||||
const dy1 = y2 - y1;
|
||||
const dx2 = x3 - x2;
|
||||
const dy2 = y3 - y2;
|
||||
|
||||
// Straight line
|
||||
if (dx1 === dx2 && dy1 === dy2) {
|
||||
if (dx1 !== 0) return RailType.HORIZONTAL;
|
||||
if (dy1 !== 0) return RailType.VERTICAL;
|
||||
}
|
||||
|
||||
// Turn (corner) cases
|
||||
if ((dx1 === 0 && dx2 !== 0) || (dx1 !== 0 && dx2 === 0)) {
|
||||
// Now figure out which type of corner
|
||||
if (dx1 === 0 && dx2 === 1 && dy1 === -1) return RailType.BOTTOM_RIGHT;
|
||||
if (dx1 === 0 && dx2 === -1 && dy1 === -1) return RailType.BOTTOM_LEFT;
|
||||
if (dx1 === 0 && dx2 === 1 && dy1 === 1) return RailType.TOP_RIGHT;
|
||||
if (dx1 === 0 && dx2 === -1 && dy1 === 1) return RailType.TOP_LEFT;
|
||||
|
||||
if (dx1 === 1 && dx2 === 0 && dy2 === -1) return RailType.TOP_LEFT;
|
||||
if (dx1 === -1 && dx2 === 0 && dy2 === -1) return RailType.TOP_RIGHT;
|
||||
if (dx1 === 1 && dx2 === 0 && dy2 === 1) return RailType.BOTTOM_LEFT;
|
||||
if (dx1 === -1 && dx2 === 0 && dy2 === 1) return RailType.BOTTOM_RIGHT;
|
||||
}
|
||||
console.warn(`Invalid rail segment: ${dx1}:${dy1}, ${dx2}:${dy2}`);
|
||||
return RailType.VERTICAL;
|
||||
}
|
||||
|
||||
tick(ticks: number): void {
|
||||
if (this.mg === null) {
|
||||
throw new Error("Not initialized");
|
||||
}
|
||||
if (!this.activeSourceOrDestination()) {
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
if (this.headIndex > this.tailIndex) {
|
||||
// Construction complete
|
||||
this.constructionComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
let updatedRailTiles: RailTile[];
|
||||
// Check if remaining tiles can be done all at once
|
||||
if (this.tailIndex - this.headIndex <= 2 * this.increment) {
|
||||
updatedRailTiles = this.railTiles.slice(this.headIndex, this.tailIndex);
|
||||
this.constructionComplete();
|
||||
} else {
|
||||
updatedRailTiles = this.railTiles.slice(
|
||||
this.headIndex,
|
||||
this.headIndex + this.increment,
|
||||
);
|
||||
updatedRailTiles = updatedRailTiles.concat(
|
||||
this.railTiles.slice(this.tailIndex - this.increment, this.tailIndex),
|
||||
);
|
||||
this.headIndex += this.increment;
|
||||
this.tailIndex -= this.increment;
|
||||
}
|
||||
if (updatedRailTiles) {
|
||||
this.mg.addUpdate({
|
||||
type: GameUpdateType.RailroadEvent,
|
||||
isActive: true,
|
||||
railTiles: updatedRailTiles,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
activeDuringSpawnPhase(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
private activeSourceOrDestination(): boolean {
|
||||
return this.railRoad.from.isActive() && this.railRoad.to.isActive();
|
||||
}
|
||||
|
||||
private constructionComplete() {
|
||||
this.redrawBuildings();
|
||||
this.active = false;
|
||||
}
|
||||
|
||||
private redrawBuildings() {
|
||||
if (this.railRoad.from.unit.isActive()) this.railRoad.from.unit.touch();
|
||||
if (this.railRoad.to.unit.isActive()) this.railRoad.to.unit.touch();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,740 @@
|
||||
import {
|
||||
Difficulty,
|
||||
Game,
|
||||
Gold,
|
||||
Player,
|
||||
PlayerType,
|
||||
Relation,
|
||||
StructureTypes,
|
||||
Unit,
|
||||
UnitType,
|
||||
} from "../../game/Game";
|
||||
import { TileRef } from "../../game/GameMap";
|
||||
import { PseudoRandom } from "../../PseudoRandom";
|
||||
import { assertNever } from "../../Util";
|
||||
import { ConstructionExecution } from "../ConstructionExecution";
|
||||
import { UpgradeStructureExecution } from "../UpgradeStructureExecution";
|
||||
import { closestTile, closestTwoTiles } from "../Util";
|
||||
import { randTerritoryTileArray } from "./NationUtils";
|
||||
|
||||
/**
|
||||
* Configuration for how many structures of each type a nation should build
|
||||
* relative to the number of cities it owns.
|
||||
*/
|
||||
interface StructureRatioConfig {
|
||||
/** How many of this structure per city (e.g., 0.75 means 3 ports for every 4 cities) */
|
||||
ratioPerCity: number;
|
||||
/** Perceived cost increase percentage per owned structure (e.g., 0.1 = 10% more expensive per owned) */
|
||||
perceivedCostIncreasePerOwned: number;
|
||||
}
|
||||
|
||||
/** SAM launcher ratio per city, keyed by difficulty */
|
||||
const SAM_RATIO_BY_DIFFICULTY: Record<Difficulty, number> = {
|
||||
[Difficulty.Easy]: 0.15,
|
||||
[Difficulty.Medium]: 0.2,
|
||||
[Difficulty.Hard]: 0.25,
|
||||
[Difficulty.Impossible]: 0.3,
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns structure ratios relative to city count, adjusted by difficulty.
|
||||
* Cities are always prioritized and built first.
|
||||
* When cities are disabled, we use TILES_PER_CITY_EQUIVALENT. That's not ideal, nations won't properly upgrade structures, but it's better than nothing. Probably 99.9% of players won't disable cities anyway.
|
||||
*/
|
||||
function getStructureRatios(
|
||||
difficulty: Difficulty,
|
||||
): Partial<Record<UnitType, StructureRatioConfig>> {
|
||||
return {
|
||||
[UnitType.Port]: { ratioPerCity: 0.75, perceivedCostIncreasePerOwned: 1 },
|
||||
[UnitType.Factory]: {
|
||||
ratioPerCity: 0.75,
|
||||
perceivedCostIncreasePerOwned: 1,
|
||||
},
|
||||
[UnitType.DefensePost]: {
|
||||
ratioPerCity: 0.25,
|
||||
perceivedCostIncreasePerOwned: 1,
|
||||
},
|
||||
[UnitType.SAMLauncher]: {
|
||||
ratioPerCity: SAM_RATIO_BY_DIFFICULTY[difficulty],
|
||||
perceivedCostIncreasePerOwned: 1,
|
||||
},
|
||||
[UnitType.MissileSilo]: {
|
||||
ratioPerCity: 0.2,
|
||||
perceivedCostIncreasePerOwned: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Perceived cost increase percentage per city owned */
|
||||
const CITY_PERCEIVED_COST_INCREASE_PER_OWNED = 1;
|
||||
|
||||
/** Factory ratio multiplier when the nation has coastal tiles */
|
||||
const FACTORY_COASTAL_RATIO_MULTIPLIER = 0.33;
|
||||
|
||||
/** Maximum number of missile silos a nation will build */
|
||||
const MAX_MISSILE_SILOS = 3;
|
||||
|
||||
/** If we have more than this many structures per tiles, prefer upgrading over building */
|
||||
const UPGRADE_DENSITY_THRESHOLD = 1 / 1500;
|
||||
|
||||
/** Maximum density of defense posts (per tile owned) before no more can be built */
|
||||
const DEFENSE_POST_DENSITY_THRESHOLD = 1 / 5000;
|
||||
|
||||
/** Estimated number of tiles per city equivalent, used when cities are disabled */
|
||||
const TILES_PER_CITY_EQUIVALENT = 2000;
|
||||
|
||||
export class NationStructureBehavior {
|
||||
constructor(
|
||||
private random: PseudoRandom,
|
||||
private game: Game,
|
||||
private player: Player,
|
||||
) {}
|
||||
|
||||
handleStructures(): boolean {
|
||||
const config = this.game.config();
|
||||
const citiesDisabled = config.isUnitDisabled(UnitType.City);
|
||||
const cityCount = citiesDisabled
|
||||
? Math.max(
|
||||
1,
|
||||
Math.floor(this.player.numTilesOwned() / TILES_PER_CITY_EQUIVALENT),
|
||||
)
|
||||
: this.player.unitsOwned(UnitType.City);
|
||||
const hasCoastalTiles = this.hasCoastalTiles();
|
||||
|
||||
// Build order for non-city structures (priority order)
|
||||
const buildOrder: UnitType[] = [
|
||||
UnitType.DefensePost,
|
||||
UnitType.Port,
|
||||
UnitType.Factory,
|
||||
UnitType.SAMLauncher,
|
||||
UnitType.MissileSilo,
|
||||
];
|
||||
|
||||
const nukesEnabled =
|
||||
!config.isUnitDisabled(UnitType.AtomBomb) ||
|
||||
!config.isUnitDisabled(UnitType.HydrogenBomb) ||
|
||||
!config.isUnitDisabled(UnitType.MIRV);
|
||||
const missileSilosEnabled = !config.isUnitDisabled(UnitType.MissileSilo);
|
||||
|
||||
for (const structureType of buildOrder) {
|
||||
// Skip disabled structure types
|
||||
if (config.isUnitDisabled(structureType)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip ports if no coastal tiles
|
||||
if (structureType === UnitType.Port && !hasCoastalTiles) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip missile silos and SAM launchers if all nukes are disabled
|
||||
if (
|
||||
!nukesEnabled &&
|
||||
(structureType === UnitType.MissileSilo ||
|
||||
structureType === UnitType.SAMLauncher)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip SAM launchers if missile silos are disabled
|
||||
if (!missileSilosEnabled && structureType === UnitType.SAMLauncher) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
this.shouldBuildStructure(structureType, cityCount, hasCoastalTiles)
|
||||
) {
|
||||
if (this.maybeSpawnStructure(structureType)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!citiesDisabled && this.maybeSpawnStructure(UnitType.City)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private hasCoastalTiles(): boolean {
|
||||
for (const tile of this.player.borderTiles()) {
|
||||
if (this.game.isOceanShore(tile)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if we should build more of this structure type based on
|
||||
* the current city count and the configured ratio.
|
||||
*/
|
||||
private shouldBuildStructure(
|
||||
type: UnitType,
|
||||
cityCount: number,
|
||||
hasCoastalTiles: boolean,
|
||||
): boolean {
|
||||
const { difficulty } = this.game.config().gameConfig();
|
||||
const ratios = getStructureRatios(difficulty);
|
||||
const config = ratios[type];
|
||||
if (config === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let ratio = config.ratioPerCity;
|
||||
|
||||
// Heavily reduce factory spawning if we have coastal tiles
|
||||
if (
|
||||
type === UnitType.Factory &&
|
||||
hasCoastalTiles &&
|
||||
!this.game.config().isUnitDisabled(UnitType.Port)
|
||||
) {
|
||||
ratio *= FACTORY_COASTAL_RATIO_MULTIPLIER;
|
||||
}
|
||||
|
||||
const owned = this.player.unitsOwned(type);
|
||||
|
||||
// Hard cap on missile silos
|
||||
if (type === UnitType.MissileSilo && owned >= MAX_MISSILE_SILOS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Density cap on defense posts (can't be upgraded so a new one would be built - problematic if it's a game with high starting gold)
|
||||
if (type === UnitType.DefensePost) {
|
||||
const tilesOwned = this.player.numTilesOwned();
|
||||
if (
|
||||
tilesOwned > 0 &&
|
||||
owned / tilesOwned >= DEFENSE_POST_DENSITY_THRESHOLD
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const targetCount = Math.floor(cityCount * ratio);
|
||||
|
||||
return owned < targetCount;
|
||||
}
|
||||
|
||||
private cost(type: UnitType): Gold {
|
||||
return this.game.unitInfo(type).cost(this.game, this.player);
|
||||
}
|
||||
|
||||
private maybeSpawnStructure(type: UnitType): boolean {
|
||||
const perceivedCost = this.getPerceivedCost(type);
|
||||
if (this.player.gold() < perceivedCost) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if we should upgrade instead of building new
|
||||
const structures = this.player.units(type);
|
||||
if (
|
||||
this.getTotalStructureDensity() > UPGRADE_DENSITY_THRESHOLD &&
|
||||
type !== UnitType.DefensePost
|
||||
) {
|
||||
if (this.maybeUpgradeStructure(structures)) {
|
||||
return true;
|
||||
}
|
||||
// Density too high but couldn't upgrade (e.g. all under construction) — don't build new, wait for construction (most relevant for SAMs)
|
||||
if (structures.length > 0) {
|
||||
return false;
|
||||
}
|
||||
// No structures of this type exist yet — fall through to build the first one
|
||||
// (even if density is high - the nation is probably on a tiny island and we need to use all building spots we can find)
|
||||
}
|
||||
|
||||
const tile = this.structureSpawnTile(type);
|
||||
if (tile === null) {
|
||||
return false;
|
||||
}
|
||||
const canBuild = this.player.canBuild(type, tile);
|
||||
if (canBuild === false) {
|
||||
return false;
|
||||
}
|
||||
this.game.addExecution(new ConstructionExecution(this.player, type, tile));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the perceived cost for a structure type.
|
||||
* The perceived cost increases by a percentage for each structure of that type already owned.
|
||||
* This makes nations save up gold for nukes.
|
||||
* Once the nation can afford its target stockpile, stop inflating costs.
|
||||
*/
|
||||
private getPerceivedCost(type: UnitType): Gold {
|
||||
const realCost = this.cost(type);
|
||||
|
||||
const saveUpTarget = this.getSaveUpTarget();
|
||||
if (saveUpTarget === 0n || this.player.gold() >= saveUpTarget) {
|
||||
return realCost;
|
||||
}
|
||||
|
||||
const owned = this.player.unitsOwned(type);
|
||||
|
||||
let increasePerOwned: number;
|
||||
if (type === UnitType.City) {
|
||||
increasePerOwned = CITY_PERCEIVED_COST_INCREASE_PER_OWNED;
|
||||
} else {
|
||||
const { difficulty } = this.game.config().gameConfig();
|
||||
const ratios = getStructureRatios(difficulty);
|
||||
const config = ratios[type];
|
||||
increasePerOwned = config?.perceivedCostIncreasePerOwned ?? 0.1;
|
||||
}
|
||||
|
||||
// Each owned structure makes the next one feel more expensive
|
||||
// Formula: realCost * (1 + increasePerOwned * owned)
|
||||
const multiplier = 1 + increasePerOwned * owned;
|
||||
return BigInt(Math.ceil(Number(realCost) * multiplier));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the gold target we want to save up for based on which nukes are enabled.
|
||||
* Returns 0 if no saving is needed.
|
||||
*/
|
||||
private getSaveUpTarget(): Gold {
|
||||
const config = this.game.config();
|
||||
|
||||
// No need to save up if missile silos are disabled
|
||||
if (config.isUnitDisabled(UnitType.MissileSilo)) {
|
||||
return 0n;
|
||||
}
|
||||
|
||||
const mirvEnabled = !config.isUnitDisabled(UnitType.MIRV);
|
||||
const hydroEnabled = !config.isUnitDisabled(UnitType.HydrogenBomb);
|
||||
const atomEnabled = !config.isUnitDisabled(UnitType.AtomBomb);
|
||||
|
||||
if (mirvEnabled) {
|
||||
// Save up for MIRV + Hydrogen Bomb
|
||||
return this.cost(UnitType.MIRV) + this.cost(UnitType.HydrogenBomb);
|
||||
}
|
||||
if (hydroEnabled) {
|
||||
// Save up for 5 hydrogen bombs
|
||||
return this.cost(UnitType.HydrogenBomb) * 5n;
|
||||
}
|
||||
if (atomEnabled) {
|
||||
// Save up for 20 atom bombs
|
||||
return this.cost(UnitType.AtomBomb) * 20n;
|
||||
}
|
||||
// No nukes enabled, no need to save up
|
||||
return 0n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to upgrade an existing structure if density threshold is exceeded.
|
||||
* @param structures The pool of structures to consider for upgrading
|
||||
* @returns true if an upgrade was initiated, false otherwise
|
||||
*/
|
||||
private maybeUpgradeStructure(structures: Unit[]): boolean {
|
||||
if (this.getTotalStructureDensity() <= UPGRADE_DENSITY_THRESHOLD) {
|
||||
return false;
|
||||
}
|
||||
if (structures.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const structureToUpgrade = this.findBestStructureToUpgrade(structures);
|
||||
if (
|
||||
structureToUpgrade !== null &&
|
||||
this.player.canUpgradeUnit(structureToUpgrade)
|
||||
) {
|
||||
this.game.addExecution(
|
||||
new UpgradeStructureExecution(this.player, structureToUpgrade.id()),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates total structure density across player's territory.
|
||||
*/
|
||||
private getTotalStructureDensity(): number {
|
||||
let totalStructures = 0;
|
||||
for (const type of StructureTypes) {
|
||||
totalStructures += this.player.units(type).length; // ignoring levels
|
||||
}
|
||||
const tilesOwned = this.player.numTilesOwned();
|
||||
return tilesOwned > 0 ? totalStructures / tilesOwned : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the best structure to upgrade, preferring structures protected by a SAM.
|
||||
* In 50% of cases, picks the second or third best to add variety.
|
||||
*/
|
||||
private findBestStructureToUpgrade(structures: Unit[]): Unit | null {
|
||||
if (structures.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Filter to only upgradable structures
|
||||
const upgradable = structures.filter((s) => this.player.canUpgradeUnit(s));
|
||||
if (upgradable.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Based on difficulty, chance to just pick a random structure
|
||||
const { difficulty } = this.game.config().gameConfig();
|
||||
let randomChance: number;
|
||||
switch (difficulty) {
|
||||
case Difficulty.Easy:
|
||||
randomChance = 70;
|
||||
break;
|
||||
case Difficulty.Medium:
|
||||
randomChance = 40;
|
||||
break;
|
||||
case Difficulty.Hard:
|
||||
randomChance = 25;
|
||||
break;
|
||||
case Difficulty.Impossible:
|
||||
randomChance = 10;
|
||||
break;
|
||||
default:
|
||||
assertNever(difficulty);
|
||||
}
|
||||
|
||||
if (this.random.nextInt(0, 100) < randomChance) {
|
||||
return this.random.randElement(upgradable);
|
||||
}
|
||||
|
||||
const samLaunchers = this.player.units(UnitType.SAMLauncher);
|
||||
|
||||
// Score each structure based on SAM protection
|
||||
const scored: { structure: Unit; score: number }[] = [];
|
||||
|
||||
for (const structure of upgradable) {
|
||||
let score = 0;
|
||||
|
||||
// Check if protected by any SAM, using per-SAM level-based range
|
||||
for (const sam of samLaunchers) {
|
||||
const samRange = this.game.config().samRange(sam.level());
|
||||
const samRangeSquared = samRange * samRange;
|
||||
const distSquared = this.game.euclideanDistSquared(
|
||||
structure.tile(),
|
||||
sam.tile(),
|
||||
);
|
||||
if (distSquared <= samRangeSquared) {
|
||||
// Protected by this SAM, add score based on SAM level
|
||||
score += 10;
|
||||
if (sam.level() > 1) {
|
||||
score += (sam.level() - 1) * 7.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add small random factor to break ties
|
||||
score += this.random.nextInt(0, 5);
|
||||
|
||||
scored.push({ structure, score });
|
||||
}
|
||||
|
||||
if (scored.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort descending by score
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
|
||||
// 50% of the time, pick the second or third best for variety
|
||||
if (scored.length >= 2 && this.random.chance(2)) {
|
||||
const pickIndex =
|
||||
scored.length >= 3
|
||||
? this.random.nextInt(1, 3) // pick index 1 or 2
|
||||
: 1; // only index 1 available
|
||||
return scored[pickIndex].structure;
|
||||
}
|
||||
|
||||
return scored[0].structure;
|
||||
}
|
||||
|
||||
private structureSpawnTile(type: UnitType): TileRef | null {
|
||||
const tiles =
|
||||
type === UnitType.Port
|
||||
? this.randCoastalTileArray(25)
|
||||
: randTerritoryTileArray(this.random, this.game, this.player, 25);
|
||||
if (tiles.length === 0) return null;
|
||||
const valueFunction = this.structureSpawnTileValue(type);
|
||||
if (valueFunction === null) return null;
|
||||
let bestTile: TileRef | null = null;
|
||||
let bestValue = 0;
|
||||
for (const t of tiles) {
|
||||
const v = valueFunction(t);
|
||||
if (v <= bestValue && bestTile !== null) continue;
|
||||
if (!this.player.canBuild(type, t)) continue;
|
||||
// Found a better tile
|
||||
bestTile = t;
|
||||
bestValue = v;
|
||||
}
|
||||
return bestTile;
|
||||
}
|
||||
|
||||
private randCoastalTileArray(numTiles: number): TileRef[] {
|
||||
const tiles = Array.from(this.player.borderTiles()).filter((t) =>
|
||||
this.game.isOceanShore(t),
|
||||
);
|
||||
return Array.from(this.arraySampler(tiles, numTiles));
|
||||
}
|
||||
|
||||
private *arraySampler<T>(a: T[], sampleSize: number): Generator<T> {
|
||||
if (a.length <= sampleSize) {
|
||||
// Return all elements
|
||||
yield* a;
|
||||
} else {
|
||||
// Sample `sampleSize` elements
|
||||
const remaining = new Set<T>(a);
|
||||
while (sampleSize--) {
|
||||
const t = this.random.randFromSet(remaining);
|
||||
remaining.delete(t);
|
||||
yield t;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private structureSpawnTileValue(
|
||||
type: UnitType,
|
||||
): ((tile: TileRef) => number) | null {
|
||||
switch (type) {
|
||||
case UnitType.City:
|
||||
case UnitType.Factory:
|
||||
case UnitType.MissileSilo:
|
||||
return this.interiorStructureValue(type);
|
||||
case UnitType.Port:
|
||||
return this.portValue();
|
||||
case UnitType.DefensePost:
|
||||
return this.defensePostValue();
|
||||
case UnitType.SAMLauncher:
|
||||
return this.samLauncherValue();
|
||||
default:
|
||||
throw new Error(`Value function not implemented for ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Value function for interior structures (City, Factory, MissileSilo).
|
||||
* Prefers high elevation, distance from border, and spacing from same-type structures.
|
||||
*/
|
||||
private interiorStructureValue(type: UnitType): (tile: TileRef) => number {
|
||||
const game = this.game;
|
||||
const borderTiles = this.player.borderTiles();
|
||||
const otherUnits = this.player.units(type);
|
||||
const { borderSpacing, structureSpacing } = this.spacingConstants();
|
||||
|
||||
return (tile) => {
|
||||
let w = 0;
|
||||
|
||||
// Prefer higher elevations
|
||||
w += game.magnitude(tile);
|
||||
|
||||
// Prefer to be away from the border
|
||||
const [, closestBorderDist] = closestTile(game, borderTiles, tile);
|
||||
w += Math.min(closestBorderDist, borderSpacing);
|
||||
|
||||
// Prefer to be away from other structures of the same type
|
||||
const otherTiles: Set<TileRef> = new Set(otherUnits.map((u) => u.tile()));
|
||||
otherTiles.delete(tile);
|
||||
const closestOther = closestTwoTiles(game, otherTiles, [tile]);
|
||||
if (closestOther !== null) {
|
||||
const d = game.manhattanDist(closestOther.x, tile);
|
||||
w += Math.min(d, structureSpacing);
|
||||
}
|
||||
|
||||
return w;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Value function for ports.
|
||||
* Prefers spacing from other ports.
|
||||
*/
|
||||
private portValue(): (tile: TileRef) => number {
|
||||
const game = this.game;
|
||||
const otherUnits = this.player.units(UnitType.Port);
|
||||
const { structureSpacing } = this.spacingConstants();
|
||||
|
||||
return (tile) => {
|
||||
let w = 0;
|
||||
|
||||
// Prefer to be away from other structures of the same type
|
||||
const otherTiles: Set<TileRef> = new Set(otherUnits.map((u) => u.tile()));
|
||||
otherTiles.delete(tile);
|
||||
const [, closestOtherDist] = closestTile(game, otherTiles, tile);
|
||||
w += Math.min(closestOtherDist, structureSpacing);
|
||||
|
||||
return w;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Value function for defense posts.
|
||||
* Returns null if there are no hostile non-bot neighbors.
|
||||
* Prefers elevation, proximity to border with hostile neighbors, and spacing.
|
||||
*/
|
||||
private defensePostValue(): ((tile: TileRef) => number) | null {
|
||||
const game = this.game;
|
||||
const player = this.player;
|
||||
const borderTiles = player.borderTiles();
|
||||
const otherUnits = player.units(UnitType.DefensePost);
|
||||
const { borderSpacing, structureSpacing } = this.spacingConstants();
|
||||
|
||||
// Check if we have any non-friendly non-bot neighbors with more troops
|
||||
const hasHostileNeighbor =
|
||||
player
|
||||
.neighbors()
|
||||
.filter(
|
||||
(n): n is Player =>
|
||||
n.isPlayer() &&
|
||||
player.isFriendly(n) === false &&
|
||||
n.type() !== PlayerType.Bot &&
|
||||
n.troops() > player.troops(),
|
||||
).length > 0;
|
||||
|
||||
// Don't build defense posts if there is no danger
|
||||
if (!hasHostileNeighbor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (tile) => {
|
||||
let w = 0;
|
||||
|
||||
// Prefer higher elevations
|
||||
w += game.magnitude(tile);
|
||||
|
||||
const [closest, closestBorderDist] = closestTile(game, borderTiles, tile);
|
||||
if (closest !== null) {
|
||||
// Prefer to be borderSpacing tiles from the border
|
||||
w += Math.max(
|
||||
0,
|
||||
borderSpacing - Math.abs(borderSpacing - closestBorderDist),
|
||||
);
|
||||
|
||||
// Prefer adjacent players who are hostile and have more troops
|
||||
const neighbors: Set<Player> = new Set();
|
||||
for (const neighborTile of game.neighbors(closest)) {
|
||||
if (!game.isLand(neighborTile)) continue;
|
||||
const id = game.ownerID(neighborTile);
|
||||
if (id === player.smallID()) continue;
|
||||
const neighbor = game.playerBySmallID(id);
|
||||
if (!neighbor.isPlayer()) continue;
|
||||
if (neighbor.type() === PlayerType.Bot) continue;
|
||||
if (neighbor.troops() <= player.troops()) continue;
|
||||
neighbors.add(neighbor);
|
||||
}
|
||||
for (const neighbor of neighbors) {
|
||||
w += borderSpacing * (Relation.Friendly - player.relation(neighbor));
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer to be away from other structures of the same type
|
||||
const otherTiles: Set<TileRef> = new Set(otherUnits.map((u) => u.tile()));
|
||||
otherTiles.delete(tile);
|
||||
const closestOther = closestTwoTiles(game, otherTiles, [tile]);
|
||||
if (closestOther !== null) {
|
||||
const d = game.manhattanDist(closestOther.x, tile);
|
||||
w += Math.min(d, structureSpacing);
|
||||
}
|
||||
|
||||
return w;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Value function for SAM launchers.
|
||||
* Prefers elevation, distance from border, spacing, and proximity to protectable structures.
|
||||
* On harder difficulties, weights by structure level and considers existing SAM coverage.
|
||||
*/
|
||||
private samLauncherValue(): (tile: TileRef) => number {
|
||||
const game = this.game;
|
||||
const player = this.player;
|
||||
const borderTiles = player.borderTiles();
|
||||
const otherUnits = player.units(UnitType.SAMLauncher);
|
||||
const { borderSpacing, structureSpacing } = this.spacingConstants();
|
||||
|
||||
const { difficulty } = game.config().gameConfig();
|
||||
const weightByLevel =
|
||||
difficulty === Difficulty.Hard || difficulty === Difficulty.Impossible;
|
||||
|
||||
const protectEntries: { tile: TileRef; weight: number }[] = [];
|
||||
for (const unit of player.units()) {
|
||||
switch (unit.type()) {
|
||||
case UnitType.City:
|
||||
case UnitType.Factory:
|
||||
case UnitType.MissileSilo:
|
||||
case UnitType.Port:
|
||||
protectEntries.push({
|
||||
tile: unit.tile(),
|
||||
weight: weightByLevel ? unit.level() : 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
const range = game.config().defaultSamRange();
|
||||
const rangeSquared = range * range;
|
||||
|
||||
const useCoverageWeighting =
|
||||
difficulty !== Difficulty.Easy && this.random.nextInt(0, 100) < 25;
|
||||
|
||||
// Pre-compute existing SAM coverage for each protectable structure
|
||||
let structureCoverage: Map<TileRef, number> | null = null;
|
||||
if (useCoverageWeighting) {
|
||||
structureCoverage = new Map<TileRef, number>();
|
||||
const existingSams = player.units(UnitType.SAMLauncher);
|
||||
for (const entry of protectEntries) {
|
||||
let coverageScore = 0;
|
||||
for (const sam of existingSams) {
|
||||
const samRange = game.config().samRange(sam.level());
|
||||
const dist = game.euclideanDistSquared(entry.tile, sam.tile());
|
||||
if (dist <= samRange * samRange) {
|
||||
coverageScore += sam.level();
|
||||
}
|
||||
}
|
||||
structureCoverage.set(entry.tile, coverageScore);
|
||||
}
|
||||
}
|
||||
|
||||
return (tile) => {
|
||||
let w = 0;
|
||||
|
||||
// Prefer higher elevations
|
||||
w += game.magnitude(tile);
|
||||
|
||||
// Prefer to be away from the border
|
||||
const closestBorder = closestTwoTiles(game, borderTiles, [tile]);
|
||||
if (closestBorder !== null) {
|
||||
const d = game.manhattanDist(closestBorder.x, tile);
|
||||
w += Math.min(d, borderSpacing);
|
||||
}
|
||||
|
||||
// Prefer to be away from other structures of the same type
|
||||
const otherTiles: Set<TileRef> = new Set(otherUnits.map((u) => u.tile()));
|
||||
otherTiles.delete(tile);
|
||||
const closestOther = closestTwoTiles(game, otherTiles, [tile]);
|
||||
if (closestOther !== null) {
|
||||
const d = game.manhattanDist(closestOther.x, tile);
|
||||
w += Math.min(d, structureSpacing);
|
||||
}
|
||||
|
||||
// Prefer to be in range of other structures (skip on easy difficulty)
|
||||
if (difficulty !== Difficulty.Easy) {
|
||||
for (const entry of protectEntries) {
|
||||
const distanceSquared = game.euclideanDistSquared(tile, entry.tile);
|
||||
if (distanceSquared > rangeSquared) continue;
|
||||
if (useCoverageWeighting && structureCoverage !== null) {
|
||||
const coverage = structureCoverage.get(entry.tile) ?? 0;
|
||||
const coverageWeight = 1 / (1 + coverage);
|
||||
w += structureSpacing * entry.weight * coverageWeight;
|
||||
} else {
|
||||
w += structureSpacing * entry.weight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return w;
|
||||
};
|
||||
}
|
||||
|
||||
/** Shared spacing constants derived from atom bomb range. */
|
||||
private spacingConstants(): {
|
||||
borderSpacing: number;
|
||||
structureSpacing: number;
|
||||
} {
|
||||
const borderSpacing = this.game
|
||||
.config()
|
||||
.nukeMagnitudes(UnitType.AtomBomb).outer;
|
||||
return { borderSpacing, structureSpacing: borderSpacing * 2 };
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
import { Game, Player, PlayerType, Relation, UnitType } from "../../game/Game";
|
||||
import { TileRef } from "../../game/GameMap";
|
||||
import { closestTile, closestTwoTiles } from "../Util";
|
||||
|
||||
export function structureSpawnTileValue(
|
||||
mg: Game,
|
||||
player: Player,
|
||||
type: UnitType,
|
||||
): ((tile: TileRef) => number) | null {
|
||||
const borderTiles = player.borderTiles();
|
||||
const otherUnits = player.units(type);
|
||||
// Prefer spacing structures out of atom bomb range
|
||||
const borderSpacing = mg.config().nukeMagnitudes(UnitType.AtomBomb).outer;
|
||||
const structureSpacing = borderSpacing * 2;
|
||||
switch (type) {
|
||||
case UnitType.City:
|
||||
case UnitType.Factory:
|
||||
case UnitType.MissileSilo: {
|
||||
return (tile) => {
|
||||
let w = 0;
|
||||
|
||||
// Prefer higher elevations
|
||||
w += mg.magnitude(tile);
|
||||
|
||||
// Prefer to be away from the border
|
||||
const [, closestBorderDist] = closestTile(mg, borderTiles, tile);
|
||||
w += Math.min(closestBorderDist, borderSpacing);
|
||||
|
||||
// Prefer to be away from other structures of the same type
|
||||
const otherTiles: Set<TileRef> = new Set(
|
||||
otherUnits.map((u) => u.tile()),
|
||||
);
|
||||
otherTiles.delete(tile);
|
||||
const closestOther = closestTwoTiles(mg, otherTiles, [tile]);
|
||||
if (closestOther !== null) {
|
||||
const d = mg.manhattanDist(closestOther.x, tile);
|
||||
w += Math.min(d, structureSpacing);
|
||||
}
|
||||
|
||||
// TODO: Cities and factories should consider train range limits
|
||||
return w;
|
||||
};
|
||||
}
|
||||
case UnitType.Port: {
|
||||
return (tile) => {
|
||||
let w = 0;
|
||||
|
||||
// Prefer to be away from other structures of the same type
|
||||
const otherTiles: Set<TileRef> = new Set(
|
||||
otherUnits.map((u) => u.tile()),
|
||||
);
|
||||
otherTiles.delete(tile);
|
||||
const [, closestOtherDist] = closestTile(mg, otherTiles, tile);
|
||||
w += Math.min(closestOtherDist, structureSpacing);
|
||||
|
||||
return w;
|
||||
};
|
||||
}
|
||||
case UnitType.DefensePost: {
|
||||
// Check if we have any non-friendly non-bot neighbors
|
||||
const hasHostileNeighbor =
|
||||
player
|
||||
.neighbors()
|
||||
.filter(
|
||||
(n): n is Player =>
|
||||
n.isPlayer() &&
|
||||
player.isFriendly(n) === false &&
|
||||
n.type() !== PlayerType.Bot,
|
||||
).length > 0;
|
||||
|
||||
// Don't build defense posts if there is no danger
|
||||
if (!hasHostileNeighbor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (tile) => {
|
||||
let w = 0;
|
||||
|
||||
// Prefer higher elevations
|
||||
w += mg.magnitude(tile);
|
||||
|
||||
const [closest, closestBorderDist] = closestTile(mg, borderTiles, tile);
|
||||
if (closest !== null) {
|
||||
// Prefer to be borderSpacing tiles from the border
|
||||
w += Math.max(
|
||||
0,
|
||||
borderSpacing - Math.abs(borderSpacing - closestBorderDist),
|
||||
);
|
||||
|
||||
// Prefer adjacent players who are hostile
|
||||
const neighbors: Set<Player> = new Set();
|
||||
for (const tile of mg.neighbors(closest)) {
|
||||
if (!mg.isLand(tile)) continue;
|
||||
const id = mg.ownerID(tile);
|
||||
if (id === player.smallID()) continue;
|
||||
const neighbor = mg.playerBySmallID(id);
|
||||
if (!neighbor.isPlayer()) continue;
|
||||
if (neighbor.type() === PlayerType.Bot) continue;
|
||||
neighbors.add(neighbor);
|
||||
}
|
||||
for (const neighbor of neighbors) {
|
||||
w +=
|
||||
borderSpacing * (Relation.Friendly - player.relation(neighbor));
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer to be away from other structures of the same type
|
||||
const otherTiles: Set<TileRef> = new Set(
|
||||
otherUnits.map((u) => u.tile()),
|
||||
);
|
||||
otherTiles.delete(tile);
|
||||
const closestOther = closestTwoTiles(mg, otherTiles, [tile]);
|
||||
if (closestOther !== null) {
|
||||
const d = mg.manhattanDist(closestOther.x, tile);
|
||||
w += Math.min(d, structureSpacing);
|
||||
}
|
||||
|
||||
return w;
|
||||
};
|
||||
}
|
||||
case UnitType.SAMLauncher: {
|
||||
const protectTiles: Set<TileRef> = new Set();
|
||||
for (const unit of player.units()) {
|
||||
switch (unit.type()) {
|
||||
case UnitType.City:
|
||||
case UnitType.Factory:
|
||||
case UnitType.MissileSilo:
|
||||
case UnitType.Port:
|
||||
protectTiles.add(unit.tile());
|
||||
}
|
||||
}
|
||||
const range = mg.config().defaultSamRange();
|
||||
const rangeSquared = range * range;
|
||||
return (tile) => {
|
||||
let w = 0;
|
||||
|
||||
// Prefer higher elevations
|
||||
w += mg.magnitude(tile);
|
||||
|
||||
// Prefer to be away from the border
|
||||
const closestBorder = closestTwoTiles(mg, borderTiles, [tile]);
|
||||
if (closestBorder !== null) {
|
||||
const d = mg.manhattanDist(closestBorder.x, tile);
|
||||
w += Math.min(d, borderSpacing);
|
||||
}
|
||||
|
||||
// Prefer to be away from other structures of the same type
|
||||
const otherTiles: Set<TileRef> = new Set(
|
||||
otherUnits.map((u) => u.tile()),
|
||||
);
|
||||
otherTiles.delete(tile);
|
||||
const closestOther = closestTwoTiles(mg, otherTiles, [tile]);
|
||||
if (closestOther !== null) {
|
||||
const d = mg.manhattanDist(closestOther.x, tile);
|
||||
w += Math.min(d, structureSpacing);
|
||||
}
|
||||
|
||||
// Prefer to be in range of other structures
|
||||
for (const maybeProtected of protectTiles) {
|
||||
const distanceSquared = mg.euclideanDistSquared(tile, maybeProtected);
|
||||
if (distanceSquared > rangeSquared) continue;
|
||||
w += structureSpacing;
|
||||
}
|
||||
|
||||
return w;
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new Error(`Value function not implemented for ${type}`);
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,7 @@ export enum GameMapType {
|
||||
DidierFrance = "Didier France",
|
||||
AmazonRiver = "Amazon River",
|
||||
Yenisei = "Yenisei",
|
||||
TradersDream = "Traders Dream",
|
||||
}
|
||||
|
||||
export type GameMapName = keyof typeof GameMapType;
|
||||
@@ -178,6 +179,7 @@ export const mapCategories: Record<string, GameMapType[]> = {
|
||||
GameMapType.FourIslands,
|
||||
GameMapType.Svalmel,
|
||||
GameMapType.Surrounded,
|
||||
GameMapType.TradersDream,
|
||||
],
|
||||
arcade: [
|
||||
GameMapType.TheBox,
|
||||
@@ -845,6 +847,7 @@ export interface BuildableUnit {
|
||||
canUpgrade: number | false;
|
||||
type: UnitType;
|
||||
cost: Gold;
|
||||
overlappingRailroads: number[];
|
||||
}
|
||||
|
||||
export interface PlayerProfile {
|
||||
|
||||
@@ -44,7 +44,9 @@ export enum GameUpdateType {
|
||||
Hash,
|
||||
UnitIncoming,
|
||||
BonusEvent,
|
||||
RailroadEvent,
|
||||
RailroadDestructionEvent,
|
||||
RailroadConstructionEvent,
|
||||
RailroadSnapEvent,
|
||||
ConquestEvent,
|
||||
EmbargoEvent,
|
||||
GamePaused,
|
||||
@@ -67,7 +69,9 @@ export type GameUpdate =
|
||||
| UnitIncomingUpdate
|
||||
| AllianceExtensionUpdate
|
||||
| BonusEventUpdate
|
||||
| RailroadUpdate
|
||||
| RailroadConstructionUpdate
|
||||
| RailroadDestructionUpdate
|
||||
| RailroadSnapUpdate
|
||||
| ConquestUpdate
|
||||
| EmbargoUpdate
|
||||
| GamePausedUpdate;
|
||||
@@ -80,24 +84,24 @@ export interface BonusEventUpdate {
|
||||
troops: number;
|
||||
}
|
||||
|
||||
export enum RailType {
|
||||
VERTICAL,
|
||||
HORIZONTAL,
|
||||
TOP_LEFT,
|
||||
TOP_RIGHT,
|
||||
BOTTOM_LEFT,
|
||||
BOTTOM_RIGHT,
|
||||
export interface RailroadConstructionUpdate {
|
||||
type: GameUpdateType.RailroadConstructionEvent;
|
||||
id: number;
|
||||
tiles: TileRef[];
|
||||
}
|
||||
|
||||
export interface RailTile {
|
||||
tile: TileRef;
|
||||
railType: RailType;
|
||||
export interface RailroadDestructionUpdate {
|
||||
type: GameUpdateType.RailroadDestructionEvent;
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface RailroadUpdate {
|
||||
type: GameUpdateType.RailroadEvent;
|
||||
isActive: boolean;
|
||||
railTiles: RailTile[];
|
||||
export interface RailroadSnapUpdate {
|
||||
type: GameUpdateType.RailroadSnapEvent;
|
||||
originalId: number;
|
||||
newId1: number;
|
||||
newId2: number;
|
||||
tiles1: TileRef[];
|
||||
tiles2: TileRef[];
|
||||
}
|
||||
|
||||
export interface ConquestUpdate {
|
||||
|
||||
@@ -603,12 +603,20 @@ export class GameView implements GameMap {
|
||||
private _config: Config,
|
||||
private _mapData: TerrainMapData,
|
||||
private _myClientID: ClientID,
|
||||
private _myUsername: string,
|
||||
private _gameID: GameID,
|
||||
private humans: Player[],
|
||||
) {
|
||||
this._map = this._mapData.gameMap;
|
||||
this.lastUpdate = null;
|
||||
this.unitGrid = new UnitGrid(this._map);
|
||||
// Replace the local player's username with their own stored username.
|
||||
// This way the user does not know they are being censored.
|
||||
for (const h of this.humans) {
|
||||
if (h.clientID === this._myClientID) {
|
||||
h.username = this._myUsername;
|
||||
}
|
||||
}
|
||||
this._cosmetics = new Map(
|
||||
this.humans.map((h) => [h.clientID, h.cosmetics ?? {}]),
|
||||
);
|
||||
|
||||
@@ -960,20 +960,25 @@ export class PlayerImpl implements Player {
|
||||
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();
|
||||
}
|
||||
if (tile !== null) {
|
||||
canBuild = this.canBuild(u, tile, validTiles);
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: u,
|
||||
canBuild:
|
||||
this.mg.inSpawnPhase() || tile === null
|
||||
? false
|
||||
: this.canBuild(u, tile, validTiles),
|
||||
canUpgrade: canUpgrade,
|
||||
canBuild,
|
||||
canUpgrade,
|
||||
cost: this.mg.config().unitInfo(u).cost(this.mg, this),
|
||||
overlappingRailroads:
|
||||
canBuild !== false
|
||||
? this.mg.railNetwork().overlappingRailroads(canBuild)
|
||||
: [],
|
||||
} as BuildableUnit;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Unit } from "./Game";
|
||||
import { TileRef } from "./GameMap";
|
||||
import { StationManager } from "./RailNetworkImpl";
|
||||
import { TrainStation } from "./TrainStation";
|
||||
|
||||
@@ -7,4 +8,5 @@ export interface RailNetwork {
|
||||
removeStation(unit: Unit): void;
|
||||
findStationsPath(from: TrainStation, to: TrainStation): TrainStation[];
|
||||
stationManager(): StationManager;
|
||||
overlappingRailroads(tile: TileRef): number[];
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { RailroadExecution } from "../execution/RailroadExecution";
|
||||
import { PathFinding } from "../pathfinding/PathFinder";
|
||||
import { Game, Unit, UnitType } from "./Game";
|
||||
import { TileRef } from "./GameMap";
|
||||
import { GameUpdateType } from "./GameUpdates";
|
||||
import { RailNetwork } from "./RailNetwork";
|
||||
import { Railroad } from "./Railroad";
|
||||
import { RailSpatialGrid } from "./RailroadSpatialGrid";
|
||||
@@ -85,6 +85,7 @@ export class RailNetworkImpl implements RailNetwork {
|
||||
private stationRadius: number = 3;
|
||||
private gridCellSize: number = 4;
|
||||
private railGrid: RailSpatialGrid;
|
||||
private nextId: number = 0;
|
||||
|
||||
constructor(
|
||||
private game: Game,
|
||||
@@ -141,6 +142,7 @@ export class RailNetworkImpl implements RailNetwork {
|
||||
for (const rail of rails) {
|
||||
const from = rail.from;
|
||||
const to = rail.to;
|
||||
const originalId = rail.id;
|
||||
const closestRailIndex = rail.getClosestTileIndex(
|
||||
this.game,
|
||||
station.tile(),
|
||||
@@ -158,11 +160,13 @@ export class RailNetworkImpl implements RailNetwork {
|
||||
from,
|
||||
station,
|
||||
rail.tiles.slice(0, closestRailIndex),
|
||||
this.nextId++,
|
||||
);
|
||||
const newRailTo = new Railroad(
|
||||
station,
|
||||
to,
|
||||
rail.tiles.slice(closestRailIndex),
|
||||
this.nextId++,
|
||||
);
|
||||
|
||||
// New station is connected to both new rails
|
||||
@@ -179,6 +183,14 @@ export class RailNetworkImpl implements RailNetwork {
|
||||
cluster.addStation(station);
|
||||
editedClusters.add(cluster);
|
||||
}
|
||||
this.game.addUpdate({
|
||||
type: GameUpdateType.RailroadSnapEvent,
|
||||
originalId,
|
||||
newId1: newRailFrom.id,
|
||||
newId2: newRailTo.id,
|
||||
tiles1: newRailFrom.tiles,
|
||||
tiles2: newRailTo.tiles,
|
||||
});
|
||||
}
|
||||
// If multiple clusters own the new station, merge them into a single cluster
|
||||
if (editedClusters.size > 1) {
|
||||
@@ -187,6 +199,12 @@ export class RailNetworkImpl implements RailNetwork {
|
||||
return editedClusters.size !== 0;
|
||||
}
|
||||
|
||||
overlappingRailroads(tile: TileRef): number[] {
|
||||
return [...this.railGrid.query(tile, this.stationRadius)].map(
|
||||
(railroad: Railroad) => railroad.id,
|
||||
);
|
||||
}
|
||||
|
||||
private connectToNearbyStations(station: TrainStation) {
|
||||
const neighbors = this.game.nearbyUnits(
|
||||
station.tile(),
|
||||
@@ -256,11 +274,15 @@ export class RailNetworkImpl implements RailNetwork {
|
||||
private connect(from: TrainStation, to: TrainStation) {
|
||||
const path = this.pathService.findTilePath(from.tile(), to.tile());
|
||||
if (path.length > 0 && path.length < this.game.config().railroadMaxSize()) {
|
||||
const railRoad = new Railroad(from, to, path);
|
||||
this.game.addExecution(new RailroadExecution(railRoad));
|
||||
from.addRailroad(railRoad);
|
||||
to.addRailroad(railRoad);
|
||||
this.railGrid.register(railRoad);
|
||||
const railroad = new Railroad(from, to, path, this.nextId++);
|
||||
this.game.addUpdate({
|
||||
type: GameUpdateType.RailroadConstructionEvent,
|
||||
id: railroad.id,
|
||||
tiles: railroad.tiles,
|
||||
});
|
||||
from.addRailroad(railroad);
|
||||
to.addRailroad(railroad);
|
||||
this.railGrid.register(railroad);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Game } from "./Game";
|
||||
import { TileRef } from "./GameMap";
|
||||
import { GameUpdateType, RailTile, RailType } from "./GameUpdates";
|
||||
import { GameUpdateType } from "./GameUpdates";
|
||||
import { TrainStation } from "./TrainStation";
|
||||
|
||||
export class Railroad {
|
||||
@@ -8,17 +8,13 @@ export class Railroad {
|
||||
public from: TrainStation,
|
||||
public to: TrainStation,
|
||||
public tiles: TileRef[],
|
||||
public id: number,
|
||||
) {}
|
||||
|
||||
delete(game: Game) {
|
||||
const railTiles: RailTile[] = this.tiles.map((tile) => ({
|
||||
tile,
|
||||
railType: RailType.VERTICAL,
|
||||
}));
|
||||
game.addUpdate({
|
||||
type: GameUpdateType.RailroadEvent,
|
||||
isActive: false,
|
||||
railTiles,
|
||||
type: GameUpdateType.RailroadDestructionEvent,
|
||||
id: this.id,
|
||||
});
|
||||
this.from.removeRailroad(this);
|
||||
this.to.removeRailroad(this);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { TrainExecution } from "../execution/TrainExecution";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { Game, Player, Unit, UnitType } from "./Game";
|
||||
import { TileRef } from "./GameMap";
|
||||
import { GameUpdateType, RailTile, RailType } from "./GameUpdates";
|
||||
import { GameUpdateType } from "./GameUpdates";
|
||||
import { Railroad } from "./Railroad";
|
||||
|
||||
/**
|
||||
@@ -92,14 +92,9 @@ export class TrainStation {
|
||||
(r) => r.from === station || r.to === station,
|
||||
);
|
||||
if (toRemove) {
|
||||
const railTiles: RailTile[] = toRemove.tiles.map((tile) => ({
|
||||
tile,
|
||||
railType: RailType.VERTICAL,
|
||||
}));
|
||||
this.mg.addUpdate({
|
||||
type: GameUpdateType.RailroadEvent,
|
||||
isActive: false,
|
||||
railTiles,
|
||||
type: GameUpdateType.RailroadDestructionEvent,
|
||||
id: toRemove.id,
|
||||
});
|
||||
this.removeRailroad(toRemove);
|
||||
}
|
||||
|
||||
@@ -1,92 +1,9 @@
|
||||
import {
|
||||
RegExpMatcher,
|
||||
collapseDuplicatesTransformer,
|
||||
englishDataset,
|
||||
englishRecommendedTransformers,
|
||||
resolveConfusablesTransformer,
|
||||
resolveLeetSpeakTransformer,
|
||||
skipNonAlphabeticTransformer,
|
||||
} from "obscenity";
|
||||
import { translateText } from "../../client/Utils";
|
||||
import { UsernameSchema } from "../Schemas";
|
||||
import { getClanTagOriginalCase, simpleHash } from "../Util";
|
||||
|
||||
const matcher = new RegExpMatcher({
|
||||
...englishDataset.build(),
|
||||
...englishRecommendedTransformers,
|
||||
...resolveConfusablesTransformer(),
|
||||
...skipNonAlphabeticTransformer(),
|
||||
...collapseDuplicatesTransformer(),
|
||||
...resolveLeetSpeakTransformer(),
|
||||
});
|
||||
|
||||
export const MIN_USERNAME_LENGTH = 3;
|
||||
export const MAX_USERNAME_LENGTH = 27;
|
||||
|
||||
const shadowNames = [
|
||||
"NicePeopleOnly",
|
||||
"BeKindPlz",
|
||||
"LearningManners",
|
||||
"StayClassy",
|
||||
"BeNicer",
|
||||
"NeedHugs",
|
||||
"MakeFriends",
|
||||
];
|
||||
|
||||
export function fixProfaneUsername(username: string): string {
|
||||
if (isProfaneUsername(username)) {
|
||||
return shadowNames[simpleHash(username) % shadowNames.length];
|
||||
}
|
||||
return username;
|
||||
}
|
||||
|
||||
export function isProfaneUsername(username: string): boolean {
|
||||
return matcher.hasMatch(username);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes and censors profane usernames and clan tags.
|
||||
* Profane username is overwritten, profane clan tag is removed.
|
||||
*
|
||||
* Preserves non-profane clan tag:
|
||||
* prevents desync after clan team assignment because local player's own clan tag and name aren't overwritten
|
||||
*
|
||||
* Removing bad clan tags won't hurt existing clans nor cause desyncs:
|
||||
* - full name including clan tag was overwritten in the past, if any part of name was bad
|
||||
* - only each separate local player name with a profane clan tag will remain, no clan team assignment
|
||||
*
|
||||
* Examples:
|
||||
* - "GoodName" -> "GoodName"
|
||||
* - "BadName" -> "Censored"
|
||||
* - "[CLAN]GoodName" -> "[CLAN]GoodName"
|
||||
* - "[CLaN]BadName" -> "[CLaN] Censored"
|
||||
* - "[BAD]GoodName" -> "GoodName"
|
||||
* - "[BAD]BadName" -> "Censored"
|
||||
*/
|
||||
export function censorNameWithClanTag(username: string): string {
|
||||
// Don't use getClanTag because that returns upperCase and if original isn't, str replace `[{$clanTag}]` won't match
|
||||
const clanTag = getClanTagOriginalCase(username);
|
||||
|
||||
const nameWithoutClan = clanTag
|
||||
? username.replace(`[${clanTag}]`, "").trim()
|
||||
: username;
|
||||
|
||||
const clanTagIsProfane = clanTag ? isProfaneUsername(clanTag) : false;
|
||||
const usernameIsProfane = isProfaneUsername(nameWithoutClan);
|
||||
|
||||
const censoredNameWithoutClan = usernameIsProfane
|
||||
? fixProfaneUsername(nameWithoutClan)
|
||||
: nameWithoutClan;
|
||||
|
||||
// Restore clan tag if it existed and is not profane
|
||||
if (clanTag && !clanTagIsProfane) {
|
||||
return `[${clanTag.toUpperCase()}] ${censoredNameWithoutClan}`;
|
||||
}
|
||||
|
||||
// Don't restore profane or nonexistent clan tag
|
||||
return censoredNameWithoutClan;
|
||||
}
|
||||
|
||||
export function validateUsername(username: string): {
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
|
||||
@@ -18,8 +18,8 @@ export class Client {
|
||||
public readonly flares: string[] | undefined,
|
||||
public readonly ip: string,
|
||||
public readonly username: string,
|
||||
public readonly uncensoredUsername: string,
|
||||
public ws: WebSocket,
|
||||
public readonly cosmetics: PlayerCosmetics | undefined,
|
||||
public readonly isRejoin: boolean = false,
|
||||
) {}
|
||||
}
|
||||
|
||||
+14
-16
@@ -8,7 +8,7 @@ import {
|
||||
GameMode,
|
||||
GameType,
|
||||
} from "../core/game/Game";
|
||||
import { ClientRejoinMessage, GameConfig, GameID } from "../core/Schemas";
|
||||
import { GameConfig, GameID } from "../core/Schemas";
|
||||
import { Client } from "./Client";
|
||||
import { GamePhase, GameServer } from "./GameServer";
|
||||
|
||||
@@ -32,32 +32,30 @@ export class GameManager {
|
||||
);
|
||||
}
|
||||
|
||||
joinClient(client: Client, gameID: GameID): boolean {
|
||||
joinClient(
|
||||
client: Client,
|
||||
gameID: GameID,
|
||||
): "joined" | "kicked" | "rejected" | "not_found" {
|
||||
const game = this.games.get(gameID);
|
||||
if (game) {
|
||||
game.joinClient(client);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
if (!game) return "not_found";
|
||||
return game.joinClient(client);
|
||||
}
|
||||
|
||||
rejoinClient(
|
||||
ws: WebSocket,
|
||||
persistentID: string,
|
||||
msg: ClientRejoinMessage,
|
||||
gameID: GameID,
|
||||
lastTurn: number = 0,
|
||||
): boolean {
|
||||
const game = this.games.get(msg.gameID);
|
||||
if (game) {
|
||||
game.rejoinClient(ws, persistentID, msg);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
const game = this.games.get(gameID);
|
||||
if (!game) return false;
|
||||
return game.rejoinClient(ws, persistentID, lastTurn);
|
||||
}
|
||||
|
||||
createGame(
|
||||
id: GameID,
|
||||
gameConfig: GameConfig | undefined,
|
||||
creatorClientID?: string,
|
||||
creatorPersistentID?: string,
|
||||
startsAt?: number,
|
||||
) {
|
||||
const game = new GameServer(
|
||||
@@ -83,7 +81,7 @@ export class GameManager {
|
||||
disabledUnits: [],
|
||||
...gameConfig,
|
||||
},
|
||||
creatorClientID,
|
||||
creatorPersistentID,
|
||||
startsAt,
|
||||
);
|
||||
this.games.set(id, game);
|
||||
|
||||
+95
-81
@@ -7,13 +7,11 @@ import { GameType } from "../core/game/Game";
|
||||
import {
|
||||
ClientID,
|
||||
ClientMessageSchema,
|
||||
ClientRejoinMessage,
|
||||
ClientSendWinnerMessage,
|
||||
GameConfig,
|
||||
GameInfo,
|
||||
GameStartInfo,
|
||||
GameStartInfoSchema,
|
||||
Intent,
|
||||
PlayerRecord,
|
||||
ServerDesyncSchema,
|
||||
ServerErrorMessage,
|
||||
@@ -21,6 +19,7 @@ import {
|
||||
ServerPrestartMessageSchema,
|
||||
ServerStartGameMessage,
|
||||
ServerTurnMessage,
|
||||
StampedIntent,
|
||||
Turn,
|
||||
} from "../core/Schemas";
|
||||
import { createPartialGameRecord, getClanTag } from "../core/Util";
|
||||
@@ -43,9 +42,11 @@ export class GameServer {
|
||||
private disconnectedTimeout = 1 * 30 * 1000; // 30 seconds
|
||||
|
||||
private turns: Turn[] = [];
|
||||
private intents: Intent[] = [];
|
||||
private intents: StampedIntent[] = [];
|
||||
public activeClients: Client[] = [];
|
||||
private allClients: Map<ClientID, Client> = new Map();
|
||||
// Map persistentID to clientID for reconnection lookup
|
||||
private persistentIdToClientId: Map<string, ClientID> = new Map();
|
||||
private clientsDisconnectedStatus: Map<ClientID, boolean> = new Map();
|
||||
private _hasStarted = false;
|
||||
private _startTime: number | null = null;
|
||||
@@ -63,7 +64,7 @@ export class GameServer {
|
||||
|
||||
private _hasPrestarted = false;
|
||||
|
||||
private kickedClients: Set<ClientID> = new Set();
|
||||
private kickedPersistentIds: Set<string> = new Set();
|
||||
private outOfSyncClients: Set<ClientID> = new Set();
|
||||
|
||||
private isPaused = false;
|
||||
@@ -87,12 +88,18 @@ export class GameServer {
|
||||
public readonly createdAt: number,
|
||||
private config: ServerConfig,
|
||||
public gameConfig: GameConfig,
|
||||
private lobbyCreatorID?: string,
|
||||
private creatorPersistentID?: string,
|
||||
private startsAt?: number,
|
||||
) {
|
||||
this.log = log_.child({ gameID: id });
|
||||
}
|
||||
|
||||
private get lobbyCreatorID(): ClientID | undefined {
|
||||
return this.creatorPersistentID
|
||||
? this.persistentIdToClientId.get(this.creatorPersistentID)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
public updateGameConfig(gameConfig: Partial<GameConfig>): void {
|
||||
if (gameConfig.gameMap !== undefined) {
|
||||
this.gameConfig.gameMap = gameConfig.gameMap;
|
||||
@@ -150,20 +157,24 @@ export class GameServer {
|
||||
}
|
||||
}
|
||||
|
||||
public joinClient(client: Client) {
|
||||
this.websockets.add(client.ws);
|
||||
if (this.kickedClients.has(client.clientID)) {
|
||||
this.log.warn(`cannot add client, already kicked`, {
|
||||
clientID: client.clientID,
|
||||
});
|
||||
return;
|
||||
}
|
||||
private isKicked(clientID: ClientID): boolean {
|
||||
const persistentID = this.allClients.get(clientID)?.persistentID;
|
||||
return (
|
||||
persistentID !== undefined && this.kickedPersistentIds.has(persistentID)
|
||||
);
|
||||
}
|
||||
|
||||
if (this.allClients.has(client.clientID)) {
|
||||
this.log.warn("cannot add client, already in game", {
|
||||
clientID: client.clientID,
|
||||
});
|
||||
return;
|
||||
// Get existing clientID for this persistentID, or null if new player
|
||||
public getClientIdForPersistentId(persistentID: string): ClientID | null {
|
||||
const clientID = this.persistentIdToClientId.get(persistentID);
|
||||
if (!clientID) return null;
|
||||
if (this.kickedPersistentIds.has(persistentID)) return null;
|
||||
return clientID;
|
||||
}
|
||||
|
||||
public joinClient(client: Client): "joined" | "kicked" | "rejected" {
|
||||
if (this.kickedPersistentIds.has(client.persistentID)) {
|
||||
return "kicked";
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -180,16 +191,9 @@ export class GameServer {
|
||||
error: "full-lobby",
|
||||
} satisfies ServerErrorMessage),
|
||||
);
|
||||
return;
|
||||
return "rejected";
|
||||
}
|
||||
|
||||
// Log when lobby creator joins private game
|
||||
if (client.clientID === this.lobbyCreatorID) {
|
||||
this.log.info("Lobby creator joined", {
|
||||
gameID: this.id,
|
||||
creatorID: this.lobbyCreatorID,
|
||||
});
|
||||
}
|
||||
this.log.info("client joining game", {
|
||||
clientID: client.clientID,
|
||||
persistentID: client.persistentID,
|
||||
@@ -206,7 +210,7 @@ export class GameServer {
|
||||
clientID: client.clientID,
|
||||
clientIP: ipAnonymize(client.ip),
|
||||
});
|
||||
return;
|
||||
return "rejected";
|
||||
}
|
||||
|
||||
if (this.config.env() === GameEnv.Prod) {
|
||||
@@ -231,6 +235,8 @@ export class GameServer {
|
||||
}
|
||||
|
||||
// Client connection accepted
|
||||
this.websockets.add(client.ws);
|
||||
this.persistentIdToClientId.set(client.persistentID, client.clientID);
|
||||
this.activeClients.push(client);
|
||||
client.lastPing = Date.now();
|
||||
this.markClientDisconnected(client.clientID, false);
|
||||
@@ -242,54 +248,47 @@ export class GameServer {
|
||||
if (this._hasStarted) {
|
||||
this.sendStartGameMsg(client.ws, 0);
|
||||
}
|
||||
|
||||
return "joined";
|
||||
}
|
||||
|
||||
// Attempt to reconnect a client by persistentID. Returns true if successful.
|
||||
// Only the WebSocket is updated — username, cosmetics, etc. are preserved
|
||||
// from the original join to maintain consistency throughout the game session.
|
||||
public rejoinClient(
|
||||
ws: WebSocket,
|
||||
persistentID: string,
|
||||
msg: ClientRejoinMessage,
|
||||
): void {
|
||||
lastTurn: number = 0,
|
||||
): boolean {
|
||||
const clientID = this.getClientIdForPersistentId(persistentID);
|
||||
if (!clientID) return false;
|
||||
const client = this.allClients.get(clientID);
|
||||
if (!client) return false;
|
||||
|
||||
this.websockets.add(ws);
|
||||
this.log.info("client rejoining", { clientID, lastTurn });
|
||||
|
||||
if (this.kickedClients.has(msg.clientID)) {
|
||||
this.log.warn("cannot rejoin client, client has been kicked", {
|
||||
clientID: msg.clientID,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const client = this.allClients.get(msg.clientID);
|
||||
if (!client) {
|
||||
this.log.warn("cannot rejoin client, existing client not found", {
|
||||
clientID: msg.clientID,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (client.persistentID !== persistentID) {
|
||||
this.log.error("persistent ids do not match", {
|
||||
clientID: msg.clientID,
|
||||
clientPersistentID: persistentID,
|
||||
existingIP: ipAnonymize(client.ip),
|
||||
existingPersistentID: client.persistentID,
|
||||
});
|
||||
return;
|
||||
// Close old WebSocket to prevent resource leaks
|
||||
if (client.ws !== ws) {
|
||||
client.ws.removeAllListeners();
|
||||
client.ws.close();
|
||||
}
|
||||
|
||||
this.activeClients = this.activeClients.filter(
|
||||
(c) => c.clientID !== msg.clientID,
|
||||
(c) => c.clientID !== client.clientID,
|
||||
);
|
||||
this.activeClients.push(client);
|
||||
client.lastPing = Date.now();
|
||||
this.markClientDisconnected(msg.clientID, false);
|
||||
this.markClientDisconnected(client.clientID, false);
|
||||
|
||||
client.ws = ws;
|
||||
this.addListeners(client);
|
||||
this.startLobbyInfoBroadcast();
|
||||
|
||||
if (this._hasStarted) {
|
||||
this.sendStartGameMsg(client.ws, msg.lastTurn);
|
||||
this.sendStartGameMsg(client.ws, lastTurn);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private addListeners(client: Client) {
|
||||
@@ -321,13 +320,12 @@ export class GameServer {
|
||||
break;
|
||||
}
|
||||
case "intent": {
|
||||
if (clientMsg.intent.clientID !== client.clientID) {
|
||||
this.log.warn(
|
||||
`client id mismatch, client: ${client.clientID}, intent: ${clientMsg.intent.clientID}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
switch (clientMsg.intent.type) {
|
||||
// Server stamps clientID from the authenticated connection
|
||||
const stampedIntent = {
|
||||
...clientMsg.intent,
|
||||
clientID: client.clientID,
|
||||
};
|
||||
switch (stampedIntent.type) {
|
||||
case "mark_disconnected": {
|
||||
this.log.warn(
|
||||
`Should not receive mark_disconnected intent from client`,
|
||||
@@ -342,14 +340,14 @@ export class GameServer {
|
||||
this.log.warn(`Only lobby creator can kick players`, {
|
||||
clientID: client.clientID,
|
||||
creatorID: this.lobbyCreatorID,
|
||||
target: clientMsg.intent.target,
|
||||
target: stampedIntent.target,
|
||||
gameID: this.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't allow lobby creator to kick themselves
|
||||
if (client.clientID === clientMsg.intent.target) {
|
||||
if (client.clientID === stampedIntent.target) {
|
||||
this.log.warn(`Cannot kick yourself`, {
|
||||
clientID: client.clientID,
|
||||
});
|
||||
@@ -359,13 +357,13 @@ export class GameServer {
|
||||
// Log and execute the kick
|
||||
this.log.info(`Lobby creator initiated kick of player`, {
|
||||
creatorID: client.clientID,
|
||||
target: clientMsg.intent.target,
|
||||
target: stampedIntent.target,
|
||||
gameID: this.id,
|
||||
kickMethod: "websocket",
|
||||
});
|
||||
|
||||
this.kickClient(
|
||||
clientMsg.intent.target,
|
||||
stampedIntent.target,
|
||||
KICK_REASON_LOBBY_CREATOR,
|
||||
);
|
||||
return;
|
||||
@@ -400,7 +398,7 @@ export class GameServer {
|
||||
return;
|
||||
}
|
||||
|
||||
if (clientMsg.intent.config.gameType === GameType.Public) {
|
||||
if (stampedIntent.config.gameType === GameType.Public) {
|
||||
this.log.warn(`Cannot update game to public via WebSocket`, {
|
||||
gameID: this.id,
|
||||
clientID: client.clientID,
|
||||
@@ -416,7 +414,7 @@ export class GameServer {
|
||||
},
|
||||
);
|
||||
|
||||
this.updateGameConfig(clientMsg.intent.config);
|
||||
this.updateGameConfig(stampedIntent.config);
|
||||
return;
|
||||
}
|
||||
case "toggle_pause": {
|
||||
@@ -430,15 +428,15 @@ export class GameServer {
|
||||
return;
|
||||
}
|
||||
|
||||
if (clientMsg.intent.paused) {
|
||||
if (stampedIntent.paused) {
|
||||
// Pausing: send intent and complete current turn before pause takes effect
|
||||
this.addIntent(clientMsg.intent);
|
||||
this.addIntent(stampedIntent);
|
||||
this.endTurn();
|
||||
this.isPaused = true;
|
||||
} else {
|
||||
// Unpausing: clear pause flag before sending intent so next turn can execute
|
||||
this.isPaused = false;
|
||||
this.addIntent(clientMsg.intent);
|
||||
this.addIntent(stampedIntent);
|
||||
this.endTurn();
|
||||
}
|
||||
|
||||
@@ -451,7 +449,7 @@ export class GameServer {
|
||||
default: {
|
||||
// Don't process intents while game is paused
|
||||
if (!this.isPaused) {
|
||||
this.addIntent(clientMsg.intent);
|
||||
this.addIntent(stampedIntent);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -501,6 +499,17 @@ export class GameServer {
|
||||
client.ws.close(1002, "WS_ERR_UNEXPECTED_RSV_1");
|
||||
}
|
||||
});
|
||||
|
||||
// Check if WebSocket already closed before we added the listener (race condition)
|
||||
if (client.ws.readyState >= 2) {
|
||||
this.log.info("client WebSocket already closing/closed, removing", {
|
||||
clientID: client.clientID,
|
||||
readyState: client.ws.readyState,
|
||||
});
|
||||
this.activeClients = this.activeClients.filter(
|
||||
(c) => c.clientID !== client.clientID,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public numClients(): number {
|
||||
@@ -569,12 +578,14 @@ export class GameServer {
|
||||
}
|
||||
|
||||
private broadcastLobbyInfo() {
|
||||
const msg = JSON.stringify({
|
||||
type: "lobby_info",
|
||||
lobby: this.gameInfo(),
|
||||
} satisfies ServerLobbyInfoMessage);
|
||||
const lobbyInfo = this.gameInfo();
|
||||
this.activeClients.forEach((c) => {
|
||||
if (c.ws.readyState === WebSocket.OPEN) {
|
||||
const msg = JSON.stringify({
|
||||
type: "lobby_info",
|
||||
lobby: lobbyInfo,
|
||||
myClientID: c.clientID,
|
||||
} satisfies ServerLobbyInfoMessage);
|
||||
c.ws.send(msg);
|
||||
}
|
||||
});
|
||||
@@ -621,7 +632,7 @@ export class GameServer {
|
||||
});
|
||||
}
|
||||
|
||||
private addIntent(intent: Intent) {
|
||||
private addIntent(intent: StampedIntent) {
|
||||
this.intents.push(intent);
|
||||
}
|
||||
|
||||
@@ -646,6 +657,7 @@ export class GameServer {
|
||||
turns: this.turns.slice(lastTurn),
|
||||
gameStartInfo: this.gameStartInfo,
|
||||
lobbyCreatedAt: this.createdAt,
|
||||
myClientID: client.clientID,
|
||||
} satisfies ServerStartGameMessage),
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -808,6 +820,7 @@ export class GameServer {
|
||||
username: c.username,
|
||||
clientID: c.clientID,
|
||||
})),
|
||||
lobbyCreatorClientID: this.lobbyCreatorID,
|
||||
gameConfig: this.gameConfig,
|
||||
startsAt: this.startsAt,
|
||||
serverTime: Date.now(),
|
||||
@@ -822,7 +835,7 @@ export class GameServer {
|
||||
clientID: ClientID,
|
||||
reasonKey: string = KICK_REASON_DUPLICATE_SESSION,
|
||||
): void {
|
||||
if (this.kickedClients.has(clientID)) {
|
||||
if (this.isKicked(clientID)) {
|
||||
this.log.warn(`cannot kick client, already kicked`, {
|
||||
clientID,
|
||||
reasonKey,
|
||||
@@ -830,7 +843,8 @@ export class GameServer {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.allClients.has(clientID)) {
|
||||
const clientToKick = this.allClients.get(clientID);
|
||||
if (!clientToKick) {
|
||||
this.log.warn(`cannot kick client, not found in game`, {
|
||||
clientID,
|
||||
reasonKey,
|
||||
@@ -838,7 +852,7 @@ export class GameServer {
|
||||
return;
|
||||
}
|
||||
|
||||
this.kickedClients.add(clientID);
|
||||
this.kickedPersistentIds.add(clientToKick.persistentID);
|
||||
|
||||
const client = this.activeClients.find((c) => c.clientID === clientID);
|
||||
if (client) {
|
||||
@@ -1041,7 +1055,7 @@ export class GameServer {
|
||||
private handleWinner(client: Client, clientMsg: ClientSendWinnerMessage) {
|
||||
if (
|
||||
this.outOfSyncClients.has(client.clientID) ||
|
||||
this.kickedClients.has(client.clientID) ||
|
||||
this.isKicked(client.clientID) ||
|
||||
this.winner !== null ||
|
||||
client.reportedWinner !== null
|
||||
) {
|
||||
|
||||
@@ -67,6 +67,7 @@ const frequency: Partial<Record<GameMapName, number>> = {
|
||||
Sierpinski: 10,
|
||||
TheBox: 3,
|
||||
Yenisei: 6,
|
||||
TradersDream: 4,
|
||||
};
|
||||
|
||||
interface MapWithMode {
|
||||
|
||||
+119
-1
@@ -1,3 +1,14 @@
|
||||
import {
|
||||
DataSet,
|
||||
RegExpMatcher,
|
||||
collapseDuplicatesTransformer,
|
||||
englishDataset,
|
||||
pattern,
|
||||
resolveConfusablesTransformer,
|
||||
resolveLeetSpeakTransformer,
|
||||
skipNonAlphabeticTransformer,
|
||||
toAsciiLowerCaseTransformer,
|
||||
} from "obscenity";
|
||||
import { Cosmetics } from "../core/CosmeticSchemas";
|
||||
import { decodePatternData } from "../core/PatternDecoder";
|
||||
import {
|
||||
@@ -7,6 +18,95 @@ import {
|
||||
PlayerCosmetics,
|
||||
PlayerPattern,
|
||||
} from "../core/Schemas";
|
||||
import { getClanTagOriginalCase, simpleHash } from "../core/Util";
|
||||
|
||||
export const shadowNames = [
|
||||
"UnhuggedToday",
|
||||
"DaddysLilChamp",
|
||||
"BunnyKisses67",
|
||||
"SnugglePuppy",
|
||||
"CuddleMonster67",
|
||||
"DaddysLilStar",
|
||||
"SnuggleMuffin",
|
||||
"PeesALittle",
|
||||
"PleaseFullSendMe",
|
||||
"NanasLilMan",
|
||||
"NoAlliances",
|
||||
"TryingTooHard67",
|
||||
"MommysLilStinker",
|
||||
"NeedHugs",
|
||||
"MommysLilPeanut",
|
||||
"IWillBetrayU",
|
||||
"DaddysLilTater",
|
||||
"PreciousBubbles",
|
||||
"67 Cringelord",
|
||||
"Peace And Love",
|
||||
"AlmostPottyTrained",
|
||||
];
|
||||
|
||||
export function createMatcher(bannedWords: string[]): RegExpMatcher {
|
||||
const customDataset = new DataSet<{ originalWord: string }>().addAll(
|
||||
englishDataset,
|
||||
);
|
||||
|
||||
for (const word of bannedWords) {
|
||||
customDataset.addPhrase((phrase) =>
|
||||
phrase.setMetadata({ originalWord: word }).addPattern(pattern`${word}`),
|
||||
);
|
||||
}
|
||||
|
||||
return new RegExpMatcher({
|
||||
...customDataset.build(),
|
||||
blacklistMatcherTransformers: [
|
||||
toAsciiLowerCaseTransformer(),
|
||||
resolveConfusablesTransformer(),
|
||||
resolveLeetSpeakTransformer(),
|
||||
collapseDuplicatesTransformer(),
|
||||
skipNonAlphabeticTransformer(),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes and censors profane usernames and clan tags.
|
||||
* Profane username is overwritten, profane clan tag is removed.
|
||||
*
|
||||
* Removing bad clan tags won't hurt existing clans nor cause desyncs:
|
||||
* - full name including clan tag was overwritten in the past, if any part of name was bad
|
||||
* - only each separate local player name with a profane clan tag will remain, no clan team assignment
|
||||
*
|
||||
* Examples:
|
||||
* - "GoodName" -> "GoodName"
|
||||
* - "BadName" -> "Censored"
|
||||
* - "[CLAN]GoodName" -> "[CLAN]GoodName"
|
||||
* - "[CLaN]BadName" -> "[CLAN] Censored"
|
||||
* - "[BAD]GoodName" -> "GoodName"
|
||||
* - "[BAD]BadName" -> "Censored"
|
||||
*/
|
||||
function censorUsernameWithMatcher(
|
||||
username: string,
|
||||
matcher: RegExpMatcher,
|
||||
): string {
|
||||
const clanTag = getClanTagOriginalCase(username);
|
||||
|
||||
const nameWithoutClan = clanTag
|
||||
? username.replace(`[${clanTag}]`, "").trim()
|
||||
: username;
|
||||
|
||||
const clanTagIsProfane = clanTag ? matcher.hasMatch(clanTag) : false;
|
||||
const usernameIsProfane = matcher.hasMatch(nameWithoutClan);
|
||||
|
||||
const censoredName = usernameIsProfane
|
||||
? shadowNames[simpleHash(nameWithoutClan) % shadowNames.length]
|
||||
: nameWithoutClan;
|
||||
|
||||
// Restore clan tag only if it's clean, otherwise remove it entirely
|
||||
if (clanTag && !clanTagIsProfane) {
|
||||
return `[${clanTag.toUpperCase()}] ${censoredName}`;
|
||||
}
|
||||
|
||||
return censoredName;
|
||||
}
|
||||
|
||||
type CosmeticResult =
|
||||
| { type: "allowed"; cosmetics: PlayerCosmetics }
|
||||
@@ -14,13 +114,19 @@ type CosmeticResult =
|
||||
|
||||
export interface PrivilegeChecker {
|
||||
isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult;
|
||||
censorUsername(username: string): string;
|
||||
}
|
||||
|
||||
export class PrivilegeCheckerImpl implements PrivilegeChecker {
|
||||
private matcher: RegExpMatcher;
|
||||
|
||||
constructor(
|
||||
private cosmetics: Cosmetics,
|
||||
private b64urlDecode: (base64: string) => Uint8Array,
|
||||
) {}
|
||||
bannedWords: string[],
|
||||
) {
|
||||
this.matcher = createMatcher(bannedWords);
|
||||
}
|
||||
|
||||
isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult {
|
||||
const cosmetics: PlayerCosmetics = {};
|
||||
@@ -106,10 +212,22 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker {
|
||||
}
|
||||
return { color };
|
||||
}
|
||||
|
||||
censorUsername(username: string): string {
|
||||
return censorUsernameWithMatcher(username, this.matcher);
|
||||
}
|
||||
}
|
||||
|
||||
// Default matcher with no custom banned words (just englishDataset)
|
||||
const defaultMatcher = createMatcher([]);
|
||||
|
||||
export class FailOpenPrivilegeChecker implements PrivilegeChecker {
|
||||
isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult {
|
||||
return { type: "allowed", cosmetics: {} };
|
||||
}
|
||||
|
||||
censorUsername(username: string): string {
|
||||
// Fail open: use matcher with just the built-in English profanity dataset
|
||||
return censorUsernameWithMatcher(username, defaultMatcher);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
PrivilegeCheckerImpl,
|
||||
} from "./Privilege";
|
||||
|
||||
// Refreshes the privilege checker every 5 minutes.
|
||||
// Refreshes the privilege checker every 3 minutes.
|
||||
// WARNING: This fails open if cosmetics.json is not available.
|
||||
export class PrivilegeRefresher {
|
||||
private privilegeChecker: PrivilegeChecker | null = null;
|
||||
@@ -18,7 +18,9 @@ export class PrivilegeRefresher {
|
||||
private log: Logger;
|
||||
|
||||
constructor(
|
||||
private endpoint: string,
|
||||
private cosmeticsEndpoint: string,
|
||||
private profaneWordsEndpoint: string,
|
||||
private apiKey: string,
|
||||
parentLog: Logger,
|
||||
private refreshInterval: number = 1000 * 60 * 3,
|
||||
) {
|
||||
@@ -37,27 +39,62 @@ export class PrivilegeRefresher {
|
||||
}
|
||||
|
||||
private async loadPrivilegeChecker(): Promise<void> {
|
||||
this.log.info(`Loading privilege checker from ${this.endpoint}`);
|
||||
this.log.info(`Loading privilege checker`);
|
||||
try {
|
||||
const response = await fetch(this.endpoint);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
const fetchWithTimeout = async (url: string) => {
|
||||
try {
|
||||
return await fetch(url, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
headers: { "x-api-key": this.apiKey },
|
||||
});
|
||||
} catch (error) {
|
||||
this.log.warn(`Failed to fetch ${url}: ${error}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const [cosmeticsResponse, profaneWordsResponse] = await Promise.all([
|
||||
fetchWithTimeout(this.cosmeticsEndpoint),
|
||||
fetchWithTimeout(this.profaneWordsEndpoint),
|
||||
]);
|
||||
|
||||
if (!cosmeticsResponse || !cosmeticsResponse.ok) {
|
||||
throw new Error(
|
||||
`Cosmetics HTTP error! status: ${cosmeticsResponse?.status ?? "network error"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const cosmeticsData = await response.json();
|
||||
const cosmeticsData = await cosmeticsResponse.json();
|
||||
const result = CosmeticsSchema.safeParse(cosmeticsData);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(`Invalid cosmetics data: ${result.error.message}`);
|
||||
}
|
||||
|
||||
let bannedWords: string[] = [];
|
||||
if (profaneWordsResponse && profaneWordsResponse.ok) {
|
||||
try {
|
||||
bannedWords = await profaneWordsResponse.json();
|
||||
this.log.info(
|
||||
`Loaded ${bannedWords.length} profane words from ${this.profaneWordsEndpoint}`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.log.warn(`Failed to parse profane words JSON, using empty list`);
|
||||
}
|
||||
} else {
|
||||
this.log.warn(
|
||||
`Failed to fetch profane words (status ${profaneWordsResponse?.status ?? "network error"}), using empty list`,
|
||||
);
|
||||
}
|
||||
|
||||
this.privilegeChecker = new PrivilegeCheckerImpl(
|
||||
result.data,
|
||||
base64url.decode,
|
||||
bannedWords,
|
||||
);
|
||||
this.log.info(`Privilege checker loaded successfully`);
|
||||
} catch (error) {
|
||||
this.log.error(`Failed to fetch cosmetics from ${this.endpoint}:`, error);
|
||||
this.log.error(`Failed to load privilege checker:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
+70
-29
@@ -12,7 +12,6 @@ import { GameType } from "../core/game/Game";
|
||||
import {
|
||||
ClientMessageSchema,
|
||||
GameID,
|
||||
ID,
|
||||
PartialGameRecordSchema,
|
||||
ServerErrorMessage,
|
||||
} from "../core/Schemas";
|
||||
@@ -68,6 +67,8 @@ export async function startWorker() {
|
||||
|
||||
const privilegeRefresher = new PrivilegeRefresher(
|
||||
config.jwtIssuer() + "/cosmetics.json",
|
||||
config.jwtIssuer() + "/profane_words_game_server",
|
||||
config.apiKey(),
|
||||
log,
|
||||
);
|
||||
privilegeRefresher.start();
|
||||
@@ -125,12 +126,27 @@ export async function startWorker() {
|
||||
|
||||
app.post("/api/create_game/:id", async (req, res) => {
|
||||
const id = req.params.id;
|
||||
const creatorClientID = (() => {
|
||||
if (typeof req.query.creatorClientID !== "string") return undefined;
|
||||
|
||||
const trimmed = req.query.creatorClientID.trim();
|
||||
return ID.safeParse(trimmed).success ? trimmed : undefined;
|
||||
})();
|
||||
// Extract persistentID from Authorization header token
|
||||
// Never accept persistentID directly from client
|
||||
let creatorPersistentID: string | undefined;
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
const token = authHeader.substring("Bearer ".length);
|
||||
const result = await verifyClientToken(token, config);
|
||||
if (result.type === "success") {
|
||||
creatorPersistentID = result.persistentId;
|
||||
} else {
|
||||
log.warn(`Invalid creator token: ${result.message}`);
|
||||
return res.status(401).json({ error: "Invalid creator token" });
|
||||
}
|
||||
} else if (
|
||||
!req.headers[config.adminHeader()] // Public games use admin token instead
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Authorization header required to create a game" });
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
log.warn(`cannot create game, id not found`);
|
||||
@@ -164,11 +180,11 @@ export async function startWorker() {
|
||||
return res.status(400).json({ error: "Worker, game id mismatch" });
|
||||
}
|
||||
|
||||
// Pass creatorClientID to createGame
|
||||
const game = gm.createGame(id, gc, creatorClientID);
|
||||
// Pass creatorPersistentID to createGame
|
||||
const game = gm.createGame(id, gc, creatorPersistentID);
|
||||
|
||||
log.info(
|
||||
`Worker ${workerId}: IP ${ipAnonymize(clientIP)} creating ${game.isPublic() ? "Public" : "Private"}${gc?.gameMode ? ` ${gc.gameMode}` : ""} game with id ${id}${creatorClientID ? `, creator: ${creatorClientID}` : ""}`,
|
||||
`Worker ${workerId}: IP ${ipAnonymize(clientIP)} creating ${game.isPublic() ? "Public" : "Private"}${gc?.gameMode ? ` ${gc.gameMode}` : ""} game with id ${id}${creatorPersistentID ? `, creator: ${creatorPersistentID.substring(0, 8)}...` : ""}`,
|
||||
);
|
||||
res.json(game.gameInfo());
|
||||
});
|
||||
@@ -311,12 +327,9 @@ export async function startWorker() {
|
||||
const result = await verifyClientToken(clientMsg.token, config);
|
||||
if (result.type === "error") {
|
||||
log.warn(`Invalid token: ${result.message}`, {
|
||||
clientID: clientMsg.clientID,
|
||||
gameID: clientMsg.gameID,
|
||||
});
|
||||
ws.close(
|
||||
1002,
|
||||
`Unauthorized: invalid token for client ${clientMsg.clientID}`,
|
||||
);
|
||||
ws.close(1002, `Unauthorized: invalid token`);
|
||||
return;
|
||||
}
|
||||
const { persistentId, claims } = result;
|
||||
@@ -324,11 +337,14 @@ export async function startWorker() {
|
||||
if (clientMsg.type === "rejoin") {
|
||||
log.info("rejoining game", {
|
||||
gameID: clientMsg.gameID,
|
||||
clientID: clientMsg.clientID,
|
||||
persistentID: persistentId,
|
||||
});
|
||||
const wasFound = gm.rejoinClient(ws, persistentId, clientMsg);
|
||||
|
||||
const wasFound = gm.rejoinClient(
|
||||
ws,
|
||||
persistentId,
|
||||
clientMsg.gameID,
|
||||
clientMsg.lastTurn,
|
||||
);
|
||||
if (!wasFound) {
|
||||
log.warn(
|
||||
`game ${clientMsg.gameID} not found on worker ${workerId}`,
|
||||
@@ -338,6 +354,12 @@ export async function startWorker() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to reconnect an existing client (e.g., page refresh)
|
||||
// If successful, skip all authorization
|
||||
if (gm.rejoinClient(ws, persistentId, clientMsg.gameID)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let roles: string[] | undefined;
|
||||
let flares: string[] | undefined;
|
||||
|
||||
@@ -353,12 +375,10 @@ export async function startWorker() {
|
||||
const result = await getUserMe(clientMsg.token, config);
|
||||
if (result.type === "error") {
|
||||
log.warn(`Unauthorized: ${result.message}`, {
|
||||
clientID: clientMsg.clientID,
|
||||
persistentID: persistentId,
|
||||
gameID: clientMsg.gameID,
|
||||
});
|
||||
ws.close(
|
||||
1002,
|
||||
`Unauthorized: user me fetch failed for client ${clientMsg.clientID}`,
|
||||
);
|
||||
ws.close(1002, "Unauthorized: user me fetch failed");
|
||||
return;
|
||||
}
|
||||
roles = result.response.player.roles;
|
||||
@@ -384,7 +404,8 @@ export async function startWorker() {
|
||||
|
||||
if (cosmeticResult.type === "forbidden") {
|
||||
log.warn(`Forbidden: ${cosmeticResult.reason}`, {
|
||||
clientID: clientMsg.clientID,
|
||||
persistentID: persistentId,
|
||||
gameID: clientMsg.gameID,
|
||||
});
|
||||
ws.close(1002, cosmeticResult.reason);
|
||||
return;
|
||||
@@ -401,7 +422,8 @@ export async function startWorker() {
|
||||
break;
|
||||
case "rejected":
|
||||
log.warn("Unauthorized: Turnstile token rejected", {
|
||||
clientID: clientMsg.clientID,
|
||||
persistentID: persistentId,
|
||||
gameID: clientMsg.gameID,
|
||||
reason: turnstileResult.reason,
|
||||
});
|
||||
ws.close(1002, "Unauthorized: Turnstile token rejected");
|
||||
@@ -409,30 +431,49 @@ export async function startWorker() {
|
||||
case "error":
|
||||
// Fail open, allow the client to join.
|
||||
log.error("Turnstile token error", {
|
||||
clientID: clientMsg.clientID,
|
||||
persistentID: persistentId,
|
||||
gameID: clientMsg.gameID,
|
||||
reason: turnstileResult.reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Censor profane usernames server-side (don't reject, just rename)
|
||||
const censoredUsername = privilegeRefresher
|
||||
.get()
|
||||
.censorUsername(clientMsg.username);
|
||||
|
||||
// Create client and add to game
|
||||
const client = new Client(
|
||||
clientMsg.clientID,
|
||||
generateID(),
|
||||
persistentId,
|
||||
claims,
|
||||
roles,
|
||||
flares,
|
||||
ip,
|
||||
censoredUsername,
|
||||
clientMsg.username,
|
||||
ws,
|
||||
cosmeticResult.cosmetics,
|
||||
);
|
||||
|
||||
const wasFound = gm.joinClient(client, clientMsg.gameID);
|
||||
const joinResult = gm.joinClient(client, clientMsg.gameID);
|
||||
|
||||
if (!wasFound) {
|
||||
if (joinResult === "not_found") {
|
||||
log.info(`game ${clientMsg.gameID} not found on worker ${workerId}`);
|
||||
// Handle game not found case
|
||||
ws.close(1002, "Game not found");
|
||||
} else if (joinResult === "kicked") {
|
||||
log.warn(`kicked client tried to join game ${clientMsg.gameID}`, {
|
||||
gameID: clientMsg.gameID,
|
||||
workerId,
|
||||
});
|
||||
ws.close(1002, "Cannot join game");
|
||||
} else if (joinResult === "rejected") {
|
||||
log.info(`client rejected from game ${clientMsg.gameID}`, {
|
||||
gameID: clientMsg.gameID,
|
||||
workerId,
|
||||
});
|
||||
ws.close(1002, "Lobby full");
|
||||
}
|
||||
|
||||
// Handle other message types
|
||||
|
||||
Reference in New Issue
Block a user