mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-30 05:22:11 +00:00
Merge branch 'main' into randmap
This commit is contained in:
@@ -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
@@ -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)}
|
||||
|
||||
@@ -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
@@ -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}>×</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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}>×</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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}>×</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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}>×</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
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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()}
|
||||
>
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
)}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
Submodule
+1
Submodule src/server/gatekeeper added at 2f19b93a6a
Reference in New Issue
Block a user