Merge branch 'main' into randmap

This commit is contained in:
Todd Groff
2025-03-02 16:29:57 -05:00
committed by GitHub
83 changed files with 2788 additions and 24367 deletions
+16 -1
View File
@@ -6,11 +6,16 @@ import { ClientID, GameConfig, GameID, ServerMessage } from "../core/Schemas";
import { loadTerrainMap } from "../core/game/TerrainMapLoader";
import {
SendAttackIntentEvent,
SendHashEvent,
SendSpawnIntentEvent,
Transport,
} from "./Transport";
import { createCanvas } from "./Utils";
import { ErrorUpdate } from "../core/game/GameUpdates";
import {
ErrorUpdate,
GameUpdateType,
HashUpdate,
} from "../core/game/GameUpdates";
import { WorkerClient } from "../core/worker/WorkerClient";
import { consolex, initRemoteSender } from "../core/Consolex";
import { getConfig, getServerConfig } from "../core/configuration/Config";
@@ -171,6 +176,9 @@ export class ClientGameRunner {
showErrorModal(gu.errMsg, gu.stack, this.clientID);
return;
}
gu.updates[GameUpdateType.Hash].forEach((hu: HashUpdate) => {
this.eventBus.emit(new SendHashEvent(hu.tick, hu.hash));
});
this.gameView.update(gu);
this.renderer.tick();
});
@@ -205,6 +213,13 @@ export class ClientGameRunner {
this.turnsSeen++;
}
}
if (message.type == "desync") {
showErrorModal(
`desync from server: ${JSON.stringify(message)}`,
"",
this.clientID,
);
}
if (message.type == "turn") {
if (!this.hasJoined) {
this.transport.joinGame(0);
+10 -7
View File
@@ -1,8 +1,7 @@
import { LitElement, html, css } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import Countries from "./data/countries.json";
import { UserSettings } from "../core/game/UserSettings";
import { ModalOverlay } from "./components/ModalOverlay";
const flagKey: string = "flag";
@customElement("flag-input")
@@ -10,7 +9,6 @@ export class FlagInput extends LitElement {
@state() private flag: string = "";
@state() private search: string = "";
@state() private showModal: boolean = false;
private userSettings: UserSettings = new UserSettings();
static styles = css`
@media (max-width: 768px) {
@@ -29,11 +27,10 @@ export class FlagInput extends LitElement {
}
private setFlag(flag: string) {
if (flag == "") {
this.flag = "";
} else {
this.flag = flag;
if (flag == "xx") {
flag = "";
}
this.flag = flag;
this.showModal = false;
this.storeFlag(flag);
}
@@ -80,6 +77,12 @@ export class FlagInput extends LitElement {
render() {
return html`
<div
class="absolute left-0 top-0 w-full h-full ${this.showModal
? ""
: "hidden"}"
@click=${() => (this.showModal = false)}
></div>
<div class="flex relative">
<button
@click=${() => (this.showModal = !this.showModal)}
+42
View File
@@ -0,0 +1,42 @@
import { LitElement, html, css } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("google-ad")
export class GoogleAdElement extends LitElement {
createRenderRoot() {
return this;
}
static styles = css`
.google-ad-container {
margin-top: 1rem;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 0.5rem;
padding: 0.5rem;
width: 100%;
overflow: hidden;
}
.dark .google-ad-container {
background-color: rgba(0, 0, 0, 0.2);
}
`;
render() {
return html`
<div class="google-ad-container">
<ins
class="adsbygoogle"
style="display:block"
data-ad-client="ca-pub-7035513310742290"
data-ad-slot="rightsidebar"
data-ad-format="auto"
data-full-width-responsive="true"
></ins>
<script>
(adsbygoogle = window.adsbygoogle || []).push({});
</script>
</div>
`;
}
}
+24 -4
View File
@@ -1,5 +1,5 @@
import { LitElement, html, css } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import "./components/Difficulties";
import "./components/Maps";
@@ -207,11 +207,17 @@ export class HelpModal extends LitElement {
class="modal-overlay"
style="display: ${this.isModalOpen ? "flex" : "none"}"
>
<div
class="absolute left-0 top-0 w-full h-full ${
this.isModalOpen ? "" : "hidden"
}"
@click=${this.close}
></div>
<div class="modal-content">
<span class="close" @click=${this.close}>&times;</span>
<div class="flex flex-col items-center">
<div class="text-center text-2xl font-bold mb-4">Keybinds</div>
<div class="text-center text-2xl font-bold mb-4">Hotkeys</div>
<table>
<thead>
<tr>
@@ -220,10 +226,18 @@ export class HelpModal extends LitElement {
</tr>
</thead>
<tbody class="text-left">
<tr>
<td>CTRL + Left Click</td>
<td>Open build menu</td>
</tr>
<tr>
<td>Space</td>
<td>Alternate view</td>
</tr>
<tr>
<td>C</td>
<td>Center camera on player</td>
</tr>
<tr>
<td>Q / E</td>
<td>Zoom out/in</td>
@@ -236,6 +250,10 @@ export class HelpModal extends LitElement {
<td>1 / 2</td>
<td>Decrease/Increase attack ratio</td>
</tr>
<tr>
<td>Shift + scroll down / scroll up</td>
<td>Decrease/Increase attack ratio</td>
</tr>
<tr>
<td>ALT + R</td>
<td>Reset graphics</td>
@@ -303,7 +321,8 @@ export class HelpModal extends LitElement {
<p class="mb-4">Right clicking (or touch on mobile) opens the radial menu. From there you can:</p>
<ul>
<li class="mb-4"><div class="inline-block icon build-icon"></div> - Open the build menu.</li>
<li class="mb-4"><div class="inline-block icon info-icon"></div> - Open the Info menu.</li>
<li class="mb-4">
<img src="/images/InfoIcon.svg" class="inline-block icon" style="fill: white; background: transparent;"/> - Open the Info menu.</li>
<li class="mb-4"><div class="inline-block icon boat-icon"></div> - Send a boat to attack at the selected location (only available if you have access to water).</li>
<li class="mb-4"><div class="inline-block icon cancel-icon"></div> - Close the menu.</li>
</ul>
@@ -455,5 +474,6 @@ export class HelpModal extends LitElement {
public close() {
this.isModalOpen = false;
console.log("closing modal");
}
}
+61 -45
View File
@@ -1,13 +1,16 @@
import { LitElement, html, css } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { Difficulty, GameMapType, GameType } from "../core/game/Game";
import { Lobby } from "../core/Schemas";
import { GameConfig, GameInfo } from "../core/Schemas";
import { consolex } from "../core/Consolex";
import "./components/Difficulties";
import { DifficultyDescription } from "./components/Difficulties";
import "./components/Maps";
import { generateID } from "../core/Util";
import { getConfig, getServerConfig } from "../core/configuration/Config";
import randomMap from "../../resources/images/RandomMap.png";
@customElement("host-lobby-modal")
export class HostLobbyModal extends LitElement {
@state() private isModalOpen = false;
@@ -323,6 +326,11 @@ export class HostLobbyModal extends LitElement {
class="modal-overlay"
style="display: ${this.isModalOpen ? "flex" : "none"}"
>
<div
style="position: absolute; left: 0px; top: 0px; width: 100%; height: 100%;"
class="${this.isModalOpen ? "" : "hidden"}"
@click=${this.close}
></div>
<div class="modal-content">
<span class="close" @click=${this.close}>&times;</span>
@@ -430,7 +438,7 @@ export class HostLobbyModal extends LitElement {
step="1"
@input=${this.handleBotsChange}
@change=${this.handleBotsChange}
.value="${this.bots}"
.value="${String(this.bots)}"
/>
<div class="option-card-title">
Bots: ${this.bots == 0 ? "Disabled" : this.bots}
@@ -526,7 +534,7 @@ export class HostLobbyModal extends LitElement {
public open() {
createLobby()
.then((lobby) => {
this.lobbyId = lobby.id;
this.lobbyId = lobby.gameID;
// join lobby
})
.then(() => {
@@ -535,7 +543,7 @@ export class HostLobbyModal extends LitElement {
detail: {
gameType: GameType.Private,
lobby: {
id: this.lobbyId,
gameID: this.lobbyId,
},
map: this.selectedMap,
difficulty: this.selectedDifficulty,
@@ -604,21 +612,24 @@ export class HostLobbyModal extends LitElement {
}
private async putGameConfig() {
const response = await fetch(`/private_lobby/${this.lobbyId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
const response = await fetch(
`${window.location.origin}/${getServerConfig().workerPath(this.lobbyId)}/game/${this.lobbyId}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
gameMap: this.selectedMap,
difficulty: this.selectedDifficulty,
disableNPCs: this.disableNPCs,
bots: this.bots,
infiniteGold: this.infiniteGold,
infiniteTroops: this.infiniteTroops,
instantBuild: this.instantBuild,
} as GameConfig),
},
body: JSON.stringify({
gameMap: this.selectedMap,
difficulty: this.selectedDifficulty,
disableNPCs: this.disableNPCs,
bots: this.bots,
infiniteGold: this.infiniteGold,
infiniteTroops: this.infiniteTroops,
instantBuild: this.instantBuild,
}),
});
);
}
private getRandomMap(): GameMapType {
@@ -637,12 +648,15 @@ export class HostLobbyModal extends LitElement {
`Starting private game with map: ${GameMapType[this.selectedMap]} ${this.useRandomMap ? " (Randomly selected)" : ""}`,
);
this.close();
const response = await fetch(`/start_private_lobby/${this.lobbyId}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
const response = await fetch(
`${window.location.origin}/${getServerConfig().workerPath(this.lobbyId)}/start_game/${this.lobbyId}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
},
});
);
}
private async copyToClipboard() {
@@ -656,34 +670,42 @@ export class HostLobbyModal extends LitElement {
this.copySuccess = false;
}, 2000);
} catch (err) {
consolex.error("Failed to copy text: ", err);
consolex.error(`Failed to copy text: ${err}`);
}
}
private async pollPlayers() {
fetch(`/lobby/${this.lobbyId}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
fetch(
`/${getServerConfig().workerPath(this.lobbyId)}/game/${this.lobbyId}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
},
},
})
)
.then((response) => response.json())
.then((data) => {
.then((data: GameInfo) => {
console.log(`got response: ${data}`);
this.players = data.players.map((p) => p.username);
this.players = data.clients.map((p) => p.username);
});
}
}
async function createLobby(): Promise<Lobby> {
async function createLobby(): Promise<GameInfo> {
const serverConfig = getServerConfig();
try {
const response = await fetch("/private_lobby", {
method: "POST",
headers: {
"Content-Type": "application/json",
const id = generateID();
const response = await fetch(
`/${serverConfig.workerPath(id)}/create_game/${id}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
// body: JSON.stringify(data), // Include this if you need to send data
},
// body: JSON.stringify(data), // Include this if you need to send data
});
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
@@ -692,13 +714,7 @@ async function createLobby(): Promise<Lobby> {
const data = await response.json();
consolex.log("Success:", data);
// Assuming the server returns an object with an 'id' property
const lobby: Lobby = {
id: data.id,
// Add other properties as needed
};
return lobby;
return data as GameInfo;
} catch (error) {
consolex.error("Error creating lobby:", error);
throw error; // Re-throw the error so the caller can handle it
+24 -2
View File
@@ -1,5 +1,4 @@
import { EventBus, GameEvent } from "../core/EventBus";
import { Game } from "../core/game/Game";
export class MouseUpEvent implements GameEvent {
constructor(
@@ -63,6 +62,10 @@ export class AttackRatioEvent implements GameEvent {
constructor(public readonly attackRatio: number) {}
}
export class CenterCameraEvent implements GameEvent {
constructor() {}
}
export class InputHandler {
private lastPointerX: number = 0;
private lastPointerY: number = 0;
@@ -95,6 +98,9 @@ export class InputHandler {
this.canvas.addEventListener("wheel", (e) => this.onScroll(e), {
passive: false,
});
this.canvas.addEventListener("wheel", (e) => this.onShiftScroll(e), {
passive: false,
});
window.addEventListener("pointermove", this.onPointerMove.bind(this));
this.canvas.addEventListener("contextmenu", (e: MouseEvent) => {
this.onContextMenu(e);
@@ -173,6 +179,7 @@ export class InputHandler {
"KeyQ",
"Digit1",
"Digit2",
"KeyC",
].includes(e.code)
) {
this.activeKeys.add(e.code);
@@ -200,6 +207,11 @@ export class InputHandler {
this.eventBus.emit(new AttackRatioEvent(10));
}
if (e.code === "KeyC") {
e.preventDefault();
this.eventBus.emit(new CenterCameraEvent());
}
// Remove all movement keys from activeKeys
if (
[
@@ -217,6 +229,7 @@ export class InputHandler {
"KeyQ",
"Digit1",
"Digit2",
"KeyC",
].includes(e.code)
) {
this.activeKeys.delete(e.code);
@@ -272,7 +285,16 @@ export class InputHandler {
}
private onScroll(event: WheelEvent) {
this.eventBus.emit(new ZoomEvent(event.x, event.y, event.deltaY));
if (!event.shiftKey) {
this.eventBus.emit(new ZoomEvent(event.x, event.y, event.deltaY));
}
}
private onShiftScroll(event: WheelEvent) {
if (event.shiftKey) {
const ratio = event.deltaY > 0 ? -10 : 10;
this.eventBus.emit(new AttackRatioEvent(ratio));
}
}
private onPointerMove(event: PointerEvent) {
+26 -13
View File
@@ -1,7 +1,9 @@
import { LitElement, html, css } from "lit";
import { customElement, property, state, query } from "lit/decorators.js";
import { GameMapType, GameType } from "../core/game/Game";
import { LitElement, css, html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { getServerConfig } from "../core/configuration/Config";
import { consolex } from "../core/Consolex";
import { GameMapType, GameType } from "../core/game/Game";
import { GameInfo } from "../core/Schemas";
@customElement("join-private-lobby-modal")
export class JoinPrivateLobbyModal extends LitElement {
@@ -231,6 +233,11 @@ export class JoinPrivateLobbyModal extends LitElement {
class="modal-overlay"
style="display: ${this.isModalOpen ? "flex" : "none"}"
>
<div
style="position: absolute; left: 0px; top: 0px; width: 100%; height: 100%;"
class="${this.isModalOpen ? "" : "hidden"}"
@click=${this.close}
></div>
<div class="modal-content">
<span class="close" @click=${this.closeAndLeave}>&times;</span>
<div class="title">Join Private Lobby</div>
@@ -358,13 +365,16 @@ export class JoinPrivateLobbyModal extends LitElement {
consolex.log(`Joining lobby with ID: ${lobbyId}`);
this.message = "Checking lobby..."; // Set initial message
fetch(`/lobby/${lobbyId}/exists`, {
const url = `/${getServerConfig().workerPath(lobbyId)}/game/${lobbyId}/exists`;
fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
})
.then((response) => response.json())
.then((response) => {
return response.json();
})
.then((data) => {
if (data.exists) {
this.message = "Joined successfully! Waiting for game to start...";
@@ -372,7 +382,7 @@ export class JoinPrivateLobbyModal extends LitElement {
this.dispatchEvent(
new CustomEvent("join-lobby", {
detail: {
lobby: { id: lobbyId },
lobby: { gameID: lobbyId },
gameType: GameType.Private,
map: GameMapType.World,
},
@@ -394,15 +404,18 @@ export class JoinPrivateLobbyModal extends LitElement {
private async pollPlayers() {
if (!this.lobbyIdInput?.value) return;
fetch(`/lobby/${this.lobbyIdInput.value}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
fetch(
`/${getServerConfig().workerPath(this.lobbyIdInput.value)}/game/${this.lobbyIdInput.value}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
},
},
})
)
.then((response) => response.json())
.then((data) => {
this.players = data.players.map((p) => p.username);
.then((data: GameInfo) => {
this.players = data.clients.map((p) => p.username);
})
.catch((error) => {
consolex.error("Error polling players:", error);
+8 -2
View File
@@ -1,4 +1,9 @@
import { Config, GameEnv, ServerConfig } from "../core/configuration/Config";
import {
Config,
GameEnv,
getServerConfig,
ServerConfig,
} from "../core/configuration/Config";
import { consolex } from "../core/Consolex";
import { GameEvent } from "../core/EventBus";
import {
@@ -125,6 +130,7 @@ export class LocalServer {
const blob = new Blob([JSON.stringify(GameRecordSchema.parse(record))], {
type: "application/json",
});
navigator.sendBeacon("/archive_singleplayer_game", blob);
const workerPath = getServerConfig().workerPath(this.lobbyConfig.gameID);
navigator.sendBeacon(`/${workerPath}/archive_singleplayer_game`, blob);
}
}
+9 -13
View File
@@ -17,6 +17,7 @@ import { PublicLobby } from "./PublicLobby";
import { UserSettings } from "../core/game/UserSettings";
import "./DarkModeButton";
import { DarkModeButton } from "./DarkModeButton";
import "./GoogleAdElement";
import { HelpModal } from "./HelpModal";
import { GameType } from "../core/game/Game";
@@ -65,10 +66,6 @@ class Client {
setFavicon();
document.addEventListener("join-lobby", this.handleJoinLobby.bind(this));
document.addEventListener("leave-lobby", this.handleLeaveLobby.bind(this));
document.addEventListener(
"single-player",
this.handleSinglePlayer.bind(this),
);
const spModal = document.querySelector(
"single-player-modal",
@@ -112,9 +109,9 @@ class Client {
});
if (this.userSettings.darkMode()) {
document.body.classList.add("dark");
document.documentElement.classList.add("dark");
} else {
document.body.classList.remove("dark");
document.documentElement.classList.remove("dark");
}
page("/join/:lobbyId", (ctx) => {
if (ctx.init && sessionStorage.getItem("inLobby")) {
@@ -143,9 +140,12 @@ class Client {
this.gameStop = joinLobby(
{
gameType: gameType,
flag: (): string => this.flagInput.getCurrentFlag(),
flag: (): string =>
this.flagInput.getCurrentFlag() == "xx"
? ""
: this.flagInput.getCurrentFlag(),
playerName: (): string => this.usernameInput.getCurrentUsername(),
gameID: lobby.id,
gameID: lobby.gameID,
persistentID: getPersistentIDFromCookie(),
playerID: generateID(),
clientID: generateID(),
@@ -161,7 +161,7 @@ class Client {
this.joinModal.close();
this.publicLobby.stop();
if (gameType != GameType.Singleplayer) {
window.history.pushState({}, "", `/join/${lobby.id}`);
window.history.pushState({}, "", `/join/${lobby.gameID}`);
sessionStorage.setItem("inLobby", "true");
}
},
@@ -177,10 +177,6 @@ class Client {
this.gameStop = null;
this.publicLobby.leaveLobby();
}
private async handleSinglePlayer(event: CustomEvent) {
alert("coming soon");
}
}
// Initialize the client when the DOM is loaded
+10 -7
View File
@@ -1,16 +1,16 @@
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { Lobby } from "../core/Schemas";
import { Difficulty, GameMapType, GameType } from "../core/game/Game";
import { consolex } from "../core/Consolex";
import { getMapsImage } from "./utilities/Maps";
import { GameInfo } from "../core/Schemas";
@customElement("public-lobby")
export class PublicLobby extends LitElement {
@state() private lobbies: Lobby[] = [];
@state() private lobbies: GameInfo[] = [];
@state() public isLobbyHighlighted: boolean = false;
private lobbiesInterval: number | null = null;
private currLobby: Lobby = null;
private currLobby: GameInfo = null;
createRenderRoot() {
return this;
@@ -42,9 +42,9 @@ export class PublicLobby extends LitElement {
}
}
async fetchLobbies(): Promise<Lobby[]> {
async fetchLobbies(): Promise<GameInfo[]> {
try {
const response = await fetch("/lobbies");
const response = await fetch(`/public_lobbies`);
if (!response.ok)
throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
@@ -67,6 +67,9 @@ export class PublicLobby extends LitElement {
if (this.lobbies.length === 0) return html``;
const lobby = this.lobbies[0];
if (!lobby?.gameConfig) {
return;
}
const timeRemaining = Math.max(0, Math.floor(lobby.msUntilStart / 1000));
// Format time to show minutes and seconds
@@ -81,7 +84,7 @@ export class PublicLobby extends LitElement {
? "bg-gradient-to-r from-green-600 to-green-500"
: "bg-gradient-to-r from-blue-600 to-blue-500"} text-white font-medium rounded-xl transition-opacity duration-200 hover:opacity-90"
>
<div class="text-lg md:text-2xl font-semibold mb-2">Next Game</div>
<div class="text-lg md:text-2xl font-semibold mb-2">Join next Game</div>
<div class="flex">
<img
src="${getMapsImage(lobby.gameConfig.gameMap)}"
@@ -121,7 +124,7 @@ export class PublicLobby extends LitElement {
this.currLobby = null;
}
private lobbyClicked(lobby: Lobby) {
private lobbyClicked(lobby: GameInfo) {
if (this.currLobby == null) {
this.isLobbyHighlighted = true;
this.currLobby = lobby;
+6 -1
View File
@@ -256,6 +256,11 @@ export class SinglePlayerModal extends LitElement {
class="modal-overlay"
style="display: ${this.isModalOpen ? "flex" : "none"}"
>
<div
style="position: absolute; left: 0px; top: 0px; width: 100%; height: 100%;"
class="${this.isModalOpen ? "" : "hidden"}"
@click=${this.close}
></div>
<div class="modal-content">
<span class="close" @click=${this.close}>&times;</span>
@@ -476,7 +481,7 @@ export class SinglePlayerModal extends LitElement {
detail: {
gameType: GameType.Singleplayer,
lobby: {
id: generateID(),
gameID: generateID(),
},
map: this.selectedMap,
difficulty: this.selectedDifficulty,
+56 -3
View File
@@ -9,6 +9,7 @@ import {
Player,
PlayerID,
PlayerType,
Tick,
UnitType,
} from "../core/game/Game";
import {
@@ -23,6 +24,7 @@ import {
GameConfig,
ClientLogMessageSchema,
ClientSendWinnerSchema,
ClientMessageSchema,
} from "../core/Schemas";
import { LobbyConfig } from "./ClientGameRunner";
import { LocalServer } from "./LocalServer";
@@ -100,6 +102,14 @@ export class SendDonateIntentEvent implements GameEvent {
) {}
}
export class SendEmbargoIntentEvent implements GameEvent {
constructor(
public readonly sender: PlayerView,
public readonly target: PlayerView,
public readonly action: "start" | "stop",
) {}
}
export class SendSetTargetTroopRatioEvent implements GameEvent {
constructor(public readonly ratio: number) {}
}
@@ -107,6 +117,12 @@ export class SendSetTargetTroopRatioEvent implements GameEvent {
export class SendWinnerEvent implements GameEvent {
constructor(public readonly winner: ClientID) {}
}
export class SendHashEvent implements GameEvent {
constructor(
public readonly tick: Tick,
public readonly hash: number,
) {}
}
export class Transport {
private socket: WebSocket;
@@ -151,6 +167,9 @@ export class Transport {
);
this.eventBus.on(SendEmojiIntentEvent, (e) => this.onSendEmojiIntent(e));
this.eventBus.on(SendDonateIntentEvent, (e) => this.onSendDonateIntent(e));
this.eventBus.on(SendEmbargoIntentEvent, (e) =>
this.onSendEmbargoIntent(e),
);
this.eventBus.on(SendSetTargetTroopRatioEvent, (e) =>
this.onSendSetTargetTroopRatioEvent(e),
);
@@ -159,6 +178,7 @@ export class Transport {
this.eventBus.on(SendLogEvent, (e) => this.onSendLogEvent(e));
this.eventBus.on(PauseGameEvent, (e) => this.onPauseGameEvent(e));
this.eventBus.on(SendWinnerEvent, (e) => this.onSendWinnerEvent(e));
this.eventBus.on(SendHashEvent, (e) => this.onSendHashEvent(e));
}
private startPing() {
@@ -219,9 +239,10 @@ export class Transport {
) {
this.startPing();
this.maybeKillSocket();
const wsHost = process.env.WEBSOCKET_URL || window.location.host;
const wsHost = window.location.host;
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
this.socket = new WebSocket(`${wsProtocol}//${wsHost}`);
const workerPath = this.serverConfig.workerPath(this.lobbyConfig.gameID);
this.socket = new WebSocket(`${wsProtocol}//${wsHost}/${workerPath}`);
this.onconnect = onconnect;
this.onmessage = onmessage;
this.socket.onopen = () => {
@@ -237,7 +258,9 @@ export class Transport {
const serverMsg = ServerMessageSchema.parse(JSON.parse(event.data));
this.onmessage(serverMsg);
} catch (error) {
console.error("Failed to process server message:", error);
console.error(
`Failed to process server message ${event.data}: ${error}`,
);
}
};
this.socket.onerror = (err) => {
@@ -397,6 +420,16 @@ export class Transport {
});
}
private onSendEmbargoIntent(event: SendEmbargoIntentEvent) {
this.sendIntent({
type: "embargo",
clientID: this.lobbyConfig.clientID,
playerID: this.lobbyConfig.playerID,
targetID: event.target.id(),
action: event.action,
});
}
private onSendSetTargetTroopRatioEvent(event: SendSetTargetTroopRatioEvent) {
this.sendIntent({
type: "troop_ratio",
@@ -448,6 +481,26 @@ export class Transport {
}
}
private onSendHashEvent(event: SendHashEvent) {
if (this.isLocal || this.socket.readyState === WebSocket.OPEN) {
const msg = ClientMessageSchema.parse({
type: "hash",
clientID: this.lobbyConfig.clientID,
persistentID: this.lobbyConfig.persistentID,
gameID: this.lobbyConfig.gameID,
tick: event.tick,
hash: event.hash,
});
this.sendMsg(JSON.stringify(msg));
} else {
console.log(
"WebSocket is not open. Current state:",
this.socket.readyState,
);
console.log("attempting reconnect");
}
}
private sendIntent(intent: Intent) {
if (this.isLocal || this.socket.readyState === WebSocket.OPEN) {
const msg = ClientIntentMessageSchema.parse({
+26
View File
@@ -0,0 +1,26 @@
import { LitElement, html, css } from "lit";
import { customElement, property } from "lit/decorators";
@customElement("modal-overlay")
export class ModalOverlay extends LitElement {
@property({ reflect: true }) public visible: boolean = false;
static styles = css`
.overlay {
position: absolute;
left: 0px;
top: 0px;
width: 100%;
height: 100%;
}
`;
render() {
return html`
<div
class="overlay ${this.visible ? "" : "hidden"}"
@click=${() => (this.visible = false)}
></div>
`;
}
}
+10 -1
View File
@@ -5,7 +5,7 @@ import {
calculateBoundingBox,
calculateBoundingBoxCenter,
} from "../../core/Util";
import { ZoomEvent, DragEvent } from "../InputHandler";
import { ZoomEvent, DragEvent, CenterCameraEvent } from "../InputHandler";
import { GoToPlayerEvent } from "./layers/Leaderboard";
import { placeName } from "./NameBoxCalculator";
import { GameView } from "../../core/game/GameView";
@@ -27,6 +27,7 @@ export class TransformHandler {
this.eventBus.on(ZoomEvent, (e) => this.onZoom(e));
this.eventBus.on(DragEvent, (e) => this.onMove(e));
this.eventBus.on(GoToPlayerEvent, (e) => this.onGoToPlayer(e));
this.eventBus.on(CenterCameraEvent, () => this.centerCamera());
}
boundingRect(): DOMRect {
@@ -148,6 +149,14 @@ export class TransformHandler {
this.intervalID = setInterval(() => this.goTo(), 1);
}
centerCamera() {
this.clearTarget();
const player = this.game.myPlayer();
if (!player || !player.nameLocation()) return;
this.target = new Cell(player.nameLocation().x, player.nameLocation().y);
this.intervalID = setInterval(() => this.goTo(), 1);
}
private goTo() {
const { screenX, screenY } = this.screenCenter();
const screenMapCenter = new Cell(screenX, screenY);
+1 -1
View File
@@ -190,7 +190,7 @@ export class ControlPanel extends LitElement implements Layer {
</style>
<div
class="${this._isVisible
? "w-full text-sm lg:text-m lg:w-72 bg-gray-800/70 p-2 pr-3 lg:p-4 shadow-lg rounded-lg backdrop-blur"
? "w-full text-sm lg:text-m lg:w-72 bg-gray-800/70 p-2 pr-3 lg:p-4 shadow-lg lg:rounded-lg backdrop-blur"
: "hidden"}"
@contextmenu=${(e) => e.preventDefault()}
>
+36 -6
View File
@@ -25,6 +25,7 @@ import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { onlyImages, sanitize } from "../../../core/Util";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { renderTroops } from "../../Utils";
import { GoToPlayerEvent } from "./Leaderboard";
interface Event {
description: string;
@@ -33,6 +34,7 @@ interface Event {
text: string;
className: string;
action: () => void;
preventClose?: boolean;
}[];
type: MessageType;
highlight?: boolean;
@@ -53,6 +55,15 @@ export class EventsDisplay extends LitElement implements Layer {
@state() private incomingAttacks: AttackUpdate[] = [];
@state() private outgoingAttacks: AttackUpdate[] = [];
@state() private _hidden: boolean = false;
@state() private newEvents: number = 0;
private toggleHidden() {
this._hidden = !this._hidden;
if (this._hidden) {
this.newEvents = 0;
}
this.requestUpdate();
}
private updateMap = new Map([
[GameUpdateType.DisplayEvent, (u) => this.onDisplayMessageEvent(u)],
@@ -63,7 +74,7 @@ export class EventsDisplay extends LitElement implements Layer {
],
[GameUpdateType.BrokeAlliance, (u) => this.onBrokeAllianceEvent(u)],
[GameUpdateType.TargetPlayer, (u) => this.onTargetPlayerEvent(u)],
[GameUpdateType.EmojiUpdate, (u) => this.onEmojiMessageEvent(u)],
[GameUpdateType.Emoji, (u) => this.onEmojiMessageEvent(u)],
]);
constructor() {
@@ -119,6 +130,9 @@ export class EventsDisplay extends LitElement implements Layer {
private addEvent(event: Event) {
this.events = [...this.events, event];
if (this._hidden == true) {
this.newEvents++;
}
this.requestUpdate();
}
@@ -169,6 +183,12 @@ export class EventsDisplay extends LitElement implements Layer {
this.addEvent({
description: `${requestor.name()} requests an alliance!`,
buttons: [
{
text: "Focus",
className: "btn-gray",
action: () => this.eventBus.emit(new GoToPlayerEvent(requestor)),
preventClose: true,
},
{
text: "Accept",
className: "btn",
@@ -397,7 +417,7 @@ export class EventsDisplay extends LitElement implements Layer {
<div
class="${this._hidden
? "w-fit px-[10px] py-[5px]"
: ""} rounded-md bg-black bg-opacity-60 relative max-h-[30vh] flex flex-col-reverse overflow-y-auto w-full lg:bottom-2.5 lg:right-2.5 z-50 lg:max-w-3xl lg:w-full lg:w-auto"
: ""} rounded-md bg-black bg-opacity-60 relative max-h-[30vh] flex flex-col-reverse overflow-y-auto w-full lg:bottom-2.5 lg:right-2.5 z-50 lg:max-w-[30vw] lg:w-full lg:w-auto"
>
<div>
<div class="w-full bg-black/80 sticky top-0 px-[10px]">
@@ -406,7 +426,7 @@ export class EventsDisplay extends LitElement implements Layer {
._hidden
? "hidden"
: ""}"
@click=${() => (this._hidden = true)}
@click=${this.toggleHidden}
>
Hide
</button>
@@ -415,9 +435,15 @@ export class EventsDisplay extends LitElement implements Layer {
class="text-white cursor-pointer pointer-events-auto ${this._hidden
? ""
: "hidden"}"
@click=${() => (this._hidden = false)}
@click=${this.toggleHidden}
>
Events
<span
class="${this.newEvents
? ""
: "hidden"} inline-block px-2 bg-red-500 rounded-sm"
>${this.newEvents}</span
>
</button>
<table
class="w-full border-collapse text-white shadow-lg lg:text-xl text-xs ${this
@@ -447,10 +473,14 @@ export class EventsDisplay extends LitElement implements Layer {
class="inline-block px-3 py-1 text-white rounded text-sm cursor-pointer transition-colors duration-300
${btn.className.includes("btn-info")
? "bg-blue-500 hover:bg-blue-600"
: "bg-green-600 hover:bg-green-700"}"
: btn.className.includes("btn-gray")
? "bg-gray-500 hover:bg-gray-600"
: "bg-green-600 hover:bg-green-700"}"
@click=${() => {
btn.action();
this.removeEvent(index);
if (!btn.preventClose) {
this.removeEvent(index);
}
this.requestUpdate();
}}
>
+60 -24
View File
@@ -1,17 +1,18 @@
import { LitElement, html, css } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { Layer } from "./Layer";
import { ClientID } from "../../../core/Schemas";
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { EventBus, GameEvent } from "../../../core/EventBus";
import { renderNumber } from "../../Utils";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { ClientID } from "../../../core/Schemas";
import { renderNumber } from "../../Utils";
import { Layer } from "./Layer";
interface Entry {
name: string;
position: number;
score: string;
gold: string;
troops: string;
isMyPlayer: boolean;
player: PlayerView;
}
@@ -26,6 +27,13 @@ export class Leaderboard extends LitElement implements Layer {
public clientID: ClientID;
public eventBus: EventBus;
players: Entry[] = [];
@state()
private _leaderboardHidden = true;
private _shownOnInit = false;
private showTopFive = true;
init() {}
tick() {
@@ -58,14 +66,25 @@ export class Leaderboard extends LitElement implements Layer {
const numTilesWithoutFallout =
this.game.numLandTiles() - this.game.numTilesWithFallout();
this.players = sorted.slice(0, 5).map((player, index) => ({
name: player.displayName(),
position: index + 1,
score: formatPercentage(player.numTilesOwned() / numTilesWithoutFallout),
gold: renderNumber(player.gold()),
isMyPlayer: player == myPlayer,
player: player,
}));
const playersToShow = this.showTopFive ? sorted.slice(0, 5) : sorted;
this.players = playersToShow.map((player, index) => {
let troops = player.troops() / 10;
if (!player.isAlive()) {
troops = 0;
}
return {
name: player.displayName(),
position: index + 1,
score: formatPercentage(
player.numTilesOwned() / numTilesWithoutFallout,
),
gold: renderNumber(player.gold()),
troops: renderNumber(troops),
isMyPlayer: player == myPlayer,
player: player,
};
});
if (myPlayer != null && this.players.find((p) => p.isMyPlayer) == null) {
let place = 0;
@@ -76,6 +95,10 @@ export class Leaderboard extends LitElement implements Layer {
}
}
let myPlayerTroops = myPlayer.troops() / 10;
if (!myPlayer.isAlive()) {
myPlayerTroops = 0;
}
this.players.pop();
this.players.push({
name: myPlayer.displayName(),
@@ -84,6 +107,7 @@ export class Leaderboard extends LitElement implements Layer {
myPlayer.numTilesOwned() / this.game.numLandTiles(),
),
gold: renderNumber(myPlayer.gold()),
troops: renderNumber(myPlayerTroops),
isMyPlayer: true,
player: myPlayer,
});
@@ -119,10 +143,10 @@ export class Leaderboard extends LitElement implements Layer {
padding-top: 0px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
border-radius: 10px;
max-width: 300px;
max-height: 80vh;
max-width: 500px;
max-height: 30vh;
overflow-y: auto;
width: 300px;
width: 400px;
backdrop-filter: blur(5px);
}
table {
@@ -180,6 +204,13 @@ export class Leaderboard extends LitElement implements Layer {
cursor: pointer;
}
.leaderboard-top-five-button {
background: none;
border: none;
color: white;
cursor: pointer;
}
.player-name {
max-width: 10ch;
overflow: hidden;
@@ -188,7 +219,8 @@ export class Leaderboard extends LitElement implements Layer {
@media (max-width: 1000px) {
.leaderboard {
top: 60px;
top: 70px;
left: 0px;
}
.leaderboard-button {
@@ -198,13 +230,6 @@ export class Leaderboard extends LitElement implements Layer {
}
`;
players: Entry[] = [];
@state()
private _leaderboardHidden = true;
private _shownOnInit = false;
render() {
return html`
<button
@@ -225,6 +250,15 @@ export class Leaderboard extends LitElement implements Layer {
>
Hide
</button>
<button
class="leaderboard-top-five-button"
@click=${() => {
this.showTopFive = !this.showTopFive;
this.updateLeaderboard();
}}
>
${this.showTopFive ? "Show All" : "Show Top 5"}
</button>
<table>
<thead>
<tr>
@@ -232,6 +266,7 @@ export class Leaderboard extends LitElement implements Layer {
<th>Player</th>
<th>Owned</th>
<th>Gold</th>
<th>Troops</th>
</tr>
</thead>
<tbody>
@@ -245,6 +280,7 @@ export class Leaderboard extends LitElement implements Layer {
<td class="player-name">${unsafeHTML(player.name)}</td>
<td>${player.score}</td>
<td>${player.gold}</td>
<td>${player.troops}</td>
</tr>
`,
)}
+44
View File
@@ -11,8 +11,10 @@ import { Layer } from "./Layer";
import { TransformHandler } from "../TransformHandler";
import traitorIcon from "../../../../resources/images/TraitorIcon.svg";
import allianceIcon from "../../../../resources/images/AllianceIcon.svg";
import allianceRequestIcon from "../../../../resources/images/AllianceRequestIcon.svg";
import crownIcon from "../../../../resources/images/CrownIcon.svg";
import targetIcon from "../../../../resources/images/TargetIcon.svg";
import embargoIcon from "../../../../resources/images/EmbargoIcon.svg";
import { ClientID } from "../../../core/Schemas";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { createCanvas, renderTroops } from "../../Utils";
@@ -40,9 +42,11 @@ export class NameLayer implements Layer {
private renders: RenderInfo[] = [];
private seenPlayers: Set<PlayerView> = new Set();
private traitorIconImage: HTMLImageElement;
private allianceRequestIconImage: HTMLImageElement;
private allianceIconImage: HTMLImageElement;
private targetIconImage: HTMLImageElement;
private crownIconImage: HTMLImageElement;
private embargoIconImage: HTMLImageElement;
private container: HTMLDivElement;
private myPlayer: PlayerView | null = null;
private firstPlace: PlayerView | null = null;
@@ -57,10 +61,14 @@ export class NameLayer implements Layer {
this.traitorIconImage.src = traitorIcon;
this.allianceIconImage = new Image();
this.allianceIconImage.src = allianceIcon;
this.allianceRequestIconImage = new Image();
this.allianceRequestIconImage.src = allianceRequestIcon;
this.crownIconImage = new Image();
this.crownIconImage.src = crownIcon;
this.targetIconImage = new Image();
this.targetIconImage.src = targetIcon;
this.embargoIconImage = new Image();
this.embargoIconImage.src = embargoIcon;
}
resizeCanvas() {
@@ -162,6 +170,7 @@ export class NameLayer implements Layer {
iconsDiv.style.justifyContent = "center";
iconsDiv.style.alignItems = "center";
iconsDiv.style.zIndex = "2";
iconsDiv.style.opacity = "0.8";
element.appendChild(iconsDiv);
const nameDiv = document.createElement("div");
@@ -314,6 +323,23 @@ export class NameLayer implements Layer {
existingAlliance.remove();
}
// Alliance request icon
const data = '[data-icon="alliance-request"]';
const existingRequestAlliance = iconsDiv.querySelector(data);
if (myPlayer != null && render.player.isRequestingAllianceWith(myPlayer)) {
if (!existingRequestAlliance) {
iconsDiv.appendChild(
this.createIconElement(
this.allianceRequestIconImage.src,
iconSize,
"alliance-request",
),
);
}
} else if (existingRequestAlliance) {
existingRequestAlliance.remove();
}
// Target icon
const existingTarget = iconsDiv.querySelector('[data-icon="target"]');
if (
@@ -359,6 +385,24 @@ export class NameLayer implements Layer {
existingEmoji.remove();
}
const existingEmbargo = iconsDiv.querySelector('[data-icon="embargo"]');
const hasEmbargo =
render.player.hasEmbargoAgainst(myPlayer) ||
myPlayer.hasEmbargoAgainst(render.player);
if (myPlayer && hasEmbargo) {
if (!existingEmbargo) {
iconsDiv.appendChild(
this.createIconElement(
this.embargoIconImage.src,
iconSize,
"embargo",
),
);
}
} else if (existingEmbargo) {
existingEmbargo.remove();
}
// Update all icon sizes
const icons = iconsDiv.getElementsByTagName("img");
for (const icon of icons) {
+2 -2
View File
@@ -107,7 +107,7 @@ export class OptionsMenu extends LitElement implements Layer {
tick() {
this.hasWinner =
this.hasWinner ||
this.game.updatesSinceLastTick()[GameUpdateType.WinUpdate].length > 0;
this.game.updatesSinceLastTick()[GameUpdateType.Win].length > 0;
if (this.game.inSpawnPhase()) {
this.timer = 0;
} else if (!this.hasWinner && this.game.ticks() % 10 == 0) {
@@ -127,7 +127,7 @@ export class OptionsMenu extends LitElement implements Layer {
@contextmenu=${(e) => e.preventDefault()}
>
<div
class="bg-opacity-60 bg-gray-900 p-1 lg:p-2 rounded-lg backdrop-blur-md"
class="bg-opacity-60 bg-gray-900 p-1 lg:p-2 rounded-es-sm lg:rounded-lg backdrop-blur-md"
>
<div class="flex items-stretch gap-1 lg:gap-2">
${button({
+98 -12
View File
@@ -4,7 +4,12 @@ import { EventBus } from "../../../core/EventBus";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { Layer } from "./Layer";
import { MouseUpEvent } from "../../InputHandler";
import { AllPlayers, Player, PlayerActions } from "../../../core/game/Game";
import {
AllPlayers,
Player,
PlayerActions,
UnitType,
} from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { renderNumber, renderTroops } from "../../Utils";
import targetIcon from "../../../../resources/images/TargetIconWhite.svg";
@@ -18,6 +23,7 @@ import {
SendDonateIntentEvent,
SendEmojiIntentEvent,
SendTargetPlayerIntentEvent,
SendEmbargoIntentEvent,
} from "../../Transport";
import { EmojiTable } from "./EmojiTable";
@@ -76,6 +82,26 @@ export class PlayerPanel extends LitElement implements Layer {
this.hide();
}
private handleEmbargoClick(
e: Event,
myPlayer: PlayerView,
other: PlayerView,
) {
e.stopPropagation();
this.eventBus.emit(new SendEmbargoIntentEvent(myPlayer, other, "start"));
this.hide();
}
private handleStopEmbargoClick(
e: Event,
myPlayer: PlayerView,
other: PlayerView,
) {
e.stopPropagation();
this.eventBus.emit(new SendEmbargoIntentEvent(myPlayer, other, "stop"));
this.hide();
}
private handleEmojiClick(e: Event, myPlayer: PlayerView, other: PlayerView) {
e.stopPropagation();
this.emojiTable.showTable((emoji: string) => {
@@ -107,6 +133,24 @@ export class PlayerPanel extends LitElement implements Layer {
this.requestUpdate();
}
getTotalNukesSent(): number {
const stats = this.actions.interaction?.stats;
if (!stats) {
return 0;
}
let sum = 0;
const nukes = stats.sentNukes[this.g.myPlayer().id()];
if (!nukes) {
return 0;
}
for (const nukeType in nukes) {
if (nukeType != UnitType.MIRVWarhead) {
sum += nukes[nukeType];
}
}
return sum;
}
render() {
if (!this.isVisible) {
return html``;
@@ -131,6 +175,7 @@ export class PlayerPanel extends LitElement implements Layer {
: this.actions.interaction?.canSendEmoji;
const canBreakAlliance = this.actions.interaction?.canBreakAlliance;
const canTarget = this.actions.interaction?.canTarget;
const canEmbargo = this.actions.interaction?.canEmbargo;
return html`
<div
@@ -143,7 +188,7 @@ export class PlayerPanel extends LitElement implements Layer {
<!-- Close button -->
<button
@click=${this.handleClose}
class="absolute -top-2 -right-2 w-6 h-6 flex items-center justify-center
class="absolute -top-2 -right-2 w-6 h-6 flex items-center justify-center
bg-red-500 hover:bg-red-600 text-white rounded-full
text-sm font-bold transition-colors"
>
@@ -155,7 +200,7 @@ export class PlayerPanel extends LitElement implements Layer {
<div class="flex items-center gap-1 lg:gap-2">
<div
class="px-4 h-8 lg:h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 text-opacity-90 text-white
bg-opacity-50 bg-gray-700 text-opacity-90 text-white
rounded text-sm lg:text-xl w-full"
>
${other?.name()}
@@ -190,12 +235,32 @@ export class PlayerPanel extends LitElement implements Layer {
</div>
</div>
<!-- Embargo -->
<div class="flex flex-col gap-1">
<div class="text-white text-opacity-80 text-sm px-2">
Embargo against you
</div>
<div class="bg-opacity-50 bg-gray-700 rounded p-2 text-white">
${other.hasEmbargoAgainst(myPlayer) ? "Yes" : "No"}
</div>
</div>
<!-- Stats -->
<div class="flex flex-col gap-1">
<div class="text-white text-opacity-80 text-sm px-2">
Nukes sent by them to you
</div>
<div class="bg-opacity-50 bg-gray-700 rounded p-2 text-white">
${this.getTotalNukesSent()}
</div>
</div>
<!-- Action buttons -->
<div class="flex justify-center gap-2">
${canTarget
? html`<button
@click=${(e) => this.handleTargetClick(e, other)}
class="w-10 h-10 flex items-center justify-center
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
text-white rounded-lg transition-colors"
>
@@ -206,8 +271,8 @@ export class PlayerPanel extends LitElement implements Layer {
? html`<button
@click=${(e) =>
this.handleBreakAllianceClick(e, myPlayer, other)}
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
text-white rounded-lg transition-colors"
>
<img
@@ -221,8 +286,8 @@ export class PlayerPanel extends LitElement implements Layer {
? html`<button
@click=${(e) =>
this.handleAllianceClick(e, myPlayer, other)}
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
text-white rounded-lg transition-colors"
>
<img src=${allianceIcon} alt="Alliance" class="w-6 h-6" />
@@ -231,8 +296,8 @@ export class PlayerPanel extends LitElement implements Layer {
${canDonate
? html`<button
@click=${(e) => this.handleDonateClick(e, myPlayer, other)}
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
text-white rounded-lg transition-colors"
>
<img src=${donateIcon} alt="Donate" class="w-6 h-6" />
@@ -241,14 +306,35 @@ export class PlayerPanel extends LitElement implements Layer {
${canSendEmoji
? html`<button
@click=${(e) => this.handleEmojiClick(e, myPlayer, other)}
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
text-white rounded-lg transition-colors"
>
<img src=${emojiIcon} alt="Emoji" class="w-6 h-6" />
</button>`
: ""}
</div>
${canEmbargo && other != myPlayer
? html`<button
@click=${(e) => this.handleEmbargoClick(e, myPlayer, other)}
class="w-100 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
text-white rounded-lg transition-colors"
>
Start embargo
</button>`
: ""}
${!canEmbargo && other != myPlayer
? html`<button
@click=${(e) =>
this.handleStopEmbargoClick(e, myPlayer, other)}
class="w-100 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
text-white rounded-lg transition-colors"
>
Stop embargo
</button>`
: ""}
</div>
</div>
</div>
+19 -2
View File
@@ -8,6 +8,9 @@ import { renderNumber, renderTroops } from "../../Utils";
export class TopBar extends LitElement implements Layer {
public game: GameView;
private isVisible = false;
private _population = 0;
private _lastPopulationIncreaseRate = 0;
private _popRateIsIncreasing = false;
createRenderRoot() {
return this;
@@ -19,6 +22,15 @@ export class TopBar extends LitElement implements Layer {
}
tick() {
if (this.game?.myPlayer() !== null) {
const popIncreaseRate =
this.game.myPlayer().population() - this._population;
if (this.game.ticks() % 5 == 0) {
this._popRateIsIncreasing =
popIncreaseRate >= this._lastPopulationIncreaseRate;
this._lastPopulationIncreaseRate = popIncreaseRate;
}
}
this.requestUpdate();
}
@@ -38,7 +50,7 @@ export class TopBar extends LitElement implements Layer {
return html`
<div
class="fixed top-0 z-50 bg-gray-800/70 text-white text-sm p-1 rounded grid grid-cols-1 sm:grid-cols-2 w-1/2 sm:w-2/3 md:w-1/2 lg:hidden backdrop-blur"
class="fixed top-0 z-50 bg-gray-800/70 text-white text-sm p-1 rounded-ee-sm lg:rounded grid grid-cols-1 sm:grid-cols-2 w-1/2 sm:w-2/3 md:w-1/2 lg:hidden backdrop-blur"
>
<!-- Pop section (takes 2 columns on desktop) -->
<div
@@ -49,7 +61,12 @@ export class TopBar extends LitElement implements Layer {
>${renderTroops(myPlayer.population())} /
${renderTroops(maxPop)}</span
>
<span>(+${renderTroops(popRate)})</span>
<span
class="${this._popRateIsIncreasing
? "text-green-500"
: "text-yellow-500"}"
>(+${renderTroops(popRate)})</span
>
</div>
<!-- Gold section (takes 1 column on desktop) -->
<div
+48 -27
View File
@@ -34,12 +34,13 @@ export class WinModal extends LitElement implements Layer {
private _title: string;
private won: boolean;
static styles = css`
:host {
display: block;
}
// Override to prevent shadow DOM creation
createRenderRoot() {
return this;
}
.modal {
static styles = css`
.win-modal {
display: none;
position: fixed;
top: 50%;
@@ -58,7 +59,7 @@ export class WinModal extends LitElement implements Layer {
visibility 0.3s ease-in-out;
}
.modal.visible {
.win-modal.visible {
display: block;
animation: fadeIn 0.3s ease-out;
}
@@ -74,14 +75,14 @@ export class WinModal extends LitElement implements Layer {
}
}
h2 {
.win-modal h2 {
margin: 0 0 15px 0;
font-size: 24px;
text-align: center;
color: white;
}
p {
.win-modal p {
margin: 0 0 20px 0;
text-align: center;
background-color: rgba(0, 0, 0, 0.3);
@@ -95,7 +96,7 @@ export class WinModal extends LitElement implements Layer {
gap: 10px;
}
button {
.win-modal button {
flex: 1;
padding: 12px;
font-size: 16px;
@@ -109,38 +110,46 @@ export class WinModal extends LitElement implements Layer {
transform 0.1s ease;
}
button:hover {
.win-modal button:hover {
background: rgba(0, 150, 255, 0.8);
transform: translateY(-1px);
}
button:active {
.win-modal button:active {
transform: translateY(1px);
}
@media (max-width: 768px) {
.modal {
.win-modal {
width: 90%;
max-width: 300px;
padding: 20px;
}
h2 {
.win-modal h2 {
font-size: 20px;
}
button {
.win-modal button {
padding: 10px;
font-size: 14px;
}
}
`;
constructor() {
super();
// Add styles to document
const styleEl = document.createElement("style");
styleEl.textContent = WinModal.styles.toString();
document.head.appendChild(styleEl);
}
render() {
return html`
<div class="modal ${this.isVisible ? "visible" : ""}">
<h2>${this._title}</h2>
<div>${this.won ? this.supportHTML() : this.adsHTML()}</div>
<div class="win-modal ${this.isVisible ? "visible" : ""}">
<h2>${this._title || ""}</h2>
${this.won ? this.supportHTML() : this.adsHTML()}
<div class="button-container">
<button @click=${this._handleExit}>Exit Game</button>
<button @click=${this.hide}>Keep Playing</button>
@@ -149,24 +158,40 @@ export class WinModal extends LitElement implements Layer {
`;
}
adsHTML(): ReturnType<typeof html> {
updated(changedProperties) {
super.updated(changedProperties);
// Initialize ads if modal is visible and showing ads
if (changedProperties.has("isVisible") && this.isVisible && !this.won) {
try {
setTimeout(() => {
(adsbygoogle = window.adsbygoogle || []).push({});
}, 0);
} catch (error) {
console.error("Error initializing ad:", error);
}
}
}
adsHTML() {
return html`<ins
class="adsbygoogle"
style="display:block"
data-ad-client="ca-pub-7035513310742290"
data-ad-slot="3772893937"
data-ad-slot="winmodalad"
data-ad-format="auto"
data-full-width-responsive="true"
></ins>`;
}
supportHTML(): ReturnType<typeof html> {
supportHTML() {
return html`
<div style="text-align: center; margin: 15px 0;">
<p>
Like the game? Help make this my full-time project!
<a
href="https://discord.com/channels/1284581928254701718/shop/1330243291366559744"
href="https://discord.gg/k22YrnAzGp"
target="_blank"
rel="noopener noreferrer"
style="color: #0096ff; text-decoration: underline; display: block; margin-top: 5px;"
>
Support the game!
@@ -201,14 +226,10 @@ export class WinModal extends LitElement implements Layer {
this.hasShownDeathModal = true;
this._title = "You died";
this.won = false;
try {
(adsbygoogle = window.adsbygoogle || []).push({});
} catch (error) {
console.error("Error initializing ad:", error);
}
this.show();
}
this.game.updatesSinceLastTick()[GameUpdateType.WinUpdate].forEach((wu) => {
this.game.updatesSinceLastTick()[GameUpdateType.Win].forEach((wu) => {
const winner = this.game.playerBySmallID(wu.winnerID) as PlayerView;
this.eventBus.emit(new SendWinnerEvent(winner.clientID()));
if (winner == this.game.myPlayer()) {
+9 -3
View File
@@ -153,19 +153,20 @@
</g>
</svg>
<div class="flex justify-center text-sm font-bold mt-[-5px] logo-version">
v0.16.0
v0.17.1
</div>
</div>
<div class="bg-image"></div>
<dark-mode-button></dark-mode-button>
<google-ad></google-ad>
<!-- Main container with responsive padding -->
<div class="flex justify-center items-center flex-grow">
<div class="container px-4 sm:px-6 lg:px-8 py-4 sm:py-6 lg:py-8">
<div
class="relative flex gap-1 items-center max-w-sm sm:max-w-md lg:max-w-lg xl:max-w-xl mx-auto p-2 pb-4"
class="flex gap-1 items-center max-w-sm sm:max-w-md lg:max-w-lg xl:max-w-xl mx-auto p-2 pb-4"
>
<flag-input class="w-[20%] md:w-[15%]"></flag-input>
<username-input class="w-full"></username-input>
@@ -174,8 +175,13 @@
<div class="max-w-sm sm:max-w-md lg:max-w-lg xl:max-w-xl mx-auto mt-4">
<a
href="https://discord.gg/k22YrnAzGp"
class="w-full bg-[#5865F2] hover:bg-[#4752C4] text-white p-3 sm:p-4 lg:p-5 font-medium text-lg sm:text-xl lg:text-2xl rounded-lg border-none cursor-pointer transition-colors duration-300 flex justify-center"
class="w-full bg-[#5865F2] hover:bg-[#4752C4] text-white p-3 sm:p-4 lg:p-5 font-medium text-lg sm:text-xl lg:text-2xl rounded-lg border-none cursor-pointer transition-colors duration-300 flex justify-center items-center gap-5"
>
<img
style="height: 50px; width: 50px"
alt="Discord"
src="../../resources/icons/discord.svg"
/>
Join the Discord!
</a>
</div>
+2
View File
@@ -167,6 +167,8 @@ export class GameRunner {
canSendAllianceRequest: player.canSendAllianceRequest(other),
canBreakAlliance: player.isAlliedWith(other),
canDonate: player.canDonate(other),
canEmbargo: !player.hasEmbargoAgainst(other),
stats: this.game.stats().getPlayerStats(other.id()),
};
}
+47 -14
View File
@@ -22,7 +22,8 @@ export type Intent =
| EmojiIntent
| DonateIntent
| TargetTroopRatioIntent
| BuildUnitIntent;
| BuildUnitIntent
| EmbargoIntent;
export type AttackIntent = z.infer<typeof AttackIntentSchema>;
export type SpawnIntent = z.infer<typeof SpawnIntentSchema>;
@@ -35,6 +36,7 @@ export type BreakAllianceIntent = z.infer<typeof BreakAllianceIntentSchema>;
export type TargetPlayerIntent = z.infer<typeof TargetPlayerIntentSchema>;
export type EmojiIntent = z.infer<typeof EmojiIntentSchema>;
export type DonateIntent = z.infer<typeof DonateIntentSchema>;
export type EmbargoIntent = z.infer<typeof EmbargoIntentSchema>;
export type TargetTroopRatioIntent = z.infer<
typeof TargetTroopRatioIntentSchema
>;
@@ -48,29 +50,44 @@ export type ClientMessage =
| ClientPingMessage
| ClientIntentMessage
| ClientJoinMessage
| ClientLogMessage;
| ClientLogMessage
| ClientHashMessage;
export type ServerMessage =
| ServerSyncMessage
| ServerStartGameMessage
| ServerPingMessage;
| ServerPingMessage
| ServerDesyncMessage;
export type ServerSyncMessage = z.infer<typeof ServerTurnMessageSchema>;
export type ServerStartGameMessage = z.infer<
typeof ServerStartGameMessageSchema
>;
export type ServerPingMessage = z.infer<typeof ServerPingMessageSchema>;
export type ServerDesyncMessage = z.infer<typeof ServerDesyncSchema>;
export type ClientSendWinnerMessage = z.infer<typeof ClientSendWinnerSchema>;
export type ClientPingMessage = z.infer<typeof ClientPingMessageSchema>;
export type ClientIntentMessage = z.infer<typeof ClientIntentMessageSchema>;
export type ClientJoinMessage = z.infer<typeof ClientJoinMessageSchema>;
export type ClientLogMessage = z.infer<typeof ClientLogMessageSchema>;
export type ClientHashMessage = z.infer<typeof ClientHashSchema>;
export type PlayerRecord = z.infer<typeof PlayerRecordSchema>;
export type GameRecord = z.infer<typeof GameRecordSchema>;
const PlayerTypeSchema = z.nativeEnum(PlayerType);
export interface GameInfo {
gameID: GameID;
clients?: ClientInfo[];
numClients?: number;
msUntilStart?: number;
gameConfig?: GameConfig;
}
export interface ClientInfo {
clientID: ClientID;
username: string;
}
export enum LogSeverity {
Debug = "DEBUG",
Info = "INFO",
@@ -79,15 +96,6 @@ export enum LogSeverity {
Fatal = "FATAL",
}
// TODO: create Cell schema
export interface Lobby {
id: string;
msUntilStart?: number;
numClients?: number;
gameConfig?: GameConfig;
}
const GameConfigSchema = z.object({
gameMap: z.nativeEnum(GameMapType),
difficulty: z.nativeEnum(Difficulty),
@@ -133,6 +141,7 @@ const BaseIntentSchema = z.object({
"emoji",
"troop_ratio",
"build_unit",
"embargo",
]),
clientID: ID,
playerID: ID,
@@ -196,6 +205,13 @@ export const EmojiIntentSchema = BaseIntentSchema.extend({
emoji: EmojiSchema,
});
export const EmbargoIntentSchema = BaseIntentSchema.extend({
type: z.literal("embargo"),
playerID: ID,
targetID: ID,
action: z.union([z.literal("start"), z.literal("stop")]),
});
export const DonateIntentSchema = BaseIntentSchema.extend({
type: z.literal("donate"),
playerID: ID,
@@ -229,6 +245,7 @@ const IntentSchema = z.union([
DonateIntentSchema,
TargetTroopRatioIntentSchema,
BuildUnitIntentSchema,
EmbargoIntentSchema,
]);
export const TurnSchema = z.object({
@@ -240,7 +257,7 @@ export const TurnSchema = z.object({
// Server
const ServerBaseMessageSchema = z.object({
type: SafeString,
type: z.enum(["turn", "ping", "start", "desync"]),
});
export const ServerTurnMessageSchema = ServerBaseMessageSchema.extend({
@@ -259,16 +276,25 @@ export const ServerStartGameMessageSchema = ServerBaseMessageSchema.extend({
config: GameConfigSchema,
});
export const ServerDesyncSchema = ServerBaseMessageSchema.extend({
type: z.literal("desync"),
turn: z.number(),
correctHash: z.number().nullable(),
clientsWithCorrectHash: z.number(),
totalActiveClients: z.number(),
});
export const ServerMessageSchema = z.union([
ServerTurnMessageSchema,
ServerStartGameMessageSchema,
ServerPingMessageSchema,
ServerDesyncSchema,
]);
// Client
const ClientBaseMessageSchema = z.object({
type: z.enum(["winner", "join", "intent", "ping", "log"]),
type: z.enum(["winner", "join", "intent", "ping", "log", "hash"]),
clientID: ID,
persistentID: SafeString.nullable(), // WARNING: persistent id is private.
gameID: ID,
@@ -279,6 +305,12 @@ export const ClientSendWinnerSchema = ClientBaseMessageSchema.extend({
winner: ID.nullable(),
});
export const ClientHashSchema = ClientBaseMessageSchema.extend({
type: z.literal("hash"),
hash: z.number(),
tick: z.number(),
});
export const ClientLogMessageSchema = ClientBaseMessageSchema.extend({
type: z.literal("log"),
severity: z.nativeEnum(LogSeverity),
@@ -308,6 +340,7 @@ export const ClientMessageSchema = z.union([
ClientIntentMessageSchema,
ClientJoinMessageSchema,
ClientLogMessageSchema,
ClientHashSchema,
]);
export const PlayerRecordSchema = z.object({
+17 -2
View File
@@ -208,13 +208,12 @@ export function getMode(list: Set<number>): number {
export function sanitize(name: string): string {
return Array.from(name)
.join("")
.replace(/[^\p{L}\p{N}\s\p{Emoji}\p{Emoji_Component}\[\]]/gu, "");
.replace(/[^\p{L}\p{N}\s\p{Emoji}\p{Emoji_Component}\[\]_]/gu, "");
}
export function processName(name: string): string {
// First sanitize the raw input - strip everything except text and emojis
const sanitizedName = sanitize(name);
// Process emojis with twemoji
const withEmojis = twemoji.parse(sanitizedName, {
base: "https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/",
@@ -307,3 +306,19 @@ export function generateID(): GameID {
);
return nanoid();
}
export function toInt(num: number): bigint {
return BigInt(Math.floor(num));
}
export function maxInt(a: bigint, b: bigint): bigint {
return a > b ? a : b;
}
export function minInt(a: bigint, b: bigint): bigint {
return a < b ? a : b;
}
export function withinInt(num: bigint, min: bigint, max: bigint): bigint {
const atLeastMin = maxInt(num, min);
return minInt(atLeastMin, max);
}
+8 -3
View File
@@ -15,7 +15,7 @@ import { Colord, colord } from "colord";
import { preprodConfig } from "./PreprodConfig";
import { prodConfig } from "./ProdConfig";
import { consolex } from "../Consolex";
import { GameConfig } from "../Schemas";
import { GameConfig, GameID } from "../Schemas";
import { DefaultConfig } from "./DefaultConfig";
import { DevConfig, DevServerConfig } from "./DevConfig";
import { GameMap, TileRef } from "../game/GameMap";
@@ -63,9 +63,14 @@ export function getServerConfig(): ServerConfig {
export interface ServerConfig {
turnIntervalMs(): number;
gameCreationRate(): number;
lobbyLifetime(): number;
gameCreationRate(highTraffic: boolean): number;
lobbyLifetime(highTraffic): number;
discordRedirectURI(): string;
numWorkers(): number;
workerIndex(gameID: GameID): number;
workerPath(gameID: GameID): string;
workerPort(gameID: GameID): number;
workerPortByIndex(workerID: number): number;
env(): GameEnv;
}
+26 -9
View File
@@ -1,10 +1,8 @@
import { renderNumber } from "../../client/Utils";
import {
Difficulty,
Game,
GameType,
Gold,
MessageType,
Player,
PlayerInfo,
PlayerType,
@@ -14,26 +12,45 @@ import {
UnitInfo,
UnitType,
} from "../game/Game";
import { GameMap, TileRef } from "../game/GameMap";
import { TileRef } from "../game/GameMap";
import { PlayerView } from "../game/GameView";
import { UserSettings } from "../game/UserSettings";
import { GameConfig } from "../Schemas";
import { assertNever, within } from "../Util";
import { GameConfig, GameID } from "../Schemas";
import { assertNever, simpleHash, within } from "../Util";
import { Config, GameEnv, ServerConfig, Theme } from "./Config";
import { pastelTheme } from "./PastelTheme";
import { pastelThemeDark } from "./PastelThemeDark";
export abstract class DefaultServerConfig implements ServerConfig {
numWorkers(): number {
return 2;
}
abstract env(): GameEnv;
abstract discordRedirectURI(): string;
turnIntervalMs(): number {
return 100;
}
gameCreationRate(): number {
return 1 * 60 * 1000;
gameCreationRate(highTraffic: boolean): number {
if (highTraffic) {
return 30 * 1000;
} else {
return 60 * 1000;
}
}
lobbyLifetime(): number {
return 2 * 60 * 1000;
lobbyLifetime(highTraffic: boolean): number {
return this.gameCreationRate(highTraffic) * 2;
}
workerIndex(gameID: GameID): number {
return simpleHash(gameID) % this.numWorkers();
}
workerPath(gameID: GameID): string {
return `w${this.workerIndex(gameID)}`;
}
workerPort(gameID: GameID): number {
return this.workerPortByIndex(this.workerIndex(gameID));
}
workerPortByIndex(index: number): number {
return 3001 + index;
}
}
+2 -6
View File
@@ -8,12 +8,8 @@ export class DevServerConfig extends DefaultServerConfig {
env(): GameEnv {
return GameEnv.Dev;
}
gameCreationRate(): number {
return 10 * 1000;
}
lobbyLifetime(): number {
return 10 * 1000;
gameCreationRate(highTraffic: boolean): number {
return 5 * 1000;
}
discordRedirectURI(): string {
+1 -1
View File
@@ -253,7 +253,7 @@ export const pastelThemeDark = new (class implements Theme {
}
textColor(playerInfo: PlayerInfo): string {
return playerInfo.playerType == PlayerType.Human ? "#ffffff" : "#dbdbdb";
return playerInfo.playerType == PlayerType.Human ? "#ffffff" : "#e6e6e6";
}
borderColor(playerInfo: PlayerInfo): Colord {
+44
View File
@@ -0,0 +1,44 @@
import { consolex } from "../Consolex";
import { Execution, Game, Player, PlayerID } from "../game/Game";
export class EmbargoExecution implements Execution {
private active = true;
constructor(
private player: Player,
private targetID: PlayerID,
private readonly action: "start" | "stop",
) {}
init(mg: Game, _: number): void {
if (!mg.hasPlayer(this.player.id())) {
console.warn(`EmbargoExecution: sender ${this.player.id()} not found`);
this.active = false;
return;
}
if (!mg.hasPlayer(this.targetID)) {
console.warn(`EmbargoExecution recipient ${this.targetID} not found`);
this.active = false;
return;
}
}
tick(_: number): void {
if (this.action == "start") this.player.addEmbargo(this.targetID);
else this.player.stopEmbargo(this.targetID);
this.active = false;
}
owner(): Player {
return null;
}
isActive(): boolean {
return this.active;
}
activeDuringSpawnPhase(): boolean {
return false;
}
}
+6 -1
View File
@@ -34,6 +34,7 @@ import { SetTargetTroopRatioExecution } from "./SetTargetTroopRatioExecution";
import { ConstructionExecution } from "./ConstructionExecution";
import { fixProfaneUsername, isProfaneUsername } from "../validations/username";
import { NoOpExecution } from "./NoOpExecution";
import { EmbargoExecution } from "./EmbargoExecution";
export class Executor {
// private random = new PseudoRandom(999)
@@ -53,6 +54,7 @@ export class Executor {
}
createExec(intent: Intent): Execution {
let player: Player;
if (intent.type != "spawn") {
if (!this.mg.hasPlayer(intent.playerID)) {
console.warn(
@@ -60,7 +62,7 @@ export class Executor {
);
return new NoOpExecution();
}
const player = this.mg.player(intent.playerID);
player = this.mg.player(intent.playerID);
if (player.clientID() != intent.clientID) {
console.warn(
`intent ${intent.type} has incorrect clientID ${intent.clientID} for player ${player.name()} with clientID ${player.clientID()}`,
@@ -68,6 +70,7 @@ export class Executor {
return new NoOpExecution();
}
}
switch (intent.type) {
case "attack": {
return new AttackExecution(
@@ -124,6 +127,8 @@ export class Executor {
);
case "troop_ratio":
return new SetTargetTroopRatioExecution(intent.playerID, intent.ratio);
case "embargo":
return new EmbargoExecution(player, intent.targetID, intent.action);
case "build_unit":
return new ConstructionExecution(
intent.playerID,
+8
View File
@@ -55,6 +55,14 @@ export class MirvExecution implements Execution {
this.pathFinder = PathFinder.Mini(mg, 10_000, true);
this.player = mg.player(this.senderID);
this.targetPlayer = this.mg.owner(this.dst);
this.mg
.stats()
.increaseNukeCount(
this.player.id(),
this.targetPlayer.id(),
UnitType.MIRV,
);
}
tick(ticks: number): void {
+11 -4
View File
@@ -8,6 +8,7 @@ import {
UnitType,
TerraNullius,
MessageType,
NukeType,
} from "../game/Game";
import { PseudoRandom } from "../PseudoRandom";
import { consolex } from "../Consolex";
@@ -22,10 +23,7 @@ export class NukeExecution implements Execution {
private random: PseudoRandom;
constructor(
private type:
| UnitType.AtomBomb
| UnitType.HydrogenBomb
| UnitType.MIRVWarhead,
private type: NukeType,
private senderID: PlayerID,
private dst: TileRef,
private src?: TileRef,
@@ -74,6 +72,14 @@ export class NukeExecution implements Execution {
target.id(),
);
}
this.mg
.stats()
.increaseNukeCount(
this.senderID,
target.id(),
this.nuke.type() as NukeType,
);
}
}
if (this.waitTicks > 0) {
@@ -157,6 +163,7 @@ export class NukeExecution implements Execution {
const prev = attacked.get(mp);
attacked.set(mp, prev + 1);
}
if (this.mg.isLand(tile)) {
this.mg.setFallout(tile, true);
}
+1 -1
View File
@@ -76,7 +76,7 @@ export class PlayerExecution implements Execution {
}
const popInc = this.config.populationIncreaseRate(this.player);
this.player.addWorkers(popInc * (1 - this.player.targetTroopRatio())); // (1 - this.player.targetTroopRatio()))
this.player.addWorkers(popInc * (1 - this.player.targetTroopRatio()));
this.player.addTroops(popInc * this.player.targetTroopRatio());
this.player.addGold(this.config.goldAdditionRate(this.player));
const adjustRate = this.config.troopAdjustmentRate(this.player);
+8 -10
View File
@@ -69,22 +69,20 @@ export class PortExecution implements Execution {
return;
}
const alliedPorts = this.player()
.alliances()
.map((a) => a.other(this.player()))
const tradingPartnersPorts = this.player()
.tradingPartners()
.flatMap((p) => p.units(UnitType.Port));
const alliedPortsSet = new Set(alliedPorts);
const tradingPartnersPortsSet = new Set(tradingPartnersPorts);
const allyConnections = new Set(
const tradingPartnersConnections = new Set(
Array.from(this.portPaths.keys()).map((p) => p.owner()),
);
allyConnections;
for (const port of alliedPorts) {
if (allyConnections.has(port.owner())) {
for (const port of tradingPartnersPorts) {
if (tradingPartnersConnections.has(port.owner())) {
continue;
}
allyConnections.add(port.owner());
tradingPartnersConnections.add(port.owner());
if (this.computingPaths.has(port)) {
const aStar = this.computingPaths.get(port);
switch (aStar.compute()) {
@@ -114,7 +112,7 @@ export class PortExecution implements Execution {
}
for (const port of this.portPaths.keys()) {
if (!port.isActive() || !alliedPortsSet.has(port)) {
if (!port.isActive() || !tradingPartnersPortsSet.has(port)) {
this.portPaths.delete(port);
this.computingPaths.delete(port);
}
+17 -8
View File
@@ -27,7 +27,7 @@ export class TradeShipExecution implements Execution {
constructor(
private _owner: PlayerID,
private srcPort: Unit,
private dstPort: Unit,
private _dstPort: Unit,
private pathFinder: PathFinder,
// don't modify
private path: TileRef[],
@@ -49,7 +49,12 @@ export class TradeShipExecution implements Execution {
this.active = false;
return;
}
this.tradeShip = this.origOwner.buildUnit(UnitType.TradeShip, 0, spawn);
this.tradeShip = this.origOwner.buildUnit(
UnitType.TradeShip,
0,
spawn,
this._dstPort,
);
}
if (!this.tradeShip.isActive()) {
@@ -64,8 +69,8 @@ export class TradeShipExecution implements Execution {
if (
!this.wasCaptured &&
(!this.dstPort.isActive() ||
!this.tradeShip.owner().isAlliedWith(this.dstPort.owner()))
(!this._dstPort.isActive() ||
!this.tradeShip.owner().canTrade(this._dstPort.owner()))
) {
this.tradeShip.delete(false);
this.active = false;
@@ -122,17 +127,17 @@ export class TradeShipExecution implements Execution {
const gold = this.mg
.config()
.tradeShipGold(
this.mg.manhattanDist(this.srcPort.tile(), this.dstPort.tile()),
this.mg.manhattanDist(this.srcPort.tile(), this._dstPort.tile()),
);
this.srcPort.owner().addGold(gold);
this.dstPort.owner().addGold(gold);
this._dstPort.owner().addGold(gold);
this.mg.displayMessage(
`Received ${renderNumber(gold)} gold from trade with ${this.srcPort.owner().displayName()}`,
MessageType.SUCCESS,
this.dstPort.owner().id(),
this._dstPort.owner().id(),
);
this.mg.displayMessage(
`Received ${renderNumber(gold)} gold from trade with ${this.dstPort.owner().displayName()}`,
`Received ${renderNumber(gold)} gold from trade with ${this._dstPort.owner().displayName()}`,
MessageType.SUCCESS,
this.srcPort.owner().id(),
);
@@ -154,4 +159,8 @@ export class TradeShipExecution implements Execution {
activeDuringSpawnPhase(): boolean {
return false;
}
dstPort(): TileRef {
return this._dstPort.tile();
}
}
+5 -1
View File
@@ -78,7 +78,11 @@ export class WarshipExecution implements Execution {
.filter((u) => u.owner() != this.warship.owner())
.filter((u) => u != this.warship)
.filter((u) => !u.owner().isAlliedWith(this.warship.owner()))
.filter((u) => !this.alreadySentShell.has(u));
.filter((u) => !this.alreadySentShell.has(u))
.filter(
(u) =>
u.type() != UnitType.TradeShip || u.dstPort().owner() != this.owner(),
);
this.target =
ships.sort((a, b) => {
+27 -2
View File
@@ -9,6 +9,7 @@ import {
PlayerUpdate,
UnitUpdate,
} from "./GameUpdates";
import { PlayerStats, Stats } from "./Stats";
export type PlayerID = string;
export type Tick = number;
@@ -79,6 +80,11 @@ export enum UnitType {
MIRVWarhead = "MIRV Warhead",
Construction = "Construction",
}
export type NukeType =
| UnitType.AtomBomb
| UnitType.HydrogenBomb
| UnitType.MIRVWarhead
| UnitType.MIRV;
export enum Relation {
Hostile = 0,
@@ -214,6 +220,9 @@ export interface Unit {
// Updates
toUpdate(): UnitUpdate;
// Only for some types, otherwise return null
dstPort(): Unit;
}
export interface TerraNullius {
@@ -267,7 +276,12 @@ export interface Player {
units(...types: UnitType[]): Unit[];
unitsIncludingConstruction(type: UnitType): Unit[];
canBuild(type: UnitType, targetTile: TileRef): TileRef | false;
buildUnit(type: UnitType, troops: number, tile: TileRef): Unit;
buildUnit(
type: UnitType,
troops: number,
tile: TileRef,
dstPort?: Unit,
): Unit;
captureUnit(unit: Unit): void;
// Relations & Diplomacy
@@ -300,10 +314,17 @@ export interface Player {
outgoingEmojis(): EmojiMessage[];
sendEmoji(recipient: Player | typeof AllPlayers, emoji: string): void;
// Trading
// Donation
canDonate(recipient: Player): boolean;
donate(recipient: Player, troops: number): void;
// Embargo
hasEmbargoAgainst(other: Player): boolean;
tradingPartners(): Player[];
addEmbargo(other: PlayerID): void;
stopEmbargo(other: PlayerID): void;
canTrade(other: Player): boolean;
// Attacking.
canAttack(tile: TileRef): boolean;
createAttack(
@@ -364,6 +385,8 @@ export interface Game extends GameMap {
nations(): Nation[];
numTilesWithFallout(): number;
// Optional as it's not initialized before the end of spawn phase
stats(): Stats;
}
export interface PlayerActions {
@@ -392,6 +415,8 @@ export interface PlayerInteraction {
canBreakAlliance: boolean;
canTarget: boolean;
canDonate: boolean;
canEmbargo: boolean;
stats: PlayerStats;
}
export interface EmojiMessage {
+26 -10
View File
@@ -30,6 +30,8 @@ import { UnitImpl } from "./UnitImpl";
import { consolex } from "../Consolex";
import { GameMap, GameMapImpl, TileRef, TileUpdate } from "./GameMap";
import { DefenseGrid } from "./DefensePostGrid";
import { StatsImpl } from "./StatsImpl";
import { Stats } from "./Stats";
export function createGame(
gameMap: GameMap,
@@ -66,6 +68,9 @@ export class GameImpl implements Game {
private updates: GameUpdates = createGameUpdatesMap();
private defenseGrid: DefenseGrid;
// Not initialized until the game has finished spawning
private _stats: StatsImpl = new StatsImpl();
constructor(
private _map: GameMap,
private miniGameMap: GameMap,
@@ -241,21 +246,29 @@ export class GameImpl implements Game {
this.execs.push(...inited);
this.unInitExecs = unInited;
this._ticks++;
if (this._ticks % 100 == 0) {
let hash = 1;
this._players.forEach((p) => {
hash += p.hash();
});
consolex.log(`tick ${this._ticks}: hash ${hash}`);
}
for (const player of this._players.values()) {
// Players change each to so always add them
this.addUpdate(player.toUpdate());
}
if (this.ticks() % 10 == 0) {
this.addUpdate({
type: GameUpdateType.Hash,
tick: this.ticks(),
hash: this.hash(),
});
}
this._ticks++;
return this.updates;
}
private hash(): number {
let hash = 1;
this._players.forEach((p) => {
hash += p.hash();
});
return hash;
}
terraNullius(): TerraNullius {
return this._terraNullius;
}
@@ -494,14 +507,14 @@ export class GameImpl implements Game {
sendEmojiUpdate(msg: EmojiMessage): void {
this.addUpdate({
type: GameUpdateType.EmojiUpdate,
type: GameUpdateType.Emoji,
emoji: msg,
});
}
setWinner(winner: Player): void {
this.addUpdate({
type: GameUpdateType.WinUpdate,
type: GameUpdateType.Win,
winnerID: winner.smallID(),
});
}
@@ -630,6 +643,9 @@ export class GameImpl implements Game {
numTilesWithFallout(): number {
return this._map.numTilesWithFallout();
}
stats(): Stats {
return this._stats;
}
}
// Or a more dynamic approach that will catch new enum values:
+16 -5
View File
@@ -1,5 +1,6 @@
import { ClientID } from "../Schemas";
import {
AllianceRequest,
EmojiMessage,
GameUpdates,
MapPos,
@@ -34,8 +35,9 @@ export enum GameUpdateType {
BrokeAlliance,
AllianceExpired,
TargetPlayer,
EmojiUpdate,
WinUpdate,
Emoji,
Win,
Hash,
}
export type GameUpdate =
@@ -49,7 +51,8 @@ export type GameUpdate =
| DisplayMessageUpdate
| TargetPlayerUpdate
| EmojiUpdate
| WinUpdate;
| WinUpdate
| HashUpdate;
export interface TileUpdateWrapper {
type: GameUpdateType.Tile;
@@ -94,11 +97,13 @@ export interface PlayerUpdate {
troops: number;
targetTroopRatio: number;
allies: number[];
embargoes: Set<PlayerID>;
isTraitor: boolean;
targets: number[];
outgoingEmojis: EmojiMessage[];
outgoingAttacks: AttackUpdate[];
incomingAttacks: AttackUpdate[];
outgoingAllianceRequests: PlayerID[];
}
export interface AllianceRequestUpdate {
@@ -133,7 +138,7 @@ export interface TargetPlayerUpdate {
}
export interface EmojiUpdate {
type: GameUpdateType.EmojiUpdate;
type: GameUpdateType.Emoji;
emoji: EmojiMessage;
}
@@ -145,6 +150,12 @@ export interface DisplayMessageUpdate {
}
export interface WinUpdate {
type: GameUpdateType.WinUpdate;
type: GameUpdateType.Win;
winnerID: number;
}
export interface HashUpdate {
type: GameUpdateType.Hash;
tick: Tick;
hash: number;
}
+8
View File
@@ -187,6 +187,14 @@ export class PlayerView {
return this.data.allies.some((n) => other.smallID() == n);
}
isRequestingAllianceWith(other: PlayerView) {
return this.data.outgoingAllianceRequests.some((id) => other.id() == id);
}
hasEmbargoAgainst(other: PlayerView): boolean {
return this.data.embargoes.has(other.id());
}
profile(): Promise<PlayerProfile> {
return this.game.worker.playerProfile(this.smallID());
}
+66 -26
View File
@@ -26,9 +26,12 @@ import {
assertNever,
closestOceanShoreFromPlayer,
distSortUnit,
maxInt,
minInt,
simpleHash,
sourceDstOceanShore,
targetTransportTile,
toInt,
within,
} from "../Util";
import { CellString, GameImpl } from "./GameImpl";
@@ -37,7 +40,6 @@ import { MessageType } from "./Game";
import { renderTroops } from "../../client/Utils";
import { TerraNulliusImpl } from "./TerraNulliusImpl";
import { andFN, manhattanDistFN, TileRef } from "./GameMap";
import { Emoji } from "discord.js";
import { AttackImpl } from "./AttackImpl";
interface Target {
@@ -55,13 +57,17 @@ class Donation {
export class PlayerImpl implements Player {
public _lastTileChange: number = 0;
private _gold: Gold;
private _troops: number;
private _workers: number;
private _targetTroopRatio: number = 1;
private _gold: bigint;
private _troops: bigint;
private _workers: bigint;
// 0 to 100
private _targetTroopRatio: bigint = 100n;
isTraitor_ = false;
private embargoes: Set<PlayerID> = new Set();
public _borderTiles: Set<TileRef> = new Set();
public _units: UnitImpl[] = [];
@@ -88,20 +94,24 @@ export class PlayerImpl implements Player {
private mg: GameImpl,
private _smallID: number,
private readonly playerInfo: PlayerInfo,
startPopulation: number,
startTroops: number,
) {
this._flag = playerInfo.flag;
this._name = playerInfo.name;
this._targetTroopRatio = 1;
this._troops = startPopulation * this._targetTroopRatio;
this._workers = startPopulation * (1 - this._targetTroopRatio);
this._gold = 0;
this._targetTroopRatio = 100n;
this._troops = toInt(startTroops);
this._workers = 0n;
this._gold = 0n;
this._displayName = this._name; // processName(this._name)
}
largestClusterBoundingBox: { min: Cell; max: Cell } | null;
toUpdate(): PlayerUpdate {
const outgoingAllianceRequests = this.outgoingAllianceRequests().map((ar) =>
ar.recipient().id(),
);
return {
type: GameUpdateType.Player,
clientID: this.clientID(),
@@ -113,12 +123,13 @@ export class PlayerImpl implements Player {
playerType: this.type(),
isAlive: this.isAlive(),
tilesOwned: this.numTilesOwned(),
gold: this._gold,
gold: Number(this._gold),
population: this.population(),
workers: this.workers(),
troops: this.troops(),
targetTroopRatio: this.targetTroopRatio(),
allies: this.alliances().map((a) => a.other(this).smallID()),
embargoes: this.embargoes,
isTraitor: this.isTraitor(),
targets: this.targets().map((p) => p.smallID()),
outgoingEmojis: this.outgoingEmojis(),
@@ -138,6 +149,7 @@ export class PlayerImpl implements Player {
troops: a.troops(),
}) as AttackUpdate,
),
outgoingAllianceRequests: outgoingAllianceRequests,
};
}
@@ -229,7 +241,7 @@ export class PlayerImpl implements Player {
return true as const;
}
setTroops(troops: number) {
this._troops = Math.floor(troops);
this._troops = toInt(troops);
}
conquer(tile: TileRef) {
this.mg.conquer(this, tile);
@@ -497,12 +509,34 @@ export class PlayerImpl implements Player {
);
}
hasEmbargoAgainst(other: Player): boolean {
return this.embargoes.has(other.id());
}
canTrade(other: Player): boolean {
return !other.hasEmbargoAgainst(this) && !this.hasEmbargoAgainst(other);
}
addEmbargo(other: PlayerID): void {
this.embargoes.add(other);
}
stopEmbargo(other: PlayerID): void {
this.embargoes.delete(other);
}
tradingPartners(): Player[] {
return this.mg
.players()
.filter((other) => other != this && this.canTrade(other));
}
gold(): Gold {
return this._gold;
return Number(this._gold);
}
addGold(toAdd: Gold): void {
this._gold += toAdd;
this._gold += toInt(toAdd);
}
removeGold(toRemove: Gold): void {
@@ -511,24 +545,24 @@ export class PlayerImpl implements Player {
`Player ${this} does not enough gold (${toRemove} vs ${this._gold}))`,
);
}
this._gold -= toRemove;
this._gold -= toInt(toRemove);
}
population(): number {
return this._troops + this._workers;
return Number(this._troops + this._workers);
}
workers(): number {
return Math.max(1, this._workers);
return Math.max(1, Number(this._workers));
}
addWorkers(toAdd: number): void {
this._workers += toAdd;
this._workers += toInt(toAdd);
}
removeWorkers(toRemove: number): void {
this._workers = Math.max(1, this._workers - toRemove);
this._workers = maxInt(1n, this._workers - toInt(toRemove));
}
targetTroopRatio(): number {
return this._targetTroopRatio;
return Number(this._targetTroopRatio) / 100;
}
setTargetTroopRatio(target: number): void {
@@ -537,11 +571,11 @@ export class PlayerImpl implements Player {
`invalid targetTroopRatio ${target} set on player ${PlayerImpl}`,
);
}
this._targetTroopRatio = target;
this._targetTroopRatio = toInt(target * 100);
}
troops(): number {
return this._troops;
return Number(this._troops);
}
addTroops(troops: number): void {
@@ -549,15 +583,15 @@ export class PlayerImpl implements Player {
this.removeTroops(-1 * troops);
return;
}
this._troops += Math.floor(troops);
this._troops += toInt(troops);
}
removeTroops(troops: number): number {
if (troops <= 1) {
return 0;
}
const toRemove = Math.floor(Math.min(this._troops - 1, troops));
const toRemove = minInt(this._troops, toInt(troops));
this._troops -= toRemove;
return toRemove;
return Number(toRemove);
}
captureUnit(unit: Unit): void {
@@ -583,7 +617,12 @@ export class PlayerImpl implements Player {
);
}
buildUnit(type: UnitType, troops: number, spawnTile: TileRef): UnitImpl {
buildUnit(
type: UnitType,
troops: number,
spawnTile: TileRef,
dstPort?: Unit,
): UnitImpl {
const cost = this.mg.unitInfo(type).cost(this);
const b = new UnitImpl(
type,
@@ -592,6 +631,7 @@ export class PlayerImpl implements Player {
troops,
this.mg.nextUnitID(),
this,
dstPort,
);
this._units.push(b);
this.removeGold(cost);
+15
View File
@@ -0,0 +1,15 @@
import { NukeType, PlayerID } from "./Game";
export interface PlayerStats {
sentNukes: {
// target
[key: PlayerID]: {
[key in NukeType]: number;
};
};
}
export interface Stats {
increaseNukeCount(sender: PlayerID, target: PlayerID, type: NukeType): void;
getPlayerStats(player: PlayerID): PlayerStats;
}
+34
View File
@@ -0,0 +1,34 @@
import { NukeType, Player, PlayerID, UnitType } from "./Game";
import { PlayerStats, Stats } from "./Stats";
interface StatsInternalData {
// player
[key: PlayerID]: PlayerStats;
}
export class StatsImpl implements Stats {
data: StatsInternalData = {};
_createUserData(sender: PlayerID, target: PlayerID): void {
if (!this.data[sender]) {
this.data[sender] = { sentNukes: {} };
}
if (!this.data[sender].sentNukes[target]) {
this.data[sender].sentNukes[target] = {
[UnitType.MIRV]: 0,
[UnitType.MIRVWarhead]: 0,
[UnitType.AtomBomb]: 0,
[UnitType.HydrogenBomb]: 0,
};
}
}
increaseNukeCount(sender: PlayerID, target: PlayerID, type: NukeType): void {
this._createUserData(sender, target);
this.data[sender].sentNukes[target][type]++;
}
getPlayerStats(player: PlayerID): PlayerStats {
return this.data[player];
}
}
+17 -8
View File
@@ -1,7 +1,7 @@
import { MessageType } from "./Game";
import { UnitUpdate } from "./GameUpdates";
import { GameUpdateType } from "./GameUpdates";
import { simpleHash, within } from "../Util";
import { simpleHash, toInt, within, withinInt } from "../Util";
import { Unit, TerraNullius, UnitType, Player, UnitInfo } from "./Game";
import { GameImpl } from "./GameImpl";
import { PlayerImpl } from "./PlayerImpl";
@@ -9,7 +9,7 @@ import { TileRef } from "./GameMap";
export class UnitImpl implements Unit {
private _active = true;
private _health: number;
private _health: bigint;
private _lastTile: TileRef = null;
private _constructionType: UnitType = undefined;
@@ -21,9 +21,10 @@ export class UnitImpl implements Unit {
private _troops: number,
private _id: number,
public _owner: PlayerImpl,
private _dstPort?: Unit,
) {
// default to half health (or 1 is no health specified)
this._health = (this.mg.unitInfo(_type).maxHealth ?? 2) / 2;
// default to 60% health (or 1.2 is no health specified)
this._health = toInt((this.mg.unitInfo(_type).maxHealth ?? 2) * 0.6);
this._lastTile = _tile;
}
@@ -37,7 +38,7 @@ export class UnitImpl implements Unit {
isActive: this._active,
pos: this._tile,
lastPos: this._lastTile,
health: this.hasHealth() ? this._health : undefined,
health: this.hasHealth() ? Number(this._health) : undefined,
constructionType: this._constructionType,
};
}
@@ -65,7 +66,7 @@ export class UnitImpl implements Unit {
return this._troops;
}
health(): number {
return this._health;
return Number(this._health);
}
hasHealth(): boolean {
return this.info().maxHealth != undefined;
@@ -94,7 +95,11 @@ export class UnitImpl implements Unit {
}
modifyHealth(delta: number): void {
this._health = within(this._health + delta, 0, this.info().maxHealth ?? 1);
this._health = withinInt(
this._health + toInt(delta),
0n,
toInt(this.info().maxHealth ?? 1),
);
}
delete(displayMessage: boolean = true): void {
@@ -135,10 +140,14 @@ export class UnitImpl implements Unit {
}
hash(): number {
return this.tile() + simpleHash(this.type());
return this.tile() + simpleHash(this.type()) * this._id;
}
toString(): string {
return `Unit:${this._type},owner:${this.owner().name()}`;
}
dstPort(): Unit {
return this._dstPort;
}
}
+5 -1
View File
@@ -10,7 +10,6 @@ export class UserSettings {
set(key: string, value: boolean) {
localStorage.setItem(key, value ? "true" : "false");
document.body.classList.toggle("dark");
}
emojis() {
@@ -27,5 +26,10 @@ export class UserSettings {
toggleDarkMode() {
this.set("settings.darkMode", !this.darkMode());
if (this.darkMode()) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const mapName = "Asia";
const mapName = "Africa";
interface Coord {
x: number;
+8 -8
View File
@@ -121,9 +121,13 @@ async function archiveToGCS(gameRecord: GameRecord) {
});
const file = bucket.file(recordCopy.id);
await file.save(JSON.stringify(GameRecordSchema.parse(recordCopy)), {
contentType: "application/json",
});
try {
await file.save(JSON.stringify(recordCopy), {
contentType: "application/json",
});
} catch (error) {
console.log(`error saving game ${gameRecord.id}`);
}
console.log(`${gameRecord.id}: game record successfully written to GCS`);
}
@@ -142,11 +146,7 @@ export async function readGameRecord(gameId: GameID): Promise<GameRecord> {
const [content] = await file.download();
const gameRecord = JSON.parse(content.toString());
// Validate the parsed content against the schema
const validatedRecord = GameRecordSchema.parse(gameRecord);
console.log(`${gameId}: Successfully read game record from GCS`);
return validatedRecord;
return gameRecord as GameRecord;
} catch (error) {
console.error(`${gameId}: Error reading game record from GCS: ${error}`, {
message: error?.message || error,
+3
View File
@@ -1,9 +1,12 @@
import WebSocket from "ws";
import { ClientID } from "../core/Schemas";
import { Tick } from "../core/game/Game";
export class Client {
public lastPing: number;
public hashes: Map<Tick, number> = new Map();
constructor(
public readonly clientID: ClientID,
public readonly persistentID: string,
+43 -134
View File
@@ -1,32 +1,23 @@
import { Config, ServerConfig } from "../core/configuration/Config";
import { ClientID, GameConfig, GameID } from "../core/Schemas";
import { v4 as uuidv4 } from "uuid";
import { ServerConfig } from "../core/configuration/Config";
import { GameConfig, GameID } from "../core/Schemas";
import { Client } from "./Client";
import { GamePhase, GameServer } from "./GameServer";
import { Difficulty, GameMapType, GameType } from "../core/game/Game";
import { generateID } from "../core/Util";
import { PseudoRandom } from "../core/PseudoRandom";
import { isHighTrafficTime } from "./Util";
export class GameManager {
private lastNewLobby: number = 0;
private mapsPlaylist: GameMapType[] = [];
private games: Map<GameID, GameServer> = new Map();
private games: GameServer[] = [];
private random = new PseudoRandom(123);
constructor(private config: ServerConfig) {}
public game(id: GameID): GameServer | null {
return this.games.find((g) => g.id == id);
constructor(private config: ServerConfig) {
setInterval(() => this.tick(), 1000);
}
gamesByPhase(phase: GamePhase): GameServer[] {
return this.games.filter((g) => g.phase() == phase);
public game(id: GameID): GameServer | null {
return this.games.get(id);
}
addClient(client: Client, gameID: GameID, lastTurn: number): boolean {
const game = this.games.find((g) => g.id == gameID);
const game = this.games.get(gameID);
if (game) {
game.addClient(client, lastTurn);
return true;
@@ -34,23 +25,13 @@ export class GameManager {
return false;
}
updateGameConfig(gameID: GameID, gameConfig: GameConfig) {
const game = this.games.find((g) => g.id == gameID);
if (game == null) {
console.warn(`game ${gameID} not found`);
return;
}
if (game.isPublic) {
console.warn(`cannot update public game ${gameID}`);
return;
}
game.updateGameConfig(gameConfig);
}
createPrivateGame(): string {
const id = generateID();
this.games.push(
new GameServer(id, Date.now(), false, this.config, {
createGame(id: GameID, gameConfig: GameConfig | undefined) {
const game = new GameServer(
id,
Date.now(),
isHighTrafficTime(),
this.config,
{
gameMap: GameMapType.World,
gameType: GameType.Private,
difficulty: Difficulty.Medium,
@@ -59,109 +40,37 @@ export class GameManager {
infiniteTroops: false,
instantBuild: false,
bots: 400,
}),
...gameConfig,
},
);
return id;
}
hasActiveGame(gameID: GameID): boolean {
const game = this.games
.filter((g) => g.id == gameID)
.filter(
(g) => g.phase() == GamePhase.Lobby || g.phase() == GamePhase.Active,
);
return game.length > 0;
}
// TODO: stop private games to prevent memory leak.
startPrivateGame(gameID: GameID) {
const game = this.games.find((g) => g.id == gameID);
console.log(`found game ${game}`);
if (game) {
game.start();
} else {
throw new Error(`cannot start private game, game ${gameID} not found`);
}
}
private getNextMap(): GameMapType {
if (this.mapsPlaylist.length > 0) {
return this.mapsPlaylist.shift();
}
const frequency = {
World: 4,
Europe: 4,
Mena: 2,
NorthAmerica: 2,
Oceania: 1,
BlackSea: 2,
Africa: 2,
Asia: 2,
Mars: 0,
};
Object.keys(GameMapType).map((key) => {
let count = parseInt(frequency[key]);
while (count > 0) {
this.mapsPlaylist.push(GameMapType[key]);
count--;
}
});
while (true) {
this.random.shuffleArray(this.mapsPlaylist);
if (this.allNonConsecutive(this.mapsPlaylist)) {
return this.mapsPlaylist.shift();
}
}
}
private allNonConsecutive(maps: GameMapType[]): boolean {
// Check for consecutive duplicates in the maps array
for (let i = 0; i < maps.length - 1; i++) {
if (maps[i] === maps[i + 1]) {
return false;
}
}
return true;
this.games.set(id, game);
return game;
}
tick() {
const lobbies = this.gamesByPhase(GamePhase.Lobby);
const active = this.gamesByPhase(GamePhase.Active);
const finished = this.gamesByPhase(GamePhase.Finished);
const now = Date.now();
if (now > this.lastNewLobby + this.config.gameCreationRate()) {
this.lastNewLobby = now;
lobbies.push(
new GameServer(generateID(), now, true, this.config, {
gameMap: this.getNextMap(),
gameType: GameType.Public,
difficulty: Difficulty.Medium,
infiniteGold: false,
infiniteTroops: false,
instantBuild: false,
disableNPCs: false,
bots: 400,
}),
);
}
active
.filter((g) => !g.hasStarted() && g.isPublic)
.forEach((g) => {
g.start();
});
finished.forEach((g) => {
try {
g.endGame();
} catch (error) {
console.log(`error ending game ${g.id}: `, error);
const active = new Map<GameID, GameServer>();
for (const [id, game] of this.games) {
const phase = game.phase();
if (phase == GamePhase.Active) {
if (game.isPublic && !game.hasStarted()) {
try {
game.start();
} catch (error) {
console.log(`error starting game ${id}: ${error}`);
}
}
}
});
this.games = [...lobbies, ...active];
if (phase == GamePhase.Finished) {
try {
game.end();
} catch (error) {
console.log(`error ending game ${id}: ${error}`);
}
} else {
active.set(id, game);
}
}
this.games = active;
}
}
+203 -76
View File
@@ -1,24 +1,25 @@
import { RateLimiterMemory } from "rate-limiter-flexible";
import WebSocket from "ws";
import {
ClientID,
ClientMessage,
ClientMessageSchema,
GameConfig,
GameRecordSchema,
GameInfo,
Intent,
PlayerRecord,
ServerPingMessageSchema,
ServerStartGameMessage,
ServerDesyncSchema,
ServerStartGameMessageSchema,
ServerTurnMessageSchema,
Turn,
} from "../core/Schemas";
import { Config, ServerConfig } from "../core/configuration/Config";
import { Client } from "./Client";
import WebSocket from "ws";
import { slog } from "./StructuredLog";
import { CreateGameRecord } from "../core/Util";
import { ServerConfig } from "../core/configuration/Config";
import { GameType } from "../core/game/Game";
import { archive } from "./Archive";
import { RateLimiterMemory } from "rate-limiter-flexible";
import { Client } from "./Client";
import { slog } from "./StructuredLog";
import { gatekeeper } from "./Gatekeeper";
export enum GamePhase {
Lobby = "LOBBY",
@@ -27,10 +28,7 @@ export enum GamePhase {
}
export class GameServer {
private rateLimiter = new RateLimiterMemory({
points: 50,
duration: 1, // per 1 second
});
private outOfSyncClients = new Set<ClientID>();
private maxGameDuration = 3 * 60 * 60 * 1000; // 3 hours
@@ -51,7 +49,7 @@ export class GameServer {
constructor(
public readonly id: string,
public readonly createdAt: number,
public readonly isPublic: boolean,
public readonly highTraffic: boolean,
private config: ServerConfig,
public gameConfig: GameConfig,
) {}
@@ -97,6 +95,7 @@ export class GameServer {
});
if (
this.gameConfig.gameType == GameType.Public &&
this.activeClients.filter(
(c) => c.ip == client.ip && c.clientID != client.clientID,
).length >= 3
@@ -122,52 +121,55 @@ export class GameServer {
this.allClients.set(client.clientID, client);
client.ws.on("message", async (message: string) => {
try {
await this.rateLimiter.consume(client.ip);
} catch (error) {
console.warn(`Rate limit exceeded for ${client.ip}`);
return;
}
try {
const clientMsg: ClientMessage = ClientMessageSchema.parse(
JSON.parse(message),
);
if (this.allClients.has(clientMsg.clientID)) {
const client = this.allClients.get(clientMsg.clientID);
if (client.persistentID != clientMsg.persistentID) {
console.warn(
`Client ID ${clientMsg.clientID} sent incorrect id ${clientMsg.persistentID}, does not match persistent id ${client.persistentID}`,
);
return;
client.ws.on(
"message",
gatekeeper.wsHandler(client.ip, async (message: string) => {
try {
let clientMsg: ClientMessage = null;
try {
clientMsg = ClientMessageSchema.parse(JSON.parse(message));
} catch (error) {
throw Error(`error parsing schema for ${client.ip}`);
}
}
// Clear out persistent id to make sure it doesn't get sent to other clients.
clientMsg.persistentID = null;
if (clientMsg.type == "intent") {
if (clientMsg.gameID == this.id) {
this.addIntent(clientMsg.intent);
} else {
console.warn(
`${this.id}: client ${clientMsg.clientID} sent to wrong game`,
);
if (this.allClients.has(clientMsg.clientID)) {
const client = this.allClients.get(clientMsg.clientID);
if (client.persistentID != clientMsg.persistentID) {
console.warn(
`Client ID ${clientMsg.clientID} sent incorrect id ${clientMsg.persistentID}, does not match persistent id ${client.persistentID}`,
);
return;
}
}
// Clear out persistent id to make sure it doesn't get sent to other clients.
clientMsg.persistentID = null;
if (clientMsg.type == "intent") {
if (clientMsg.gameID == this.id) {
this.addIntent(clientMsg.intent);
} else {
console.warn(
`${this.id}: client ${clientMsg.clientID} sent to wrong game`,
);
}
}
if (clientMsg.type == "ping") {
this.lastPingUpdate = Date.now();
client.lastPing = Date.now();
}
if (clientMsg.type == "hash") {
client.hashes.set(clientMsg.tick, clientMsg.hash);
}
if (clientMsg.type == "winner") {
this.winner = clientMsg.winner;
}
} catch (error) {
console.log(
`error handline websocket request in game server: ${error}`,
);
}
if (clientMsg.type == "ping") {
this.lastPingUpdate = Date.now();
client.lastPing = Date.now();
}
if (clientMsg.type == "winner") {
this.winner = clientMsg.winner;
}
} catch (error) {
console.log(
`error handline websocket request in game server: ${error}`,
);
}
});
}),
);
client.ws.on("close", () => {
console.log(`${this.id}: client ${client.clientID} disconnected`);
this.activeClients = this.activeClients.filter(
@@ -195,7 +197,7 @@ export class GameServer {
return this._startTime;
} else {
//game hasn't started yet, only works for public games
return this.createdAt + this.config.lobbyLifetime();
return this.createdAt + this.config.lobbyLifetime(this.highTraffic);
}
}
@@ -221,15 +223,19 @@ export class GameServer {
}
private sendStartGameMsg(ws: WebSocket, lastTurn: number) {
ws.send(
JSON.stringify(
ServerStartGameMessageSchema.parse({
type: "start",
turns: this.turns.slice(lastTurn),
config: this.gameConfig,
}),
),
);
try {
ws.send(
JSON.stringify(
ServerStartGameMessageSchema.parse({
type: "start",
turns: this.turns.slice(lastTurn),
config: this.gameConfig,
}),
),
);
} catch (error) {
throw new Error(`error sending start message for game ${this.id}`);
}
}
private endTurn() {
@@ -241,18 +247,27 @@ export class GameServer {
this.turns.push(pastTurn);
this.intents = [];
const msg = JSON.stringify(
ServerTurnMessageSchema.parse({
type: "turn",
turn: pastTurn,
}),
);
this.maybeSendDesync();
let msg = "";
try {
msg = JSON.stringify(
ServerTurnMessageSchema.parse({
type: "turn",
turn: pastTurn,
}),
);
} catch (error) {
console.log(`error sending message for game ${this.id}`);
return;
}
this.activeClients.forEach((c) => {
c.ws.send(msg);
});
}
async endGame() {
async end() {
// Close all WebSocket connections
clearInterval(this.endTurnIntervalID);
this.allClients.forEach((client) => {
@@ -337,7 +352,7 @@ export class GameServer {
const noRecentPings = now > this.lastPingUpdate + 20 * 1000;
const noActive = this.activeClients.length == 0;
if (!this.isPublic) {
if (this.gameConfig.gameType != GameType.Public) {
if (this._hasStarted) {
if (noActive && noRecentPings) {
console.log(`${this.id}: private game: ${this.id} complete`);
@@ -350,11 +365,12 @@ export class GameServer {
}
}
if (now - this.createdAt < this.config.lobbyLifetime()) {
if (now - this.createdAt < this.config.lobbyLifetime(this.highTraffic)) {
return GamePhase.Lobby;
}
const warmupOver =
now > this.createdAt + this.config.lobbyLifetime() + 30 * 1000;
now >
this.createdAt + this.config.lobbyLifetime(this.highTraffic) + 30 * 1000;
if (noActive && warmupOver && noRecentPings) {
return GamePhase.Finished;
}
@@ -365,4 +381,115 @@ export class GameServer {
hasStarted(): boolean {
return this._hasStarted;
}
public gameInfo(): GameInfo {
return {
gameID: this.id,
clients: this.activeClients.map((c) => ({
username: c.username,
clientID: c.clientID,
})),
gameConfig: this.gameConfig,
msUntilStart: this.isPublic()
? this.createdAt + this.config.lobbyLifetime(this.highTraffic)
: undefined,
};
}
public isPublic(): boolean {
return this.gameConfig.gameType == GameType.Public;
}
private maybeSendDesync() {
if (this.activeClients.length <= 1) {
return;
}
if (this.turns.length % 10 == 0 && this.turns.length != 0) {
const lastHashTurn = this.turns.length - 10;
let { mostCommonHash, outOfSyncClients } =
this.findOutOfSyncClients(lastHashTurn);
if (
outOfSyncClients.length >= Math.floor(this.activeClients.length / 2)
) {
// If half clients out of sync assume all are out of sync.
outOfSyncClients = this.activeClients;
}
for (const oos of outOfSyncClients) {
if (!this.outOfSyncClients.has(oos.clientID)) {
console.warn(
`Game ${this.id}: has out of sync client ${oos.clientID} on turn ${lastHashTurn}`,
);
this.outOfSyncClients.add(oos.clientID);
}
}
return;
// TODO: renable this once desync issue fixed
const serverDesync = ServerDesyncSchema.safeParse({
type: "desync",
turn: lastHashTurn,
correctHash: mostCommonHash,
clientsWithCorrectHash:
this.activeClients.length - outOfSyncClients.length,
totalActiveClients: this.activeClients.length,
});
if (serverDesync.success) {
const desyncMsg = JSON.stringify(serverDesync.data);
for (const c of outOfSyncClients) {
console.log(
`game: ${this.id}: sending desync to client ${c.clientID}`,
);
c.ws.send(desyncMsg);
}
} else {
console.warn(`failed to create desync message ${serverDesync.error}`);
}
}
}
findOutOfSyncClients(turnNumber: number): {
mostCommonHash: number | null;
outOfSyncClients: Client[];
} {
const counts = new Map<number, number>();
// Count occurrences of each hash
for (const client of this.activeClients) {
if (client.hashes.has(turnNumber)) {
const clientHash = client.hashes.get(turnNumber)!;
counts.set(clientHash, (counts.get(clientHash) || 0) + 1);
}
}
// Find the most common hash
let mostCommonHash: number | null = null;
let maxCount = 0;
for (const [hash, count] of counts.entries()) {
if (count > maxCount) {
mostCommonHash = hash;
maxCount = count;
}
}
// Create a list of clients whose hash doesn't match the most common one
const outOfSyncClients: Client[] = [];
for (const client of this.activeClients) {
if (client.hashes.has(turnNumber)) {
const clientHash = client.hashes.get(turnNumber)!;
if (clientHash !== mostCommonHash) {
outOfSyncClients.push(client);
}
}
}
return {
mostCommonHash,
outOfSyncClients,
};
}
}
+124
View File
@@ -0,0 +1,124 @@
// src/server/Security.ts
import { Request, Response, NextFunction } from "express";
import http from "http";
import path from "path";
import { fileURLToPath } from "url";
import fs from "fs";
export enum LimiterType {
Get = "get",
Post = "post",
Put = "put",
WebSocket = "websocket",
}
export interface Gatekeeper {
// The wrapper for request handlers with optional rate limiting
httpHandler: (
limiterType: LimiterType,
fn: (req: Request, res: Response, next: NextFunction) => Promise<any>,
) => (req: Request, res: Response, next: NextFunction) => Promise<void>;
// The wrapper for WebSocket message handlers with rate limiting
wsHandler: (
req: http.IncomingMessage | string,
fn: (message: string) => Promise<void>,
) => (message: string) => Promise<void>;
}
// Function to get the appropriate security middleware implementation
async function getGatekeeper(): Promise<Gatekeeper> {
try {
// Get the current file's directory
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
try {
// Check if the file exists before attempting to import it
const realMiddlewarePath = path.resolve(
__dirname,
"./gatekeeper/RealGatekeeper.js",
);
const tsMiddlewarePath = path.resolve(
__dirname,
"./gatekeeper/RealGatekeeper.ts",
);
if (
!fs.existsSync(realMiddlewarePath) &&
!fs.existsSync(tsMiddlewarePath)
) {
console.log("RealGatekeeper file not found, using NoOpGatekeeper");
return new NoOpGatekeeper();
}
// Use dynamic import for ES modules
// Using a type assertion to avoid TypeScript errors for optional modules
const module = await import(
"./gatekeeper/RealGatekeeper.js" as any
).catch(() => import("./gatekeeper/RealGatekeeper.js" as any));
if (!module || !module.RealGatekeeper) {
console.log(
"RealGatekeeper class not found in module, using NoOpGatekeeper",
);
return new NoOpGatekeeper();
}
console.log("Successfully loaded real gatekeeper");
return new module.RealGatekeeper();
} catch (error) {
console.log("Failed to load real gatekeeper:", error);
return new NoOpGatekeeper();
}
} catch (e) {
// Fall back to no-op if real implementation isn't available
console.log("using no-op gatekeeper", e);
return new NoOpGatekeeper();
}
}
export class NoOpGatekeeper implements Gatekeeper {
// Simple pass-through with no rate limiting
httpHandler(
limiterType: LimiterType,
fn: (req: Request, res: Response, next: NextFunction) => Promise<any>,
) {
return async (req: Request, res: Response, next: NextFunction) => {
try {
await fn(req, res, next);
} catch (error) {
next(error);
}
};
}
// Corrected implementation for WebSocket handler wrapper
wsHandler(
req: http.IncomingMessage | string,
fn: (message: string) => Promise<void>,
) {
return async (message: string) => {
try {
await fn(message);
} catch (error) {
console.error("WebSocket handler error:", error);
}
};
}
}
// Initialize the security middleware with a default implementation
// We'll use the NoOpSecurityMiddleware initially and then replace it
// with the real implementation once it's loaded
export const gatekeeper: Gatekeeper = new NoOpGatekeeper();
// Immediately try to load the real middleware
getGatekeeper()
.then((middleware) => {
// Replace the methods of securityMiddleware with those from the loaded middleware
Object.assign(gatekeeper, middleware);
})
.catch((error) => {
console.error("Failed to initialize gatekeeper:", error);
});
+285
View File
@@ -0,0 +1,285 @@
import cluster from "cluster";
import http from "http";
import express from "express";
import { GameMapType, GameType, Difficulty } from "../core/game/Game";
import { generateID } from "../core/Util";
import { PseudoRandom } from "../core/PseudoRandom";
import { GameEnv, getServerConfig } from "../core/configuration/Config";
import { GameInfo } from "../core/Schemas";
import path from "path";
import rateLimit from "express-rate-limit";
import { fileURLToPath } from "url";
import { isHighTrafficTime } from "./Util";
import { gatekeeper, LimiterType } from "./Gatekeeper";
const config = getServerConfig();
const readyWorkers = new Set();
const app = express();
const server = http.createServer(app);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
app.use(express.json());
// Serve static files from the 'out' directory
app.use(express.static(path.join(__dirname, "../../out")));
app.use(express.json());
app.set("trust proxy", 3);
app.use(
rateLimit({
windowMs: 1000, // 1 second
max: 20, // 20 requests per IP per second
}),
);
let publicLobbiesJsonStr = "";
let publicLobbyIDs: Set<string> = new Set();
// Start the master process
export async function startMaster() {
if (!cluster.isPrimary) {
throw new Error(
"startMaster() should only be called in the primary process",
);
}
console.log(`Primary ${process.pid} is running`);
console.log(`Setting up ${config.numWorkers()} workers...`);
// Fork workers
for (let i = 0; i < config.numWorkers(); i++) {
const worker = cluster.fork({
WORKER_ID: i,
});
console.log(`Started worker ${i} (PID: ${worker.process.pid})`);
}
cluster.on("message", (worker, message) => {
if (message.type === "WORKER_READY") {
const workerId = message.workerId;
readyWorkers.add(workerId);
console.log(
`Worker ${workerId} is ready. (${readyWorkers.size}/${config.numWorkers()} ready)`,
);
// Start scheduling when all workers are ready
if (readyWorkers.size === config.numWorkers()) {
console.log("All workers ready, starting game scheduling");
// Safe implementation of dynamic interval
let timeoutId = null;
const scheduleLobbies = () => {
schedulePublicGame()
.catch((error) => {
console.error("Error scheduling public game:", error);
})
.finally(() => {
// Schedule next run with the current config value
const currentLifetime =
config.gameCreationRate(isHighTrafficTime());
timeoutId = setTimeout(scheduleLobbies, currentLifetime);
});
};
// Run first execution immediately
scheduleLobbies();
// Regular interval for fetching lobbies
setInterval(() => fetchLobbies(), 250);
}
}
});
// Handle worker crashes
cluster.on("exit", (worker, code, signal) => {
const workerId = (worker as any).process?.env?.WORKER_ID;
if (!workerId) {
console.error(`worker crashed could not find id`);
return;
}
console.warn(
`Worker ${workerId} (PID: ${worker.process.pid}) died with code: ${code} and signal: ${signal}`,
);
console.log(`Restarting worker ${workerId}...`);
// Restart the worker with the same ID
const newWorker = cluster.fork({
WORKER_ID: workerId,
});
console.log(
`Restarted worker ${workerId} (New PID: ${newWorker.process.pid})`,
);
});
const PORT = 3000;
server.listen(PORT, () => {
console.log(`Master HTTP server listening on port ${PORT}`);
});
}
// Add lobbies endpoint to list public games for this worker
app.get(
"/public_lobbies",
gatekeeper.httpHandler(LimiterType.Get, async (req, res) => {
res.send(publicLobbiesJsonStr);
}),
);
async function fetchLobbies(): Promise<void> {
const fetchPromises = [];
for (const gameID of publicLobbyIDs) {
const port = config.workerPort(gameID);
const promise = fetch(`http://localhost:${port}/game/${gameID}`)
.then((resp) => resp.json())
.then((json) => {
return json as GameInfo;
})
.catch((error) => {
console.error(`Error fetching game ${gameID}:`, error);
// Return null or a placeholder if fetch fails
return null;
});
fetchPromises.push(promise);
}
// Wait for all promises to resolve
const results = await Promise.all(fetchPromises);
// Filter out any null results from failed fetches
const lobbyInfos: GameInfo[] = results
.filter((result) => result !== null)
.map((gi: GameInfo) => {
return {
gameID: gi.gameID,
numClients: gi?.clients?.length ?? 0,
gameConfig: gi.gameConfig,
msUntilStart: (gi.msUntilStart ?? Date.now()) - Date.now(),
} as GameInfo;
});
lobbyInfos.forEach((l) => {
if (l.msUntilStart <= 250) {
publicLobbyIDs.delete(l.gameID);
}
});
// Update the JSON string
publicLobbiesJsonStr = JSON.stringify({
lobbies: lobbyInfos,
});
}
// Function to schedule a new public game
async function schedulePublicGame() {
const gameID = generateID();
publicLobbyIDs.add(gameID);
// Create the default public game config (from your GameManager)
const defaultGameConfig = {
gameMap: getNextMap(),
gameType: GameType.Public,
difficulty: Difficulty.Medium,
infiniteGold: false,
infiniteTroops: false,
instantBuild: false,
disableNPCs: false,
bots: 400,
};
const workerPath = config.workerPath(gameID);
// Send request to the worker to start the game
try {
const response = await fetch(
`http://localhost:${config.workerPort(gameID)}/create_game/${gameID}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Internal-Request": "true", // Special header for internal requests
},
body: JSON.stringify({
gameID: gameID,
gameConfig: defaultGameConfig,
}),
},
);
if (!response.ok) {
throw new Error(`Failed to schedule public game: ${response.statusText}`);
}
const data = await response.json();
} catch (error) {
console.error(
`Failed to schedule public game on worker ${workerPath}:`,
error,
);
throw error;
}
}
// Map rotation management (moved from GameManager)
let mapsPlaylist: GameMapType[] = [];
const random = new PseudoRandom(123);
// Get the next map in rotation
function getNextMap(): GameMapType {
if (mapsPlaylist.length > 0) {
return mapsPlaylist.shift()!;
}
const frequency = {
World: 4,
Europe: 4,
Mena: 2,
NorthAmerica: 2,
Oceania: 1,
BlackSea: 2,
Africa: 2,
Asia: 2,
Mars: 0,
};
Object.keys(GameMapType).forEach((key) => {
let count = parseInt(frequency[key]);
while (count > 0) {
mapsPlaylist.push(GameMapType[key]);
count--;
}
});
while (true) {
random.shuffleArray(mapsPlaylist);
if (allNonConsecutive(mapsPlaylist)) {
return mapsPlaylist.shift()!;
}
}
}
// Check for consecutive duplicates in the maps array
function allNonConsecutive(maps: GameMapType[]): boolean {
for (let i = 0; i < maps.length - 1; i++) {
if (maps[i] === maps[i + 1]) {
return false;
}
}
return true;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// SPA fallback route
app.get("*", function (req, res) {
res.sendFile(path.join(__dirname, "../../out/index.html"));
});
+18 -486
View File
@@ -1,490 +1,22 @@
import express, { json, Request, Response, NextFunction } from "express";
import http from "http";
import { WebSocketServer } from "ws";
import path from "path";
import { fileURLToPath } from "url";
import { GameManager } from "./GameManager";
import {
ClientMessage,
ClientMessageSchema,
GameRecord,
GameRecordSchema,
LogSeverity,
ServerStartGameMessageSchema,
} from "../core/Schemas";
import {
GameEnv,
getConfig,
getServerConfig,
} from "../core/configuration/Config";
import { slog } from "./StructuredLog";
import { Client } from "./Client";
import { GamePhase, GameServer } from "./GameServer";
import { archive, gameRecordExists, readGameRecord } from "./Archive";
import { DiscordBot } from "./DiscordBot";
import {
sanitizeUsername,
validateUsername,
} from "../core/validations/username";
import { SecretManagerServiceClient } from "@google-cloud/secret-manager";
import dotenv from "dotenv";
import crypto from "crypto";
dotenv.config();
import rateLimit from "express-rate-limit";
import { RateLimiterMemory } from "rate-limiter-flexible";
import * as si from "systeminformation";
import cluster from "cluster";
import { startMaster } from "./Master";
import { startWorker } from "./Worker";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const server = http.createServer(app);
const wss = new WebSocketServer({ server });
const serverConfig = getServerConfig();
// Initialize Secret Manager
const secretManager = new SecretManagerServiceClient();
// Discord OAuth Configuration (will be populated from secrets)
let DISCORD_CLIENT_ID: string;
let DISCORD_CLIENT_SECRET: string;
// Serve static files from the 'out' directory
app.use(express.static(path.join(__dirname, "../../out")));
app.use(express.json());
app.set("trust proxy", 2);
app.use(
rateLimit({
windowMs: 1000, // 1 second
max: 20, // 20 requests per IP per second
}),
);
const rateLimiter = new RateLimiterMemory({
points: 50, // 50 messages
duration: 1, // per 1 second
});
const updateRateLimiter = new RateLimiterMemory({
points: 10,
duration: 240, // 4 minutes
});
const gm = new GameManager(getServerConfig());
const bot = new DiscordBot();
try {
await bot.start();
} catch (error) {
console.error("Failed to start bot:", error);
}
let lobbiesString = "";
// Async error wrapper with rate limiting support
const asyncHandler =
(fn: Function, limiter = null) =>
async (req: Request, res: Response, next: NextFunction) => {
try {
// Apply rate limiting if a limiter is provided
if (limiter) {
const clientIP = req.ip || req.socket.remoteAddress || "unknown";
try {
await limiter.consume(clientIP);
} catch (error) {
console.warn(`Rate limited for IP ${clientIP}`);
return res.status(429).json({ error: "Too many requests" });
}
}
// Execute the route handler
await fn(req, res, next);
} catch (error) {
// Pass any errors to Express error handler
next(error);
}
};
// Discord OAuth callback endpoint
app.get(
"/auth/callback",
asyncHandler(async (req: Request, res: Response) => {
const { code } = req.query;
if (!code) {
return res.status(400).send("No code provided");
}
// Exchange code for access token
const tokenResponse = await fetch("https://discord.com/api/oauth2/token", {
method: "POST",
body: new URLSearchParams({
client_id: DISCORD_CLIENT_ID!,
client_secret: DISCORD_CLIENT_SECRET!,
code: code as string,
grant_type: "authorization_code",
redirect_uri: serverConfig.discordRedirectURI(),
}),
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
if (!tokenResponse.ok) {
throw new Error("Failed to get access token");
}
const tokenData = await tokenResponse.json();
// Get user information
const userResponse = await fetch("https://discord.com/api/users/@me", {
headers: {
Authorization: `Bearer ${tokenData.access_token}`,
},
});
if (!userResponse.ok) {
throw new Error("Failed to get user information");
}
const userData = await userResponse.json();
const sessionToken = crypto.randomBytes(32).toString("hex");
// TODO: store userData and sessionToken in database.
res.cookie("session", sessionToken, {
httpOnly: true,
secure: true,
sameSite: "strict",
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days in milliseconds
});
res.redirect(`/`);
}),
);
app.get("/auth/discord", (req: Request, res: Response) => {
console.log("Redirecting to Discord OAuth...");
const redirectUri = serverConfig.discordRedirectURI();
const authorizeUrl = `https://discord.com/api/oauth2/authorize?client_id=${DISCORD_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=identify`;
console.log("Auth URL:", authorizeUrl);
res.redirect(authorizeUrl);
});
// New GET endpoint to list lobbies
app.get("/lobbies", (req: Request, res: Response) => {
res.send(lobbiesString);
});
app.post(
"/private_lobby",
asyncHandler(async (req, res) => {
const clientIP = req.ip || req.socket.remoteAddress || "unknown";
const id = gm.createPrivateGame();
console.log(`ip ${clientIP} creating private lobby with id ${id}`);
res.json({
id: id,
});
}, updateRateLimiter),
);
app.post(
"/archive_singleplayer_game",
asyncHandler(async (req, res) => {
const gameRecord: GameRecord = req.body;
const clientIP = req.ip || req.socket.remoteAddress || "unknown";
if (!gameRecord) {
console.log("game record not found in request");
res.status(404).json({ error: "Game record not found" });
return;
}
gameRecord.players.forEach((p) => (p.ip = clientIP));
GameRecordSchema.parse(gameRecord);
archive(gameRecord);
res.json({
success: true,
});
}, updateRateLimiter),
);
app.post(
"/start_private_lobby/:id",
asyncHandler(async (req, res) => {
const clientIP = req.ip || req.socket.remoteAddress || "unknown";
console.log(`starting private lobby with id ${req.params.id}`);
gm.startPrivateGame(req.params.id);
res.status(200).json({ success: true });
}, updateRateLimiter),
);
app.put(
"/private_lobby/:id",
asyncHandler(async (req, res) => {
const lobbyID = req.params.id;
gm.updateGameConfig(lobbyID, {
gameMap: req.body.gameMap,
difficulty: req.body.difficulty,
infiniteGold: req.body.infiniteGold,
infiniteTroops: req.body.infiniteTroops,
instantBuild: req.body.instantBuild,
bots: req.body.bots,
disableNPCs: req.body.disableNPCs,
});
res.status(200).json({ success: true });
}),
);
app.get(
"/lobby/:id/exists",
asyncHandler(async (req, res) => {
const lobbyId = req.params.id;
let gameExists = gm.hasActiveGame(lobbyId);
if (!gameExists) {
gameExists = await gameRecordExists(lobbyId);
}
res.json({
exists: gameExists,
});
}),
);
app.get(
"/lobby/:id",
asyncHandler(async (req, res) => {
const game = gm.game(req.params.id);
if (game == null) {
console.log(`lobby ${req.params.id} not found`);
return res.status(404).json({ error: "Game not found" });
}
res.json({
players: game.activeClients.map((c) => ({
username: c.username,
clientID: c.clientID,
})),
});
}),
);
app.get(
"/private_lobby/:id",
asyncHandler(async (req, res) => {
res.json({
hi: "5",
});
}),
);
app.get(
"/debug-ip",
asyncHandler(async (req, res) => {
res.send({
"x-forwarded-for": req.headers["x-forwarded-for"],
"real-ip": req.ip,
"raw-headers": req.rawHeaders,
});
}),
);
app.get("*", function (req, res) {
// SPA routing
res.sendFile(path.join(__dirname, "../../out/index.html"));
});
wss.on("connection", (ws, req) => {
ws.on("message", async (message: string) => {
let ip = "";
try {
const forwarded = req.headers["x-forwarded-for"];
ip = Array.isArray(forwarded)
? forwarded[0]
: forwarded || req.socket.remoteAddress;
await rateLimiter.consume(ip);
} catch (error) {
console.warn(`rate limit exceede for ${ip}`);
return;
}
try {
const clientMsg: ClientMessage = ClientMessageSchema.parse(
JSON.parse(message.toString()),
);
if (clientMsg.type == "join") {
const forwarded = req.headers["x-forwarded-for"];
let ip = Array.isArray(forwarded)
? forwarded[0]
: forwarded || req.socket.remoteAddress;
if (Array.isArray(ip)) {
ip = ip[0];
}
const { isValid, error } = validateUsername(clientMsg.username);
if (!isValid) {
console.log(
`game ${clientMsg.gameID}, client ${clientMsg.clientID} received invalid username, ${error}`,
);
return;
}
clientMsg.username = sanitizeUsername(clientMsg.username);
const wasFound = gm.addClient(
new Client(
clientMsg.clientID,
clientMsg.persistentID,
ip,
clientMsg.username,
ws,
),
clientMsg.gameID,
clientMsg.lastTurn,
);
if (!wasFound) {
console.log(`game ${clientMsg.gameID} not found, loading from gcs`);
const record = await readGameRecord(clientMsg.gameID);
ws.send(
JSON.stringify(
ServerStartGameMessageSchema.parse({
type: "start",
turns: record.turns,
config: record.gameConfig,
}),
),
);
}
}
if (clientMsg.type == "log") {
slog({
logKey: "client_console_log",
msg: clientMsg.log,
severity: clientMsg.severity,
clientID: clientMsg.clientID,
gameID: clientMsg.gameID,
persistentID: clientMsg.persistentID,
});
}
} catch (error) {
console.warn(`errror handling websocket message for ${ip}: ${error}`);
}
});
ws.on("error", (error: Error) => {
if ((error as any).code === "WS_ERR_UNEXPECTED_RSV_1") {
ws.close(1002);
}
});
});
// Global error handler
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
console.error(`Error in ${req.method} ${req.path}:`, err);
slog({
logKey: "server_error",
msg: `Unhandled exception in ${req.method} ${req.path}: ${err.message}`,
severity: LogSeverity.Error,
stack: err.stack,
});
res.status(500).json({ error: "An unexpected error occurred" });
});
function startServer() {
setInterval(() => tick(), 1000);
setInterval(() => updateLobbies(), 100);
setInterval(async () => {
await getCurrentCpuUsage();
console.log("---");
}, 5 * 1000);
initializeSecrets();
const PORT = process.env.PORT || 3000;
console.log(`Server will try to run on http://localhost:${PORT}`);
server.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});
}
function tick() {
gm.tick();
}
function updateLobbies() {
lobbiesString = JSON.stringify({
lobbies: gm
.gamesByPhase(GamePhase.Lobby)
.filter((g) => g.isPublic)
.map((g) => ({
id: g.id,
msUntilStart: g.startTime() - Date.now(),
numClients: g.numClients(),
gameConfig: g.gameConfig,
}))
.sort((a, b) => a.msUntilStart - b.msUntilStart),
});
}
// Process-level unhandled exception handlers
process.on("uncaughtException", (err) => {
console.error("Uncaught exception:", err);
slog({
logKey: "uncaught_exception",
msg: `Uncaught exception: ${err.message}`,
severity: LogSeverity.Error,
stack: err.stack,
});
// Note: We're not exiting the process to maintain uptime
// but be aware the app might be in an inconsistent state
});
process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled rejection at:", promise, "reason:", reason);
slog({
logKey: "unhandled_rejection",
msg: `Unhandled promise rejection: ${reason}`,
severity: LogSeverity.Error,
});
});
// Initialize secrets and start server
async function initializeSecrets() {
try {
DISCORD_CLIENT_ID = await getSecret(
"DISCORD_CLIENT_ID",
serverConfig.env(),
);
DISCORD_CLIENT_SECRET = await getSecret(
"DISCORD_CLIENT_SECRET",
serverConfig.env(),
);
if (!DISCORD_CLIENT_ID || !DISCORD_CLIENT_SECRET) {
throw new Error("Failed to load Discord secrets");
}
} catch (error) {
console.error("Failed to initialize secrets:", error);
// Main entry point of the application
async function main() {
// Check if this is the primary (master) process
if (cluster.isPrimary) {
console.log("Starting master process...");
await startMaster();
} else {
// This is a worker process
console.log("Starting worker process...");
await startWorker();
}
}
async function getSecret(secretName: string, ge: GameEnv) {
if (ge == GameEnv.Dev) {
console.log(`loading secret ${secretName} from environment variable`);
const value = process.env[secretName];
if (!value) {
throw Error(`error loading secret ${secretName}`);
}
}
console.log(`loading secret ${secretName} from Google secrets manager`);
const name = `projects/openfrontio/secrets/${secretName}/versions/latest`;
const [version] = await secretManager.accessSecretVersion({ name });
return version.payload?.data?.toString();
}
async function getCurrentCpuUsage(): Promise<void> {
const cpuData = await si.currentLoad();
console.log(`Current CPU Load: ${cpuData.currentLoad.toFixed(2)}%`);
console.log(
`Current CPU Load (User): ${cpuData.currentLoadUser.toFixed(2)}%`,
);
console.log(
`Current CPU Load (System): ${cpuData.currentLoadSystem.toFixed(2)}%`,
);
}
startServer();
// Start the application
main().catch((error) => {
console.error("Failed to start server:", error);
process.exit(1);
});
+17
View File
@@ -0,0 +1,17 @@
export function isHighTrafficTime(): boolean {
// More traffic from 4am to 4pm
const now = new Date();
// Convert current time to PST (America/Los_Angeles timezone)
// Using a more compatible approach
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone: "America/Los_Angeles",
hour: "numeric",
hour12: false,
});
const formattedTime = formatter.format(now);
const hourPST = parseInt(formattedTime.split(":")[0], 10);
return hourPST >= 4 && hourPST < 16;
}
+346
View File
@@ -0,0 +1,346 @@
import express, { Request, Response, NextFunction } from "express";
import http from "http";
import { WebSocketServer } from "ws";
import path from "path";
import { fileURLToPath } from "url";
import { GameManager } from "./GameManager";
import { getServerConfig } from "../core/configuration/Config";
import { WebSocket } from "ws";
import { Client } from "./Client";
import rateLimit from "express-rate-limit";
import { RateLimiterMemory } from "rate-limiter-flexible";
import { GameConfig, GameRecord, LogSeverity } from "../core/Schemas";
import { slog } from "./StructuredLog";
import { GameType } from "../core/game/Game";
import { archive } from "./Archive";
import { LimiterType, gatekeeper } from "./Gatekeeper";
const config = getServerConfig();
// Worker setup
export function startWorker() {
// Get worker ID from environment variable
const workerId = parseInt(process.env.WORKER_ID || "0");
console.log(`Worker ${workerId} starting...`);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const server = http.createServer(app);
const wss = new WebSocketServer({ server });
const gm = new GameManager(config);
// Middleware to handle /wX path prefix
app.use((req, res, next) => {
// Extract the original path without the worker prefix
const originalPath = req.url;
const match = originalPath.match(/^\/w(\d+)(.*)$/);
if (match) {
const pathWorkerId = parseInt(match[1]);
const actualPath = match[2] || "/";
// Verify this request is for the correct worker
if (pathWorkerId !== workerId) {
return res.status(404).json({
error: "Worker mismatch",
message: `This is worker ${workerId}, but you requested worker ${pathWorkerId}`,
});
}
// Update the URL to remove the worker prefix
req.url = actualPath;
}
next();
});
app.set("trust proxy", 3);
app.use(express.json());
app.use(express.static(path.join(__dirname, "../../out")));
app.use(
rateLimit({
windowMs: 1000, // 1 second
max: 20, // 20 requests per IP per second
}),
);
// Endpoint to create a private lobby
app.post(
"/create_game/:id",
gatekeeper.httpHandler(LimiterType.Post, async (req, res) => {
const id = req.params.id;
if (!id) {
console.warn(`cannot create game, id not found`);
return;
}
// TODO: if game is public make sure request came from localhohst!!!
const clientIP = req.ip || req.socket.remoteAddress || "unknown";
const gc = req.body?.gameConfig as GameConfig;
if (gc?.gameType == GameType.Public && !isLocalhost(req)) {
console.warn(
`cannot create public game ${id}, ip ${clientIP} not localhost`,
);
return res.status(400);
}
// Double-check this worker should host this game
const expectedWorkerId = config.workerIndex(id);
if (expectedWorkerId !== workerId) {
console.warn(
`This game ${id} should be on worker ${expectedWorkerId}, but this is worker ${workerId}`,
);
return res.status(400);
}
const game = gm.createGame(id, gc);
console.log(
`Worker ${workerId}: IP ${clientIP} creating game ${game.isPublic() ? "Public" : "Private"} with id ${id}`,
);
res.json(game.gameInfo());
}),
);
// Add other endpoints from your original server
app.post(
"/start_game/:id",
gatekeeper.httpHandler(LimiterType.Post, async (req, res) => {
console.log(`starting private lobby with id ${req.params.id}`);
const game = gm.game(req.params.id);
if (!game) {
return;
}
if (game.isPublic()) {
const clientIP = req.ip || req.socket.remoteAddress || "unknown";
console.log(
`cannot start public game ${game.id}, game is public, ip: ${clientIP}`,
);
return;
}
game.start();
res.status(200).json({ success: true });
}),
);
app.put(
"/game/:id",
gatekeeper.httpHandler(LimiterType.Put, async (req, res) => {
// TODO: only update public game if from local host
const lobbyID = req.params.id;
if (req.body.gameType == GameType.Public) {
console.log(`cannot update game ${lobbyID} to public`);
return res.status(400);
}
const game = gm.game(lobbyID);
if (!game) {
return res.status(400);
}
if (game.isPublic()) {
const clientIP = req.ip || req.socket.remoteAddress || "unknown";
console.warn(`cannot update public game ${game.id}, ip: ${clientIP}`);
return res.status(400);
}
game.updateGameConfig({
gameMap: req.body.gameMap,
difficulty: req.body.difficulty,
infiniteGold: req.body.infiniteGold,
infiniteTroops: req.body.infiniteTroops,
instantBuild: req.body.instantBuild,
bots: req.body.bots,
disableNPCs: req.body.disableNPCs,
});
res.status(200).json({ success: true });
}),
);
app.get(
"/game/:id/exists",
gatekeeper.httpHandler(LimiterType.Get, async (req, res) => {
const lobbyId = req.params.id;
res.json({
exists: gm.game(lobbyId) != null,
});
}),
);
app.get(
"/game/:id",
gatekeeper.httpHandler(LimiterType.Get, async (req, res) => {
const game = gm.game(req.params.id);
if (game == null) {
console.log(`lobby ${req.params.id} not found`);
return res.status(404).json({ error: "Game not found" });
}
res.json(game.gameInfo());
}),
);
app.post(
"/archive_singleplayer_game",
gatekeeper.httpHandler(LimiterType.Post, async (req, res) => {
const gameRecord: GameRecord = req.body;
const clientIP = req.ip || req.socket.remoteAddress || "unknown";
if (!gameRecord) {
console.log("game record not found in request");
res.status(404).json({ error: "Game record not found" });
return;
}
gameRecord.players.forEach((p) => (p.ip = clientIP));
archive(gameRecord);
res.json({
success: true,
});
}),
);
// WebSocket handling
wss.on("connection", (ws: WebSocket, req) => {
ws.on(
"message",
gatekeeper.wsHandler(req, async (message: string) => {
const forwarded = req.headers["x-forwarded-for"];
const ip = Array.isArray(forwarded)
? forwarded[0]
: forwarded || req.socket.remoteAddress;
try {
// Process WebSocket messages as in your original code
// Parse and handle client messages
const clientMsg = JSON.parse(message.toString());
if (clientMsg.type == "join") {
// Verify this worker should handle this game
const expectedWorkerId = config.workerIndex(clientMsg.gameID);
if (expectedWorkerId !== workerId) {
console.warn(
`Worker mismatch: Game ${clientMsg.gameID} should be on worker ${expectedWorkerId}, but this is worker ${workerId}`,
);
return;
}
// Create client and add to game
const client = new Client(
clientMsg.clientID,
clientMsg.persistentID,
ip,
clientMsg.username,
ws,
);
const wasFound = gm.addClient(
client,
clientMsg.gameID,
clientMsg.lastTurn,
);
if (!wasFound) {
console.log(
`game ${clientMsg.gameID} not found on worker ${workerId}`,
);
// Handle game not found case
}
}
// Handle other message types
} catch (error) {
console.warn(
`error handling websocket message for ${ip}: ${error}`.substring(
0,
250,
),
);
}
}),
);
ws.on("error", (error: Error) => {
if ((error as any).code === "WS_ERR_UNEXPECTED_RSV_1") {
ws.close(1002);
}
});
});
// Set up ticker
setInterval(() => gm.tick(), 1000);
// The load balancer will handle routing to this server based on path
const PORT = config.workerPortByIndex(workerId);
server.listen(PORT, () => {
console.log(`Worker ${workerId} running on http://localhost:${PORT}`);
console.log(`Handling requests with path prefix /w${workerId}/`);
// Signal to the master process that this worker is ready
if (process.send) {
process.send({
type: "WORKER_READY",
workerId: workerId,
});
console.log(`Worker ${workerId} signaled ready state to master`);
}
});
// Global error handler
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
console.error(`Error in ${req.method} ${req.path}:`, err);
slog({
logKey: "server_error",
msg: `Unhandled exception in ${req.method} ${req.path}: ${err.message}`,
severity: LogSeverity.Error,
stack: err.stack,
});
res.status(500).json({ error: "An unexpected error occurred" });
});
// Process-level error handlers
process.on("uncaughtException", (err) => {
console.error(`Worker ${workerId} uncaught exception:`, err);
slog({
logKey: "uncaught_exception",
msg: `Worker ${workerId} uncaught exception: ${err.message}`,
severity: LogSeverity.Error,
stack: err.stack,
});
});
process.on("unhandledRejection", (reason, promise) => {
console.error(
`Worker ${workerId} unhandled rejection at:`,
promise,
"reason:",
reason,
);
slog({
logKey: "unhandled_rejection",
msg: `Worker ${workerId} unhandled promise rejection: ${reason}`,
severity: LogSeverity.Error,
});
});
}
const isLocalhost = (req: Request): boolean => {
// Get client IP address from various possible sources
const clientIP =
req.ip ||
req.socket.remoteAddress ||
(req.headers["x-forwarded-for"] as string)?.split(",").shift() ||
"unknown";
// Check if the request is from a loopback address
const isLoopbackIP =
// IPv4 localhost
clientIP === "127.0.0.1" ||
// IPv6 localhost
clientIP === "::1" ||
// Full loopback range
clientIP.startsWith("127.");
// Check hostname
const isLocalHostname =
req.hostname === "localhost" || req.headers.host?.startsWith("localhost:");
// Consider request local if either IP is loopback or hostname is localhost
return isLoopbackIP || isLocalHostname;
};