mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-04 00:00:40 +00:00
Merge commit 'd49272e17e6b162b9bf4a6934d0f89de3285316e' into HEAD
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import { z } from "zod";
|
||||
import { base64urlToUuid } from "./Base64";
|
||||
|
||||
export const RefreshResponseSchema = z.object({
|
||||
token: z.string(),
|
||||
});
|
||||
export type RefreshResponse = z.infer<typeof RefreshResponseSchema>;
|
||||
|
||||
export const TokenPayloadSchema = z.object({
|
||||
jti: z.string(),
|
||||
sub: z
|
||||
.string()
|
||||
.refine(
|
||||
(val) => {
|
||||
const uuid = base64urlToUuid(val);
|
||||
return uuid != null;
|
||||
},
|
||||
{
|
||||
message: "Invalid base64-encoded UUID",
|
||||
},
|
||||
)
|
||||
.transform((val) => {
|
||||
const uuid = base64urlToUuid(val);
|
||||
if (!uuid) throw new Error("Invalid base64 UUID");
|
||||
return uuid;
|
||||
}),
|
||||
iat: z.number(),
|
||||
iss: z.string(),
|
||||
aud: z.string(),
|
||||
exp: z.number(),
|
||||
rol: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => val.split(",")),
|
||||
});
|
||||
export type TokenPayload = z.infer<typeof TokenPayloadSchema>;
|
||||
|
||||
export const UserMeResponseSchema = z.object({
|
||||
user: z.object({
|
||||
id: z.string(),
|
||||
avatar: z.string(),
|
||||
username: z.string(),
|
||||
global_name: z.string(),
|
||||
discriminator: z.string(),
|
||||
locale: z.string(),
|
||||
}),
|
||||
});
|
||||
export type UserMeResponse = z.infer<typeof UserMeResponseSchema>;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { base64url } from "jose";
|
||||
|
||||
/**
|
||||
* Converts a UUID string to a base64url-encoded binary representation.
|
||||
* @param uuid - The UUID string (e.g., '123e4567-e89b-12d3-a456-426614174000')
|
||||
* @returns base64url string (e.g., 'Ej5FZ+i7EtOkVkJmFBdAAA')
|
||||
*/
|
||||
export function uuidToBase64url(uuid: string): string {
|
||||
const hex = uuid.replace(/-/g, "");
|
||||
const bytes = new Uint8Array(16);
|
||||
|
||||
for (let i = 0; i < 16; i++) {
|
||||
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
|
||||
return base64url.encode(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a base64url-encoded binary UUID back to its canonical UUID string.
|
||||
* @param encoded - base64url string (e.g., 'Ej5FZ+i7EtOkVkJmFBdAAA')
|
||||
* @returns UUID string (e.g., '123e4567-e89b-12d3-a456-426614174000')
|
||||
*/
|
||||
export function base64urlToUuid(encoded: string): string {
|
||||
const bytes = base64url.decode(encoded);
|
||||
const hex = Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
|
||||
return [
|
||||
hex.slice(0, 8),
|
||||
hex.slice(8, 12),
|
||||
hex.slice(12, 16),
|
||||
hex.slice(16, 20),
|
||||
hex.slice(20),
|
||||
].join("-");
|
||||
}
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
import { createGameRecord } from "../core/Util";
|
||||
import { ServerConfig } from "../core/configuration/Config";
|
||||
import { getConfig } from "../core/configuration/ConfigLoader";
|
||||
import { Team, UnitType } from "../core/game/Game";
|
||||
import { Cell, Team, UnitType } from "../core/game/Game";
|
||||
import { TileRef } from "../core/game/GameMap";
|
||||
import {
|
||||
ErrorUpdate,
|
||||
GameUpdateType,
|
||||
@@ -28,6 +29,7 @@ import { endGame, startGame, startTime } from "./LocalPersistantStats";
|
||||
import { getPersistentIDFromCookie } from "./Main";
|
||||
import {
|
||||
SendAttackIntentEvent,
|
||||
SendBoatAttackIntentEvent,
|
||||
SendHashEvent,
|
||||
SendSpawnIntentEvent,
|
||||
Transport,
|
||||
@@ -221,6 +223,7 @@ export class ClientGameRunner {
|
||||
|
||||
public start() {
|
||||
consolex.log("starting client game");
|
||||
|
||||
this.isActive = true;
|
||||
this.lastMessageTime = Date.now();
|
||||
setTimeout(() => {
|
||||
@@ -362,8 +365,13 @@ export class ClientGameRunner {
|
||||
this.myPlayer = myPlayer;
|
||||
}
|
||||
this.myPlayer.actions(tile).then((actions) => {
|
||||
console.log(`got actions: ${JSON.stringify(actions)}`);
|
||||
if (this.myPlayer === null) return;
|
||||
const bu = actions.buildableUnits.find(
|
||||
(bu) => bu.type == UnitType.TransportShip,
|
||||
);
|
||||
if (bu == null) {
|
||||
console.warn(`no transport ship buildable units`);
|
||||
return;
|
||||
}
|
||||
if (actions.canAttack) {
|
||||
this.eventBus.emit(
|
||||
new SendAttackIntentEvent(
|
||||
@@ -371,6 +379,30 @@ export class ClientGameRunner {
|
||||
this.myPlayer.troops() * this.renderer.uiState.attackRatio,
|
||||
),
|
||||
);
|
||||
} else if (
|
||||
bu.canBuild !== false &&
|
||||
this.shouldBoat(tile, bu.canBuild) &&
|
||||
this.gameView.isLand(tile)
|
||||
) {
|
||||
this.myPlayer
|
||||
.bestTransportShipSpawn(this.gameView.ref(cell.x, cell.y))
|
||||
.then((spawn: number | false) => {
|
||||
let spawnCell = null;
|
||||
if (spawn !== false) {
|
||||
spawnCell = new Cell(
|
||||
this.gameView.x(spawn),
|
||||
this.gameView.y(spawn),
|
||||
);
|
||||
}
|
||||
this.eventBus.emit(
|
||||
new SendBoatAttackIntentEvent(
|
||||
this.gameView.owner(tile).id(),
|
||||
cell,
|
||||
this.myPlayer.troops() * this.renderer.uiState.attackRatio,
|
||||
spawnCell,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const owner = this.gameView.owner(tile);
|
||||
@@ -382,6 +414,18 @@ export class ClientGameRunner {
|
||||
});
|
||||
}
|
||||
|
||||
private shouldBoat(tile: TileRef, src: TileRef) {
|
||||
// TODO: Global enable flag
|
||||
// TODO: Global limit autoboat to nearby shore flag
|
||||
// if (!enableAutoBoat) return false;
|
||||
// if (!limitAutoBoatNear) return true;
|
||||
const distanceSquared = this.gameView.euclideanDistSquared(tile, src);
|
||||
const limit = 100;
|
||||
const limitSquared = limit * limit;
|
||||
if (distanceSquared > limitSquared) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private onMouseMove(event: MouseMoveEvent) {
|
||||
this.lastMousePosition = { x: event.x, y: event.y };
|
||||
this.checkTileUnderCursor();
|
||||
|
||||
@@ -4,7 +4,13 @@ import randomMap from "../../resources/images/RandomMap.webp";
|
||||
import { translateText } from "../client/Utils";
|
||||
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
|
||||
import { consolex } from "../core/Consolex";
|
||||
import { Difficulty, GameMapType, GameMode } from "../core/game/Game";
|
||||
import {
|
||||
Difficulty,
|
||||
Duos,
|
||||
GameMapType,
|
||||
GameMode,
|
||||
mapCategories,
|
||||
} from "../core/game/Game";
|
||||
import { GameConfig, GameInfo } from "../core/Schemas";
|
||||
import { generateID } from "../core/Util";
|
||||
import "./components/baseComponents/Modal";
|
||||
@@ -23,6 +29,7 @@ export class HostLobbyModal extends LitElement {
|
||||
@state() private selectedDifficulty: Difficulty = Difficulty.Medium;
|
||||
@state() private disableNPCs = false;
|
||||
@state() private gameMode: GameMode = GameMode.FFA;
|
||||
@state() private teamCount: number | typeof Duos = 2;
|
||||
@state() private disableNukes: boolean = false;
|
||||
@state() private bots: number = 400;
|
||||
@state() private infiniteGold: boolean = false;
|
||||
@@ -73,23 +80,40 @@ export class HostLobbyModal extends LitElement {
|
||||
<!-- Map Selection -->
|
||||
<div class="options-section">
|
||||
<div class="option-title">${translateText("map.map")}</div>
|
||||
<div class="option-cards">
|
||||
${Object.entries(GameMapType)
|
||||
.filter(([key]) => isNaN(Number(key)))
|
||||
.map(
|
||||
([key, value]) => html`
|
||||
<div @click=${() => this.handleMapSelection(value)}>
|
||||
<map-display
|
||||
.mapKey=${key}
|
||||
.selected=${!this.useRandomMap &&
|
||||
this.selectedMap === value}
|
||||
.translation=${translateText(
|
||||
`map.${key.toLowerCase()}`,
|
||||
)}
|
||||
></map-display>
|
||||
<div class="option-cards flex-col">
|
||||
<!-- Use the imported mapCategories -->
|
||||
${Object.entries(mapCategories).map(
|
||||
([categoryKey, maps]) => html`
|
||||
<div class="w-full mb-4">
|
||||
<h3
|
||||
class="text-lg font-semibold mb-2 text-center text-gray-300"
|
||||
>
|
||||
${translateText(`map_categories.${categoryKey}`)}
|
||||
</h3>
|
||||
<div class="flex flex-row flex-wrap justify-center gap-4">
|
||||
${maps.map((mapValue) => {
|
||||
const mapKey = Object.keys(GameMapType).find(
|
||||
(key) => GameMapType[key] === mapValue,
|
||||
);
|
||||
return html`
|
||||
<div
|
||||
@click=${() => this.handleMapSelection(mapValue)}
|
||||
>
|
||||
<map-display
|
||||
.mapKey=${mapKey}
|
||||
.selected=${!this.useRandomMap &&
|
||||
this.selectedMap === mapValue}
|
||||
.translation=${translateText(
|
||||
`map.${mapKey.toLowerCase()}`,
|
||||
)}
|
||||
></map-display>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
<div
|
||||
class="option-card random-map ${
|
||||
this.useRandomMap ? "selected" : ""
|
||||
@@ -103,7 +127,9 @@ export class HostLobbyModal extends LitElement {
|
||||
style="width:100%; aspect-ratio: 4/2; object-fit:cover; border-radius:8px;"
|
||||
/>
|
||||
</div>
|
||||
<div class="option-card-title">${translateText("map.random")}</div>
|
||||
<div class="option-card-title">
|
||||
${translateText("map.random")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -159,6 +185,33 @@ export class HostLobbyModal extends LitElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${
|
||||
this.gameMode === GameMode.FFA
|
||||
? ""
|
||||
: html`
|
||||
<!-- Team Count Selection -->
|
||||
<div class="options-section">
|
||||
<div class="option-title">
|
||||
${translateText("host_modal.team_count")}
|
||||
</div>
|
||||
<div class="option-cards">
|
||||
${[Duos, 2, 3, 4, 5, 6, 7].map(
|
||||
(o) => html`
|
||||
<div
|
||||
class="option-card ${this.teamCount === o
|
||||
? "selected"
|
||||
: ""}"
|
||||
@click=${() => this.handleTeamCountSelection(o)}
|
||||
>
|
||||
<div class="option-card-title">${o}</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
<!-- Game Options -->
|
||||
<div class="options-section">
|
||||
<div class="option-title">
|
||||
@@ -413,6 +466,11 @@ export class HostLobbyModal extends LitElement {
|
||||
this.putGameConfig();
|
||||
}
|
||||
|
||||
private async handleTeamCountSelection(value: number | typeof Duos) {
|
||||
this.teamCount = value === Duos ? Duos : Number(value);
|
||||
this.putGameConfig();
|
||||
}
|
||||
|
||||
private async putGameConfig() {
|
||||
const config = await getServerConfigFromClient();
|
||||
const response = await fetch(
|
||||
@@ -432,6 +490,7 @@ export class HostLobbyModal extends LitElement {
|
||||
infiniteTroops: this.infiniteTroops,
|
||||
instantBuild: this.instantBuild,
|
||||
gameMode: this.gameMode,
|
||||
playerTeams: this.teamCount,
|
||||
} as GameConfig),
|
||||
},
|
||||
);
|
||||
|
||||
+56
-63
@@ -113,6 +113,17 @@ export class InputHandler {
|
||||
) {}
|
||||
|
||||
initialize() {
|
||||
const keybinds = {
|
||||
toggleView: "Space",
|
||||
centerCamera: "KeyC",
|
||||
moveUp: "KeyW",
|
||||
moveDown: "KeyS",
|
||||
moveLeft: "KeyA",
|
||||
moveRight: "KeyD",
|
||||
zoomOut: "KeyQ",
|
||||
zoomIn: "KeyE",
|
||||
...JSON.parse(localStorage.getItem("settings.keybinds") ?? "{}"),
|
||||
};
|
||||
this.canvas.addEventListener("pointerdown", (e) => this.onPointerDown(e));
|
||||
window.addEventListener("pointerup", (e) => this.onPointerUp(e));
|
||||
this.canvas.addEventListener(
|
||||
@@ -122,59 +133,65 @@ export class InputHandler {
|
||||
this.onShiftScroll(e);
|
||||
e.preventDefault();
|
||||
},
|
||||
{
|
||||
passive: false,
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
window.addEventListener("pointermove", this.onPointerMove.bind(this));
|
||||
this.canvas.addEventListener("contextmenu", (e: MouseEvent) => {
|
||||
this.onContextMenu(e);
|
||||
});
|
||||
this.canvas.addEventListener("contextmenu", (e) => this.onContextMenu(e));
|
||||
window.addEventListener("mousemove", (e) => {
|
||||
if (e.movementX === 0 && e.movementY === 0) {
|
||||
return;
|
||||
if (e.movementX || e.movementY) {
|
||||
this.eventBus.emit(new MouseMoveEvent(e.clientX, e.clientY));
|
||||
}
|
||||
this.eventBus.emit(new MouseMoveEvent(e.clientX, e.clientY));
|
||||
});
|
||||
this.pointers.clear();
|
||||
|
||||
// Initialize the combined movement interval
|
||||
this.moveInterval = setInterval(() => {
|
||||
let deltaX = 0;
|
||||
let deltaY = 0;
|
||||
|
||||
// Handle both WASD and arrow keys
|
||||
if (this.activeKeys.has("KeyW") || this.activeKeys.has("ArrowUp"))
|
||||
if (
|
||||
this.activeKeys.has(keybinds.moveUp) ||
|
||||
this.activeKeys.has("ArrowUp")
|
||||
)
|
||||
deltaY += this.PAN_SPEED;
|
||||
if (this.activeKeys.has("KeyS") || this.activeKeys.has("ArrowDown"))
|
||||
if (
|
||||
this.activeKeys.has(keybinds.moveDown) ||
|
||||
this.activeKeys.has("ArrowDown")
|
||||
)
|
||||
deltaY -= this.PAN_SPEED;
|
||||
if (this.activeKeys.has("KeyA") || this.activeKeys.has("ArrowLeft"))
|
||||
if (
|
||||
this.activeKeys.has(keybinds.moveLeft) ||
|
||||
this.activeKeys.has("ArrowLeft")
|
||||
)
|
||||
deltaX += this.PAN_SPEED;
|
||||
if (this.activeKeys.has("KeyD") || this.activeKeys.has("ArrowRight"))
|
||||
if (
|
||||
this.activeKeys.has(keybinds.moveRight) ||
|
||||
this.activeKeys.has("ArrowRight")
|
||||
)
|
||||
deltaX -= this.PAN_SPEED;
|
||||
|
||||
if (deltaX !== 0 || deltaY !== 0) {
|
||||
if (deltaX || deltaY) {
|
||||
this.eventBus.emit(new DragEvent(deltaX, deltaY));
|
||||
}
|
||||
|
||||
// Handle zooming
|
||||
const screenCenterX = window.innerWidth / 2;
|
||||
const screenCenterY = window.innerHeight / 2;
|
||||
const cx = window.innerWidth / 2;
|
||||
const cy = window.innerHeight / 2;
|
||||
|
||||
if (this.activeKeys.has("Minus") || this.activeKeys.has("KeyQ")) {
|
||||
this.eventBus.emit(
|
||||
new ZoomEvent(screenCenterX, screenCenterY, this.ZOOM_SPEED),
|
||||
);
|
||||
if (
|
||||
this.activeKeys.has(keybinds.zoomOut) ||
|
||||
this.activeKeys.has("Minus")
|
||||
) {
|
||||
this.eventBus.emit(new ZoomEvent(cx, cy, this.ZOOM_SPEED));
|
||||
}
|
||||
if (this.activeKeys.has("Equal") || this.activeKeys.has("KeyE")) {
|
||||
this.eventBus.emit(
|
||||
new ZoomEvent(screenCenterX, screenCenterY, -this.ZOOM_SPEED),
|
||||
);
|
||||
if (
|
||||
this.activeKeys.has(keybinds.zoomIn) ||
|
||||
this.activeKeys.has("Equal")
|
||||
) {
|
||||
this.eventBus.emit(new ZoomEvent(cx, cy, -this.ZOOM_SPEED));
|
||||
}
|
||||
}, 1);
|
||||
|
||||
window.addEventListener("keydown", (e) => {
|
||||
if (e.code === "Space") {
|
||||
if (e.code === keybinds.toggleView) {
|
||||
e.preventDefault();
|
||||
if (!this.alternateView) {
|
||||
this.alternateView = true;
|
||||
@@ -187,24 +204,23 @@ export class InputHandler {
|
||||
this.eventBus.emit(new CloseViewEvent());
|
||||
}
|
||||
|
||||
// Add all movement keys to activeKeys
|
||||
if (
|
||||
[
|
||||
"KeyW",
|
||||
"KeyA",
|
||||
"KeyS",
|
||||
"KeyD",
|
||||
keybinds.moveUp,
|
||||
keybinds.moveDown,
|
||||
keybinds.moveLeft,
|
||||
keybinds.moveRight,
|
||||
keybinds.zoomOut,
|
||||
keybinds.zoomIn,
|
||||
"ArrowUp",
|
||||
"ArrowLeft",
|
||||
"ArrowDown",
|
||||
"ArrowRight",
|
||||
"Minus",
|
||||
"Equal",
|
||||
"KeyE",
|
||||
"KeyQ",
|
||||
"Digit1",
|
||||
"Digit2",
|
||||
"KeyC",
|
||||
keybinds.centerCamera,
|
||||
"ControlLeft",
|
||||
"ControlRight",
|
||||
].includes(e.code)
|
||||
@@ -212,13 +228,13 @@ export class InputHandler {
|
||||
this.activeKeys.add(e.code);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("keyup", (e) => {
|
||||
if (e.code === "Space") {
|
||||
if (e.code === keybinds.toggleView) {
|
||||
e.preventDefault();
|
||||
this.alternateView = false;
|
||||
this.eventBus.emit(new AlternateViewEvent(false));
|
||||
}
|
||||
|
||||
if (e.key.toLowerCase() === "r" && e.altKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
this.eventBus.emit(new RefreshGraphicsEvent());
|
||||
@@ -234,35 +250,12 @@ export class InputHandler {
|
||||
this.eventBus.emit(new AttackRatioEvent(10));
|
||||
}
|
||||
|
||||
if (e.code === "KeyC") {
|
||||
if (e.code === keybinds.centerCamera) {
|
||||
e.preventDefault();
|
||||
this.eventBus.emit(new CenterCameraEvent());
|
||||
}
|
||||
|
||||
// Remove all movement keys from activeKeys
|
||||
if (
|
||||
[
|
||||
"KeyW",
|
||||
"KeyA",
|
||||
"KeyS",
|
||||
"KeyD",
|
||||
"ArrowUp",
|
||||
"ArrowLeft",
|
||||
"ArrowDown",
|
||||
"ArrowRight",
|
||||
"Minus",
|
||||
"Equal",
|
||||
"KeyE",
|
||||
"KeyQ",
|
||||
"Digit1",
|
||||
"Digit2",
|
||||
"KeyC",
|
||||
"ControlLeft",
|
||||
"ControlRight",
|
||||
].includes(e.code)
|
||||
) {
|
||||
this.activeKeys.delete(e.code);
|
||||
}
|
||||
this.activeKeys.delete(e.code);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,14 +3,21 @@ import { customElement, state } from "lit/decorators.js";
|
||||
import "./LanguageModal";
|
||||
|
||||
import bg from "../../resources/lang/bg.json";
|
||||
import bn from "../../resources/lang/bn.json";
|
||||
import de from "../../resources/lang/de.json";
|
||||
import en from "../../resources/lang/en.json";
|
||||
import eo from "../../resources/lang/eo.json";
|
||||
import es from "../../resources/lang/es.json";
|
||||
import fr from "../../resources/lang/fr.json";
|
||||
import hi from "../../resources/lang/hi.json";
|
||||
import it from "../../resources/lang/it.json";
|
||||
import ja from "../../resources/lang/ja.json";
|
||||
import nl from "../../resources/lang/nl.json";
|
||||
import pl from "../../resources/lang/pl.json";
|
||||
import pt_br from "../../resources/lang/pt_br.json";
|
||||
import ru from "../../resources/lang/ru.json";
|
||||
import sh from "../../resources/lang/sh.json";
|
||||
import tr from "../../resources/lang/tr.json";
|
||||
import uk from "../../resources/lang/uk.json";
|
||||
|
||||
@customElement("lang-selector")
|
||||
@@ -26,14 +33,21 @@ export class LangSelector extends LitElement {
|
||||
|
||||
private languageMap: Record<string, any> = {
|
||||
bg,
|
||||
bn,
|
||||
de,
|
||||
en,
|
||||
es,
|
||||
eo,
|
||||
fr,
|
||||
it,
|
||||
hi,
|
||||
ja,
|
||||
nl,
|
||||
pl,
|
||||
pt_br,
|
||||
ru,
|
||||
sh,
|
||||
tr,
|
||||
uk,
|
||||
};
|
||||
|
||||
@@ -159,6 +173,7 @@ export class LangSelector extends LitElement {
|
||||
"help-modal",
|
||||
"username-input",
|
||||
"public-lobby",
|
||||
"user-setting",
|
||||
"o-modal",
|
||||
"o-button",
|
||||
];
|
||||
|
||||
@@ -21,12 +21,17 @@ import { LangSelector } from "./LangSelector";
|
||||
import { LanguageModal } from "./LanguageModal";
|
||||
import "./PublicLobby";
|
||||
import { PublicLobby } from "./PublicLobby";
|
||||
import "./RandomNameButton";
|
||||
import { RandomNameButton } from "./RandomNameButton";
|
||||
import { SinglePlayerModal } from "./SinglePlayerModal";
|
||||
import { UserSettingModal } from "./UserSettingModal";
|
||||
import "./UsernameInput";
|
||||
import { UsernameInput } from "./UsernameInput";
|
||||
import { generateCryptoRandomUUID } from "./Utils";
|
||||
import "./components/baseComponents/Button";
|
||||
import { OButton } from "./components/baseComponents/Button";
|
||||
import "./components/baseComponents/Modal";
|
||||
import { discordLogin, getUserMe, isLoggedIn, logOut } from "./jwt";
|
||||
import "./styles.css";
|
||||
|
||||
export interface JoinLobbyEvent {
|
||||
@@ -45,6 +50,7 @@ class Client {
|
||||
private usernameInput: UsernameInput | null = null;
|
||||
private flagInput: FlagInput | null = null;
|
||||
private darkModeButton: DarkModeButton | null = null;
|
||||
private randomNameButton: RandomNameButton | null = null;
|
||||
|
||||
private joinModal: JoinPrivateLobbyModal;
|
||||
private publicLobby: PublicLobby;
|
||||
@@ -79,6 +85,20 @@ class Client {
|
||||
consolex.warn("Dark mode button element not found");
|
||||
}
|
||||
|
||||
this.randomNameButton = document.querySelector(
|
||||
"random-name-button",
|
||||
) as RandomNameButton;
|
||||
if (!this.randomNameButton) {
|
||||
consolex.warn("Random name button element not found");
|
||||
}
|
||||
|
||||
const loginDiscordButton = document.getElementById(
|
||||
"login-discord",
|
||||
) as OButton;
|
||||
const logoutDiscordButton = document.getElementById(
|
||||
"logout-discord",
|
||||
) as OButton;
|
||||
|
||||
this.usernameInput = document.querySelector(
|
||||
"username-input",
|
||||
) as UsernameInput;
|
||||
@@ -122,6 +142,49 @@ class Client {
|
||||
hlpModal.open();
|
||||
});
|
||||
|
||||
const claims = isLoggedIn();
|
||||
if (claims === false) {
|
||||
// Not logged in
|
||||
loginDiscordButton.disable = false;
|
||||
loginDiscordButton.translationKey = "main.login_discord";
|
||||
loginDiscordButton.addEventListener("click", discordLogin);
|
||||
logoutDiscordButton.hidden = true;
|
||||
} else {
|
||||
// JWT appears to be valid, assume we are logged in
|
||||
loginDiscordButton.disable = true;
|
||||
loginDiscordButton.translationKey = "main.logged_in";
|
||||
logoutDiscordButton.hidden = false;
|
||||
logoutDiscordButton.addEventListener("click", () => {
|
||||
// Log out
|
||||
logOut();
|
||||
loginDiscordButton.disable = false;
|
||||
loginDiscordButton.translationKey = "main.login_discord";
|
||||
loginDiscordButton.addEventListener("click", discordLogin);
|
||||
logoutDiscordButton.hidden = true;
|
||||
});
|
||||
// Look up the discord user object.
|
||||
// TODO: Add caching
|
||||
getUserMe().then((userMeResponse) => {
|
||||
if (userMeResponse === false) {
|
||||
// Not logged in
|
||||
loginDiscordButton.disable = false;
|
||||
loginDiscordButton.translationKey = "main.login_discord";
|
||||
loginDiscordButton.addEventListener("click", discordLogin);
|
||||
logoutDiscordButton.hidden = true;
|
||||
return;
|
||||
}
|
||||
// TODO: Update the page for logged in user
|
||||
});
|
||||
}
|
||||
|
||||
const settingsModal = document.querySelector(
|
||||
"user-setting",
|
||||
) as UserSettingModal;
|
||||
settingsModal instanceof UserSettingModal;
|
||||
document.getElementById("settings-button").addEventListener("click", () => {
|
||||
settingsModal.open();
|
||||
});
|
||||
|
||||
const hostModal = document.querySelector(
|
||||
"host-lobby-modal",
|
||||
) as HostPrivateLobbyModal;
|
||||
@@ -207,6 +270,33 @@ class Client {
|
||||
gameRecord: lobby.gameRecord,
|
||||
},
|
||||
() => {
|
||||
console.log("Closing modals");
|
||||
document.getElementById("settings-button").classList.add("hidden");
|
||||
[
|
||||
"single-player-modal",
|
||||
"host-lobby-modal",
|
||||
"join-private-lobby-modal",
|
||||
"game-starting-modal",
|
||||
"top-bar",
|
||||
"help-modal",
|
||||
"user-setting",
|
||||
].forEach((tag) => {
|
||||
const modal = document.querySelector(tag) as HTMLElement & {
|
||||
close?: () => void;
|
||||
isModalOpen?: boolean;
|
||||
};
|
||||
if (modal?.close) {
|
||||
modal.close();
|
||||
} else if ("isModalOpen" in modal) {
|
||||
modal.isModalOpen = false;
|
||||
}
|
||||
});
|
||||
this.publicLobby.stop();
|
||||
document.querySelectorAll(".ad").forEach((ad) => {
|
||||
(ad as HTMLElement).style.display = "none";
|
||||
});
|
||||
|
||||
// show when the game loads
|
||||
const startingModal = document.querySelector(
|
||||
"game-starting-modal",
|
||||
) as GameStartingModal;
|
||||
@@ -254,6 +344,11 @@ function setFavicon(): void {
|
||||
|
||||
// WARNING: DO NOT EXPOSE THIS ID
|
||||
export function getPersistentIDFromCookie(): string {
|
||||
const claims = isLoggedIn();
|
||||
if (claims !== false && claims.sub) {
|
||||
return claims.sub;
|
||||
}
|
||||
|
||||
const COOKIE_NAME = "player_persistent_id";
|
||||
|
||||
// Try to get existing cookie
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
export class MultiTabDetector {
|
||||
private readonly tabId = `${Date.now()}-${Math.random()}`;
|
||||
private readonly lockKey = "multi-tab-lock";
|
||||
private readonly heartbeatIntervalMs = 1_000;
|
||||
private readonly staleThresholdMs = 3_000;
|
||||
|
||||
private heartbeatTimer: number | null = null;
|
||||
private isPunished = false;
|
||||
private punishmentCount = 0;
|
||||
private startPenaltyCallback: (duration: number) => void = () => {};
|
||||
|
||||
constructor() {
|
||||
window.addEventListener("storage", this.onStorageEvent.bind(this));
|
||||
window.addEventListener("beforeunload", this.onBeforeUnload.bind(this));
|
||||
}
|
||||
|
||||
public startMonitoring(startPenalty: (duration: number) => void): void {
|
||||
this.startPenaltyCallback = startPenalty;
|
||||
this.writeLock();
|
||||
this.heartbeatTimer = window.setInterval(
|
||||
() => this.heartbeat(),
|
||||
this.heartbeatIntervalMs,
|
||||
);
|
||||
}
|
||||
|
||||
public stopMonitoring(): void {
|
||||
if (this.heartbeatTimer !== null) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
|
||||
const lock = this.readLock();
|
||||
if (lock?.owner === this.tabId) {
|
||||
localStorage.removeItem(this.lockKey);
|
||||
}
|
||||
window.removeEventListener("storage", this.onStorageEvent.bind(this));
|
||||
window.removeEventListener("beforeunload", this.onBeforeUnload.bind(this));
|
||||
}
|
||||
|
||||
private heartbeat(): void {
|
||||
const now = Date.now();
|
||||
const lock = this.readLock();
|
||||
|
||||
if (
|
||||
!lock ||
|
||||
lock.owner === this.tabId ||
|
||||
now - lock.timestamp > this.staleThresholdMs
|
||||
) {
|
||||
this.writeLock();
|
||||
this.isPunished = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isPunished) {
|
||||
this.applyPunishment();
|
||||
}
|
||||
}
|
||||
|
||||
private onStorageEvent(e: StorageEvent): void {
|
||||
if (e.key === this.lockKey && e.newValue) {
|
||||
let other: { owner: string; timestamp: number };
|
||||
try {
|
||||
other = JSON.parse(e.newValue);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse lock", e);
|
||||
return;
|
||||
}
|
||||
if (other.owner !== this.tabId && !this.isPunished) {
|
||||
this.applyPunishment();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onBeforeUnload(): void {
|
||||
const lock = this.readLock();
|
||||
if (lock?.owner === this.tabId) {
|
||||
localStorage.removeItem(this.lockKey);
|
||||
}
|
||||
}
|
||||
|
||||
private applyPunishment(): void {
|
||||
this.isPunished = true;
|
||||
this.punishmentCount++;
|
||||
const delay = 10_000;
|
||||
this.startPenaltyCallback(delay);
|
||||
setTimeout(() => {
|
||||
this.isPunished = false;
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private writeLock(): void {
|
||||
localStorage.setItem(
|
||||
this.lockKey,
|
||||
JSON.stringify({ owner: this.tabId, timestamp: Date.now() }),
|
||||
);
|
||||
}
|
||||
|
||||
private readLock(): { owner: string; timestamp: number } | null {
|
||||
const raw = localStorage.getItem(this.lockKey);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse lock", raw, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,11 @@ export class PublicLobby extends LitElement {
|
||||
const seconds = timeRemaining % 60;
|
||||
const timeDisplay = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
|
||||
|
||||
const teamCount =
|
||||
lobby.gameConfig.gameMode === GameMode.Team
|
||||
? lobby.gameConfig.playerTeams || 0
|
||||
: null;
|
||||
|
||||
return html`
|
||||
<button
|
||||
@click=${() => this.lobbyClicked(lobby)}
|
||||
@@ -123,8 +128,8 @@ export class PublicLobby extends LitElement {
|
||||
)}
|
||||
</div>
|
||||
<div class="text-md font-medium text-blue-100">
|
||||
${lobby.gameConfig.gameMode === GameMode.Team
|
||||
? translateText("game_mode.teams")
|
||||
${lobby.gameConfig.gameMode == GameMode.Team
|
||||
? translateText("public_lobby.teams", { num: teamCount })
|
||||
: translateText("game_mode.ffa")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { UserSettings } from "../core/game/UserSettings";
|
||||
|
||||
@customElement("random-name-button")
|
||||
export class RandomNameButton extends LitElement {
|
||||
private userSettings: UserSettings = new UserSettings();
|
||||
@state() private randomName: boolean = this.userSettings.anonymousNames();
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
toggleRandomName() {
|
||||
this.userSettings.toggleRandomName();
|
||||
this.randomName = this.userSettings.anonymousNames();
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<button
|
||||
title="Random Name"
|
||||
class="absolute top-0 left-0 md:top-[10px] md:left-[10px] border-none bg-none cursor-pointer text-2xl"
|
||||
@click=${() => this.toggleRandomName()}
|
||||
>
|
||||
${this.randomName ? "🥷" : "🕵️"}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,14 @@ import { customElement, query, state } from "lit/decorators.js";
|
||||
import randomMap from "../../resources/images/RandomMap.webp";
|
||||
import { translateText } from "../client/Utils";
|
||||
import { consolex } from "../core/Consolex";
|
||||
import { Difficulty, GameMapType, GameMode, GameType } from "../core/game/Game";
|
||||
import {
|
||||
Difficulty,
|
||||
Duos,
|
||||
GameMapType,
|
||||
GameMode,
|
||||
GameType,
|
||||
mapCategories,
|
||||
} from "../core/game/Game";
|
||||
import { generateID } from "../core/Util";
|
||||
import "./components/baseComponents/Button";
|
||||
import "./components/baseComponents/Modal";
|
||||
@@ -30,6 +37,7 @@ export class SinglePlayerModal extends LitElement {
|
||||
@state() private instantBuild: boolean = false;
|
||||
@state() private useRandomMap: boolean = false;
|
||||
@state() private gameMode: GameMode = GameMode.FFA;
|
||||
@state() private teamCount: number | typeof Duos = 2;
|
||||
|
||||
render() {
|
||||
return html`
|
||||
@@ -38,27 +46,40 @@ export class SinglePlayerModal extends LitElement {
|
||||
<!-- Map Selection -->
|
||||
<div class="options-section">
|
||||
<div class="option-title">${translateText("map.map")}</div>
|
||||
<div class="option-cards">
|
||||
${Object.entries(GameMapType)
|
||||
.filter(([key]) => isNaN(Number(key)))
|
||||
.map(
|
||||
([key, value]) => html`
|
||||
<div
|
||||
@click=${function () {
|
||||
this.handleMapSelection(value);
|
||||
}}
|
||||
<div class="option-cards flex-col">
|
||||
<!-- Use the imported mapCategories -->
|
||||
${Object.entries(mapCategories).map(
|
||||
([categoryKey, maps]) => html`
|
||||
<div class="w-full mb-4">
|
||||
<h3
|
||||
class="text-lg font-semibold mb-2 text-center text-gray-300"
|
||||
>
|
||||
<map-display
|
||||
.mapKey=${key}
|
||||
.selected=${!this.useRandomMap &&
|
||||
this.selectedMap === value}
|
||||
.translation=${translateText(
|
||||
`map.${key.toLowerCase()}`,
|
||||
)}
|
||||
></map-display>
|
||||
${translateText(`map_categories.${categoryKey}`)}
|
||||
</h3>
|
||||
<div class="flex flex-row flex-wrap justify-center gap-4">
|
||||
${maps.map((mapValue) => {
|
||||
const mapKey = Object.keys(GameMapType).find(
|
||||
(key) => GameMapType[key] === mapValue,
|
||||
);
|
||||
return html`
|
||||
<div
|
||||
@click=${() => this.handleMapSelection(mapValue)}
|
||||
>
|
||||
<map-display
|
||||
.mapKey=${mapKey}
|
||||
.selected=${!this.useRandomMap &&
|
||||
this.selectedMap === mapValue}
|
||||
.translation=${translateText(
|
||||
`map.${mapKey.toLowerCase()}`,
|
||||
)}
|
||||
></map-display>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
<div
|
||||
class="option-card random-map ${this.useRandomMap
|
||||
? "selected"
|
||||
@@ -136,6 +157,31 @@ export class SinglePlayerModal extends LitElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this.gameMode === GameMode.FFA
|
||||
? ""
|
||||
: html`
|
||||
<!-- Team Count Selection -->
|
||||
<div class="options-section">
|
||||
<div class="option-title">
|
||||
${translateText("host_modal.team_count")}
|
||||
</div>
|
||||
<div class="option-cards">
|
||||
${["Duos", 2, 3, 4, 5, 6, 7].map(
|
||||
(o) => html`
|
||||
<div
|
||||
class="option-card ${this.teamCount === o
|
||||
? "selected"
|
||||
: ""}"
|
||||
@click=${() => this.handleTeamCountSelection(o)}
|
||||
>
|
||||
<div class="option-card-title">${o}</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
<!-- Game Options -->
|
||||
<div class="options-section">
|
||||
<div class="option-title">
|
||||
@@ -310,6 +356,10 @@ export class SinglePlayerModal extends LitElement {
|
||||
this.gameMode = value;
|
||||
}
|
||||
|
||||
private handleTeamCountSelection(value: number | string) {
|
||||
this.teamCount = value === "Duos" ? Duos : Number(value);
|
||||
}
|
||||
|
||||
private getRandomMap(): GameMapType {
|
||||
const maps = Object.values(GameMapType);
|
||||
const randIdx = Math.floor(Math.random() * maps.length);
|
||||
@@ -361,6 +411,7 @@ export class SinglePlayerModal extends LitElement {
|
||||
gameMap: this.selectedMap,
|
||||
gameType: GameType.Singleplayer,
|
||||
gameMode: this.gameMode,
|
||||
playerTeams: this.teamCount,
|
||||
difficulty: this.selectedDifficulty,
|
||||
disableNPCs: this.disableNPCs,
|
||||
disableNukes: this.disableNukes,
|
||||
|
||||
@@ -67,9 +67,10 @@ export class SendAttackIntentEvent implements GameEvent {
|
||||
|
||||
export class SendBoatAttackIntentEvent implements GameEvent {
|
||||
constructor(
|
||||
public readonly targetID: PlayerID | null,
|
||||
public readonly cell: Cell,
|
||||
public readonly targetID: PlayerID,
|
||||
public readonly dst: Cell,
|
||||
public readonly troops: number,
|
||||
public readonly src: Cell | null = null,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -87,7 +88,7 @@ export class SendTargetPlayerIntentEvent implements GameEvent {
|
||||
export class SendEmojiIntentEvent implements GameEvent {
|
||||
constructor(
|
||||
public readonly recipient: PlayerView | typeof AllPlayers,
|
||||
public readonly emoji: string,
|
||||
public readonly emoji: number,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -421,8 +422,10 @@ export class Transport {
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
targetID: event.targetID,
|
||||
troops: event.troops,
|
||||
x: event.cell.x,
|
||||
y: event.cell.y,
|
||||
dstX: event.dst.x,
|
||||
dstY: event.dst.y,
|
||||
srcX: event.src?.x,
|
||||
srcY: event.src?.y,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import { translateText } from "../client/Utils";
|
||||
import { UserSettings } from "../core/game/UserSettings";
|
||||
import "./components/baseComponents/setting/SettingKeybind";
|
||||
import { SettingKeybind } from "./components/baseComponents/setting/SettingKeybind";
|
||||
import "./components/baseComponents/setting/SettingNumber";
|
||||
import "./components/baseComponents/setting/SettingSlider";
|
||||
import "./components/baseComponents/setting/SettingToggle";
|
||||
|
||||
@customElement("user-setting")
|
||||
export class UserSettingModal extends LitElement {
|
||||
private userSettings: UserSettings = new UserSettings();
|
||||
|
||||
@state() private settingsMode: "basic" | "keybinds" = "basic";
|
||||
@state() private keybinds: Record<string, string> = {};
|
||||
|
||||
@state() private keySequence: string[] = [];
|
||||
@state() private showEasterEggSettings = false;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("keydown", this.handleKeyDown);
|
||||
|
||||
const savedKeybinds = localStorage.getItem("settings.keybinds");
|
||||
if (savedKeybinds) {
|
||||
try {
|
||||
this.keybinds = JSON.parse(savedKeybinds);
|
||||
} catch (e) {
|
||||
console.warn("Invalid keybinds JSON:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@query("o-modal") private modalEl!: HTMLElement & {
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
isModalOpen: boolean;
|
||||
};
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener("keydown", this.handleKeyDown);
|
||||
super.disconnectedCallback();
|
||||
document.body.style.overflow = "auto";
|
||||
}
|
||||
|
||||
private handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!this.modalEl?.isModalOpen || this.showEasterEggSettings) return;
|
||||
|
||||
const key = e.key.toLowerCase();
|
||||
const nextSequence = [...this.keySequence, key].slice(-4);
|
||||
this.keySequence = nextSequence;
|
||||
|
||||
if (nextSequence.join("") === "evan") {
|
||||
this.triggerEasterEgg();
|
||||
this.keySequence = [];
|
||||
}
|
||||
};
|
||||
|
||||
private triggerEasterEgg() {
|
||||
console.log("🪺 Setting~ unlocked by EVAN combo!");
|
||||
this.showEasterEggSettings = true;
|
||||
const popup = document.createElement("div");
|
||||
popup.className = "easter-egg-popup";
|
||||
popup.textContent = "🎉 You found a secret setting!";
|
||||
document.body.appendChild(popup);
|
||||
|
||||
setTimeout(() => {
|
||||
popup.remove();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
toggleDarkMode(e: CustomEvent<{ checked: boolean }>) {
|
||||
const enabled = e.detail?.checked;
|
||||
|
||||
if (typeof enabled !== "boolean") {
|
||||
console.warn("Unexpected toggle event payload", e);
|
||||
return;
|
||||
}
|
||||
|
||||
this.userSettings.set("settings.darkMode", enabled);
|
||||
|
||||
if (enabled) {
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
|
||||
console.log("🌙 Dark Mode:", enabled ? "ON" : "OFF");
|
||||
}
|
||||
|
||||
private toggleEmojis(e: CustomEvent<{ checked: boolean }>) {
|
||||
const enabled = e.detail?.checked;
|
||||
if (typeof enabled !== "boolean") return;
|
||||
|
||||
this.userSettings.set("settings.emojis", enabled);
|
||||
|
||||
console.log("🤡 Emojis:", enabled ? "ON" : "OFF");
|
||||
}
|
||||
|
||||
private toggleLeftClickOpensMenu(e: CustomEvent<{ checked: boolean }>) {
|
||||
const enabled = e.detail?.checked;
|
||||
if (typeof enabled !== "boolean") return;
|
||||
|
||||
this.userSettings.set("settings.leftClickOpensMenu", enabled);
|
||||
console.log("🖱️ Left Click Opens Menu:", enabled ? "ON" : "OFF");
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private sliderAttackRatio(e: CustomEvent<{ value: number }>) {
|
||||
const value = e.detail?.value;
|
||||
if (typeof value === "number") {
|
||||
const ratio = value / 100;
|
||||
localStorage.setItem("settings.attackRatio", ratio.toString());
|
||||
} else {
|
||||
console.warn("Slider event missing detail.value", e);
|
||||
}
|
||||
}
|
||||
|
||||
private sliderTroopRatio(e: CustomEvent<{ value: number }>) {
|
||||
const value = e.detail?.value;
|
||||
if (typeof value === "number") {
|
||||
const ratio = value / 100;
|
||||
localStorage.setItem("settings.troopRatio", ratio.toString());
|
||||
} else {
|
||||
console.warn("Slider event missing detail.value", e);
|
||||
}
|
||||
}
|
||||
|
||||
private handleKeybindChange(
|
||||
e: CustomEvent<{ action: string; value: string }>,
|
||||
) {
|
||||
const { action, value } = e.detail;
|
||||
const prevValue = this.keybinds[action] ?? "";
|
||||
|
||||
const values = Object.entries(this.keybinds)
|
||||
.filter(([k]) => k !== action)
|
||||
.map(([, v]) => v);
|
||||
if (values.includes(value) && value !== "Null") {
|
||||
const popup = document.createElement("div");
|
||||
popup.className = "setting-popup";
|
||||
popup.textContent = `The key "${value}" is already assigned to another action.`;
|
||||
document.body.appendChild(popup);
|
||||
const element = this.renderRoot.querySelector(
|
||||
`setting-keybind[action="${action}"]`,
|
||||
) as SettingKeybind;
|
||||
if (element) {
|
||||
element.value = prevValue;
|
||||
element.requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.keybinds = { ...this.keybinds, [action]: value };
|
||||
localStorage.setItem("settings.keybinds", JSON.stringify(this.keybinds));
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<o-modal title="${translateText("user_setting.title")}">
|
||||
<div class="modal-overlay">
|
||||
<div class="modal-content user-setting-modal">
|
||||
<div class="flex mb-4 w-full justify-center">
|
||||
<button
|
||||
class="w-1/2 text-center px-3 py-1 rounded-l
|
||||
${this.settingsMode === "basic"
|
||||
? "bg-white/10 text-white"
|
||||
: "bg-transparent text-gray-400"}"
|
||||
@click=${() => (this.settingsMode = "basic")}
|
||||
>
|
||||
${translateText("user_setting.tab_basic")}
|
||||
</button>
|
||||
<button
|
||||
class="w-1/2 text-center px-3 py-1 rounded-r
|
||||
${this.settingsMode === "keybinds"
|
||||
? "bg-white/10 text-white"
|
||||
: "bg-transparent text-gray-400"}"
|
||||
@click=${() => (this.settingsMode = "keybinds")}
|
||||
>
|
||||
${translateText("user_setting.tab_keybinds")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-list">
|
||||
${this.settingsMode === "basic"
|
||||
? this.renderBasicSettings()
|
||||
: this.renderKeybindSettings()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderBasicSettings() {
|
||||
return html`
|
||||
<!-- 🌙 Dark Mode -->
|
||||
<setting-toggle
|
||||
label="${translateText("user_setting.dark_mode_label")}"
|
||||
description="${translateText("user_setting.dark_mode_desc")}"
|
||||
id="dark-mode-toggle"
|
||||
.checked=${this.userSettings.darkMode()}
|
||||
@change=${(e: CustomEvent<{ checked: boolean }>) =>
|
||||
this.toggleDarkMode(e)}
|
||||
></setting-toggle>
|
||||
|
||||
<!-- 😊 Emojis -->
|
||||
<setting-toggle
|
||||
label="${translateText("user_setting.emojis_label")}"
|
||||
description="${translateText("user_setting.emojis_desc")}"
|
||||
id="emoji-toggle"
|
||||
.checked=${this.userSettings.emojis()}
|
||||
@change=${this.toggleEmojis}
|
||||
></setting-toggle>
|
||||
|
||||
<!-- 🖱️ Left Click Menu -->
|
||||
<setting-toggle
|
||||
label="${translateText("user_setting.left_click_label")}"
|
||||
description="${translateText("user_setting.left_click_desc")}"
|
||||
id="left-click-toggle"
|
||||
.checked=${this.userSettings.leftClickOpensMenu()}
|
||||
@change=${this.toggleLeftClickOpensMenu}
|
||||
></setting-toggle>
|
||||
|
||||
<!-- ⚔️ Attack Ratio -->
|
||||
<setting-slider
|
||||
label="${translateText("user_setting.attack_ratio_label")}"
|
||||
description="${translateText("user_setting.attack_ratio_desc")}"
|
||||
min="1"
|
||||
max="100"
|
||||
.value=${Number(localStorage.getItem("settings.attackRatio") ?? "0.2") *
|
||||
100}
|
||||
@change=${this.sliderAttackRatio}
|
||||
></setting-slider>
|
||||
|
||||
<!-- 🪖🛠️ Troop Ratio -->
|
||||
<setting-slider
|
||||
label="${translateText("user_setting.troop_ratio_label")}"
|
||||
description="${translateText("user_setting.troop_ratio_desc")}"
|
||||
min="1"
|
||||
max="100"
|
||||
.value=${Number(localStorage.getItem("settings.troopRatio") ?? "0.95") *
|
||||
100}
|
||||
@change=${this.sliderTroopRatio}
|
||||
></setting-slider>
|
||||
|
||||
${this.showEasterEggSettings
|
||||
? html`
|
||||
<setting-slider
|
||||
label="${translateText(
|
||||
"user_setting.easter_writing_speed_label",
|
||||
)}"
|
||||
description="${translateText(
|
||||
"user_setting.easter_writing_speed_desc",
|
||||
)}"
|
||||
min="0"
|
||||
max="100"
|
||||
value="40"
|
||||
easter="true"
|
||||
@change=${(e: CustomEvent) => {
|
||||
const value = e.detail?.value;
|
||||
if (typeof value !== "undefined") {
|
||||
console.log("Changed:", value);
|
||||
} else {
|
||||
console.warn("Slider event missing detail.value", e);
|
||||
}
|
||||
}}
|
||||
></setting-slider>
|
||||
|
||||
<setting-number
|
||||
label="${translateText("user_setting.easter_bug_count_label")}"
|
||||
description="${translateText(
|
||||
"user_setting.easter_bug_count_desc",
|
||||
)}"
|
||||
value="100"
|
||||
min="0"
|
||||
max="1000"
|
||||
easter="true"
|
||||
@change=${(e: CustomEvent) => {
|
||||
const value = e.detail?.value;
|
||||
if (typeof value !== "undefined") {
|
||||
console.log("Changed:", value);
|
||||
} else {
|
||||
console.warn("Slider event missing detail.value", e);
|
||||
}
|
||||
}}
|
||||
></setting-number>
|
||||
`
|
||||
: null}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderKeybindSettings() {
|
||||
return html`
|
||||
<div class="text-center text-white text-base font-semibold mt-5 mb-2">
|
||||
${translateText("user_setting.view_options")}
|
||||
</div>
|
||||
|
||||
<setting-keybind
|
||||
action="toggleView"
|
||||
label=${translateText("user_setting.toggle_view")}
|
||||
description=${translateText("user_setting.toggle_view_desc")}
|
||||
defaultKey="Space"
|
||||
.value=${this.keybinds["toggleView"] ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<div class="text-center text-white text-base font-semibold mt-5 mb-2">
|
||||
${translateText("user_setting.zoom_controls")}
|
||||
</div>
|
||||
|
||||
<setting-keybind
|
||||
action="zoomOut"
|
||||
label=${translateText("user_setting.zoom_out")}
|
||||
description=${translateText("user_setting.zoom_out_desc")}
|
||||
defaultKey="KeyQ"
|
||||
.value=${this.keybinds["zoomOut"] ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="zoomIn"
|
||||
label=${translateText("user_setting.zoom_in")}
|
||||
description=${translateText("user_setting.zoom_in_desc")}
|
||||
defaultKey="KeyE"
|
||||
.value=${this.keybinds["zoomIn"] ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<div class="text-center text-white text-base font-semibold mt-5 mb-2">
|
||||
${translateText("user_setting.camera_movement")}
|
||||
</div>
|
||||
|
||||
<setting-keybind
|
||||
action="centerCamera"
|
||||
label=${translateText("user_setting.center_camera")}
|
||||
description=${translateText("user_setting.center_camera_desc")}
|
||||
defaultKey="KeyC"
|
||||
.value=${this.keybinds["centerCamera"] ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="moveUp"
|
||||
label=${translateText("user_setting.move_up")}
|
||||
description=${translateText("user_setting.move_up_desc")}
|
||||
defaultKey="KeyW"
|
||||
.value=${this.keybinds["moveUp"] ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="moveLeft"
|
||||
label=${translateText("user_setting.move_left")}
|
||||
description=${translateText("user_setting.move_left_desc")}
|
||||
defaultKey="KeyA"
|
||||
.value=${this.keybinds["moveLeft"] ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="moveDown"
|
||||
label=${translateText("user_setting.move_down")}
|
||||
description=${translateText("user_setting.move_down_desc")}
|
||||
defaultKey="KeyS"
|
||||
.value=${this.keybinds["moveDown"] ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="moveRight"
|
||||
label=${translateText("user_setting.move_right")}
|
||||
description=${translateText("user_setting.move_right_desc")}
|
||||
defaultKey="KeyD"
|
||||
.value=${this.keybinds["moveRight"] ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
`;
|
||||
}
|
||||
|
||||
public open() {
|
||||
this.requestUpdate();
|
||||
this.modalEl?.open();
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.modalEl?.close();
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { getMapsImage } from "../utilities/Maps";
|
||||
export const MapDescription: Record<keyof typeof GameMapType, string> = {
|
||||
World: "World",
|
||||
Europe: "Europe",
|
||||
EuropeClassic: "Europe Classic",
|
||||
Mena: "MENA",
|
||||
NorthAmerica: "North America",
|
||||
Oceania: "Oceania",
|
||||
@@ -23,6 +24,8 @@ export const MapDescription: Record<keyof typeof GameMapType, string> = {
|
||||
Japan: "Japan",
|
||||
BetweenTwoSeas: "Between Two Seas",
|
||||
KnownWorld: "Known World",
|
||||
FaroeIslands: "Faroe Islands",
|
||||
DeglaciatedAntarctica: "Deglaciated Antarctica",
|
||||
};
|
||||
|
||||
@customElement("map-display")
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { translateText } from "../../../../client/Utils";
|
||||
|
||||
@customElement("setting-keybind")
|
||||
export class SettingKeybind extends LitElement {
|
||||
@property() label = "Setting";
|
||||
@property() description = "";
|
||||
@property({ type: String, reflect: true }) action = "";
|
||||
@property({ type: String }) defaultKey = "";
|
||||
@property({ type: String }) value = "";
|
||||
@property({ type: Boolean }) easter = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private listening = false;
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="setting-item column${this.easter ? " easter-egg" : ""}">
|
||||
<div class="setting-label-group">
|
||||
<label class="setting-label block mb-1">${this.label}</label>
|
||||
|
||||
<div class="setting-keybind-box">
|
||||
<div class="setting-keybind-description">${this.description}</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="setting-key"
|
||||
tabindex="0"
|
||||
@keydown=${this.handleKeydown}
|
||||
@click=${this.startListening}
|
||||
>
|
||||
${this.displayKey(this.value || this.defaultKey)}
|
||||
</span>
|
||||
|
||||
<button
|
||||
class="text-xs text-gray-400 hover:text-white border border-gray-500 px-2 py-0.5 rounded transition"
|
||||
@click=${this.resetToDefault}
|
||||
>
|
||||
${translateText("user_setting.reset")}
|
||||
</button>
|
||||
<button
|
||||
class="text-xs text-gray-400 hover:text-white border border-gray-500 px-2 py-0.5 rounded transition"
|
||||
@click=${this.unbindKey}
|
||||
>
|
||||
${translateText("user_setting.unbind")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private displayKey(key: string): string {
|
||||
if (key === " ") return "Space";
|
||||
if (key.startsWith("Key") && key.length === 4) {
|
||||
return key.slice(3);
|
||||
}
|
||||
return key.length
|
||||
? key.charAt(0).toUpperCase() + key.slice(1)
|
||||
: "Press a key";
|
||||
}
|
||||
|
||||
private startListening() {
|
||||
this.listening = true;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private handleKeydown(e: KeyboardEvent) {
|
||||
if (!this.listening) return;
|
||||
e.preventDefault();
|
||||
|
||||
const code = e.code;
|
||||
|
||||
this.value = code;
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("change", {
|
||||
detail: { action: this.action, value: code },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
this.listening = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private resetToDefault() {
|
||||
this.value = this.defaultKey;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("change", {
|
||||
detail: { action: this.action, value: this.defaultKey },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private unbindKey() {
|
||||
this.value = "";
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("change", {
|
||||
detail: { action: this.action, value: "Null" },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
@customElement("setting-number")
|
||||
export class SettingNumber extends LitElement {
|
||||
@property() label = "Setting";
|
||||
@property() description = "";
|
||||
@property({ type: Number }) value = 0;
|
||||
@property({ type: Number }) min = 0;
|
||||
@property({ type: Number }) max = 100;
|
||||
@property({ type: Boolean }) easter = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private handleInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const newValue = Number(input.value);
|
||||
this.value = newValue;
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("change", {
|
||||
detail: { value: newValue },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="setting-item${this.easter ? " easter-egg" : ""}">
|
||||
<div class="setting-label-group">
|
||||
<label class="setting-label" for="setting-number-input"
|
||||
>${this.label}</label
|
||||
>
|
||||
<div class="setting-description">${this.description}</div>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
id="setting-number-input"
|
||||
class="setting-input number"
|
||||
.value=${String(this.value ?? 0)}
|
||||
min=${this.min}
|
||||
max=${this.max}
|
||||
@input=${this.handleInput}
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
@customElement("setting-slider")
|
||||
export class SettingSlider extends LitElement {
|
||||
@property() label = "Setting";
|
||||
@property() description = "";
|
||||
@property({ type: Number }) value = 0;
|
||||
@property({ type: Number }) min = 0;
|
||||
@property({ type: Number }) max = 100;
|
||||
@property({ type: Boolean }) easter = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private handleInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
this.value = Number(input.value);
|
||||
this.updateSliderStyle(input);
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("change", {
|
||||
detail: { value: this.value },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private handleSliderChange(e: Event) {
|
||||
const detail = (e as CustomEvent)?.detail;
|
||||
if (!detail || typeof detail.value === "undefined") {
|
||||
console.warn("Invalid slider change event", e);
|
||||
return;
|
||||
}
|
||||
|
||||
const value = detail.value;
|
||||
console.log("Slider changed to", value);
|
||||
}
|
||||
|
||||
private updateSliderStyle(slider: HTMLInputElement) {
|
||||
const percent = ((this.value - this.min) / (this.max - this.min)) * 100;
|
||||
slider.style.background = `linear-gradient(to right, #2196f3 ${percent}%, #444 ${percent}%)`;
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
const slider = this.renderRoot.querySelector(
|
||||
"input[type=range]",
|
||||
) as HTMLInputElement;
|
||||
if (slider) this.updateSliderStyle(slider);
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="setting-item vertical${this.easter ? " easter-egg" : ""}">
|
||||
<div class="setting-label-group">
|
||||
<label class="setting-label" for="setting-slider-input"
|
||||
>${this.label}</label
|
||||
>
|
||||
<div class="setting-description">${this.description}</div>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
id="setting-slider-input"
|
||||
class="setting-input slider full-width"
|
||||
min=${this.min}
|
||||
max=${this.max}
|
||||
.value=${String(this.value)}
|
||||
@input=${this.handleInput}
|
||||
/>
|
||||
<div class="slider-value">${this.value}%</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
@customElement("setting-toggle")
|
||||
export class SettingToggle extends LitElement {
|
||||
@property() label = "Setting";
|
||||
@property() description = "";
|
||||
@property() id = "";
|
||||
@property({ type: Boolean, reflect: true }) checked = false;
|
||||
@property({ type: Boolean }) easter = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private handleChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
this.checked = input.checked;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("change", {
|
||||
detail: { checked: this.checked },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="setting-item vertical${this.easter ? " easter-egg" : ""}">
|
||||
<div class="toggle-row">
|
||||
<label class="setting-label" for=${this.id}>${this.label}</label>
|
||||
<label class="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
id=${this.id}
|
||||
?checked=${this.checked}
|
||||
@change=${this.handleChange}
|
||||
/>
|
||||
<span class="slider-round"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-description">${this.description}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
+1031
-555
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ import { EmojiTable } from "./layers/EmojiTable";
|
||||
import { EventsDisplay } from "./layers/EventsDisplay";
|
||||
import { Layer } from "./layers/Layer";
|
||||
import { Leaderboard } from "./layers/Leaderboard";
|
||||
import { MultiTabModal } from "./layers/MultiTabModal";
|
||||
import { NameLayer } from "./layers/NameLayer";
|
||||
import { OptionsMenu } from "./layers/OptionsMenu";
|
||||
import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay";
|
||||
@@ -125,6 +126,14 @@ export function createRenderer(
|
||||
playerPanel.eventBus = eventBus;
|
||||
playerPanel.emojiTable = emojiTable;
|
||||
|
||||
const multiTabModal = document.querySelector(
|
||||
"multi-tab-modal",
|
||||
) as MultiTabModal;
|
||||
if (!(multiTabModal instanceof MultiTabModal)) {
|
||||
console.error("multi-tab modal not found");
|
||||
}
|
||||
multiTabModal.game = game;
|
||||
|
||||
const layers: Layer[] = [
|
||||
new TerrainLayer(game, transformHandler),
|
||||
new TerritoryLayer(game, eventBus),
|
||||
@@ -153,6 +162,7 @@ export function createRenderer(
|
||||
optionsMenu,
|
||||
topBar,
|
||||
playerPanel,
|
||||
multiTabModal,
|
||||
];
|
||||
|
||||
return new GameRenderer(
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Colord } from "colord";
|
||||
import atomBombSprite from "../../../resources/sprites/atombomb.png";
|
||||
import hydrogenBombSprite from "../../../resources/sprites/hydrogenbomb.png";
|
||||
import mirvSprite from "../../../resources/sprites/mirv2.png";
|
||||
import samMissileSprite from "../../../resources/sprites/samMissile.png";
|
||||
import tradeShipSprite from "../../../resources/sprites/tradeship.png";
|
||||
import transportShipSprite from "../../../resources/sprites/transportship.png";
|
||||
import warshipSprite from "../../../resources/sprites/warship.png";
|
||||
import { Theme } from "../../core/configuration/Config";
|
||||
import { UnitType } from "../../core/game/Game";
|
||||
import { UnitView } from "../../core/game/GameView";
|
||||
|
||||
const SPRITE_CONFIG: Partial<Record<UnitType, string>> = {
|
||||
[UnitType.TransportShip]: transportShipSprite,
|
||||
[UnitType.Warship]: warshipSprite,
|
||||
[UnitType.SAMMissile]: samMissileSprite,
|
||||
[UnitType.AtomBomb]: atomBombSprite,
|
||||
[UnitType.HydrogenBomb]: hydrogenBombSprite,
|
||||
[UnitType.TradeShip]: tradeShipSprite,
|
||||
[UnitType.MIRV]: mirvSprite,
|
||||
};
|
||||
|
||||
const spriteMap: Map<UnitType, ImageBitmap> = new Map();
|
||||
|
||||
// preload all images
|
||||
export const loadAllSprites = async (): Promise<void> => {
|
||||
const entries = Object.entries(SPRITE_CONFIG);
|
||||
const totalSprites = entries.length;
|
||||
let loadedCount = 0;
|
||||
|
||||
await Promise.all(
|
||||
entries.map(async ([unitType, url]) => {
|
||||
const typedUnitType = unitType as UnitType;
|
||||
|
||||
if (!url || url === "") {
|
||||
console.warn(`No sprite URL for ${typedUnitType}, skipping...`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.src = url;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = (err) => reject(err);
|
||||
});
|
||||
|
||||
const bitmap = await createImageBitmap(img);
|
||||
spriteMap.set(typedUnitType, bitmap);
|
||||
loadedCount++;
|
||||
|
||||
if (loadedCount === totalSprites) {
|
||||
console.log("All sprites loaded.");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to load sprite for ${typedUnitType}:`, err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const getSpriteForUnit = (unitType: UnitType): ImageBitmap | null => {
|
||||
return spriteMap.get(unitType) ?? null;
|
||||
};
|
||||
|
||||
export const isSpriteReady = (unitType: UnitType): boolean => {
|
||||
return spriteMap.has(unitType);
|
||||
};
|
||||
|
||||
const coloredSpriteCache: Map<string, HTMLCanvasElement> = new Map();
|
||||
|
||||
// puts the sprite in an canvas colors it and caches the colored canvas
|
||||
export const getColoredSprite = (
|
||||
unit: UnitView,
|
||||
theme: Theme,
|
||||
customTerritoryColor?: Colord,
|
||||
customBorderColor?: Colord,
|
||||
): HTMLCanvasElement => {
|
||||
const owner = unit.owner();
|
||||
const territoryColor = customTerritoryColor ?? theme.territoryColor(owner);
|
||||
const borderColor = customBorderColor ?? theme.borderColor(owner);
|
||||
const spawnHighlightColor = theme.spawnHighlightColor();
|
||||
const colorKey = territoryColor.toRgbString() + borderColor.toRgbString();
|
||||
const key = unit.type() + colorKey;
|
||||
|
||||
if (coloredSpriteCache.has(key)) {
|
||||
return coloredSpriteCache.get(key)!;
|
||||
}
|
||||
|
||||
const sprite = getSpriteForUnit(unit.type());
|
||||
|
||||
const territoryRgb = territoryColor.toRgb();
|
||||
const borderRgb = borderColor.toRgb();
|
||||
const spawnHighlightRgb = spawnHighlightColor.toRgb();
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
canvas.width = sprite.width;
|
||||
canvas.height = sprite.height;
|
||||
|
||||
ctx.drawImage(sprite, 0, 0);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = imageData.data;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
|
||||
if (r === 180 && g === 180 && b === 180) {
|
||||
data[i] = territoryRgb.r;
|
||||
data[i + 1] = territoryRgb.g;
|
||||
data[i + 2] = territoryRgb.b;
|
||||
}
|
||||
|
||||
if (r === 70 && g === 70 && b === 70) {
|
||||
data[i] = borderRgb.r;
|
||||
data[i + 1] = borderRgb.g;
|
||||
data[i + 2] = borderRgb.b;
|
||||
}
|
||||
|
||||
if (r === 130 && g === 130 && b === 130) {
|
||||
data[i] = spawnHighlightRgb.r;
|
||||
data[i + 1] = spawnHighlightRgb.g;
|
||||
data[i + 2] = spawnHighlightRgb.b;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0.5, 0.5);
|
||||
coloredSpriteCache.set(key, canvas);
|
||||
return canvas;
|
||||
};
|
||||
@@ -97,6 +97,10 @@ export class TransformHandler {
|
||||
}
|
||||
|
||||
screenBoundingRect(): [Cell, Cell] {
|
||||
const canvasRect = this.boundingRect();
|
||||
const canvasWidth = canvasRect.width;
|
||||
const canvasHeight = canvasRect.height;
|
||||
|
||||
const LeftX = -this.game.width() / 2 / this.scale + this.offsetX;
|
||||
const TopY = -this.game.height() / 2 / this.scale + this.offsetY;
|
||||
|
||||
@@ -104,12 +108,12 @@ export class TransformHandler {
|
||||
const gameTopY = TopY + this.game.height() / 2;
|
||||
|
||||
const rightX =
|
||||
(screen.width - this.game.width() / 2) / this.scale + this.offsetX;
|
||||
const rightY =
|
||||
(screen.height - this.game.height() / 2) / this.scale + this.offsetY;
|
||||
(canvasWidth - this.game.width() / 2) / this.scale + this.offsetX;
|
||||
const bottomY =
|
||||
(canvasHeight - this.game.height() / 2) / this.scale + this.offsetY;
|
||||
|
||||
const gameRightX = rightX + this.game.width() / 2;
|
||||
const gameBottomY = rightY + this.game.height() / 2;
|
||||
const gameBottomY = bottomY + this.game.height() / 2;
|
||||
|
||||
return [
|
||||
new Cell(Math.floor(gameLeftX), Math.floor(gameTopY)),
|
||||
|
||||
@@ -300,7 +300,7 @@ export class BuildMenu extends LitElement implements Layer {
|
||||
if (unit.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return unit[0].canBuild;
|
||||
return unit[0].canBuild !== false;
|
||||
}
|
||||
|
||||
private cost(item: BuildItemDisplay): number {
|
||||
|
||||
@@ -56,8 +56,16 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
|
||||
private _popRateIsIncreasing: boolean = true;
|
||||
|
||||
private init_: boolean = false;
|
||||
|
||||
init() {
|
||||
this.attackRatio = 0.2;
|
||||
this.attackRatio = Number(
|
||||
localStorage.getItem("settings.attackRatio") ?? "0.2",
|
||||
);
|
||||
this.targetTroopRatio = Number(
|
||||
localStorage.getItem("settings.troopRatio") ?? "0.95",
|
||||
);
|
||||
this.init_ = true;
|
||||
this.uiState.attackRatio = this.attackRatio;
|
||||
this.currentTroopRatio = this.targetTroopRatio;
|
||||
this.eventBus.on(AttackRatioEvent, (event) => {
|
||||
@@ -87,6 +95,13 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
}
|
||||
|
||||
tick() {
|
||||
if (this.init_) {
|
||||
this.eventBus.emit(
|
||||
new SendSetTargetTroopRatioEvent(this.targetTroopRatio),
|
||||
);
|
||||
this.init_ = false;
|
||||
}
|
||||
|
||||
if (!this._isVisible && !this.game.inSpawnPhase()) {
|
||||
this.setVisibile(true);
|
||||
}
|
||||
|
||||
@@ -4,24 +4,11 @@ import { EventBus } from "../../../core/EventBus";
|
||||
import { AllPlayers } from "../../../core/game/Game";
|
||||
import { GameView, PlayerView } from "../../../core/game/GameView";
|
||||
import { TerraNulliusImpl } from "../../../core/game/TerraNulliusImpl";
|
||||
import { emojiTable, flattenedEmojiTable } from "../../../core/Util";
|
||||
import { ShowEmojiMenuEvent } from "../../InputHandler";
|
||||
import { SendEmojiIntentEvent } from "../../Transport";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
|
||||
const emojiTable: string[][] = [
|
||||
["😀", "😊", "🥰", "😇", "😎"],
|
||||
["😞", "🥺", "😭", "😱", "😡"],
|
||||
["😈", "🤡", "🖕", "🥱", "🤦♂️"],
|
||||
["👋", "👏", "🤌", "💪", "🫡"],
|
||||
["👍", "👎", "❓", "🐔", "🐀"],
|
||||
["🤝", "🆘", "🕊️", "🏳️", "⏳"],
|
||||
["🔥", "💥", "💀", "☢️", "⚠️"],
|
||||
["↖️", "⬆️", "↗️", "👑", "🥇"],
|
||||
["⬅️", "🎯", "➡️", "🥈", "🥉"],
|
||||
["↙️", "⬇️", "↘️", "❤️", "💔"],
|
||||
["💰", "⚓", "⛵", "🏡", "🛡️"],
|
||||
];
|
||||
|
||||
@customElement("emoji-table")
|
||||
export class EmojiTable extends LitElement {
|
||||
public eventBus: EventBus;
|
||||
@@ -130,7 +117,12 @@ export class EmojiTable extends LitElement {
|
||||
targetPlayer === this.game.myPlayer()
|
||||
? AllPlayers
|
||||
: (targetPlayer as PlayerView);
|
||||
this.eventBus.emit(new SendEmojiIntentEvent(recipient, emoji));
|
||||
this.eventBus.emit(
|
||||
new SendEmojiIntentEvent(
|
||||
recipient,
|
||||
flattenedEmojiTable.indexOf(emoji),
|
||||
),
|
||||
);
|
||||
this.hideTable();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -273,10 +273,19 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
const malusPercent = Math.round(
|
||||
(1 - this.game.config().traitorDefenseDebuff()) * 100,
|
||||
);
|
||||
const traitorDurationRaw =
|
||||
Number(this.game.config().traitorDuration) / 10;
|
||||
const traitorDurationSeconds = Math.floor(traitorDurationRaw);
|
||||
|
||||
const durationText =
|
||||
traitorDurationSeconds === 1
|
||||
? "1 second"
|
||||
: `${traitorDurationSeconds} seconds`;
|
||||
|
||||
this.addEvent({
|
||||
description:
|
||||
`You broke your alliance with ${betrayed.name()}, making you a TRAITOR ` +
|
||||
`(${malusPercent}% defense debuff)`,
|
||||
`(${malusPercent}% defense debuff for ${durationText})`,
|
||||
type: MessageType.ERROR,
|
||||
highlight: true,
|
||||
createdAt: this.game.ticks(),
|
||||
@@ -284,7 +293,7 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
});
|
||||
} else if (betrayed === myPlayer) {
|
||||
this.addEvent({
|
||||
description: `${traitor.name()}, broke their alliance with you`,
|
||||
description: `${traitor.name()} broke their alliance with you`,
|
||||
type: MessageType.ERROR,
|
||||
highlight: true,
|
||||
createdAt: this.game.ticks(),
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { GameEnv } from "../../../core/configuration/Config";
|
||||
import { GameType } from "../../../core/game/Game";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { MultiTabDetector } from "../../MultiTabDetector";
|
||||
import { translateText } from "../../Utils";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
@customElement("multi-tab-modal")
|
||||
export class MultiTabModal extends LitElement implements Layer {
|
||||
public game: GameView;
|
||||
|
||||
private detector: MultiTabDetector;
|
||||
|
||||
@property({ type: Number }) duration: number = 5000;
|
||||
@state() private countdown: number = 5;
|
||||
@state() private isVisible: boolean = false;
|
||||
@state() private fakeIp: string = "";
|
||||
@state() private deviceFingerprint: string = "";
|
||||
@state() private reported: boolean = true;
|
||||
|
||||
private intervalId?: number;
|
||||
|
||||
// Disable shadow DOM to allow Tailwind classes to work
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
tick() {
|
||||
if (
|
||||
this.game.inSpawnPhase() ||
|
||||
this.game.config().gameConfig().gameType == GameType.Singleplayer ||
|
||||
this.game.config().serverConfig().env() == GameEnv.Dev
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!this.detector) {
|
||||
this.detector = new MultiTabDetector();
|
||||
this.detector.startMonitoring((duration: number) => {
|
||||
this.show(duration);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
this.fakeIp = this.generateFakeIp();
|
||||
this.deviceFingerprint = this.generateDeviceFingerprint();
|
||||
this.reported = true;
|
||||
}
|
||||
|
||||
// Generate fake IP in format xxx.xxx.xxx.xxx
|
||||
private generateFakeIp(): string {
|
||||
return Array.from({ length: 4 }, () =>
|
||||
Math.floor(Math.random() * 255),
|
||||
).join(".");
|
||||
}
|
||||
|
||||
// Generate fake device fingerprint (32 character hex)
|
||||
private generateDeviceFingerprint(): string {
|
||||
return Array.from({ length: 32 }, () =>
|
||||
Math.floor(Math.random() * 16).toString(16),
|
||||
).join("");
|
||||
}
|
||||
|
||||
// Show the modal with penalty information
|
||||
public show(duration: number): void {
|
||||
if (!this.game.myPlayer()?.isAlive()) {
|
||||
return;
|
||||
}
|
||||
this.duration = duration;
|
||||
this.countdown = Math.ceil(duration / 1000);
|
||||
this.isVisible = true;
|
||||
|
||||
// Start countdown timer
|
||||
this.intervalId = window.setInterval(() => {
|
||||
this.countdown--;
|
||||
|
||||
if (this.countdown <= 0) {
|
||||
this.hide();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
// Hide the modal
|
||||
public hide(): void {
|
||||
this.isVisible = false;
|
||||
|
||||
if (this.intervalId) {
|
||||
window.clearInterval(this.intervalId);
|
||||
this.intervalId = undefined;
|
||||
}
|
||||
|
||||
// Dispatch event when modal is closed
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("penalty-complete", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this.intervalId) {
|
||||
window.clearInterval(this.intervalId);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.isVisible) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="fixed inset-0 z-50 overflow-auto bg-red-500/20 flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="relative p-6 bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full m-4 transition-all transform"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-2xl font-bold text-red-600 dark:text-red-400">
|
||||
${translateText("multi_tab.warning")}
|
||||
</h2>
|
||||
<div
|
||||
class="px-2 py-1 bg-red-600 text-white text-xs font-bold rounded-full animate-pulse"
|
||||
>
|
||||
RECORDING
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mb-4 text-gray-800 dark:text-gray-200">
|
||||
${translateText("multi_tab.detected")}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="mb-4 p-3 bg-gray-100 dark:bg-gray-900 rounded-md text-sm font-mono"
|
||||
>
|
||||
<div class="flex justify-between mb-1">
|
||||
<span class="text-gray-500 dark:text-gray-400">IP:</span>
|
||||
<span class="text-red-600 dark:text-red-400">${this.fakeIp}</span>
|
||||
</div>
|
||||
<div class="flex justify-between mb-1">
|
||||
<span class="text-gray-500 dark:text-gray-400"
|
||||
>Device Fingerprint:</span
|
||||
>
|
||||
<span class="text-red-600 dark:text-red-400"
|
||||
>${this.deviceFingerprint}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">Reported:</span>
|
||||
<span class="text-red-600 dark:text-red-400"
|
||||
>${this.reported ? "TRUE" : "FALSE"}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mb-4 text-gray-800 dark:text-gray-200">
|
||||
${translateText("multi_tab.please_wait")}
|
||||
<span class="font-bold text-xl">${this.countdown}</span>
|
||||
${translateText("multi_tab.seconds")}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2.5 mb-4"
|
||||
>
|
||||
<div
|
||||
class="bg-red-600 dark:bg-red-500 h-2.5 rounded-full transition-all duration-1000 ease-linear"
|
||||
style="width: ${(this.countdown / (this.duration / 1000)) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
${translateText("multi_tab.explanation")}
|
||||
</p>
|
||||
|
||||
<p class="mt-3 text-xs text-red-500 font-semibold">
|
||||
Repeated violations may result in permanent account suspension.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import crownIcon from "../../../../resources/images/CrownIcon.svg";
|
||||
import embargoIcon from "../../../../resources/images/EmbargoIcon.svg";
|
||||
import nukeRedIcon from "../../../../resources/images/NukeIconRed.svg";
|
||||
import nukeWhiteIcon from "../../../../resources/images/NukeIconWhite.svg";
|
||||
import shieldIcon from "../../../../resources/images/ShieldIconBlack.svg";
|
||||
import targetIcon from "../../../../resources/images/TargetIcon.svg";
|
||||
import traitorIcon from "../../../../resources/images/TraitorIcon.svg";
|
||||
import { PseudoRandom } from "../../../core/PseudoRandom";
|
||||
@@ -44,6 +45,7 @@ export class NameLayer implements Layer {
|
||||
private embargoIconImage: HTMLImageElement;
|
||||
private nukeWhiteIconImage: HTMLImageElement;
|
||||
private nukeRedIconImage: HTMLImageElement;
|
||||
private shieldIconImage: HTMLImageElement;
|
||||
private container: HTMLDivElement;
|
||||
private myPlayer: PlayerView | null = null;
|
||||
private firstPlace: PlayerView | null = null;
|
||||
@@ -70,6 +72,8 @@ export class NameLayer implements Layer {
|
||||
this.nukeWhiteIconImage.src = nukeWhiteIcon;
|
||||
this.nukeRedIconImage = new Image();
|
||||
this.nukeRedIconImage.src = nukeRedIcon;
|
||||
this.shieldIconImage = new Image();
|
||||
this.shieldIconImage.src = shieldIcon;
|
||||
}
|
||||
|
||||
resizeCanvas() {
|
||||
@@ -195,6 +199,7 @@ export class NameLayer implements Layer {
|
||||
nameDiv.style.alignItems = "center";
|
||||
|
||||
const nameSpan = document.createElement("span");
|
||||
nameSpan.className = "player-name-span";
|
||||
nameSpan.innerHTML = player.name();
|
||||
nameDiv.appendChild(nameSpan);
|
||||
element.appendChild(nameDiv);
|
||||
@@ -209,6 +214,21 @@ export class NameLayer implements Layer {
|
||||
troopsDiv.style.marginTop = "-5%";
|
||||
element.appendChild(troopsDiv);
|
||||
|
||||
// TODO: enable this for new meta.
|
||||
|
||||
// const shieldDiv = document.createElement("div");
|
||||
// shieldDiv.classList.add("player-shield");
|
||||
// shieldDiv.style.zIndex = "3";
|
||||
// shieldDiv.style.marginTop = "-5%";
|
||||
// shieldDiv.style.display = "flex";
|
||||
// shieldDiv.style.alignItems = "center";
|
||||
// shieldDiv.style.gap = "0px";
|
||||
// shieldDiv.innerHTML = `
|
||||
// <img src="${this.shieldIconImage.src}" style="width: 16px; height: 16px;" />
|
||||
// <span style="color: black; font-size: 10px; margin-top: -2px;">0</span>
|
||||
// `;
|
||||
// element.appendChild(shieldDiv);
|
||||
|
||||
// Start off invisible so it doesn't flash at 0,0
|
||||
element.style.display = "none";
|
||||
|
||||
@@ -262,6 +282,10 @@ export class NameLayer implements Layer {
|
||||
nameDiv.style.fontSize = `${render.fontSize}px`;
|
||||
nameDiv.style.lineHeight = `${render.fontSize}px`;
|
||||
nameDiv.style.color = render.fontColor;
|
||||
const span = nameDiv.querySelector(".player-name-span");
|
||||
if (span) {
|
||||
span.innerHTML = render.player.name();
|
||||
}
|
||||
if (flagDiv) {
|
||||
flagDiv.style.height = `${render.fontSize}px`;
|
||||
}
|
||||
@@ -269,6 +293,26 @@ export class NameLayer implements Layer {
|
||||
troopsDiv.style.color = render.fontColor;
|
||||
troopsDiv.textContent = renderTroops(render.player.troops());
|
||||
|
||||
// TODO: enable this for new meta.
|
||||
|
||||
// const density = renderNumber(
|
||||
// render.player.troops() / render.player.numTilesOwned(),
|
||||
// );
|
||||
// const shieldDiv = render.element.querySelector(
|
||||
// ".player-shield",
|
||||
// ) as HTMLDivElement;
|
||||
// const shieldImg = shieldDiv.querySelector("img");
|
||||
// const shieldNumber = shieldDiv.querySelector("span");
|
||||
// if (shieldImg) {
|
||||
// shieldImg.style.width = `${render.fontSize * 0.8}px`;
|
||||
// shieldImg.style.height = `${render.fontSize * 0.8}px`;
|
||||
// }
|
||||
// if (shieldNumber) {
|
||||
// shieldNumber.style.fontSize = `${render.fontSize * 0.6}px`;
|
||||
// shieldNumber.style.marginTop = `${-render.fontSize * 0.1}px`;
|
||||
// shieldNumber.textContent = density;
|
||||
// }
|
||||
|
||||
// Handle icons
|
||||
const iconsDiv = render.element.querySelector(
|
||||
".player-icons",
|
||||
|
||||
@@ -106,6 +106,10 @@ export class OptionsMenu extends LitElement implements Layer {
|
||||
this.eventBus.emit(new RefreshGraphicsEvent());
|
||||
}
|
||||
|
||||
private onToggleRandomNameModeButtonClick() {
|
||||
this.userSettings.toggleRandomName();
|
||||
}
|
||||
|
||||
private onToggleFocusLockedButtonClick() {
|
||||
this.userSettings.toggleFocusLocked();
|
||||
this.requestUpdate();
|
||||
@@ -197,6 +201,12 @@ export class OptionsMenu extends LitElement implements Layer {
|
||||
title: "Dark Mode",
|
||||
children: "🌙: " + (this.userSettings.darkMode() ? "On" : "Off"),
|
||||
})}
|
||||
${button({
|
||||
onClick: this.onToggleRandomNameModeButtonClick,
|
||||
title: "Random name mode",
|
||||
children:
|
||||
"🥷: " + (this.userSettings.anonymousNames() ? "On" : "Off"),
|
||||
})}
|
||||
${button({
|
||||
onClick: this.onToggleLeftClickOpensMenu,
|
||||
title: "Left click",
|
||||
|
||||
@@ -207,6 +207,9 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
: ""}
|
||||
${player.name()}
|
||||
</div>
|
||||
${player.team() != null
|
||||
? html`<div class="text-sm opacity-80">Team: ${player.team()}</div>`
|
||||
: ""}
|
||||
<div class="text-sm opacity-80">Type: ${playerType}</div>
|
||||
${player.troops() >= 1
|
||||
? html`<div class="text-sm opacity-80" translate="no">
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "../../../core/game/Game";
|
||||
import { TileRef } from "../../../core/game/GameMap";
|
||||
import { GameView, PlayerView } from "../../../core/game/GameView";
|
||||
import { flattenedEmojiTable } from "../../../core/Util";
|
||||
import { MouseUpEvent } from "../../InputHandler";
|
||||
import {
|
||||
SendAllianceRequestIntentEvent,
|
||||
@@ -121,10 +122,17 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
private handleEmojiClick(e: Event, myPlayer: PlayerView, other: PlayerView) {
|
||||
e.stopPropagation();
|
||||
this.emojiTable.showTable((emoji: string) => {
|
||||
if (myPlayer === other) {
|
||||
this.eventBus.emit(new SendEmojiIntentEvent(AllPlayers, emoji));
|
||||
if (myPlayer == other) {
|
||||
this.eventBus.emit(
|
||||
new SendEmojiIntentEvent(
|
||||
AllPlayers,
|
||||
flattenedEmojiTable.indexOf(emoji),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
this.eventBus.emit(new SendEmojiIntentEvent(other, emoji));
|
||||
this.eventBus.emit(
|
||||
new SendEmojiIntentEvent(other, flattenedEmojiTable.indexOf(emoji)),
|
||||
);
|
||||
}
|
||||
this.emojiTable.hideTable();
|
||||
this.hide();
|
||||
@@ -145,8 +153,14 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
this.eventBus.on(MouseUpEvent, (e: MouseEvent) => this.hide());
|
||||
}
|
||||
|
||||
tick() {
|
||||
this.requestUpdate();
|
||||
async tick() {
|
||||
if (this.isVisible && this.tile) {
|
||||
const myPlayer = this.g.myPlayer();
|
||||
if (myPlayer !== null && myPlayer.isAlive()) {
|
||||
this.actions = await myPlayer.actions(this.tile);
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getTotalNukesSent(otherId: PlayerID): number {
|
||||
@@ -180,7 +194,9 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
if (this.tile === null) return;
|
||||
let other = this.g.owner(this.tile);
|
||||
if (!other.isPlayer()) {
|
||||
throw new Error("Tile is not owned by a player");
|
||||
this.hide();
|
||||
console.warn("Tile is not owned by a player");
|
||||
return;
|
||||
}
|
||||
other = other as PlayerView;
|
||||
|
||||
|
||||
@@ -8,7 +8,12 @@ import swordIcon from "../../../../resources/images/SwordIconWhite.svg";
|
||||
import traitorIcon from "../../../../resources/images/TraitorIconWhite.svg";
|
||||
import { consolex } from "../../../core/Consolex";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { Cell, PlayerActions } from "../../../core/game/Game";
|
||||
import {
|
||||
Cell,
|
||||
PlayerActions,
|
||||
TerraNullius,
|
||||
UnitType,
|
||||
} from "../../../core/game/Game";
|
||||
import { TileRef } from "../../../core/game/GameMap";
|
||||
import { GameView, PlayerView } from "../../../core/game/GameView";
|
||||
import { ClientID } from "../../../core/Schemas";
|
||||
@@ -44,6 +49,7 @@ export class RadialMenu implements Layer {
|
||||
private clickedCell: Cell | null = null;
|
||||
private lastClosed: number = 0;
|
||||
|
||||
private originalTileOwner: PlayerView | TerraNullius;
|
||||
private menuElement: d3.Selection<HTMLDivElement, unknown, null, undefined>;
|
||||
private isVisible: boolean = false;
|
||||
private readonly menuItems: Map<
|
||||
@@ -147,6 +153,7 @@ export class RadialMenu implements Layer {
|
||||
.style("touch-action", "none")
|
||||
.on("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
this.hideRadialMenu();
|
||||
});
|
||||
|
||||
const svg = this.menuElement
|
||||
@@ -275,8 +282,26 @@ export class RadialMenu implements Layer {
|
||||
.style("pointer-events", "none");
|
||||
}
|
||||
|
||||
tick() {
|
||||
// Update logic if needed
|
||||
async tick() {
|
||||
// Only update when menu is visible
|
||||
if (!this.isVisible || this.clickedCell === null) return;
|
||||
const myPlayer = this.g.myPlayer();
|
||||
if (myPlayer === null || !myPlayer.isAlive()) return;
|
||||
const tile = this.g.ref(this.clickedCell.x, this.clickedCell.y);
|
||||
if (this.originalTileOwner.isPlayer()) {
|
||||
if (this.g.owner(tile) != this.originalTileOwner) {
|
||||
this.closeMenu();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (this.g.owner(tile).isPlayer() || this.g.owner(tile) == myPlayer) {
|
||||
this.closeMenu();
|
||||
return;
|
||||
}
|
||||
}
|
||||
const actions = await myPlayer.actions(tile);
|
||||
this.disableAllButtons();
|
||||
this.handlePlayerActions(myPlayer, actions, tile);
|
||||
}
|
||||
|
||||
renderLayer(context: CanvasRenderingContext2D) {
|
||||
@@ -299,12 +324,7 @@ export class RadialMenu implements Layer {
|
||||
} else {
|
||||
this.showRadialMenu(event.x, event.y);
|
||||
}
|
||||
this.enableCenterButton(false);
|
||||
for (const item of this.menuItems.values()) {
|
||||
item.disabled = true;
|
||||
this.updateMenuItemState(item);
|
||||
}
|
||||
|
||||
this.disableAllButtons();
|
||||
this.clickedCell = this.transformHandler.screenToWorldCoordinates(
|
||||
event.x,
|
||||
event.y,
|
||||
@@ -313,7 +333,7 @@ export class RadialMenu implements Layer {
|
||||
return;
|
||||
}
|
||||
const tile = this.g.ref(this.clickedCell.x, this.clickedCell.y);
|
||||
|
||||
this.originalTileOwner = this.g.owner(tile);
|
||||
if (this.g.inSpawnPhase()) {
|
||||
if (this.g.isLand(tile) && !this.g.hasOwner(tile)) {
|
||||
this.enableCenterButton(true);
|
||||
@@ -321,10 +341,8 @@ export class RadialMenu implements Layer {
|
||||
return;
|
||||
}
|
||||
|
||||
const myPlayer = this.g
|
||||
.playerViews()
|
||||
.find((p) => p.clientID() === this.clientID);
|
||||
if (myPlayer === undefined) {
|
||||
const myPlayer = this.g.myPlayer();
|
||||
if (myPlayer === null) {
|
||||
consolex.warn("my player not found");
|
||||
return;
|
||||
}
|
||||
@@ -341,9 +359,12 @@ export class RadialMenu implements Layer {
|
||||
actions: PlayerActions,
|
||||
tile: TileRef,
|
||||
) {
|
||||
this.activateMenuElement(Slot.Build, "#ebe250", buildIcon, () => {
|
||||
this.buildMenu.showMenu(tile);
|
||||
});
|
||||
if (!this.g.inSpawnPhase()) {
|
||||
this.activateMenuElement(Slot.Build, "#ebe250", buildIcon, () => {
|
||||
this.buildMenu.showMenu(tile);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.g.hasOwner(tile)) {
|
||||
this.activateMenuElement(Slot.Info, "#64748B", infoIcon, () => {
|
||||
this.playerPanel.show(actions, tile);
|
||||
@@ -370,16 +391,28 @@ export class RadialMenu implements Layer {
|
||||
);
|
||||
});
|
||||
}
|
||||
if (actions.canBoat) {
|
||||
if (
|
||||
actions.buildableUnits.find((bu) => bu.type == UnitType.TransportShip)
|
||||
?.canBuild
|
||||
) {
|
||||
this.activateMenuElement(Slot.Boat, "#3f6ab1", boatIcon, () => {
|
||||
if (this.clickedCell === null) return;
|
||||
this.eventBus.emit(
|
||||
new SendBoatAttackIntentEvent(
|
||||
this.g.owner(tile).id(),
|
||||
this.clickedCell,
|
||||
this.uiState.attackRatio * myPlayer.troops(),
|
||||
),
|
||||
);
|
||||
// BestTransportShipSpawn is an expensive operation, so
|
||||
// we calculate it here and send the spawn tile to other clients.
|
||||
myPlayer.bestTransportShipSpawn(tile).then((spawn) => {
|
||||
let spawnTile: Cell | null = null;
|
||||
if (spawn !== false) {
|
||||
spawnTile = new Cell(this.g.x(spawn), this.g.y(spawn));
|
||||
}
|
||||
|
||||
this.eventBus.emit(
|
||||
new SendBoatAttackIntentEvent(
|
||||
this.g.owner(tile).id(),
|
||||
this.clickedCell,
|
||||
this.uiState.attackRatio * myPlayer.troops(),
|
||||
spawnTile,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
if (actions.canAttack) {
|
||||
@@ -440,6 +473,14 @@ export class RadialMenu implements Layer {
|
||||
this.hideRadialMenu();
|
||||
}
|
||||
|
||||
private disableAllButtons() {
|
||||
this.enableCenterButton(false);
|
||||
for (const item of this.menuItems.values()) {
|
||||
item.disabled = true;
|
||||
this.updateMenuItemState(item);
|
||||
}
|
||||
}
|
||||
|
||||
private activateMenuElement(
|
||||
slot: Slot,
|
||||
color: string,
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { blue, red } from "../../../core/configuration/Colors";
|
||||
import { GameMode, Team } from "../../../core/game/Game";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
export class SpawnTimer implements Layer {
|
||||
private ratio = 0;
|
||||
private leftColor = "rgba(0, 128, 255, 0.7)";
|
||||
private rightColor = "rgba(0, 0, 0, 0.5)";
|
||||
private ratios = [0];
|
||||
private colors = ["rgba(0, 128, 255, 0.7)", "rgba(0, 0, 0, 0.5)"];
|
||||
|
||||
constructor(
|
||||
private game: GameView,
|
||||
@@ -18,27 +16,35 @@ export class SpawnTimer implements Layer {
|
||||
|
||||
tick() {
|
||||
if (this.game.inSpawnPhase()) {
|
||||
this.ratio = this.game.ticks() / this.game.config().numSpawnPhaseTurns();
|
||||
return;
|
||||
}
|
||||
if (this.game.config().gameConfig().gameMode !== GameMode.Team) {
|
||||
this.ratio = 0;
|
||||
this.ratios[0] =
|
||||
this.game.ticks() / this.game.config().numSpawnPhaseTurns();
|
||||
return;
|
||||
}
|
||||
|
||||
const numBlueTiles = this.game
|
||||
.players()
|
||||
.filter((p) => p.team() === Team.Blue)
|
||||
.reduce((acc, p) => acc + p.numTilesOwned(), 0);
|
||||
this.ratios = [];
|
||||
this.colors = [];
|
||||
|
||||
const numRedTiles = this.game
|
||||
.players()
|
||||
.filter((p) => p.team() === Team.Red)
|
||||
.reduce((acc, p) => acc + p.numTilesOwned(), 0);
|
||||
if (this.game.config().gameConfig().gameMode != GameMode.Team) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.ratio = numBlueTiles / (numBlueTiles + numRedTiles);
|
||||
this.leftColor = blue.toRgbString();
|
||||
this.rightColor = red.toRgbString();
|
||||
const teamTiles: Map<Team, number> = new Map();
|
||||
for (const player of this.game.players()) {
|
||||
const team = player.team();
|
||||
const tiles = teamTiles.get(team) ?? 0;
|
||||
const sum = tiles + player.numTilesOwned();
|
||||
teamTiles.set(team, sum);
|
||||
}
|
||||
|
||||
const theme = this.game.config().theme();
|
||||
const total = sumIterator(teamTiles.values());
|
||||
if (total === 0) return;
|
||||
for (const [team, count] of teamTiles) {
|
||||
const ratio = count / total;
|
||||
const color = theme.teamColor(team).toRgbString();
|
||||
this.ratios.push(ratio);
|
||||
this.colors.push(color);
|
||||
}
|
||||
}
|
||||
|
||||
shouldTransform(): boolean {
|
||||
@@ -46,18 +52,34 @@ export class SpawnTimer implements Layer {
|
||||
}
|
||||
|
||||
renderLayer(context: CanvasRenderingContext2D) {
|
||||
if (this.ratio === 0) {
|
||||
return;
|
||||
}
|
||||
if (this.ratios === null) return;
|
||||
if (this.ratios.length === 0) return;
|
||||
if (this.colors.length === 0) return;
|
||||
|
||||
const barHeight = 10;
|
||||
const barBackgroundWidth = this.transformHandler.width();
|
||||
const barWidth = this.transformHandler.width();
|
||||
|
||||
// Draw bar background
|
||||
context.fillStyle = this.rightColor;
|
||||
context.fillRect(0, 0, barBackgroundWidth, barHeight);
|
||||
let x = 0;
|
||||
let filledRatio = 0;
|
||||
for (let i = 0; i < this.ratios.length && i < this.colors.length; i++) {
|
||||
const ratio = this.ratios[i];
|
||||
const segmentWidth = barWidth * ratio;
|
||||
|
||||
context.fillStyle = this.leftColor;
|
||||
context.fillRect(0, 0, barBackgroundWidth * this.ratio, barHeight);
|
||||
context.fillStyle = this.colors[i];
|
||||
context.fillRect(x, 0, segmentWidth, barHeight);
|
||||
|
||||
x += segmentWidth;
|
||||
filledRatio += ratio;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sumIterator(values: MapIterator<number>) {
|
||||
// To use reduce, we'd need to allocate an array:
|
||||
// return Array.from(values).reduce((sum, v) => sum + v, 0);
|
||||
let total = 0;
|
||||
for (const value of values) {
|
||||
total += value;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
@@ -120,6 +120,14 @@ export class TerritoryLayer implements Layer {
|
||||
if (!centerTile) {
|
||||
continue;
|
||||
}
|
||||
let color = this.theme.spawnHighlightColor();
|
||||
if (
|
||||
this.game.myPlayer() != null &&
|
||||
this.game.myPlayer() != human &&
|
||||
this.game.myPlayer().isFriendly(human)
|
||||
) {
|
||||
color = this.theme.selfColor();
|
||||
}
|
||||
for (const tile of this.game.bfs(
|
||||
centerTile,
|
||||
euclDistFN(centerTile, 9, true),
|
||||
@@ -127,7 +135,7 @@ export class TerritoryLayer implements Layer {
|
||||
if (!this.game.hasOwner(tile)) {
|
||||
this.paintHighlightCell(
|
||||
new Cell(this.game.x(tile), this.game.y(tile)),
|
||||
this.theme.spawnHighlightColor(),
|
||||
color,
|
||||
255,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,11 +3,7 @@ import { EventBus } from "../../../core/EventBus";
|
||||
import { ClientID } from "../../../core/Schemas";
|
||||
import { Theme } from "../../../core/configuration/Config";
|
||||
import { UnitType } from "../../../core/game/Game";
|
||||
import {
|
||||
euclDistFN,
|
||||
manhattanDistFN,
|
||||
TileRef,
|
||||
} from "../../../core/game/GameMap";
|
||||
import { TileRef } from "../../../core/game/GameMap";
|
||||
import { GameUpdateType } from "../../../core/game/GameUpdates";
|
||||
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
|
||||
import {
|
||||
@@ -19,6 +15,8 @@ import { MoveWarshipIntentEvent } from "../../Transport";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
import { getColoredSprite, loadAllSprites } from "../SpriteLoader";
|
||||
|
||||
enum Relationship {
|
||||
Self,
|
||||
Ally,
|
||||
@@ -81,6 +79,8 @@ export class UnitLayer implements Layer {
|
||||
this.eventBus.on(MouseUpEvent, (e) => this.onMouseUp(e));
|
||||
this.eventBus.on(UnitSelectionEvent, (e) => this.onUnitSelectionChange(e));
|
||||
this.redraw();
|
||||
|
||||
loadAllSprites();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,14 +204,23 @@ export class UnitLayer implements Layer {
|
||||
this.canvas.height = this.game.height();
|
||||
this.transportShipTrailCanvas.width = this.game.width();
|
||||
this.transportShipTrailCanvas.height = this.game.height();
|
||||
|
||||
const updates = this.game.updatesSinceLastTick();
|
||||
const unitUpdates = updates?.[GameUpdateType.Unit] ?? [];
|
||||
for (const u of unitUpdates) {
|
||||
const unit = this.game.unit(u.id);
|
||||
if (typeof unit === "undefined") continue;
|
||||
this.onUnitEvent(unit);
|
||||
}
|
||||
this.game
|
||||
?.updatesSinceLastTick()
|
||||
?.[GameUpdateType.Unit]?.forEach((unit) => {
|
||||
this.onUnitEvent(this.game.unit(unit.id));
|
||||
});
|
||||
this.boatToTrail.forEach((trail, unit) => {
|
||||
for (const t of trail) {
|
||||
this.paintCell(
|
||||
this.game.x(t),
|
||||
this.game.y(t),
|
||||
this.relationship(unit),
|
||||
this.theme.territoryColor(unit.owner()),
|
||||
150,
|
||||
this.transportShipTrailContext,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private relationship(unit: UnitView): Relationship {
|
||||
@@ -261,65 +270,10 @@ export class UnitLayer implements Layer {
|
||||
}
|
||||
|
||||
private handleWarShipEvent(unit: UnitView) {
|
||||
const rel = this.relationship(unit);
|
||||
|
||||
// Clear previous area
|
||||
for (const t of this.game.bfs(
|
||||
unit.lastTile(),
|
||||
euclDistFN(unit.lastTile(), 6, false),
|
||||
)) {
|
||||
this.clearCell(this.game.x(t), this.game.y(t));
|
||||
}
|
||||
|
||||
if (!unit.isActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let outerColor = this.theme.territoryColor(unit.owner());
|
||||
if (unit.warshipTargetId()) {
|
||||
const targetOwner = this.game
|
||||
.units()
|
||||
.find((u) => u.id() === unit.warshipTargetId())
|
||||
?.owner();
|
||||
if (targetOwner === this.myPlayer) {
|
||||
outerColor = colord({ r: 200, b: 0, g: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
// Paint outer territory
|
||||
for (const t of this.game.bfs(
|
||||
unit.tile(),
|
||||
euclDistFN(unit.tile(), 5, false),
|
||||
)) {
|
||||
this.paintCell(this.game.x(t), this.game.y(t), rel, outerColor, 255);
|
||||
}
|
||||
|
||||
// Paint border
|
||||
for (const t of this.game.bfs(
|
||||
unit.tile(),
|
||||
manhattanDistFN(unit.tile(), 4),
|
||||
)) {
|
||||
this.paintCell(
|
||||
this.game.x(t),
|
||||
this.game.y(t),
|
||||
rel,
|
||||
this.theme.borderColor(unit.owner()),
|
||||
255,
|
||||
);
|
||||
}
|
||||
|
||||
// Paint inner territory
|
||||
for (const t of this.game.bfs(
|
||||
unit.tile(),
|
||||
euclDistFN(unit.tile(), 1, false),
|
||||
)) {
|
||||
this.paintCell(
|
||||
this.game.x(t),
|
||||
this.game.y(t),
|
||||
rel,
|
||||
this.theme.territoryColor(unit.owner()),
|
||||
255,
|
||||
);
|
||||
this.drawSprite(unit, colord({ r: 200, b: 0, g: 0 }));
|
||||
} else {
|
||||
this.drawSprite(unit);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,89 +311,11 @@ export class UnitLayer implements Layer {
|
||||
|
||||
// interception missle from SAM
|
||||
private handleMissileEvent(unit: UnitView) {
|
||||
const rel = this.relationship(unit);
|
||||
const range = 2;
|
||||
|
||||
for (const t of this.game.bfs(
|
||||
unit.lastTile(),
|
||||
euclDistFN(unit.lastTile(), range, false),
|
||||
)) {
|
||||
this.clearCell(this.game.x(t), this.game.y(t));
|
||||
}
|
||||
|
||||
if (unit.isActive()) {
|
||||
for (const t of this.game.bfs(
|
||||
unit.tile(),
|
||||
euclDistFN(unit.tile(), range, false),
|
||||
)) {
|
||||
this.paintCell(
|
||||
this.game.x(t),
|
||||
this.game.y(t),
|
||||
rel,
|
||||
this.theme.spawnHighlightColor(),
|
||||
255,
|
||||
);
|
||||
}
|
||||
|
||||
this.paintCell(
|
||||
this.game.x(unit.tile()),
|
||||
this.game.y(unit.tile()),
|
||||
rel,
|
||||
this.theme.borderColor(unit.owner()),
|
||||
255,
|
||||
);
|
||||
}
|
||||
this.drawSprite(unit);
|
||||
}
|
||||
|
||||
private handleNuke(unit: UnitView) {
|
||||
const rel = this.relationship(unit);
|
||||
let range = 0;
|
||||
switch (unit.type()) {
|
||||
case UnitType.AtomBomb:
|
||||
range = 4;
|
||||
break;
|
||||
case UnitType.HydrogenBomb:
|
||||
range = 6;
|
||||
break;
|
||||
case UnitType.MIRV:
|
||||
range = 9;
|
||||
break;
|
||||
}
|
||||
|
||||
// Clear previous area
|
||||
for (const t of this.game.bfs(
|
||||
unit.lastTile(),
|
||||
euclDistFN(unit.lastTile(), range, false),
|
||||
)) {
|
||||
this.clearCell(this.game.x(t), this.game.y(t));
|
||||
}
|
||||
|
||||
if (unit.isActive()) {
|
||||
for (const t of this.game.bfs(
|
||||
unit.tile(),
|
||||
euclDistFN(unit.tile(), range, false),
|
||||
)) {
|
||||
this.paintCell(
|
||||
this.game.x(t),
|
||||
this.game.y(t),
|
||||
rel,
|
||||
this.theme.spawnHighlightColor(),
|
||||
255,
|
||||
);
|
||||
}
|
||||
for (const t of this.game.bfs(
|
||||
unit.tile(),
|
||||
euclDistFN(unit.tile(), 2, false),
|
||||
)) {
|
||||
this.paintCell(
|
||||
this.game.x(t),
|
||||
this.game.y(t),
|
||||
rel,
|
||||
this.theme.borderColor(unit.owner()),
|
||||
255,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.drawSprite(unit);
|
||||
}
|
||||
|
||||
private handleMIRVWarhead(unit: UnitView) {
|
||||
@@ -460,45 +336,7 @@ export class UnitLayer implements Layer {
|
||||
}
|
||||
|
||||
private handleTradeShipEvent(unit: UnitView) {
|
||||
const rel = this.relationship(unit);
|
||||
|
||||
// Clear previous area
|
||||
for (const t of this.game.bfs(
|
||||
unit.lastTile(),
|
||||
euclDistFN(unit.lastTile(), 3, false),
|
||||
)) {
|
||||
this.clearCell(this.game.x(t), this.game.y(t));
|
||||
}
|
||||
|
||||
if (unit.isActive()) {
|
||||
// Paint territory
|
||||
for (const t of this.game.bfs(
|
||||
unit.tile(),
|
||||
manhattanDistFN(unit.tile(), 2),
|
||||
)) {
|
||||
this.paintCell(
|
||||
this.game.x(t),
|
||||
this.game.y(t),
|
||||
rel,
|
||||
this.theme.territoryColor(unit.owner()),
|
||||
255,
|
||||
);
|
||||
}
|
||||
|
||||
// Paint border
|
||||
for (const t of this.game.bfs(
|
||||
unit.tile(),
|
||||
manhattanDistFN(unit.tile(), 1),
|
||||
)) {
|
||||
this.paintCell(
|
||||
this.game.x(t),
|
||||
this.game.y(t),
|
||||
rel,
|
||||
this.theme.borderColor(unit.owner()),
|
||||
255,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.drawSprite(unit);
|
||||
}
|
||||
|
||||
private handleBoatEvent(unit: UnitView) {
|
||||
@@ -511,53 +349,21 @@ export class UnitLayer implements Layer {
|
||||
if (typeof trail === "undefined") return;
|
||||
trail.push(unit.lastTile());
|
||||
|
||||
// Clear previous area
|
||||
for (const t of this.game.bfs(
|
||||
unit.lastTile(),
|
||||
manhattanDistFN(unit.lastTile(), 2),
|
||||
)) {
|
||||
this.clearCell(this.game.x(t), this.game.y(t));
|
||||
// Paint trail
|
||||
for (const t of trail.slice(-1)) {
|
||||
this.paintCell(
|
||||
this.game.x(t),
|
||||
this.game.y(t),
|
||||
rel,
|
||||
this.theme.territoryColor(unit.owner()),
|
||||
150,
|
||||
this.transportShipTrailContext,
|
||||
);
|
||||
}
|
||||
|
||||
if (unit.isActive()) {
|
||||
// Paint trail
|
||||
for (const t of trail.slice(-1)) {
|
||||
this.paintCell(
|
||||
this.game.x(t),
|
||||
this.game.y(t),
|
||||
rel,
|
||||
this.theme.territoryColor(unit.owner()),
|
||||
150,
|
||||
this.transportShipTrailContext,
|
||||
);
|
||||
}
|
||||
this.drawSprite(unit);
|
||||
|
||||
for (const t of this.game.bfs(
|
||||
unit.tile(),
|
||||
manhattanDistFN(unit.tile(), 2),
|
||||
)) {
|
||||
this.paintCell(
|
||||
this.game.x(t),
|
||||
this.game.y(t),
|
||||
rel,
|
||||
this.theme.borderColor(unit.owner()),
|
||||
255,
|
||||
);
|
||||
}
|
||||
|
||||
for (const t of this.game.bfs(
|
||||
unit.tile(),
|
||||
manhattanDistFN(unit.tile(), 1),
|
||||
)) {
|
||||
this.paintCell(
|
||||
this.game.x(t),
|
||||
this.game.y(t),
|
||||
rel,
|
||||
this.theme.territoryColor(unit.owner()),
|
||||
255,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (!unit.isActive()) {
|
||||
for (const t of trail) {
|
||||
this.clearCell(
|
||||
this.game.x(t),
|
||||
@@ -620,4 +426,65 @@ export class UnitLayer implements Layer {
|
||||
) {
|
||||
context.clearRect(x, y, 1, 1);
|
||||
}
|
||||
|
||||
drawSprite(unit: UnitView, customTerritoryColor?: Colord) {
|
||||
const x = this.game.x(unit.tile());
|
||||
const y = this.game.y(unit.tile());
|
||||
const lastX = this.game.x(unit.lastTile());
|
||||
const lastY = this.game.y(unit.lastTile());
|
||||
|
||||
let alternateViewColor = null;
|
||||
|
||||
if (this.alternateView) {
|
||||
let rel = this.relationship(unit);
|
||||
if (unit.type() == UnitType.TradeShip && unit.dstPortId() != null) {
|
||||
const target = this.game.unit(unit.dstPortId())?.owner();
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (myPlayer != null && target != null) {
|
||||
if (myPlayer == target) {
|
||||
rel = Relationship.Self;
|
||||
} else if (myPlayer.isFriendly(target)) {
|
||||
rel = Relationship.Ally;
|
||||
}
|
||||
}
|
||||
}
|
||||
switch (rel) {
|
||||
case Relationship.Self:
|
||||
alternateViewColor = this.theme.selfColor();
|
||||
break;
|
||||
case Relationship.Ally:
|
||||
alternateViewColor = this.theme.allyColor();
|
||||
break;
|
||||
case Relationship.Enemy:
|
||||
alternateViewColor = this.theme.enemyColor();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const sprite = getColoredSprite(
|
||||
unit,
|
||||
this.theme,
|
||||
alternateViewColor ?? customTerritoryColor,
|
||||
alternateViewColor,
|
||||
);
|
||||
|
||||
const clearsize = sprite.width + 1;
|
||||
|
||||
this.context.clearRect(
|
||||
lastX - clearsize / 2,
|
||||
lastY - clearsize / 2,
|
||||
clearsize,
|
||||
clearsize,
|
||||
);
|
||||
|
||||
if (unit.isActive()) {
|
||||
this.context.drawImage(
|
||||
sprite,
|
||||
Math.round(x - sprite.width / 2),
|
||||
Math.round(y - sprite.height / 2),
|
||||
sprite.width,
|
||||
sprite.width,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,24 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import mastersIcon from "../../../../resources/images/MastersIcon.png";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { Team } from "../../../core/game/Game";
|
||||
import { GameUpdateType } from "../../../core/game/GameUpdates";
|
||||
import { GameView, PlayerView } from "../../../core/game/GameView";
|
||||
import { PseudoRandom } from "../../../core/PseudoRandom";
|
||||
import { simpleHash } from "../../../core/Util";
|
||||
import { SendWinnerEvent } from "../../Transport";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
// Add this at the top of your file
|
||||
declare global {
|
||||
interface Window {
|
||||
adsbygoogle: unknown[];
|
||||
}
|
||||
}
|
||||
|
||||
// Add this at the top of your file
|
||||
declare let adsbygoogle: unknown[];
|
||||
|
||||
@customElement("win-modal")
|
||||
export class WinModal extends LitElement implements Layer {
|
||||
public game: GameView;
|
||||
public eventBus: EventBus;
|
||||
|
||||
private rand: PseudoRandom;
|
||||
|
||||
private hasShownDeathModal = false;
|
||||
|
||||
@state()
|
||||
isVisible = false;
|
||||
|
||||
private _title: string;
|
||||
private won: boolean;
|
||||
|
||||
// Override to prevent shadow DOM creation
|
||||
createRenderRoot() {
|
||||
@@ -53,7 +39,7 @@ export class WinModal extends LitElement implements Layer {
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(5px);
|
||||
color: white;
|
||||
width: 300px;
|
||||
width: 350px;
|
||||
transition:
|
||||
opacity 0.3s ease-in-out,
|
||||
visibility 0.3s ease-in-out;
|
||||
@@ -77,7 +63,7 @@ export class WinModal extends LitElement implements Layer {
|
||||
|
||||
.win-modal h2 {
|
||||
margin: 0 0 15px 0;
|
||||
font-size: 24px;
|
||||
font-size: 26px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
}
|
||||
@@ -127,7 +113,7 @@ export class WinModal extends LitElement implements Layer {
|
||||
}
|
||||
|
||||
.win-modal h2 {
|
||||
font-size: 20px;
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.win-modal button {
|
||||
@@ -160,29 +146,40 @@ export class WinModal extends LitElement implements Layer {
|
||||
|
||||
innerHtml() {
|
||||
return html`
|
||||
<div style="text-align: center; margin: 15px 0; line-height: 1.5;">
|
||||
<p>
|
||||
<span style="color: red;">Time's running out!</span> <br />
|
||||
I need your support to continue working on OpenFront full-time. Please
|
||||
donate now to keep the updates, new features, and improvements coming.
|
||||
</p>
|
||||
<a
|
||||
href="https://patreon.com/OpenFront"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<div
|
||||
style="
|
||||
text-align: center;
|
||||
margin: 10px 0;
|
||||
line-height: 1.5;
|
||||
background-image: url(${mastersIcon});
|
||||
background-size: 100px;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-blend-mode: overlay;
|
||||
position: relative;
|
||||
"
|
||||
>
|
||||
<div
|
||||
style="
|
||||
display: inline-block;
|
||||
padding: 10px 20px;
|
||||
background-color: #FF424D;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
margin: 10px 0;
|
||||
padding: 14px;
|
||||
background: rgba(0, 0, 0, 0.76);
|
||||
border-radius: 5px;
|
||||
font-weight: bold;
|
||||
transition: background-color 0.2s;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-size: 22px;
|
||||
"
|
||||
>
|
||||
Support on Patreon
|
||||
</a>
|
||||
Watch the best compete in the
|
||||
<br />
|
||||
<a
|
||||
href="https://openfrontmaster.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style="color: #00bfff; font-weight: bold; text-decoration: underline;"
|
||||
>OpenFront Masters</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -202,9 +199,7 @@ export class WinModal extends LitElement implements Layer {
|
||||
window.location.href = "/";
|
||||
}
|
||||
|
||||
init() {
|
||||
this.rand = new PseudoRandom(simpleHash(this.game.myClientID()));
|
||||
}
|
||||
init() {}
|
||||
|
||||
tick() {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
@@ -217,7 +212,6 @@ export class WinModal extends LitElement implements Layer {
|
||||
) {
|
||||
this.hasShownDeathModal = true;
|
||||
this._title = "You died";
|
||||
this.won = false;
|
||||
this.show();
|
||||
}
|
||||
const updates = this.game.updatesSinceLastTick();
|
||||
@@ -229,10 +223,8 @@ export class WinModal extends LitElement implements Layer {
|
||||
);
|
||||
if (wu.winner === this.game.myPlayer()?.team()) {
|
||||
this._title = "Your team won!";
|
||||
this.won = true;
|
||||
} else {
|
||||
this._title = `${wu.winner} team has won!`;
|
||||
this.won = false;
|
||||
}
|
||||
this.show();
|
||||
} else {
|
||||
@@ -247,10 +239,8 @@ export class WinModal extends LitElement implements Layer {
|
||||
}
|
||||
if (winner === this.game.myPlayer()) {
|
||||
this._title = "You Won!";
|
||||
this.won = true;
|
||||
} else {
|
||||
this._title = `${winner.name()} has won!`;
|
||||
this.won = false;
|
||||
}
|
||||
this.show();
|
||||
}
|
||||
|
||||
+59
-38
@@ -48,8 +48,10 @@
|
||||
.left-gutter-ad {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 200px; /* Changed from top: 50% */
|
||||
transform: none; /* Removed translateY(-50%) since we don't need to center anymore */
|
||||
top: 200px;
|
||||
/* Changed from top: 50% */
|
||||
transform: none;
|
||||
/* Removed translateY(-50%) since we don't need to center anymore */
|
||||
z-index: 40;
|
||||
width: 300px;
|
||||
height: 600px;
|
||||
@@ -68,8 +70,10 @@
|
||||
.right-gutter-ad {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 200px; /* Changed from top: 50% */
|
||||
transform: none; /* Removed translateY(-50%) since we don't need to center anymore */
|
||||
top: 200px;
|
||||
/* Changed from top: 50% */
|
||||
transform: none;
|
||||
/* Removed translateY(-50%) since we don't need to center anymore */
|
||||
z-index: 40;
|
||||
width: 300px;
|
||||
height: 600px;
|
||||
@@ -137,6 +141,7 @@
|
||||
gtag("config", "G-WQGQQ8RDN4");
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body
|
||||
class="h-full select-none font-sans min-h-screen bg-opacity-0 bg-cover bg-center bg-fixed transition-opacity duration-300 ease-in-out flex flex-col"
|
||||
>
|
||||
@@ -198,47 +203,35 @@
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="l-header__highlightText">v21.2</div>
|
||||
<div class="l-header__highlightText">v22.0</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="bg-image"></div>
|
||||
<!-- Left gutter ad placement - full height, no empty space -->
|
||||
<div class="left-gutter-ad ad">
|
||||
<google-ad
|
||||
adSlot="5220834834"
|
||||
adFormat="vertical"
|
||||
fullWidthResponsive="false"
|
||||
></google-ad>
|
||||
</div>
|
||||
<div class="right-gutter-ad ad">
|
||||
<google-ad
|
||||
adSlot="1814331462"
|
||||
adFormat="vertical"
|
||||
fullWidthResponsive="false"
|
||||
></google-ad>
|
||||
</div>
|
||||
|
||||
<dark-mode-button></dark-mode-button>
|
||||
<!-- Main container with responsive padding -->
|
||||
<main class="flex justify-center items-center flex-grow">
|
||||
<div class="container">
|
||||
<main class="flex justify-center flex-grow">
|
||||
<div class="container pt-12">
|
||||
<o-button
|
||||
id="login-discord"
|
||||
title="Initializing..."
|
||||
disable="true"
|
||||
block
|
||||
></o-button>
|
||||
|
||||
<o-button
|
||||
id="logout-discord"
|
||||
title="Log out"
|
||||
translationKey="main.log_out"
|
||||
visible="false"
|
||||
block
|
||||
></o-button>
|
||||
|
||||
<div class="container__row">
|
||||
<flag-input class="w-[20%] md:w-[15%]"></flag-input>
|
||||
<username-input class="w-full"></username-input>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://discord.gg/openfront"
|
||||
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"
|
||||
/>
|
||||
<span data-i18n="main.join_discord"> Join the Discord! </span>
|
||||
</a>
|
||||
</div>
|
||||
<div></div>
|
||||
<div>
|
||||
<public-lobby class="w-full"></public-lobby>
|
||||
</div>
|
||||
@@ -279,6 +272,20 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- User Setting -->
|
||||
<button
|
||||
id="settings-button"
|
||||
title="Settings"
|
||||
class="fixed bottom-4 right-4 z-50 rounded-full p-2 shadow-lg transition-colors duration-300 flex items-center justify-center"
|
||||
style="width: 80px; height: 80px; background-color: #0075ff"
|
||||
>
|
||||
<img
|
||||
src="../../resources/images/SettingIconWhite.svg"
|
||||
alt="Settings"
|
||||
style="width: 72px; height: 72px"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Game components -->
|
||||
<div id="customMenu" class="mt-4 sm:mt-6 lg:mt-8">
|
||||
<ul></ul>
|
||||
@@ -327,15 +334,27 @@
|
||||
>
|
||||
Wiki
|
||||
</a>
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://discord.gg/jRpxXvG42t"
|
||||
class="t-link"
|
||||
>
|
||||
<span data-i18n="main.join_discord"> Join the Discord! </span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="l-footer__col t-text-white">
|
||||
© 2025
|
||||
<a
|
||||
href="https://github.com/openfrontio/OpenFrontIO"
|
||||
class="t-link"
|
||||
target="_blank"
|
||||
>
|
||||
OpenFront.io
|
||||
©2025 OpenFront™
|
||||
</a>
|
||||
<a href="/privacy-policy.html" class="t-link" target="_blank">
|
||||
Privacy Policy
|
||||
</a>
|
||||
<a href="/terms-of-service.html" class="t-link" target="_blank">
|
||||
Terms of Service
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -353,6 +372,8 @@
|
||||
<player-panel></player-panel>
|
||||
<help-modal></help-modal>
|
||||
<dark-mode-button></dark-mode-button>
|
||||
<user-setting></user-setting>
|
||||
<multi-tab-modal></multi-tab-modal>
|
||||
<div
|
||||
id="language-modal"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden flex justify-center items-center"
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { decodeJwt } from "jose";
|
||||
import {
|
||||
RefreshResponseSchema,
|
||||
TokenPayload,
|
||||
TokenPayloadSchema,
|
||||
UserMeResponse,
|
||||
UserMeResponseSchema,
|
||||
} from "./ApiSchemas";
|
||||
|
||||
function getAudience() {
|
||||
const { hostname } = new URL(window.location.href);
|
||||
const domainname = hostname.split(".").slice(-2).join(".");
|
||||
return domainname;
|
||||
}
|
||||
|
||||
function getApiBase() {
|
||||
const domainname = getAudience();
|
||||
return domainname === "localhost"
|
||||
? (localStorage.getItem("apiHost") ?? "http://localhost:8787")
|
||||
: `https://api.${domainname}`;
|
||||
}
|
||||
|
||||
function getToken(): string | null {
|
||||
const { hash } = window.location;
|
||||
if (hash.startsWith("#")) {
|
||||
const params = new URLSearchParams(hash.slice(1));
|
||||
const token = params.get("token");
|
||||
if (token) {
|
||||
localStorage.setItem("token", token);
|
||||
}
|
||||
// Clean the URL
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
window.location.pathname + window.location.search,
|
||||
);
|
||||
}
|
||||
return localStorage.getItem("token");
|
||||
}
|
||||
|
||||
export function discordLogin() {
|
||||
window.location.href = `${getApiBase()}/login/discord?redirect_uri=${window.location.href}`;
|
||||
}
|
||||
|
||||
export async function logOut(allSessions: boolean = false) {
|
||||
const token = localStorage.getItem("token");
|
||||
if (token === null) return;
|
||||
localStorage.removeItem("token");
|
||||
__isLoggedIn = false;
|
||||
|
||||
const response = await fetch(
|
||||
getApiBase() + allSessions ? "/revoke" : "/logout",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (response.ok === false) {
|
||||
console.error("Logout failed", response);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
let __isLoggedIn: TokenPayload | false | undefined = undefined;
|
||||
export function isLoggedIn(): TokenPayload | false {
|
||||
if (__isLoggedIn === undefined) {
|
||||
__isLoggedIn = _isLoggedIn();
|
||||
}
|
||||
return __isLoggedIn;
|
||||
}
|
||||
export function _isLoggedIn(): TokenPayload | false {
|
||||
try {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
// console.log("No token found");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify the JWT (requires browser support)
|
||||
// const jwks = createRemoteJWKSet(
|
||||
// new URL(getApiBase() + "/.well-known/jwks.json"),
|
||||
// );
|
||||
// const { payload, protectedHeader } = await jwtVerify(token, jwks, {
|
||||
// issuer: getApiBase(),
|
||||
// audience: getAudience(),
|
||||
// });
|
||||
|
||||
// Decode the JWT
|
||||
const payload = decodeJwt(token);
|
||||
const { iss, aud, exp, iat } = payload;
|
||||
|
||||
if (iss !== getApiBase()) {
|
||||
// JWT was not issued by the correct server
|
||||
console.error(
|
||||
'unexpected "iss" claim value',
|
||||
// JSON.stringify(payload, null, 2),
|
||||
);
|
||||
logOut();
|
||||
return false;
|
||||
}
|
||||
if (aud !== getAudience()) {
|
||||
// JWT was not issued for this website
|
||||
console.error(
|
||||
'unexpected "aud" claim value',
|
||||
// JSON.stringify(payload, null, 2),
|
||||
);
|
||||
logOut();
|
||||
return false;
|
||||
}
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (exp !== undefined && now >= exp) {
|
||||
// JWT expired
|
||||
console.error(
|
||||
'after "exp" claim value',
|
||||
// JSON.stringify(payload, null, 2),
|
||||
);
|
||||
logOut();
|
||||
return false;
|
||||
}
|
||||
const refreshAge: number = 6 * 3600; // 6 hours
|
||||
if (iat !== undefined && now >= iat + refreshAge) {
|
||||
console.log("Refreshing access token...");
|
||||
postRefresh().then((success) => {
|
||||
if (success) {
|
||||
console.log("Refreshed access token successfully.");
|
||||
} else {
|
||||
console.error("Failed to refresh access token.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const result = TokenPayloadSchema.safeParse(payload);
|
||||
if (!result.success) {
|
||||
// Invalid response
|
||||
console.error(
|
||||
"Invalid payload",
|
||||
// JSON.stringify(payload),
|
||||
JSON.stringify(result.error),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function postRefresh(): Promise<boolean> {
|
||||
try {
|
||||
const token = getToken();
|
||||
if (!token) return false;
|
||||
|
||||
// Refresh the JWT
|
||||
const response = await fetch(getApiBase() + "/refresh", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (response.status !== 200) return false;
|
||||
const body = await response.json();
|
||||
const result = RefreshResponseSchema.safeParse(body);
|
||||
if (!result.success) {
|
||||
console.error(
|
||||
"Invalid response",
|
||||
JSON.stringify(body),
|
||||
JSON.stringify(result.error),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
localStorage.setItem("token", result.data.token);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserMe(): Promise<UserMeResponse | false> {
|
||||
try {
|
||||
const token = getToken();
|
||||
if (!token) return false;
|
||||
|
||||
// Get the user object
|
||||
const response = await fetch(getApiBase() + "/users/@me", {
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (response.status !== 200) return false;
|
||||
const body = await response.json();
|
||||
const result = UserMeResponseSchema.safeParse(body);
|
||||
if (!result.success) {
|
||||
console.error(
|
||||
"Invalid response",
|
||||
JSON.stringify(body),
|
||||
JSON.stringify(result.error),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return result.data;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
@import url("./styles/layout/container.css");
|
||||
@import url("./styles/components/button.css");
|
||||
@import url("./styles/components/modal.css");
|
||||
@import url("./styles/components/setting.css");
|
||||
@import url("./styles/components/controls.css");
|
||||
* {
|
||||
-webkit-box-sizing: border-box;
|
||||
@@ -193,6 +194,11 @@ label.option-card:hover {
|
||||
margin: 8px 0px 0px 0px;
|
||||
}
|
||||
|
||||
#lobbyIdInput {
|
||||
font-family: monospace;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lobby-id-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -78,3 +78,14 @@
|
||||
font-size: 14px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.setting-input.keybind:hover .key,
|
||||
.setting-input.keybind:focus .key {
|
||||
background-color: #333;
|
||||
box-shadow: 0 2px 0 #222;
|
||||
}
|
||||
|
||||
.setting-input.keybind.listening .key {
|
||||
background-color: #1d4ed8; /* blue-700 */
|
||||
box-shadow: 0 2px 0 #0f172a; /* darker blue */
|
||||
}
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
.settings-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #333;
|
||||
border-radius: 10px;
|
||||
padding: 12px 20px;
|
||||
width: 360px !important;
|
||||
max-width: 360px !important;
|
||||
min-width: 360px !important;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
|
||||
transition: background 0.3s ease;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.setting-item.column {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@keyframes rainbow-background {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.setting-item.easter-egg {
|
||||
background: linear-gradient(
|
||||
270deg,
|
||||
#990033,
|
||||
#996600,
|
||||
#336600,
|
||||
#008080,
|
||||
#1c3f99,
|
||||
#5e0099,
|
||||
#990033
|
||||
);
|
||||
background-size: 1400% 1400%;
|
||||
animation: rainbow-background 10s ease infinite;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.easter-egg-popup {
|
||||
position: fixed;
|
||||
top: 40px;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) scale(0.9);
|
||||
padding: 16px 24px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
border-radius: 12px;
|
||||
animation: fadePop 5s ease-out forwards;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.setting-popup {
|
||||
position: fixed;
|
||||
top: 40px;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) scale(0.9);
|
||||
padding: 16px 24px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
border-radius: 12px;
|
||||
animation: fadePop_2 10s ease-out forwards;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
@keyframes fadePop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(0.6);
|
||||
}
|
||||
30% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1.05);
|
||||
}
|
||||
70% {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadePop_2 {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(0.6);
|
||||
}
|
||||
5% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1.05);
|
||||
}
|
||||
95% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1.05);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.setting-item:hover {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.setting-item.easter-egg:hover {
|
||||
background: linear-gradient(
|
||||
270deg,
|
||||
#990033,
|
||||
#996600,
|
||||
#336600,
|
||||
#008080,
|
||||
#1c3f99,
|
||||
#5e0099,
|
||||
#990033
|
||||
);
|
||||
background-size: 1400% 1400%;
|
||||
animation: rainbow-background 10s ease infinite;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
color: #f0f0f0;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.setting-input {
|
||||
margin-left: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.setting-item.vertical {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.slider-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.setting-input.slider.full-width {
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.setting-input.slider {
|
||||
-webkit-appearance: none;
|
||||
width: 180px;
|
||||
height: 10px;
|
||||
background: linear-gradient(to right, #2196f3 50%, #444 50%);
|
||||
border-radius: 5px;
|
||||
outline: none;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.setting-input.slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
border: 2px solid #2196f3;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.setting-input.slider::-moz-range-track {
|
||||
background-color: #444;
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.setting-input.slider::-moz-range-progress {
|
||||
background-color: #2196f3;
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.setting-input.slider:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.slider-value {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.setting-input.number {
|
||||
width: 80px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #aaa;
|
||||
border-radius: 6px;
|
||||
background-color: #ffffff;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.switch.switch-right {
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.slider-round {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #d9534f;
|
||||
transition: 0.4s;
|
||||
border-radius: 34px;
|
||||
}
|
||||
|
||||
.slider-round::before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: 0.4s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.switch input:checked + .slider-round {
|
||||
background-color: #4caf50;
|
||||
}
|
||||
|
||||
.switch input:checked + .slider-round::before {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
|
||||
.setting-label-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.setting-description {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-top: 2px;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.setting-keybind-box {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.setting-keybind-description {
|
||||
flex: 1;
|
||||
font-size: 0.75rem;
|
||||
color: #e5e5e5;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.setting-key {
|
||||
background-color: black;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
box-shadow: 0 2px 0 #444;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.setting-key:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -20,5 +20,5 @@
|
||||
|
||||
.l-footer__col {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import australia from "../../../resources/maps/AustraliaThumb.webp";
|
||||
import betweenTwoSeas from "../../../resources/maps/BetweenTwoSeasThumb.webp";
|
||||
import blackSea from "../../../resources/maps/BlackSeaThumb.webp";
|
||||
import britannia from "../../../resources/maps/BritanniaThumb.webp";
|
||||
import deglaciatedAntarctica from "../../../resources/maps/DeglaciatedAntarcticaThumb.webp";
|
||||
import europeClassic from "../../../resources/maps/EuropeClassicThumb.webp";
|
||||
import europe from "../../../resources/maps/EuropeThumb.webp";
|
||||
import faroeislands from "../../../resources/maps/FaroeIslandsThumb.webp";
|
||||
import gatewayToTheAtlantic from "../../../resources/maps/GatewayToTheAtlanticThumb.webp";
|
||||
import iceland from "../../../resources/maps/IcelandThumb.webp";
|
||||
import japan from "../../../resources/maps/JapanThumb.webp";
|
||||
@@ -27,6 +30,8 @@ export function getMapsImage(map: GameMapType): string {
|
||||
return oceania;
|
||||
case GameMapType.Europe:
|
||||
return europe;
|
||||
case GameMapType.EuropeClassic:
|
||||
return europeClassic;
|
||||
case GameMapType.Mena:
|
||||
return mena;
|
||||
case GameMapType.NorthAmerica:
|
||||
@@ -57,6 +62,10 @@ export function getMapsImage(map: GameMapType): string {
|
||||
return betweenTwoSeas;
|
||||
case GameMapType.KnownWorld:
|
||||
return knownworld;
|
||||
case GameMapType.FaroeIslands:
|
||||
return faroeislands;
|
||||
case GameMapType.DeglaciatedAntarctica:
|
||||
return deglaciatedAntarctica;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
||||
+53
-26
@@ -4,10 +4,11 @@ import { Executor } from "./execution/ExecutionManager";
|
||||
import { WinCheckExecution } from "./execution/WinCheckExecution";
|
||||
import {
|
||||
AllPlayers,
|
||||
BuildableUnit,
|
||||
Cell,
|
||||
Game,
|
||||
GameUpdates,
|
||||
NameViewData,
|
||||
Nation,
|
||||
Player,
|
||||
PlayerActions,
|
||||
PlayerBorderTiles,
|
||||
@@ -15,17 +16,18 @@ import {
|
||||
PlayerInfo,
|
||||
PlayerProfile,
|
||||
PlayerType,
|
||||
UnitType,
|
||||
} from "./game/Game";
|
||||
import { createGame } from "./game/GameImpl";
|
||||
import { TileRef } from "./game/GameMap";
|
||||
import {
|
||||
ErrorUpdate,
|
||||
GameUpdateType,
|
||||
GameUpdateViewData,
|
||||
} from "./game/GameUpdates";
|
||||
import { loadTerrainMap as loadGameMap } from "./game/TerrainMapLoader";
|
||||
import { PseudoRandom } from "./PseudoRandom";
|
||||
import { ClientID, GameStartInfo, Turn } from "./Schemas";
|
||||
import { sanitize } from "./Util";
|
||||
import { sanitize, simpleHash } from "./Util";
|
||||
import { fixProfaneUsername } from "./validations/username";
|
||||
|
||||
export async function createGameRunner(
|
||||
@@ -35,26 +37,48 @@ export async function createGameRunner(
|
||||
): Promise<GameRunner> {
|
||||
const config = await getConfig(gameStart.config, null);
|
||||
const gameMap = await loadGameMap(gameStart.config.gameMap);
|
||||
const game = createGame(
|
||||
gameStart.players.map(
|
||||
(p) =>
|
||||
new PlayerInfo(
|
||||
p.flag,
|
||||
p.clientID === clientID
|
||||
? sanitize(p.username)
|
||||
: fixProfaneUsername(sanitize(p.username)),
|
||||
PlayerType.Human,
|
||||
p.clientID,
|
||||
p.playerID,
|
||||
),
|
||||
),
|
||||
const random = new PseudoRandom(simpleHash(gameStart.gameID));
|
||||
|
||||
const humans = gameStart.players.map(
|
||||
(p) =>
|
||||
new PlayerInfo(
|
||||
p.flag,
|
||||
p.clientID == clientID
|
||||
? sanitize(p.username)
|
||||
: fixProfaneUsername(sanitize(p.username)),
|
||||
PlayerType.Human,
|
||||
p.clientID,
|
||||
p.playerID,
|
||||
),
|
||||
);
|
||||
|
||||
const nations = gameStart.config.disableNPCs
|
||||
? []
|
||||
: gameMap.nationMap.nations.map(
|
||||
(n) =>
|
||||
new Nation(
|
||||
new Cell(n.coordinates[0], n.coordinates[1]),
|
||||
n.strength,
|
||||
new PlayerInfo(
|
||||
n.flag || "",
|
||||
n.name,
|
||||
PlayerType.FakeHuman,
|
||||
null,
|
||||
random.nextID(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const game: Game = createGame(
|
||||
humans,
|
||||
nations,
|
||||
gameMap.gameMap,
|
||||
gameMap.miniGameMap,
|
||||
gameMap.nationMap,
|
||||
config,
|
||||
);
|
||||
|
||||
const gr = new GameRunner(
|
||||
game as Game,
|
||||
game,
|
||||
new Executor(game, gameStart.gameID, clientID),
|
||||
callBack,
|
||||
);
|
||||
@@ -161,15 +185,8 @@ export class GameRunner {
|
||||
const player = this.game.player(playerID);
|
||||
const tile = this.game.ref(x, y);
|
||||
const actions = {
|
||||
canBoat: player.canBoat(tile),
|
||||
canAttack: player.canAttack(tile),
|
||||
buildableUnits: Object.values(UnitType).map((u) => {
|
||||
return {
|
||||
type: u,
|
||||
canBuild: player.canBuild(u, tile) !== false,
|
||||
cost: this.game.config().unitInfo(u).cost(player),
|
||||
} as BuildableUnit;
|
||||
}),
|
||||
buildableUnits: player.buildableUnits(tile),
|
||||
canSendEmojiAllPlayers: player.canSendEmoji(AllPlayers),
|
||||
} as PlayerActions;
|
||||
|
||||
@@ -204,4 +221,14 @@ export class GameRunner {
|
||||
borderTiles: player.borderTiles(),
|
||||
} as PlayerBorderTiles;
|
||||
}
|
||||
public bestTransportShipSpawn(
|
||||
playerID: PlayerID,
|
||||
targetTile: TileRef,
|
||||
): TileRef | false {
|
||||
const player = this.game.player(playerID);
|
||||
if (!player.isPlayer()) {
|
||||
throw new Error(`player with id ${playerID} not found`);
|
||||
}
|
||||
return player.bestTransportShipSpawn(targetTile);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ export class PseudoRandom {
|
||||
return this.nextInt(0, odds) === 0;
|
||||
}
|
||||
|
||||
shuffleArray(array: any[]) {
|
||||
shuffleArray(array: any[]): any[] {
|
||||
for (let i = array.length - 1; i >= 0; i--) {
|
||||
const j = Math.floor(this.nextInt(0, i + 1));
|
||||
[array[i], array[j]] = [array[j], array[i]];
|
||||
|
||||
+16
-17
@@ -2,13 +2,14 @@ import { z } from "zod";
|
||||
import {
|
||||
AllPlayers,
|
||||
Difficulty,
|
||||
Duos,
|
||||
GameMapType,
|
||||
GameMode,
|
||||
GameType,
|
||||
PlayerType,
|
||||
Team,
|
||||
UnitType,
|
||||
} from "./game/Game";
|
||||
import { flattenedEmojiTable } from "./Util";
|
||||
|
||||
export type GameID = string;
|
||||
export type ClientID = string;
|
||||
@@ -121,8 +122,11 @@ const GameConfigSchema = z.object({
|
||||
infiniteTroops: z.boolean(),
|
||||
instantBuild: z.boolean(),
|
||||
maxPlayers: z.number().optional(),
|
||||
playerTeams: z.union([z.number().optional(), z.literal(Duos)]),
|
||||
});
|
||||
|
||||
export const TeamSchema = z.string();
|
||||
|
||||
const SafeString = z
|
||||
.string()
|
||||
.regex(
|
||||
@@ -130,14 +134,10 @@ const SafeString = z
|
||||
)
|
||||
.max(1000);
|
||||
|
||||
const EmojiSchema = z.string().refine(
|
||||
(val) => {
|
||||
return /\p{Emoji}/u.test(val);
|
||||
},
|
||||
{
|
||||
message: "Must contain at least one emoji character",
|
||||
},
|
||||
);
|
||||
const EmojiSchema = z
|
||||
.number()
|
||||
.nonnegative()
|
||||
.max(flattenedEmojiTable.length - 1);
|
||||
const ID = z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9]+$/)
|
||||
@@ -194,9 +194,11 @@ export const SpawnIntentSchema = BaseIntentSchema.extend({
|
||||
export const BoatAttackIntentSchema = BaseIntentSchema.extend({
|
||||
type: z.literal("boat"),
|
||||
targetID: ID.nullable(),
|
||||
troops: z.number(),
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
troops: z.number().nullable(),
|
||||
dstX: z.number(),
|
||||
dstY: z.number(),
|
||||
srcX: z.number().nullable().optional(),
|
||||
srcY: z.number().nullable().optional(),
|
||||
});
|
||||
|
||||
export const AllianceRequestIntentSchema = BaseIntentSchema.extend({
|
||||
@@ -361,7 +363,7 @@ const ClientBaseMessageSchema = z.object({
|
||||
|
||||
export const ClientSendWinnerSchema = ClientBaseMessageSchema.extend({
|
||||
type: z.literal("winner"),
|
||||
winner: ID.or(z.nativeEnum(Team)).nullable(),
|
||||
winner: z.union([ID, TeamSchema]).nullable(),
|
||||
allPlayersStats: AllPlayersStatsSchema,
|
||||
winnerType: z.enum(["player", "team"]),
|
||||
});
|
||||
@@ -422,10 +424,7 @@ export const GameRecordSchema = z.object({
|
||||
date: SafeString,
|
||||
num_turns: z.number(),
|
||||
turns: z.array(TurnSchema),
|
||||
winner: z
|
||||
.union([ID, z.nativeEnum(Team)])
|
||||
.nullable()
|
||||
.optional(),
|
||||
winner: z.union([ID, SafeString]).nullable().optional(),
|
||||
winnerType: z.enum(["player", "team"]).nullable().optional(),
|
||||
allPlayersStats: z.record(ID, PlayerStatsSchema),
|
||||
version: z.enum(["v0.0.1"]),
|
||||
|
||||
+39
-68
@@ -1,8 +1,8 @@
|
||||
import DOMPurify from "dompurify";
|
||||
import { customAlphabet } from "nanoid";
|
||||
import twemoji from "twemoji";
|
||||
import { Cell, Game, Player, Team, Unit } from "./game/Game";
|
||||
import { andFN, GameMap, manhattanDistFN, TileRef } from "./game/GameMap";
|
||||
import { Cell, Team, Unit } from "./game/Game";
|
||||
import { GameMap, TileRef } from "./game/GameMap";
|
||||
import {
|
||||
AllPlayersStats,
|
||||
ClientID,
|
||||
@@ -13,6 +13,11 @@ import {
|
||||
Turn,
|
||||
} from "./Schemas";
|
||||
|
||||
import {
|
||||
BOT_NAME_PREFIXES,
|
||||
BOT_NAME_SUFFIXES,
|
||||
} from "./execution/utils/BotNames";
|
||||
|
||||
export function manhattanDistWrapped(
|
||||
c1: Cell,
|
||||
c2: Cell,
|
||||
@@ -57,72 +62,6 @@ export function distSortUnit(
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: refactor to new file
|
||||
export function sourceDstOceanShore(
|
||||
gm: Game,
|
||||
src: Player,
|
||||
tile: TileRef,
|
||||
): [TileRef | null, TileRef | null] {
|
||||
const dst = gm.owner(tile);
|
||||
const srcTile = closestShoreFromPlayer(gm, src, tile);
|
||||
let dstTile: TileRef | null = null;
|
||||
if (dst.isPlayer()) {
|
||||
dstTile = closestShoreFromPlayer(gm, dst as Player, tile);
|
||||
} else {
|
||||
dstTile = closestShoreTN(gm, tile, 50);
|
||||
}
|
||||
return [srcTile, dstTile];
|
||||
}
|
||||
|
||||
export function targetTransportTile(gm: Game, tile: TileRef): TileRef | null {
|
||||
const dst = gm.playerBySmallID(gm.ownerID(tile));
|
||||
let dstTile: TileRef | null = null;
|
||||
if (dst.isPlayer()) {
|
||||
dstTile = closestShoreFromPlayer(gm, dst as Player, tile);
|
||||
} else {
|
||||
dstTile = closestShoreTN(gm, tile, 50);
|
||||
}
|
||||
return dstTile;
|
||||
}
|
||||
|
||||
export function closestShoreFromPlayer(
|
||||
gm: GameMap,
|
||||
player: Player,
|
||||
target: TileRef,
|
||||
): TileRef | null {
|
||||
const shoreTiles = Array.from(player.borderTiles()).filter((t) =>
|
||||
gm.isShore(t),
|
||||
);
|
||||
if (shoreTiles.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return shoreTiles.reduce((closest, current) => {
|
||||
const closestDistance = gm.manhattanDist(target, closest);
|
||||
const currentDistance = gm.manhattanDist(target, current);
|
||||
return currentDistance < closestDistance ? current : closest;
|
||||
});
|
||||
}
|
||||
|
||||
function closestShoreTN(
|
||||
gm: GameMap,
|
||||
tile: TileRef,
|
||||
searchDist: number,
|
||||
): TileRef | null {
|
||||
const tn = Array.from(
|
||||
gm.bfs(
|
||||
tile,
|
||||
andFN((_, t) => !gm.hasOwner(t), manhattanDistFN(tile, searchDist)),
|
||||
),
|
||||
)
|
||||
.filter((t) => gm.isShore(t))
|
||||
.sort((a, b) => gm.manhattanDist(tile, a) - gm.manhattanDist(tile, b));
|
||||
if (tn.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return tn[0];
|
||||
}
|
||||
|
||||
export function simpleHash(str: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
@@ -351,3 +290,35 @@ export function withinInt(num: bigint, min: bigint, max: bigint): bigint {
|
||||
const atLeastMin = maxInt(num, min);
|
||||
return minInt(atLeastMin, max);
|
||||
}
|
||||
|
||||
export function createRandomName(
|
||||
name: string,
|
||||
playerType: string,
|
||||
): string | null {
|
||||
let randomName = null;
|
||||
if (playerType === "HUMAN") {
|
||||
const hash = simpleHash(name);
|
||||
const prefixIndex = hash % BOT_NAME_PREFIXES.length;
|
||||
const suffixIndex =
|
||||
Math.floor(hash / BOT_NAME_PREFIXES.length) % BOT_NAME_SUFFIXES.length;
|
||||
|
||||
randomName = `👤 ${BOT_NAME_PREFIXES[prefixIndex]} ${BOT_NAME_SUFFIXES[suffixIndex]}`;
|
||||
}
|
||||
return randomName;
|
||||
}
|
||||
|
||||
export const emojiTable: string[][] = [
|
||||
["😀", "😊", "🥰", "😇", "😎"],
|
||||
["😞", "🥺", "😭", "😱", "😡"],
|
||||
["😈", "🤡", "🖕", "🥱", "🤦♂️"],
|
||||
["👋", "👏", "🤌", "💪", "🫡"],
|
||||
["👍", "👎", "❓", "🐔", "🐀"],
|
||||
["🤝", "🆘", "🕊️", "🏳️", "⏳"],
|
||||
["🔥", "💥", "💀", "☢️", "⚠️"],
|
||||
["↖️", "⬆️", "↗️", "👑", "🥇"],
|
||||
["⬅️", "🎯", "➡️", "🥈", "🥉"],
|
||||
["↙️", "⬇️", "↘️", "❤️", "💔"],
|
||||
["💰", "⚓", "⛵", "🏡", "🛡️"],
|
||||
];
|
||||
// 2d to 1d array
|
||||
export const flattenedEmojiTable: string[] = [].concat(...emojiTable);
|
||||
|
||||
@@ -2,6 +2,11 @@ import { colord, Colord } from "colord";
|
||||
|
||||
export const red: Colord = colord({ r: 235, g: 53, b: 53 }); // Bright Red
|
||||
export const blue: Colord = colord({ r: 41, g: 98, b: 255 }); // Royal Blue
|
||||
export const teal = colord({ h: 172, s: 66, l: 50 });
|
||||
export const purple = colord({ h: 271, s: 81, l: 56 });
|
||||
export const yellow = colord({ h: 45, s: 93, l: 47 });
|
||||
export const orange = colord({ h: 25, s: 95, l: 53 });
|
||||
export const green = colord({ h: 128, s: 49, l: 50 });
|
||||
export const botColor: Colord = colord({ r: 210, g: 206, b: 200 }); // Muted Beige Gray
|
||||
|
||||
export const territoryColors: Colord[] = [
|
||||
|
||||
@@ -2,11 +2,14 @@ import { Colord } from "colord";
|
||||
import { GameConfig, GameID } from "../Schemas";
|
||||
import {
|
||||
Difficulty,
|
||||
Duos,
|
||||
Game,
|
||||
GameMapType,
|
||||
GameMode,
|
||||
Gold,
|
||||
Player,
|
||||
PlayerInfo,
|
||||
Team,
|
||||
TerraNullius,
|
||||
Tick,
|
||||
UnitInfo,
|
||||
@@ -25,7 +28,7 @@ export enum GameEnv {
|
||||
export interface ServerConfig {
|
||||
turnIntervalMs(): number;
|
||||
gameCreationRate(): number;
|
||||
lobbyMaxPlayers(map: GameMapType): number;
|
||||
lobbyMaxPlayers(map: GameMapType, mode: GameMode): number;
|
||||
discordRedirectURI(): string;
|
||||
numWorkers(): number;
|
||||
workerIndex(gameID: GameID): number;
|
||||
@@ -42,6 +45,10 @@ export interface ServerConfig {
|
||||
r2Endpoint(): string;
|
||||
r2AccessKey(): string;
|
||||
r2SecretKey(): string;
|
||||
otelEndpoint(): string;
|
||||
otelUsername(): string;
|
||||
otelPassword(): string;
|
||||
otelEnabled(): boolean;
|
||||
}
|
||||
|
||||
export interface NukeMagnitude {
|
||||
@@ -51,6 +58,7 @@ export interface NukeMagnitude {
|
||||
|
||||
export interface Config {
|
||||
samHittingChance(): number;
|
||||
samWarheadHittingChance(): number;
|
||||
spawnImmunityDuration(): Tick;
|
||||
serverConfig(): ServerConfig;
|
||||
gameConfig(): GameConfig;
|
||||
@@ -64,7 +72,8 @@ export interface Config {
|
||||
infiniteTroops(): boolean;
|
||||
instantBuild(): boolean;
|
||||
numSpawnPhaseTurns(): number;
|
||||
userSettings(): UserSettings | null;
|
||||
userSettings(): UserSettings;
|
||||
playerTeams(): number | typeof Duos;
|
||||
|
||||
startManpower(playerInfo: PlayerInfo): number;
|
||||
populationIncreaseRate(player: Player | PlayerView): number;
|
||||
@@ -95,7 +104,7 @@ export interface Config {
|
||||
maxPopulation(player: Player | PlayerView): number;
|
||||
cityPopulationIncrease(): number;
|
||||
boatAttackAmount(attacker: Player, defender: Player | TerraNullius): number;
|
||||
warshipShellLifetime(): number;
|
||||
shellLifetime(): number;
|
||||
boatMaxNumber(): number;
|
||||
allianceDuration(): Tick;
|
||||
allianceRequestCooldown(): Tick;
|
||||
@@ -108,20 +117,29 @@ export interface Config {
|
||||
unitInfo(type: UnitType): UnitInfo;
|
||||
tradeShipGold(dist: number): Gold;
|
||||
tradeShipSpawnRate(numberOfPorts: number): number;
|
||||
safeFromPiratesCooldownMax(): number;
|
||||
defensePostRange(): number;
|
||||
SAMCooldown(): number;
|
||||
SiloCooldown(): number;
|
||||
defensePostDefenseBonus(): number;
|
||||
falloutDefenseModifier(percentOfFallout: number): number;
|
||||
difficultyModifier(difficulty: Difficulty): number;
|
||||
warshipPatrolRange(): number;
|
||||
warshipShellAttackRate(): number;
|
||||
warshipTargettingRange(): number;
|
||||
defensePostShellAttackRate(): number;
|
||||
defensePostTargettingRange(): number;
|
||||
// 0-1
|
||||
traitorDefenseDebuff(): number;
|
||||
traitorDuration(): number;
|
||||
nukeMagnitudes(unitType: UnitType): NukeMagnitude;
|
||||
defaultNukeSpeed(): number;
|
||||
nukeDeathFactor(humans: number, tilesOwned: number): number;
|
||||
structureMinDist(): number;
|
||||
}
|
||||
|
||||
export interface Theme {
|
||||
teamColor(team: Team): Colord;
|
||||
territoryColor(playerInfo: PlayerView): Colord;
|
||||
specialBuildingColor(playerInfo: PlayerView): Colord;
|
||||
borderColor(playerInfo: PlayerView): Colord;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Difficulty,
|
||||
Duos,
|
||||
Game,
|
||||
GameMapType,
|
||||
GameMode,
|
||||
@@ -24,6 +25,20 @@ import { pastelTheme } from "./PastelTheme";
|
||||
import { pastelThemeDark } from "./PastelThemeDark";
|
||||
|
||||
export abstract class DefaultServerConfig implements ServerConfig {
|
||||
otelEnabled(): boolean {
|
||||
return Boolean(
|
||||
this.otelEndpoint() && this.otelUsername() && this.otelPassword(),
|
||||
);
|
||||
}
|
||||
otelEndpoint(): string {
|
||||
return process.env.OTEL_ENDPOINT;
|
||||
}
|
||||
otelUsername(): string {
|
||||
return process.env.OTEL_USERNAME;
|
||||
}
|
||||
otelPassword(): string {
|
||||
return process.env.OTEL_PASSWORD;
|
||||
}
|
||||
region(): string {
|
||||
if (this.env() === GameEnv.Dev) {
|
||||
return "dev";
|
||||
@@ -34,7 +49,7 @@ export abstract class DefaultServerConfig implements ServerConfig {
|
||||
return process.env.GIT_COMMIT ?? "undefined";
|
||||
}
|
||||
r2Endpoint(): string {
|
||||
return process.env.R2_ENDPOINT ?? "undefined";
|
||||
return `https://${process.env.CF_ACCOUNT_ID}.r2.cloudflarestorage.com`;
|
||||
}
|
||||
r2AccessKey(): string {
|
||||
return process.env.R2_ACCESS_KEY ?? "undefined";
|
||||
@@ -42,7 +57,11 @@ export abstract class DefaultServerConfig implements ServerConfig {
|
||||
r2SecretKey(): string {
|
||||
return process.env.R2_SECRET_KEY ?? "undefined";
|
||||
}
|
||||
abstract r2Bucket(): string;
|
||||
|
||||
r2Bucket(): string {
|
||||
return process.env.R2_BUCKET;
|
||||
}
|
||||
|
||||
adminHeader(): string {
|
||||
return "x-admin-key";
|
||||
}
|
||||
@@ -58,58 +77,66 @@ export abstract class DefaultServerConfig implements ServerConfig {
|
||||
gameCreationRate(): number {
|
||||
return 60 * 1000;
|
||||
}
|
||||
lobbyMaxPlayers(map: GameMapType): number {
|
||||
// Maps with ~4 mil pixels
|
||||
if (
|
||||
[
|
||||
GameMapType.GatewayToTheAtlantic,
|
||||
GameMapType.SouthAmerica,
|
||||
GameMapType.NorthAmerica,
|
||||
GameMapType.Africa,
|
||||
GameMapType.Europe,
|
||||
].includes(map)
|
||||
) {
|
||||
return Math.random() < 0.2 ? 150 : 70;
|
||||
}
|
||||
// Maps with ~2.5 - ~3.5 mil pixels
|
||||
if (
|
||||
[
|
||||
GameMapType.Australia,
|
||||
GameMapType.Iceland,
|
||||
GameMapType.Britannia,
|
||||
GameMapType.Asia,
|
||||
].includes(map)
|
||||
) {
|
||||
return Math.random() < 0.2 ? 100 : 50;
|
||||
}
|
||||
// Maps with ~2 mil pixels
|
||||
if (
|
||||
[
|
||||
GameMapType.Mena,
|
||||
GameMapType.Mars,
|
||||
GameMapType.Oceania,
|
||||
GameMapType.Japan, // Japan at this level because its 2/3 water
|
||||
].includes(map)
|
||||
) {
|
||||
return Math.random() < 0.2 ? 70 : 40;
|
||||
}
|
||||
// Maps smaller than ~2 mil pixels
|
||||
if (
|
||||
[
|
||||
GameMapType.BetweenTwoSeas,
|
||||
GameMapType.BlackSea,
|
||||
GameMapType.Pangaea,
|
||||
].includes(map)
|
||||
) {
|
||||
return Math.random() < 0.2 ? 60 : 35;
|
||||
}
|
||||
// world belongs with the ~2 mils, but these amounts never made sense so I assume the insanity is intended.
|
||||
if (map === GameMapType.World) {
|
||||
return Math.random() < 0.2 ? 150 : 60;
|
||||
}
|
||||
// default return for non specified map
|
||||
return Math.random() < 0.2 ? 85 : 45;
|
||||
|
||||
lobbyMaxPlayers(map: GameMapType, mode: GameMode): number {
|
||||
const numPlayers = () => {
|
||||
// Maps with ~4 mil pixels
|
||||
if (
|
||||
[
|
||||
GameMapType.GatewayToTheAtlantic,
|
||||
GameMapType.SouthAmerica,
|
||||
GameMapType.NorthAmerica,
|
||||
GameMapType.Africa,
|
||||
GameMapType.Europe,
|
||||
].includes(map)
|
||||
) {
|
||||
return Math.random() < 0.2 ? 100 : 50;
|
||||
}
|
||||
// Maps with ~2.5 - ~3.5 mil pixels
|
||||
if (
|
||||
[
|
||||
GameMapType.Australia,
|
||||
GameMapType.Iceland,
|
||||
GameMapType.Britannia,
|
||||
GameMapType.Asia,
|
||||
].includes(map)
|
||||
) {
|
||||
return Math.random() < 0.3 ? 50 : 25;
|
||||
}
|
||||
// Maps with ~2 mil pixels
|
||||
if (
|
||||
[
|
||||
GameMapType.Mena,
|
||||
GameMapType.Mars,
|
||||
GameMapType.Oceania,
|
||||
GameMapType.Japan, // Japan at this level because its 2/3 water
|
||||
GameMapType.FaroeIslands,
|
||||
GameMapType.DeglaciatedAntarctica,
|
||||
GameMapType.EuropeClassic,
|
||||
].includes(map)
|
||||
) {
|
||||
return Math.random() < 0.3 ? 50 : 25;
|
||||
}
|
||||
// Maps smaller than ~2 mil pixels
|
||||
if (
|
||||
[
|
||||
GameMapType.BetweenTwoSeas,
|
||||
GameMapType.BlackSea,
|
||||
GameMapType.Pangaea,
|
||||
].includes(map)
|
||||
) {
|
||||
return Math.random() < 0.5 ? 30 : 15;
|
||||
}
|
||||
// world belongs with the ~2 mils, but these amounts never made sense so I assume the insanity is intended.
|
||||
if (map == GameMapType.World) {
|
||||
return Math.random() < 0.2 ? 150 : 50;
|
||||
}
|
||||
// default return for non specified map
|
||||
return Math.random() < 0.2 ? 50 : 20;
|
||||
};
|
||||
return Math.min(150, numPlayers() * (mode == GameMode.Team ? 2 : 1));
|
||||
}
|
||||
|
||||
workerIndex(gameID: GameID): number {
|
||||
return simpleHash(gameID) % this.numWorkers();
|
||||
}
|
||||
@@ -135,8 +162,15 @@ export class DefaultConfig implements Config {
|
||||
return 0.8;
|
||||
}
|
||||
|
||||
samWarheadHittingChance(): number {
|
||||
return 0.5;
|
||||
}
|
||||
|
||||
traitorDefenseDebuff(): number {
|
||||
return 0.8;
|
||||
return 0.5;
|
||||
}
|
||||
traitorDuration(): number {
|
||||
return 30 * 10; // 30 seconds
|
||||
}
|
||||
spawnImmunityDuration(): Tick {
|
||||
return 5 * 10;
|
||||
@@ -189,9 +223,14 @@ export class DefaultConfig implements Config {
|
||||
defensePostDefenseBonus(): number {
|
||||
return 5;
|
||||
}
|
||||
playerTeams(): number | typeof Duos {
|
||||
return this._gameConfig.playerTeams ?? 0;
|
||||
}
|
||||
|
||||
spawnNPCs(): boolean {
|
||||
return !this._gameConfig.disableNPCs;
|
||||
}
|
||||
|
||||
disableNukes(): boolean {
|
||||
return this._gameConfig.disableNukes;
|
||||
}
|
||||
@@ -326,7 +365,7 @@ export class DefaultConfig implements Config {
|
||||
p.type() === PlayerType.Human && this.infiniteGold()
|
||||
? 0
|
||||
: Math.min(
|
||||
1_500_000 * 3,
|
||||
3_000_000,
|
||||
(p.unitsIncludingConstruction(UnitType.SAMLauncher).length +
|
||||
1) *
|
||||
1_500_000,
|
||||
@@ -477,25 +516,18 @@ export class DefaultConfig implements Config {
|
||||
}
|
||||
|
||||
if (defender.isPlayer()) {
|
||||
const ratio = within(
|
||||
Math.pow(defender.troops() / attackTroops, 0.4),
|
||||
0.1,
|
||||
10,
|
||||
);
|
||||
const speedRatio = within(
|
||||
defender.troops() / (5 * attackTroops),
|
||||
0.1,
|
||||
10,
|
||||
);
|
||||
|
||||
return {
|
||||
attackerTroopLoss:
|
||||
ratio *
|
||||
within(defender.troops() / attackTroops, 0.6, 2) *
|
||||
mag *
|
||||
0.8 *
|
||||
largeLossModifier *
|
||||
(defender.isTraitor() ? this.traitorDefenseDebuff() : 1),
|
||||
defenderTroopLoss: defender.population() / defender.numTilesOwned(),
|
||||
tilesPerTickUsed: Math.floor(speedRatio * speed * largeSpeedMalus),
|
||||
defenderTroopLoss: defender.troops() / defender.numTilesOwned(),
|
||||
tilesPerTickUsed:
|
||||
within(defender.troops() / (5 * attackTroops), 0.2, 1.5) *
|
||||
speed *
|
||||
largeSpeedMalus,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
@@ -631,8 +663,7 @@ export class DefaultConfig implements Config {
|
||||
}
|
||||
|
||||
goldAdditionRate(player: Player): number {
|
||||
const ratio = Math.pow(player.workers() / player.population(), 1.3);
|
||||
return Math.floor(Math.sqrt(player.workers()) * ratio * 5);
|
||||
return Math.sqrt(player.workers() * player.numTilesOwned()) / 200;
|
||||
}
|
||||
|
||||
troopAdjustmentRate(player: Player): number {
|
||||
@@ -670,4 +701,37 @@ export class DefaultConfig implements Config {
|
||||
nukeDeathFactor(humans: number, tilesOwned: number): number {
|
||||
return (5 * humans) / Math.max(1, tilesOwned);
|
||||
}
|
||||
|
||||
structureMinDist(): number {
|
||||
// TODO: Increase this to ~15 once upgradable structures are implemented.
|
||||
return 1;
|
||||
}
|
||||
|
||||
shellLifetime(): number {
|
||||
return 50;
|
||||
}
|
||||
|
||||
warshipPatrolRange(): number {
|
||||
return 100;
|
||||
}
|
||||
|
||||
warshipTargettingRange(): number {
|
||||
return 130;
|
||||
}
|
||||
|
||||
warshipShellAttackRate(): number {
|
||||
return 20;
|
||||
}
|
||||
|
||||
defensePostShellAttackRate(): number {
|
||||
return 100;
|
||||
}
|
||||
|
||||
safeFromPiratesCooldownMax(): number {
|
||||
return 20;
|
||||
}
|
||||
|
||||
defensePostTargettingRange(): number {
|
||||
return 75;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,6 @@ import { GameEnv, ServerConfig } from "./Config";
|
||||
import { DefaultConfig, DefaultServerConfig } from "./DefaultConfig";
|
||||
|
||||
export class DevServerConfig extends DefaultServerConfig {
|
||||
r2Bucket(): string {
|
||||
return "openfront-staging";
|
||||
}
|
||||
adminToken(): string {
|
||||
return "WARNING_DEV_ADMIN_KEY_DO_NOT_USE_IN_PRODUCTION";
|
||||
}
|
||||
@@ -24,6 +21,14 @@ export class DevServerConfig extends DefaultServerConfig {
|
||||
return Math.random() < 0.5 ? 2 : 3;
|
||||
}
|
||||
|
||||
samWarheadHittingChance(): number {
|
||||
return 1;
|
||||
}
|
||||
|
||||
samHittingChance(): number {
|
||||
return 1;
|
||||
}
|
||||
|
||||
discordRedirectURI(): string {
|
||||
return "http://localhost:3000/auth/callback";
|
||||
}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { Colord, colord } from "colord";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { simpleHash } from "../Util";
|
||||
import { PlayerType, Team, TerrainType } from "../game/Game";
|
||||
import { ColoredTeams, PlayerType, Team, TerrainType } from "../game/Game";
|
||||
import { GameMap, TileRef } from "../game/GameMap";
|
||||
import { PlayerView } from "../game/GameView";
|
||||
import {
|
||||
blue,
|
||||
botColor,
|
||||
botColors,
|
||||
green,
|
||||
humanColors,
|
||||
orange,
|
||||
purple,
|
||||
red,
|
||||
teal,
|
||||
territoryColors,
|
||||
yellow,
|
||||
} from "./Colors";
|
||||
import { Theme } from "./Config";
|
||||
|
||||
@@ -36,15 +41,32 @@ export const pastelTheme = new (class implements Theme {
|
||||
|
||||
private _spawnHighlightColor = colord({ r: 255, g: 213, b: 79 });
|
||||
|
||||
teamColor(team: Team): Colord {
|
||||
switch (team) {
|
||||
case ColoredTeams.Blue:
|
||||
return blue;
|
||||
case ColoredTeams.Red:
|
||||
return red;
|
||||
case ColoredTeams.Teal:
|
||||
return teal;
|
||||
case ColoredTeams.Purple:
|
||||
return purple;
|
||||
case ColoredTeams.Yellow:
|
||||
return yellow;
|
||||
case ColoredTeams.Orange:
|
||||
return orange;
|
||||
case ColoredTeams.Green:
|
||||
return green;
|
||||
case ColoredTeams.Bot:
|
||||
return botColor;
|
||||
default:
|
||||
return humanColors[simpleHash(team) % humanColors.length];
|
||||
}
|
||||
}
|
||||
|
||||
territoryColor(player: PlayerView): Colord {
|
||||
if (player.team() === Team.Bot) {
|
||||
return botColor;
|
||||
}
|
||||
if (player.team() === Team.Red) {
|
||||
return red;
|
||||
}
|
||||
if (player.team() === Team.Blue) {
|
||||
return blue;
|
||||
if (player.team() !== null) {
|
||||
return this.teamColor(player.team());
|
||||
}
|
||||
if (player.info().playerType === PlayerType.Human) {
|
||||
return humanColors[simpleHash(player.id()) % humanColors.length];
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { Colord, colord } from "colord";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { simpleHash } from "../Util";
|
||||
import { PlayerType, Team, TerrainType } from "../game/Game";
|
||||
import { ColoredTeams, PlayerType, Team, TerrainType } from "../game/Game";
|
||||
import { GameMap, TileRef } from "../game/GameMap";
|
||||
import { PlayerView } from "../game/GameView";
|
||||
import {
|
||||
blue,
|
||||
botColor,
|
||||
botColors,
|
||||
green,
|
||||
humanColors,
|
||||
orange,
|
||||
purple,
|
||||
red,
|
||||
teal,
|
||||
territoryColors,
|
||||
yellow,
|
||||
} from "./Colors";
|
||||
import { Theme } from "./Config";
|
||||
|
||||
@@ -36,15 +41,32 @@ export const pastelThemeDark = new (class implements Theme {
|
||||
|
||||
private _spawnHighlightColor = colord({ r: 255, g: 213, b: 79 });
|
||||
|
||||
teamColor(team: Team): Colord {
|
||||
switch (team) {
|
||||
case ColoredTeams.Blue:
|
||||
return blue;
|
||||
case ColoredTeams.Red:
|
||||
return red;
|
||||
case ColoredTeams.Teal:
|
||||
return teal;
|
||||
case ColoredTeams.Purple:
|
||||
return purple;
|
||||
case ColoredTeams.Yellow:
|
||||
return yellow;
|
||||
case ColoredTeams.Orange:
|
||||
return orange;
|
||||
case ColoredTeams.Green:
|
||||
return green;
|
||||
case ColoredTeams.Bot:
|
||||
return botColor;
|
||||
default:
|
||||
return humanColors[simpleHash(team) % humanColors.length];
|
||||
}
|
||||
}
|
||||
|
||||
territoryColor(player: PlayerView): Colord {
|
||||
if (player.team() === Team.Bot) {
|
||||
return botColor;
|
||||
}
|
||||
if (player.team() === Team.Red) {
|
||||
return red;
|
||||
}
|
||||
if (player.team() === Team.Blue) {
|
||||
return blue;
|
||||
if (player.team() !== null) {
|
||||
return this.teamColor(player.team());
|
||||
}
|
||||
if (player.info().playerType === PlayerType.Human) {
|
||||
return humanColors[simpleHash(player.id()) % humanColors.length];
|
||||
|
||||
@@ -2,9 +2,6 @@ import { GameEnv } from "./Config";
|
||||
import { DefaultServerConfig } from "./DefaultConfig";
|
||||
|
||||
export const preprodConfig = new (class extends DefaultServerConfig {
|
||||
r2Bucket(): string {
|
||||
return "openfront-staging";
|
||||
}
|
||||
env(): GameEnv {
|
||||
return GameEnv.Preprod;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,6 @@ import { GameEnv } from "./Config";
|
||||
import { DefaultServerConfig } from "./DefaultConfig";
|
||||
|
||||
export const prodConfig = new (class extends DefaultServerConfig {
|
||||
r2Bucket(): string {
|
||||
return "openfront-prod";
|
||||
}
|
||||
numWorkers(): number {
|
||||
return 6;
|
||||
}
|
||||
|
||||
@@ -21,13 +21,6 @@ export class AttackExecution implements Execution {
|
||||
private active: boolean = true;
|
||||
private toConquer: PriorityQueue<TileContainer> =
|
||||
new PriorityQueue<TileContainer>((a: TileContainer, b: TileContainer) => {
|
||||
if (a.priority === b.priority) {
|
||||
if (a.tick === b.tick) {
|
||||
return 0;
|
||||
// return this.random.nextInt(-1, 1)
|
||||
}
|
||||
return a.tick - b.tick;
|
||||
}
|
||||
return a.priority - b.priority;
|
||||
});
|
||||
private random = new PseudoRandom(123);
|
||||
@@ -235,8 +228,6 @@ export class AttackExecution implements Execution {
|
||||
this.target,
|
||||
this.border.size + this.random.nextInt(0, 5),
|
||||
);
|
||||
// consolex.log(`num tiles per tick: ${numTilesPerTick}`)
|
||||
// consolex.log(`num execs: ${this.mg.executions().length}`)
|
||||
|
||||
while (numTilesPerTick > 0) {
|
||||
if (this.attack.troops() < 1) {
|
||||
@@ -290,13 +281,9 @@ export class AttackExecution implements Execution {
|
||||
continue;
|
||||
}
|
||||
this.border.add(neighbor);
|
||||
let numOwnedByMe = this.mg
|
||||
const numOwnedByMe = this.mg
|
||||
.neighbors(neighbor)
|
||||
.filter((t) => this.mg.owner(t) === this._owner).length;
|
||||
const dist = 0;
|
||||
if (numOwnedByMe > 2) {
|
||||
numOwnedByMe = 10;
|
||||
}
|
||||
.filter((t) => this.mg.owner(t) == this._owner).length;
|
||||
let mag = 0;
|
||||
switch (this.mg.terrainType(tile)) {
|
||||
case TerrainType.Plains:
|
||||
@@ -312,8 +299,9 @@ export class AttackExecution implements Execution {
|
||||
this.toConquer.enqueue(
|
||||
new TileContainer(
|
||||
neighbor,
|
||||
dist / 100 + this.random.nextInt(0, 2) - numOwnedByMe + mag,
|
||||
this.mg.ticks(),
|
||||
(this.random.nextInt(0, 7) + 10) *
|
||||
(1 - numOwnedByMe * 0.5 + mag / 2) +
|
||||
this.mg.ticks(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -366,6 +354,5 @@ class TileContainer {
|
||||
constructor(
|
||||
public readonly tile: TileRef,
|
||||
public readonly priority: number,
|
||||
public readonly tick: number,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -1,53 +1,60 @@
|
||||
import {
|
||||
Execution,
|
||||
Game,
|
||||
Player,
|
||||
PlayerType,
|
||||
TerraNullius,
|
||||
} from "../game/Game";
|
||||
import { Execution, Game, Player } from "../game/Game";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { simpleHash } from "../Util";
|
||||
import { AttackExecution } from "./AttackExecution";
|
||||
import { BotBehavior } from "./utils/BotBehavior";
|
||||
|
||||
export class BotExecution implements Execution {
|
||||
private active = true;
|
||||
private random: PseudoRandom;
|
||||
private attackRate: number;
|
||||
private mg: Game;
|
||||
private neighborsTerraNullius = true;
|
||||
|
||||
private behavior: BotBehavior | null = null;
|
||||
private attackRate: number;
|
||||
private attackTick: number;
|
||||
private triggerRatio: number;
|
||||
private reserveRatio: number;
|
||||
|
||||
constructor(private bot: Player) {
|
||||
this.random = new PseudoRandom(simpleHash(bot.id()));
|
||||
this.attackRate = this.random.nextInt(10, 50);
|
||||
this.attackRate = this.random.nextInt(40, 80);
|
||||
this.attackTick = this.random.nextInt(0, this.attackRate);
|
||||
this.triggerRatio = this.random.nextInt(60, 90) / 100;
|
||||
this.reserveRatio = this.random.nextInt(30, 60) / 100;
|
||||
}
|
||||
|
||||
activeDuringSpawnPhase(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
init(mg: Game, ticks: number) {
|
||||
init(mg: Game) {
|
||||
this.mg = mg;
|
||||
this.bot.setTargetTroopRatio(0.7);
|
||||
// this.neighborsTerra = this.bot.neighbors().filter(n => n == this.gs.terraNullius()).length > 0
|
||||
}
|
||||
|
||||
tick(ticks: number) {
|
||||
if (ticks % this.attackRate != this.attackTick) return;
|
||||
|
||||
if (!this.bot.isAlive()) {
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (ticks % this.attackRate !== 0) {
|
||||
return;
|
||||
if (this.behavior === null) {
|
||||
this.behavior = new BotBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.bot,
|
||||
this.triggerRatio,
|
||||
this.reserveRatio,
|
||||
);
|
||||
}
|
||||
|
||||
this.bot.incomingAllianceRequests().forEach((ar) => {
|
||||
if (ar.requestor().isTraitor()) {
|
||||
ar.reject();
|
||||
} else {
|
||||
ar.accept();
|
||||
}
|
||||
});
|
||||
this.behavior.handleAllianceRequests();
|
||||
this.maybeAttack();
|
||||
}
|
||||
|
||||
private maybeAttack() {
|
||||
const traitors = this.bot
|
||||
.neighbors()
|
||||
.filter((n) => n.isPlayer() && n.isTraitor()) as Player[];
|
||||
@@ -55,60 +62,25 @@ export class BotExecution implements Execution {
|
||||
const toAttack = this.random.randElement(traitors);
|
||||
const odds = this.bot.isFriendly(toAttack) ? 6 : 3;
|
||||
if (this.random.chance(odds)) {
|
||||
this.sendAttack(toAttack);
|
||||
this.behavior.sendAttack(toAttack);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.neighborsTerraNullius) {
|
||||
for (const b of this.bot.borderTiles()) {
|
||||
for (const n of this.mg.neighbors(b)) {
|
||||
if (!this.mg.hasOwner(n) && this.mg.isLand(n)) {
|
||||
this.sendAttack(this.mg.terraNullius());
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (this.bot.sharesBorderWith(this.mg.terraNullius())) {
|
||||
this.behavior.sendAttack(this.mg.terraNullius());
|
||||
return;
|
||||
}
|
||||
this.neighborsTerraNullius = false;
|
||||
}
|
||||
|
||||
const border = Array.from(this.bot.borderTiles())
|
||||
.flatMap((t) => this.mg.neighbors(t))
|
||||
.filter((t) => this.mg.hasOwner(t) && this.mg.owner(t) !== this.bot);
|
||||
|
||||
if (border.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toAttack = border[this.random.nextInt(0, border.length)];
|
||||
const owner = this.mg.owner(toAttack);
|
||||
|
||||
if (owner.isPlayer()) {
|
||||
if (this.bot.isFriendly(owner)) {
|
||||
return;
|
||||
}
|
||||
if (owner.type() === PlayerType.FakeHuman) {
|
||||
if (!this.random.chance(2)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.sendAttack(owner);
|
||||
}
|
||||
|
||||
sendAttack(toAttack: Player | TerraNullius) {
|
||||
if (toAttack.isPlayer() && this.bot.isOnSameTeam(toAttack)) return;
|
||||
this.mg.addExecution(
|
||||
new AttackExecution(
|
||||
this.bot.troops() / 20,
|
||||
this.bot.id(),
|
||||
toAttack.isPlayer() ? toAttack.id() : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
owner(): Player {
|
||||
return this.bot;
|
||||
this.behavior.forgetOldEnemies();
|
||||
this.behavior.checkIncomingAttacks();
|
||||
const enemy = this.behavior.selectRandomEnemy();
|
||||
if (!enemy) return;
|
||||
if (!this.bot.sharesBorderWith(enemy)) return;
|
||||
this.behavior.sendAttack(enemy);
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
UnitType,
|
||||
} from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { ShellExecution } from "./ShellExecution";
|
||||
|
||||
export class DefensePostExecution implements Execution {
|
||||
private player: Player;
|
||||
@@ -15,6 +16,11 @@ export class DefensePostExecution implements Execution {
|
||||
private post: Unit | null = null;
|
||||
private active: boolean = true;
|
||||
|
||||
private target: Unit = null;
|
||||
private lastShellAttack = 0;
|
||||
|
||||
private alreadySentShell = new Set<Unit>();
|
||||
|
||||
constructor(
|
||||
private ownerId: PlayerID,
|
||||
private tile: TileRef,
|
||||
@@ -30,6 +36,27 @@ export class DefensePostExecution implements Execution {
|
||||
this.player = mg.player(this.ownerId);
|
||||
}
|
||||
|
||||
private shoot() {
|
||||
const shellAttackRate = this.mg.config().defensePostShellAttackRate();
|
||||
if (this.mg.ticks() - this.lastShellAttack > shellAttackRate) {
|
||||
this.lastShellAttack = this.mg.ticks();
|
||||
this.mg.addExecution(
|
||||
new ShellExecution(
|
||||
this.post.tile(),
|
||||
this.post.owner(),
|
||||
this.post,
|
||||
this.target,
|
||||
),
|
||||
);
|
||||
if (!this.target.hasHealth()) {
|
||||
// Don't send multiple shells to target that can be oneshotted
|
||||
this.alreadySentShell.add(this.target);
|
||||
this.target = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tick(ticks: number): void {
|
||||
if (this.post === null) {
|
||||
const spawnTile = this.player.canBuild(UnitType.DefensePost, this.tile);
|
||||
@@ -48,6 +75,55 @@ export class DefensePostExecution implements Execution {
|
||||
if (this.player !== this.post.owner()) {
|
||||
this.player = this.post.owner();
|
||||
}
|
||||
|
||||
if (this.target != null && !this.target.isActive()) {
|
||||
this.target = null;
|
||||
}
|
||||
|
||||
// TODO: Reconsider how/if defense posts target ships.
|
||||
return;
|
||||
|
||||
const ships = this.mg
|
||||
.nearbyUnits(
|
||||
this.post.tile(),
|
||||
this.mg.config().defensePostTargettingRange(),
|
||||
[UnitType.TransportShip, UnitType.Warship],
|
||||
)
|
||||
.filter(
|
||||
({ unit }) =>
|
||||
unit.owner() !== this.post.owner() &&
|
||||
!unit.owner().isFriendly(this.post.owner()) &&
|
||||
!this.alreadySentShell.has(unit),
|
||||
);
|
||||
|
||||
this.target =
|
||||
ships.sort((a, b) => {
|
||||
const { unit: unitA, distSquared: distA } = a;
|
||||
const { unit: unitB, distSquared: distB } = b;
|
||||
|
||||
// Prioritize TransportShip
|
||||
if (
|
||||
unitA.type() === UnitType.TransportShip &&
|
||||
unitB.type() !== UnitType.TransportShip
|
||||
)
|
||||
return -1;
|
||||
if (
|
||||
unitA.type() !== UnitType.TransportShip &&
|
||||
unitB.type() === UnitType.TransportShip
|
||||
)
|
||||
return 1;
|
||||
|
||||
// If both are the same type, sort by distance (lower `distSquared` means closer)
|
||||
return distA - distB;
|
||||
})[0]?.unit ?? null;
|
||||
|
||||
if (this.target == null || !this.target.isActive()) {
|
||||
this.target = null;
|
||||
return;
|
||||
} else {
|
||||
this.shoot();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
PlayerID,
|
||||
PlayerType,
|
||||
} from "../game/Game";
|
||||
import { flattenedEmojiTable } from "../Util";
|
||||
|
||||
export class EmojiExecution implements Execution {
|
||||
private requestor: Player;
|
||||
@@ -17,7 +18,7 @@ export class EmojiExecution implements Execution {
|
||||
constructor(
|
||||
private senderID: PlayerID,
|
||||
private recipientID: PlayerID | typeof AllPlayers,
|
||||
private emoji: string,
|
||||
private emoji: number,
|
||||
) {}
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
@@ -40,12 +41,14 @@ export class EmojiExecution implements Execution {
|
||||
}
|
||||
|
||||
tick(ticks: number): void {
|
||||
const emojiString = flattenedEmojiTable.at(this.emoji);
|
||||
|
||||
if (this.requestor.canSendEmoji(this.recipient)) {
|
||||
this.requestor.sendEmoji(this.recipient, this.emoji);
|
||||
this.requestor.sendEmoji(this.recipient, emojiString);
|
||||
if (
|
||||
this.emoji === "🖕" &&
|
||||
this.recipient !== AllPlayers &&
|
||||
this.recipient.type() === PlayerType.FakeHuman
|
||||
emojiString == "🖕" &&
|
||||
this.recipient != AllPlayers &&
|
||||
this.recipient.type() == PlayerType.FakeHuman
|
||||
) {
|
||||
this.recipient.updateRelation(this.requestor, -100);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Execution, Game, PlayerInfo, PlayerType } from "../game/Game";
|
||||
import { Execution, Game } from "../game/Game";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { ClientID, GameID, Intent, Turn } from "../Schemas";
|
||||
import { simpleHash } from "../Util";
|
||||
@@ -65,11 +65,16 @@ export class Executor {
|
||||
this.mg.ref(intent.x, intent.y),
|
||||
);
|
||||
case "boat":
|
||||
let src = null;
|
||||
if (intent.srcX != null || intent.srcY != null) {
|
||||
src = this.mg.ref(intent.srcX, intent.srcY);
|
||||
}
|
||||
return new TransportShipExecution(
|
||||
playerID,
|
||||
intent.targetID,
|
||||
this.mg.ref(intent.x, intent.y),
|
||||
this.mg.ref(intent.dstX, intent.dstY),
|
||||
intent.troops,
|
||||
src,
|
||||
);
|
||||
case "allianceRequest":
|
||||
return new AllianceRequestExecution(playerID, intent.recipient);
|
||||
@@ -115,19 +120,7 @@ export class Executor {
|
||||
fakeHumanExecutions(): Execution[] {
|
||||
const execs: Execution[] = [];
|
||||
for (const nation of this.mg.nations()) {
|
||||
execs.push(
|
||||
new FakeHumanExecution(
|
||||
this.gameID,
|
||||
new PlayerInfo(
|
||||
nation.flag || "",
|
||||
nation.name,
|
||||
PlayerType.FakeHuman,
|
||||
null,
|
||||
this.random.nextID(),
|
||||
nation,
|
||||
),
|
||||
),
|
||||
);
|
||||
execs.push(new FakeHumanExecution(this.gameID, nation));
|
||||
}
|
||||
return execs;
|
||||
}
|
||||
|
||||
@@ -1,57 +1,65 @@
|
||||
import { consolex } from "../Consolex";
|
||||
import {
|
||||
AllianceRequest,
|
||||
Cell,
|
||||
Difficulty,
|
||||
Execution,
|
||||
Game,
|
||||
Nation,
|
||||
Player,
|
||||
PlayerID,
|
||||
PlayerInfo,
|
||||
PlayerType,
|
||||
Relation,
|
||||
TerrainType,
|
||||
TerraNullius,
|
||||
Tick,
|
||||
Unit,
|
||||
UnitType,
|
||||
} from "../game/Game";
|
||||
import { manhattanDistFN, TileRef } from "../game/GameMap";
|
||||
import { euclDistFN, manhattanDistFN, TileRef } from "../game/GameMap";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { GameID } from "../Schemas";
|
||||
import { calculateBoundingBox, simpleHash } from "../Util";
|
||||
import { AllianceRequestReplyExecution } from "./alliance/AllianceRequestReplyExecution";
|
||||
import { AttackExecution } from "./AttackExecution";
|
||||
import { calculateBoundingBox, flattenedEmojiTable, simpleHash } from "../Util";
|
||||
import { ConstructionExecution } from "./ConstructionExecution";
|
||||
import { EmojiExecution } from "./EmojiExecution";
|
||||
import { NukeExecution } from "./NukeExecution";
|
||||
import { SpawnExecution } from "./SpawnExecution";
|
||||
import { TransportShipExecution } from "./TransportShipExecution";
|
||||
import { closestTwoTiles } from "./Util";
|
||||
import { BotBehavior } from "./utils/BotBehavior";
|
||||
|
||||
export class FakeHumanExecution implements Execution {
|
||||
private firstMove = true;
|
||||
|
||||
private active = true;
|
||||
private random: PseudoRandom;
|
||||
private behavior: BotBehavior | null = null;
|
||||
private mg: Game;
|
||||
private player: Player | null = null;
|
||||
|
||||
private enemy: Player | null = null;
|
||||
private attackRate: number;
|
||||
private attackTick: number;
|
||||
private triggerRatio: number;
|
||||
private reserveRatio: number;
|
||||
|
||||
private lastEnemyUpdateTick: number = 0;
|
||||
private lastEmojiSent = new Map<Player, Tick>();
|
||||
private lastNukeSent: [Tick, TileRef][] = [];
|
||||
private embargoMalusApplied = new Set<PlayerID>();
|
||||
private heckleEmoji: number[];
|
||||
|
||||
constructor(
|
||||
gameID: GameID,
|
||||
private playerInfo: PlayerInfo,
|
||||
private nation: Nation,
|
||||
) {
|
||||
this.random = new PseudoRandom(
|
||||
simpleHash(playerInfo.id) + simpleHash(gameID),
|
||||
simpleHash(nation.playerInfo.id) + simpleHash(gameID),
|
||||
);
|
||||
this.attackRate = this.random.nextInt(40, 80);
|
||||
this.attackTick = this.random.nextInt(0, this.attackRate);
|
||||
this.triggerRatio = this.random.nextInt(60, 90) / 100;
|
||||
this.reserveRatio = this.random.nextInt(30, 60) / 100;
|
||||
this.heckleEmoji = ["🤡", "😡"].map((e) => flattenedEmojiTable.indexOf(e));
|
||||
}
|
||||
|
||||
init(mg: Game, ticks: number) {
|
||||
init(mg: Game) {
|
||||
this.mg = mg;
|
||||
if (this.random.chance(10)) {
|
||||
// this.isTraitor = true
|
||||
@@ -103,35 +111,46 @@ export class FakeHumanExecution implements Execution {
|
||||
}
|
||||
|
||||
tick(ticks: number) {
|
||||
if (ticks % this.attackRate != this.attackTick) return;
|
||||
|
||||
if (this.mg.inSpawnPhase()) {
|
||||
if (ticks % this.random.nextInt(5, 30) === 0) {
|
||||
const rl = this.randomLand();
|
||||
if (rl === null) {
|
||||
consolex.warn(`cannot spawn ${this.playerInfo.name}`);
|
||||
return;
|
||||
}
|
||||
this.mg.addExecution(new SpawnExecution(this.playerInfo, rl));
|
||||
const rl = this.randomLand();
|
||||
if (rl == null) {
|
||||
consolex.warn(`cannot spawn ${this.nation.playerInfo.name}`);
|
||||
return;
|
||||
}
|
||||
this.mg.addExecution(new SpawnExecution(this.nation.playerInfo, rl));
|
||||
return;
|
||||
}
|
||||
if (this.player === null) {
|
||||
this.player =
|
||||
this.mg.players().find((p) => p.id() === this.playerInfo.id) ?? null;
|
||||
if (this.player === null) {
|
||||
|
||||
if (this.player == null) {
|
||||
this.player = this.mg
|
||||
.players()
|
||||
.find((p) => p.id() == this.nation.playerInfo.id);
|
||||
if (this.player == null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (this.firstMove) {
|
||||
this.firstMove = false;
|
||||
this.sendAttack(this.mg.terraNullius());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.player.isAlive()) {
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (ticks % this.random.nextInt(40, 80) !== 0) {
|
||||
if (this.behavior === null) {
|
||||
// Player is unavailable during init()
|
||||
this.behavior = new BotBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
this.triggerRatio,
|
||||
this.reserveRatio,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.firstMove) {
|
||||
this.firstMove = false;
|
||||
this.behavior.sendAttack(this.mg.terraNullius());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -143,11 +162,14 @@ export class FakeHumanExecution implements Execution {
|
||||
}
|
||||
|
||||
this.updateRelationsFromEmbargos();
|
||||
this.handleAllianceRequests();
|
||||
this.behavior.handleAllianceRequests();
|
||||
this.handleEnemies();
|
||||
this.handleUnits();
|
||||
this.handleEmbargoesToHostileNations();
|
||||
this.maybeAttack();
|
||||
}
|
||||
|
||||
private maybeAttack() {
|
||||
const enemyborder = Array.from(this.player.borderTiles())
|
||||
.flatMap((t) => this.mg.neighbors(t))
|
||||
.filter(
|
||||
@@ -170,15 +192,15 @@ export class FakeHumanExecution implements Execution {
|
||||
this.mg.playerBySmallID(this.mg.ownerID(t)),
|
||||
);
|
||||
if (enemiesWithTN.filter((o) => !o.isPlayer()).length > 0) {
|
||||
this.sendAttack(this.mg.terraNullius());
|
||||
this.behavior.sendAttack(this.mg.terraNullius());
|
||||
return;
|
||||
}
|
||||
|
||||
const enemies = enemiesWithTN
|
||||
.filter((o) => o.isPlayer())
|
||||
.map((o) => o as Player)
|
||||
.sort((a, b) => a.troops() - b.troops());
|
||||
|
||||
// 5% chance to send a random alliance request
|
||||
if (this.random.chance(20)) {
|
||||
const toAlly = this.random.randElement(enemies);
|
||||
if (this.player.canSendAllianceRequest(toAlly)) {
|
||||
@@ -192,7 +214,7 @@ export class FakeHumanExecution implements Execution {
|
||||
? enemies[0]
|
||||
: this.random.randElement(enemies);
|
||||
if (this.shouldAttack(toAttack)) {
|
||||
this.sendAttack(toAttack);
|
||||
this.behavior.sendAttack(toAttack);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,7 +236,7 @@ export class FakeHumanExecution implements Execution {
|
||||
}
|
||||
}
|
||||
|
||||
shouldDiscourageAttack(other: Player) {
|
||||
private shouldDiscourageAttack(other: Player) {
|
||||
if (other.isTraitor()) {
|
||||
return false;
|
||||
}
|
||||
@@ -233,103 +255,138 @@ export class FakeHumanExecution implements Execution {
|
||||
}
|
||||
|
||||
handleEnemies() {
|
||||
if (this.mg.ticks() - this.lastEnemyUpdateTick > 100) {
|
||||
this.enemy = null;
|
||||
}
|
||||
|
||||
const player = this.player;
|
||||
if (player === null) return;
|
||||
|
||||
const target =
|
||||
player
|
||||
.allies()
|
||||
.filter((ally) => player.relation(ally) === Relation.Friendly)
|
||||
.filter((ally) => ally.targets().length > 0)
|
||||
.map((ally) => ({ ally: ally, t: ally.targets()[0] }))[0] ?? null;
|
||||
|
||||
if (
|
||||
target !== null &&
|
||||
target.t !== player &&
|
||||
!player.isAlliedWith(target.t)
|
||||
) {
|
||||
player.updateRelation(target.ally, -20);
|
||||
this.enemy = target.t;
|
||||
this.lastEnemyUpdateTick = this.mg.ticks();
|
||||
if (target.ally.type() === PlayerType.Human) {
|
||||
this.mg.addExecution(
|
||||
new EmojiExecution(player.id(), target.ally.id(), "👍"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.enemy === null) {
|
||||
const mostHated = player.allRelationsSorted()[0] ?? null;
|
||||
if (mostHated !== null && mostHated.relation === Relation.Hostile) {
|
||||
this.enemy = mostHated.player;
|
||||
this.lastEnemyUpdateTick = this.mg.ticks();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.enemy) {
|
||||
if (player.isFriendly(this.enemy)) {
|
||||
this.enemy = null;
|
||||
return;
|
||||
}
|
||||
this.maybeSendEmoji();
|
||||
this.maybeSendNuke(this.enemy);
|
||||
if (player.sharesBorderWith(this.enemy)) {
|
||||
this.sendAttack(this.enemy);
|
||||
} else {
|
||||
this.maybeSendBoatAttack(this.enemy);
|
||||
}
|
||||
return;
|
||||
this.behavior.forgetOldEnemies();
|
||||
this.behavior.checkIncomingAttacks();
|
||||
this.behavior.assistAllies();
|
||||
const enemy = this.behavior.selectEnemy();
|
||||
if (!enemy) return;
|
||||
this.maybeSendEmoji(enemy);
|
||||
this.maybeSendNuke(enemy);
|
||||
if (this.player.sharesBorderWith(enemy)) {
|
||||
this.behavior.sendAttack(enemy);
|
||||
} else {
|
||||
this.maybeSendBoatAttack(enemy);
|
||||
}
|
||||
}
|
||||
|
||||
private maybeSendEmoji() {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
if (this.enemy === null) return;
|
||||
if (this.enemy.type() !== PlayerType.Human) return;
|
||||
const lastSent = this.lastEmojiSent.get(this.enemy) ?? -300;
|
||||
private maybeSendEmoji(enemy: Player) {
|
||||
if (enemy.type() != PlayerType.Human) return;
|
||||
const lastSent = this.lastEmojiSent.get(enemy) ?? -300;
|
||||
if (this.mg.ticks() - lastSent <= 300) return;
|
||||
this.lastEmojiSent.set(this.enemy, this.mg.ticks());
|
||||
this.lastEmojiSent.set(enemy, this.mg.ticks());
|
||||
this.mg.addExecution(
|
||||
new EmojiExecution(
|
||||
this.player.id(),
|
||||
this.enemy.id(),
|
||||
this.random.randElement(["🤡", "😡"]),
|
||||
enemy.id(),
|
||||
this.random.randElement(this.heckleEmoji),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private maybeSendNuke(other: Player) {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
const silos = this.player.units(UnitType.MissileSilo);
|
||||
if (
|
||||
this.player.units(UnitType.MissileSilo).length === 0 ||
|
||||
this.player.gold() <
|
||||
this.mg.config().unitInfo(UnitType.AtomBomb).cost(this.player) ||
|
||||
silos.length == 0 ||
|
||||
this.player.gold() < this.cost(UnitType.AtomBomb) ||
|
||||
other.type() == PlayerType.Bot ||
|
||||
this.player.isOnSameTeam(other)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
outer: for (let i = 0; i < 10; i++) {
|
||||
const tile = this.randTerritoryTile(other);
|
||||
if (tile === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const structures = other.units(
|
||||
UnitType.City,
|
||||
UnitType.DefensePost,
|
||||
UnitType.MissileSilo,
|
||||
UnitType.Port,
|
||||
UnitType.SAMLauncher,
|
||||
);
|
||||
const structureTiles = structures.map((u) => u.tile());
|
||||
const randomTiles: TileRef[] = new Array(10);
|
||||
for (let i = 0; i < randomTiles.length; i++) {
|
||||
randomTiles[i] = this.randTerritoryTile(other);
|
||||
}
|
||||
const allTiles = randomTiles.concat(structureTiles);
|
||||
|
||||
let bestTile = null;
|
||||
let bestValue = 0;
|
||||
this.removeOldNukeEvents();
|
||||
outer: for (const tile of new Set(allTiles)) {
|
||||
if (tile == null) continue;
|
||||
for (const t of this.mg.bfs(tile, manhattanDistFN(tile, 15))) {
|
||||
// Make sure we nuke at least 15 tiles in border
|
||||
if (this.mg.owner(t) !== other) {
|
||||
continue outer;
|
||||
}
|
||||
}
|
||||
if (this.player.canBuild(UnitType.AtomBomb, tile)) {
|
||||
this.mg.addExecution(
|
||||
new NukeExecution(UnitType.AtomBomb, this.player.id(), tile),
|
||||
);
|
||||
return;
|
||||
if (!this.player.canBuild(UnitType.AtomBomb, tile)) continue;
|
||||
const value = this.nukeTileScore(tile, silos, structures);
|
||||
if (value > bestTile) {
|
||||
bestTile = tile;
|
||||
bestValue = value;
|
||||
}
|
||||
}
|
||||
if (bestTile != null) {
|
||||
this.sendNuke(bestTile);
|
||||
}
|
||||
}
|
||||
|
||||
private removeOldNukeEvents() {
|
||||
const maxAge = 500;
|
||||
const tick = this.mg.ticks();
|
||||
while (
|
||||
this.lastNukeSent.length > 0 &&
|
||||
this.lastNukeSent[0][0] + maxAge < tick
|
||||
) {
|
||||
this.lastNukeSent.shift();
|
||||
}
|
||||
}
|
||||
|
||||
private sendNuke(tile: TileRef) {
|
||||
const tick = this.mg.ticks();
|
||||
this.lastNukeSent.push([tick, tile]);
|
||||
this.mg.addExecution(
|
||||
new NukeExecution(UnitType.AtomBomb, this.player.id(), tile),
|
||||
);
|
||||
}
|
||||
|
||||
private nukeTileScore(tile: TileRef, silos: Unit[], targets: Unit[]): number {
|
||||
// Potential damage in a 25-tile radius
|
||||
const dist = euclDistFN(tile, 25, false);
|
||||
let tileValue = targets
|
||||
.filter((unit) => dist(this.mg, unit.tile()))
|
||||
.map((unit) => {
|
||||
switch (unit.type()) {
|
||||
case UnitType.City:
|
||||
return 25_000;
|
||||
case UnitType.DefensePost:
|
||||
return 5_000;
|
||||
case UnitType.MissileSilo:
|
||||
return 50_000;
|
||||
case UnitType.Port:
|
||||
return 10_000;
|
||||
case UnitType.SAMLauncher:
|
||||
return 5_000;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
})
|
||||
.reduce((prev, cur) => prev + cur, 0);
|
||||
|
||||
// Prefer tiles that are closer to a silo
|
||||
const siloTiles = silos.map((u) => u.tile());
|
||||
const { x: closestSilo } = closestTwoTiles(this.mg, siloTiles, [tile]);
|
||||
const distanceSquared = this.mg.euclideanDistSquared(tile, closestSilo);
|
||||
const distanceToClosestSilo = Math.sqrt(distanceSquared);
|
||||
tileValue -= distanceToClosestSilo * 30;
|
||||
|
||||
// Don't target near recent targets
|
||||
tileValue -= this.lastNukeSent
|
||||
.filter(([_tick, tile]) => dist(this.mg, tile))
|
||||
.map((_) => 1_000_000)
|
||||
.reduce((prev, cur) => prev + cur, 0);
|
||||
|
||||
return tileValue;
|
||||
}
|
||||
|
||||
private maybeSendBoatAttack(other: Player) {
|
||||
@@ -351,6 +408,7 @@ export class FakeHumanExecution implements Execution {
|
||||
other.id(),
|
||||
closest.y,
|
||||
this.player.troops() / 5,
|
||||
null,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -371,36 +429,21 @@ export class FakeHumanExecution implements Execution {
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.maybeSpawnStructure(
|
||||
UnitType.City,
|
||||
2,
|
||||
(t) => new ConstructionExecution(player.id(), t, UnitType.City),
|
||||
);
|
||||
this.maybeSpawnStructure(UnitType.City, 2);
|
||||
if (this.maybeSpawnWarship()) {
|
||||
return;
|
||||
}
|
||||
if (!this.mg.config().disableNukes()) {
|
||||
this.maybeSpawnStructure(
|
||||
UnitType.MissileSilo,
|
||||
1,
|
||||
(t) => new ConstructionExecution(player.id(), t, UnitType.MissileSilo),
|
||||
);
|
||||
this.maybeSpawnStructure(UnitType.MissileSilo, 1);
|
||||
}
|
||||
}
|
||||
|
||||
private maybeSpawnStructure(
|
||||
type: UnitType,
|
||||
maxNum: number,
|
||||
build: (tile: TileRef) => Execution,
|
||||
) {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
private maybeSpawnStructure(type: UnitType, maxNum: number) {
|
||||
const units = this.player.units(type);
|
||||
if (units.length >= maxNum) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
this.player.gold() < this.mg.config().unitInfo(type).cost(this.player)
|
||||
) {
|
||||
if (this.player.gold() < this.cost(type)) {
|
||||
return;
|
||||
}
|
||||
const tile = this.randTerritoryTile(this.player);
|
||||
@@ -411,7 +454,9 @@ export class FakeHumanExecution implements Execution {
|
||||
if (canBuild === false) {
|
||||
return;
|
||||
}
|
||||
this.mg.addExecution(build(tile));
|
||||
this.mg.addExecution(
|
||||
new ConstructionExecution(this.player.id(), tile, type),
|
||||
);
|
||||
}
|
||||
|
||||
private maybeSpawnWarship(): boolean {
|
||||
@@ -494,38 +539,6 @@ export class FakeHumanExecution implements Execution {
|
||||
return this.mg.unitInfo(type).cost(this.player);
|
||||
}
|
||||
|
||||
handleAllianceRequests() {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
for (const req of this.player.incomingAllianceRequests()) {
|
||||
if (req.requestor().isTraitor()) {
|
||||
this.replyToAllianceRequest(req, false);
|
||||
continue;
|
||||
}
|
||||
if (this.player.relation(req.requestor()) < Relation.Neutral) {
|
||||
this.replyToAllianceRequest(req, false);
|
||||
continue;
|
||||
}
|
||||
const requestorIsMuchLarger =
|
||||
req.requestor().numTilesOwned() > this.player.numTilesOwned() * 3;
|
||||
if (!requestorIsMuchLarger && req.requestor().alliances().length >= 3) {
|
||||
this.replyToAllianceRequest(req, false);
|
||||
continue;
|
||||
}
|
||||
this.replyToAllianceRequest(req, true);
|
||||
}
|
||||
}
|
||||
|
||||
private replyToAllianceRequest(req: AllianceRequest, accept: boolean): void {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
this.mg.addExecution(
|
||||
new AllianceRequestReplyExecution(
|
||||
req.requestor().id(),
|
||||
this.player.id(),
|
||||
accept,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
sendBoatRandomly() {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
const oceanShore = Array.from(this.player.borderTiles()).filter((t) =>
|
||||
@@ -548,6 +561,7 @@ export class FakeHumanExecution implements Execution {
|
||||
this.mg.owner(dst).id(),
|
||||
dst,
|
||||
this.player.troops() / 5,
|
||||
null,
|
||||
),
|
||||
);
|
||||
return;
|
||||
@@ -556,9 +570,9 @@ export class FakeHumanExecution implements Execution {
|
||||
randomLand(): TileRef | null {
|
||||
const delta = 25;
|
||||
let tries = 0;
|
||||
const cell = this.playerInfo.nation?.cell;
|
||||
if (typeof cell === "undefined") return null;
|
||||
while (tries++ < 50) {
|
||||
while (tries < 50) {
|
||||
tries++;
|
||||
const cell = this.nation.spawnCell;
|
||||
const x = this.random.nextInt(cell.x - delta, cell.x + delta);
|
||||
const y = this.random.nextInt(cell.y - delta, cell.y + delta);
|
||||
if (!this.mg.isValidCoord(x, y)) {
|
||||
@@ -578,18 +592,6 @@ export class FakeHumanExecution implements Execution {
|
||||
return null;
|
||||
}
|
||||
|
||||
sendAttack(toAttack: Player | TerraNullius) {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
if (toAttack.isPlayer() && this.player.isOnSameTeam(toAttack)) return;
|
||||
this.mg.addExecution(
|
||||
new AttackExecution(
|
||||
this.player.troops() / 5,
|
||||
this.player.id(),
|
||||
toAttack.isPlayer() ? toAttack.id() : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private randOceanShoreTile(tile: TileRef, dist: number): TileRef | null {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
const x = this.mg.x(tile);
|
||||
|
||||
@@ -10,8 +10,7 @@ import {
|
||||
UnitType,
|
||||
} from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { PathFindResultType } from "../pathfinding/AStar";
|
||||
import { PathFinder } from "../pathfinding/PathFinding";
|
||||
import { AirPathFinder } from "../pathfinding/PathFinding";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { simpleHash } from "../Util";
|
||||
import { NukeExecution } from "./NukeExecution";
|
||||
@@ -30,7 +29,7 @@ export class MirvExecution implements Execution {
|
||||
|
||||
private random: PseudoRandom;
|
||||
|
||||
private pathFinder: PathFinder;
|
||||
private pathFinder: AirPathFinder;
|
||||
|
||||
private targetPlayer: Player | TerraNullius;
|
||||
|
||||
@@ -50,7 +49,7 @@ export class MirvExecution implements Execution {
|
||||
|
||||
this.random = new PseudoRandom(mg.ticks() + simpleHash(this.senderID));
|
||||
this.mg = mg;
|
||||
this.pathFinder = PathFinder.Mini(mg, 10_000, true);
|
||||
this.pathFinder = new AirPathFinder(mg, this.random);
|
||||
this.player = mg.player(this.senderID);
|
||||
this.targetPlayer = this.mg.owner(this.dst);
|
||||
|
||||
@@ -90,23 +89,12 @@ export class MirvExecution implements Execution {
|
||||
this.nuke.tile(),
|
||||
this.separateDst,
|
||||
);
|
||||
switch (result.type) {
|
||||
case PathFindResultType.Completed:
|
||||
this.nuke.move(result.tile);
|
||||
this.separate();
|
||||
this.active = false;
|
||||
return;
|
||||
case PathFindResultType.NextTile:
|
||||
this.nuke.move(result.tile);
|
||||
break;
|
||||
case PathFindResultType.Pending:
|
||||
break;
|
||||
case PathFindResultType.PathNotFound:
|
||||
consolex.warn(
|
||||
`nuke cannot find path from ${this.nuke.tile()} to ${this.dst}`,
|
||||
);
|
||||
this.active = false;
|
||||
return;
|
||||
if (result === true) {
|
||||
this.separate();
|
||||
this.active = false;
|
||||
return;
|
||||
} else {
|
||||
this.nuke.move(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,7 +159,6 @@ export class MirvExecution implements Execution {
|
||||
if (!this.mg.isValidCoord(x, y)) {
|
||||
continue;
|
||||
}
|
||||
console.log(`got coord ${x}, ${y}`);
|
||||
const tile = this.mg.ref(x, y);
|
||||
if (!this.mg.isLand(tile)) {
|
||||
continue;
|
||||
|
||||
@@ -32,19 +32,16 @@ export class MissileSiloExecution implements Execution {
|
||||
}
|
||||
|
||||
tick(ticks: number): void {
|
||||
if (this.mg === null || this.player === null) {
|
||||
throw new Error("Not initialized");
|
||||
}
|
||||
|
||||
if (this.silo === null) {
|
||||
if (!this.player.canBuild(UnitType.MissileSilo, this.tile)) {
|
||||
if (this.silo == null) {
|
||||
const spawn = this.player.canBuild(UnitType.MissileSilo, this.tile);
|
||||
if (spawn === false) {
|
||||
consolex.warn(
|
||||
`player ${this.player} cannot build missile silo at ${this.tile}`,
|
||||
);
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
this.silo = this.player.buildUnit(UnitType.MissileSilo, 0, this.tile, {
|
||||
this.silo = this.player.buildUnit(UnitType.MissileSilo, 0, spawn, {
|
||||
cooldownDuration: this.mg.config().SiloCooldown(),
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
UnitType,
|
||||
} from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { AirPathFinder } from "../pathfinding/PathFinding";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
|
||||
export class NukeExecution implements Execution {
|
||||
@@ -20,6 +21,7 @@ export class NukeExecution implements Execution {
|
||||
private nuke: Unit | null = null;
|
||||
|
||||
private random: PseudoRandom;
|
||||
private pathFinder: AirPathFinder;
|
||||
|
||||
constructor(
|
||||
private type: NukeType,
|
||||
@@ -43,6 +45,7 @@ export class NukeExecution implements Execution {
|
||||
if (this.speed === -1) {
|
||||
this.speed = this.mg.config().defaultNukeSpeed();
|
||||
}
|
||||
this.pathFinder = new AirPathFinder(mg, this.random);
|
||||
}
|
||||
|
||||
public target(): Player | TerraNullius {
|
||||
@@ -156,45 +159,14 @@ export class NukeExecution implements Execution {
|
||||
return;
|
||||
}
|
||||
|
||||
const r = (this.mg.y(this.dst) * this.mg.x(this.dst)) % 10;
|
||||
const s = this.speed + (this.mg.ticks() % r);
|
||||
|
||||
for (let i = 0; i < this.speed; i++) {
|
||||
const x = this.mg.x(this.nuke.tile());
|
||||
const y = this.mg.y(this.nuke.tile());
|
||||
const dstX = this.mg.x(this.dst);
|
||||
const dstY = this.mg.y(this.dst);
|
||||
|
||||
// If we've reached the destination, detonate
|
||||
if (x === dstX && y === dstY) {
|
||||
// Move to next tile
|
||||
const nextTile = this.pathFinder.nextTile(this.nuke.tile(), this.dst);
|
||||
if (nextTile === true) {
|
||||
this.detonate();
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate next position
|
||||
let nextX = x;
|
||||
let nextY = y;
|
||||
|
||||
const ratio = Math.floor(
|
||||
1 + Math.abs(dstY - y) / (Math.abs(dstX - x) + 1),
|
||||
);
|
||||
|
||||
if (this.random.chance(ratio) && x !== dstX) {
|
||||
if (x < dstX) nextX++;
|
||||
else if (x > dstX) nextX--;
|
||||
} else {
|
||||
if (y < dstY) nextY++;
|
||||
else if (y > dstY) nextY--;
|
||||
}
|
||||
|
||||
// Move to next tile
|
||||
const nextTile = this.mg.ref(nextX, nextY);
|
||||
if (nextTile !== undefined) {
|
||||
this.nuke.move(nextTile);
|
||||
} else {
|
||||
consolex.warn(`invalid tile position ${nextX},${nextY}`);
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ export class PortExecution implements Execution {
|
||||
}
|
||||
|
||||
const port = this.random.randElement(ports);
|
||||
const pf = PathFinder.Mini(this.mg, 2500, false);
|
||||
const pf = PathFinder.Mini(this.mg, 2500);
|
||||
this.mg.addExecution(
|
||||
new TradeShipExecution(this.player().id(), this.port, port, pf),
|
||||
);
|
||||
|
||||
@@ -13,21 +13,27 @@ import { PseudoRandom } from "../PseudoRandom";
|
||||
import { SAMMissileExecution } from "./SAMMissileExecution";
|
||||
|
||||
export class SAMLauncherExecution implements Execution {
|
||||
private player: Player | null = null;
|
||||
private mg: Game | null = null;
|
||||
private sam: Unit | null = null;
|
||||
private player: Player;
|
||||
private mg: Game;
|
||||
private active: boolean = true;
|
||||
|
||||
private target: Unit | null = null;
|
||||
|
||||
private searchRangeRadius = 75;
|
||||
private searchRangeRadius = 80;
|
||||
// As MIRV go very fast we have to detect them very early but we only
|
||||
// shoot the one targeting very close (MIRVWarheadProtectionRadius)
|
||||
private MIRVWarheadSearchRadius = 400;
|
||||
private MIRVWarheadProtectionRadius = 50;
|
||||
|
||||
private pseudoRandom: PseudoRandom;
|
||||
|
||||
constructor(
|
||||
private ownerId: PlayerID,
|
||||
private tile: TileRef,
|
||||
) {}
|
||||
private sam: Unit | null = null,
|
||||
) {
|
||||
if (sam != null) {
|
||||
this.tile = sam.tile();
|
||||
}
|
||||
}
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
this.mg = mg;
|
||||
@@ -39,6 +45,52 @@ export class SAMLauncherExecution implements Execution {
|
||||
this.player = mg.player(this.ownerId);
|
||||
}
|
||||
|
||||
private getSingleTarget(): Unit | null {
|
||||
const nukes = this.mg
|
||||
.nearbyUnits(this.sam.tile(), this.searchRangeRadius, [
|
||||
UnitType.AtomBomb,
|
||||
UnitType.HydrogenBomb,
|
||||
])
|
||||
.filter(
|
||||
({ unit }) =>
|
||||
unit.owner() !== this.player && !this.player.isFriendly(unit.owner()),
|
||||
);
|
||||
|
||||
return (
|
||||
nukes.sort((a, b) => {
|
||||
const { unit: unitA, distSquared: distA } = a;
|
||||
const { unit: unitB, distSquared: distB } = b;
|
||||
|
||||
// Prioritize Hydrogen Bombs
|
||||
if (
|
||||
unitA.type() === UnitType.HydrogenBomb &&
|
||||
unitB.type() !== UnitType.HydrogenBomb
|
||||
)
|
||||
return -1;
|
||||
if (
|
||||
unitA.type() !== UnitType.HydrogenBomb &&
|
||||
unitB.type() === UnitType.HydrogenBomb
|
||||
)
|
||||
return 1;
|
||||
|
||||
// If both are the same type, sort by distance (lower `distSquared` means closer)
|
||||
return distA - distB;
|
||||
})[0]?.unit ?? null
|
||||
);
|
||||
}
|
||||
|
||||
private isHit(type: UnitType, random: number): boolean {
|
||||
if (type == UnitType.AtomBomb) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (type == UnitType.MIRVWarhead) {
|
||||
return random < this.mg.config().samWarheadHittingChance();
|
||||
}
|
||||
|
||||
return random < this.mg.config().samHittingChance();
|
||||
}
|
||||
|
||||
tick(ticks: number): void {
|
||||
if (this.mg === null || this.player === null) {
|
||||
throw new Error("Not initialized");
|
||||
@@ -67,37 +119,27 @@ export class SAMLauncherExecution implements Execution {
|
||||
this.pseudoRandom = new PseudoRandom(this.sam.id());
|
||||
}
|
||||
|
||||
const nukes = this.mg
|
||||
.nearbyUnits(this.sam.tile(), this.searchRangeRadius, [
|
||||
UnitType.AtomBomb,
|
||||
UnitType.HydrogenBomb,
|
||||
])
|
||||
const mirvWarheadTargets = this.mg
|
||||
.nearbyUnits(
|
||||
this.sam.tile(),
|
||||
this.MIRVWarheadSearchRadius,
|
||||
UnitType.MIRVWarhead,
|
||||
)
|
||||
.map(({ unit }) => unit)
|
||||
.filter(
|
||||
({ unit }) =>
|
||||
unit.owner() !== this.player &&
|
||||
!this.player?.isFriendly(unit.owner()),
|
||||
(unit) =>
|
||||
unit.owner() !== this.player && !this.player.isFriendly(unit.owner()),
|
||||
)
|
||||
.filter(
|
||||
(unit) =>
|
||||
this.mg.manhattanDist(unit.detonationDst(), this.sam.tile()) <
|
||||
this.MIRVWarheadProtectionRadius,
|
||||
);
|
||||
|
||||
this.target =
|
||||
nukes.sort((a, b) => {
|
||||
const { unit: unitA, distSquared: distA } = a;
|
||||
const { unit: unitB, distSquared: distB } = b;
|
||||
|
||||
// Prioritize Hydrogen Bombs
|
||||
if (
|
||||
unitA.type() === UnitType.HydrogenBomb &&
|
||||
unitB.type() !== UnitType.HydrogenBomb
|
||||
)
|
||||
return -1;
|
||||
if (
|
||||
unitA.type() !== UnitType.HydrogenBomb &&
|
||||
unitB.type() === UnitType.HydrogenBomb
|
||||
)
|
||||
return 1;
|
||||
|
||||
// If both are the same type, sort by distance (lower `distSquared` means closer)
|
||||
return distA - distB;
|
||||
})[0]?.unit ?? null;
|
||||
let target: Unit | null = null;
|
||||
if (mirvWarheadTargets.length == 0) {
|
||||
target = this.getSingleTarget();
|
||||
}
|
||||
|
||||
if (
|
||||
this.sam.isCooldown() &&
|
||||
@@ -106,29 +148,43 @@ export class SAMLauncherExecution implements Execution {
|
||||
this.sam.setCooldown(false);
|
||||
}
|
||||
|
||||
if (this.target && !this.sam.isCooldown() && !this.target.targetedBySAM()) {
|
||||
const isSingleTarget = target && !target.targetedBySAM();
|
||||
if (
|
||||
(isSingleTarget || mirvWarheadTargets.length > 0) &&
|
||||
!this.sam.isCooldown()
|
||||
) {
|
||||
this.sam.setCooldown(true);
|
||||
const type =
|
||||
mirvWarheadTargets.length > 0 ? UnitType.MIRVWarhead : target.type();
|
||||
const random = this.pseudoRandom.next();
|
||||
let hit = true;
|
||||
if (this.target.type() !== UnitType.AtomBomb) {
|
||||
hit = random < this.mg.config().samHittingChance();
|
||||
}
|
||||
const hit = this.isHit(type, random);
|
||||
if (!hit) {
|
||||
this.mg.displayMessage(
|
||||
`Missile failed to intercept ${this.target.type()}`,
|
||||
`Missile failed to intercept ${type}`,
|
||||
MessageType.ERROR,
|
||||
this.sam.owner().id(),
|
||||
);
|
||||
} else {
|
||||
this.target.setTargetedBySAM(true);
|
||||
this.mg.addExecution(
|
||||
new SAMMissileExecution(
|
||||
this.sam.tile(),
|
||||
this.sam.owner(),
|
||||
this.sam,
|
||||
this.target,
|
||||
),
|
||||
);
|
||||
if (mirvWarheadTargets.length > 0) {
|
||||
// Message
|
||||
this.mg.displayMessage(
|
||||
`${mirvWarheadTargets.length} MIRV warheads intercepted`,
|
||||
MessageType.SUCCESS,
|
||||
this.sam.owner().id(),
|
||||
);
|
||||
// Delete warheads
|
||||
mirvWarheadTargets.forEach((u) => u.delete());
|
||||
} else {
|
||||
target.setTargetedBySAM(true);
|
||||
this.mg.addExecution(
|
||||
new SAMMissileExecution(
|
||||
this.sam.tile(),
|
||||
this.sam.owner(),
|
||||
this.sam,
|
||||
target,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { consolex } from "../Consolex";
|
||||
import {
|
||||
Execution,
|
||||
Game,
|
||||
@@ -8,12 +7,12 @@ import {
|
||||
UnitType,
|
||||
} from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { PathFindResultType } from "../pathfinding/AStar";
|
||||
import { PathFinder } from "../pathfinding/PathFinding";
|
||||
import { AirPathFinder } from "../pathfinding/PathFinding";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
|
||||
export class SAMMissileExecution implements Execution {
|
||||
private active = true;
|
||||
private pathFinder: PathFinder;
|
||||
private pathFinder: AirPathFinder;
|
||||
private SAMMissile: Unit;
|
||||
private mg: Game;
|
||||
|
||||
@@ -26,7 +25,7 @@ export class SAMMissileExecution implements Execution {
|
||||
) {}
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
this.pathFinder = PathFinder.Mini(mg, 2000, true, 10);
|
||||
this.pathFinder = new AirPathFinder(mg, new PseudoRandom(mg.ticks()));
|
||||
this.mg = mg;
|
||||
}
|
||||
|
||||
@@ -58,29 +57,19 @@ export class SAMMissileExecution implements Execution {
|
||||
const result = this.pathFinder.nextTile(
|
||||
this.SAMMissile.tile(),
|
||||
this.target.tile(),
|
||||
3,
|
||||
);
|
||||
switch (result.type) {
|
||||
case PathFindResultType.Completed:
|
||||
this.mg.displayMessage(
|
||||
`Missile intercepted ${this.target.type()}`,
|
||||
MessageType.SUCCESS,
|
||||
this._owner.id(),
|
||||
);
|
||||
this.active = false;
|
||||
this.target.delete();
|
||||
this.SAMMissile.delete(false);
|
||||
return;
|
||||
case PathFindResultType.NextTile:
|
||||
this.SAMMissile.move(result.tile);
|
||||
break;
|
||||
case PathFindResultType.Pending:
|
||||
return;
|
||||
case PathFindResultType.PathNotFound:
|
||||
consolex.log(`Missile ${this.SAMMissile} could not find target`);
|
||||
this.active = false;
|
||||
this.SAMMissile.delete(false);
|
||||
return;
|
||||
if (result === true) {
|
||||
this.mg.displayMessage(
|
||||
`Missile intercepted ${this.target.type()}`,
|
||||
MessageType.SUCCESS,
|
||||
this._owner.id(),
|
||||
);
|
||||
this.active = false;
|
||||
this.target.delete();
|
||||
this.SAMMissile.delete(false);
|
||||
return;
|
||||
} else {
|
||||
this.SAMMissile.move(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { consolex } from "../Consolex";
|
||||
import { Execution, Game, Player, Unit, UnitType } from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { PathFindResultType } from "../pathfinding/AStar";
|
||||
import { PathFinder } from "../pathfinding/PathFinding";
|
||||
import { AirPathFinder } from "../pathfinding/PathFinding";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
|
||||
export class ShellExecution implements Execution {
|
||||
private active = true;
|
||||
private pathFinder: PathFinder | null = null;
|
||||
private shell: Unit | null = null;
|
||||
private mg: Game | null = null;
|
||||
private pathFinder: AirPathFinder;
|
||||
private shell: Unit;
|
||||
private mg: Game;
|
||||
private destroyAtTick: number = -1;
|
||||
|
||||
constructor(
|
||||
@@ -19,7 +18,7 @@ export class ShellExecution implements Execution {
|
||||
) {}
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
this.pathFinder = PathFinder.Mini(mg, 2000, true, 10);
|
||||
this.pathFinder = new AirPathFinder(mg, new PseudoRandom(mg.ticks()));
|
||||
this.mg = mg;
|
||||
}
|
||||
|
||||
@@ -44,37 +43,31 @@ export class ShellExecution implements Execution {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.destroyAtTick === -1 && !this.ownerUnit.isActive()) {
|
||||
this.destroyAtTick =
|
||||
this.mg.ticks() + this.mg.config().warshipShellLifetime();
|
||||
if (this.destroyAtTick == -1 && !this.ownerUnit.isActive()) {
|
||||
this.destroyAtTick = this.mg.ticks() + this.mg.config().shellLifetime();
|
||||
}
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const result = this.pathFinder.nextTile(
|
||||
this.shell.tile(),
|
||||
this.target.tile(),
|
||||
3,
|
||||
);
|
||||
switch (result.type) {
|
||||
case PathFindResultType.Completed:
|
||||
this.active = false;
|
||||
this.target.modifyHealth(-(this.shell.info().damage ?? 0));
|
||||
this.shell.delete(false);
|
||||
return;
|
||||
case PathFindResultType.NextTile:
|
||||
this.shell.move(result.tile);
|
||||
break;
|
||||
case PathFindResultType.Pending:
|
||||
return;
|
||||
case PathFindResultType.PathNotFound:
|
||||
consolex.log(`Shell ${this.shell} could not find target`);
|
||||
this.active = false;
|
||||
this.shell.delete(false);
|
||||
return;
|
||||
if (result === true) {
|
||||
this.active = false;
|
||||
this.target.modifyHealth(-this.effectOnTarget());
|
||||
this.shell.delete(false);
|
||||
return;
|
||||
} else {
|
||||
this.shell.move(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private effectOnTarget(): number {
|
||||
const baseDamage: number = this.mg.config().unitInfo(UnitType.Shell).damage;
|
||||
return baseDamage;
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ export class TradeShipExecution implements Execution {
|
||||
}
|
||||
this.tradeShip = this.origOwner.buildUnit(UnitType.TradeShip, 0, spawn, {
|
||||
dstPort: this._dstPort,
|
||||
lastSetSafeFromPirates: ticks,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -58,12 +59,12 @@ export class TradeShipExecution implements Execution {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.origOwner !== this.tradeShip.owner()) {
|
||||
// Store as vairable in case ship is recaptured by previous owner
|
||||
if (this.origOwner != this.tradeShip.owner()) {
|
||||
// Store as variable in case ship is recaptured by previous owner
|
||||
this.wasCaptured = true;
|
||||
}
|
||||
|
||||
// If a player captures an other player's port while trading we should delete
|
||||
// If a player captures another player's port while trading we should delete
|
||||
// the ship.
|
||||
if (this._dstPort.owner().id() === this.srcPort.owner().id()) {
|
||||
this.tradeShip.delete(false);
|
||||
@@ -110,6 +111,10 @@ export class TradeShipExecution implements Execution {
|
||||
this.tradeShip.move(this.tradeShip.tile());
|
||||
break;
|
||||
case PathFindResultType.NextTile:
|
||||
// Update safeFromPirates status
|
||||
if (this.mg.isWater(result.tile) && this.mg.isShoreline(result.tile)) {
|
||||
this.tradeShip.setSafeFromPirates();
|
||||
}
|
||||
this.tradeShip.move(result.tile);
|
||||
break;
|
||||
case PathFindResultType.PathNotFound:
|
||||
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
UnitType,
|
||||
} from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { targetTransportTile } from "../game/TransportShipUtils";
|
||||
import { PathFindResultType } from "../pathfinding/AStar";
|
||||
import { PathFinder } from "../pathfinding/PathFinding";
|
||||
import { targetTransportTile } from "../Util";
|
||||
import { AttackExecution } from "./AttackExecution";
|
||||
|
||||
export class TransportShipExecution implements Execution {
|
||||
@@ -29,7 +29,6 @@ export class TransportShipExecution implements Execution {
|
||||
|
||||
// TODO make private
|
||||
public path: TileRef[];
|
||||
private src: TileRef | null;
|
||||
private dst: TileRef | null;
|
||||
|
||||
private boat: Unit;
|
||||
@@ -40,7 +39,8 @@ export class TransportShipExecution implements Execution {
|
||||
private attackerID: PlayerID,
|
||||
private targetID: PlayerID | null,
|
||||
private ref: TileRef,
|
||||
private troops: number,
|
||||
private troops: number | null,
|
||||
private src: TileRef | null,
|
||||
) {}
|
||||
|
||||
activeDuringSpawnPhase(): boolean {
|
||||
@@ -63,7 +63,7 @@ export class TransportShipExecution implements Execution {
|
||||
|
||||
this.lastMove = ticks;
|
||||
this.mg = mg;
|
||||
this.pathFinder = PathFinder.Mini(mg, 10_000, false, 10);
|
||||
this.pathFinder = PathFinder.Mini(mg, 10_000, 10);
|
||||
|
||||
this.attacker = mg.player(this.attackerID);
|
||||
|
||||
@@ -115,14 +115,32 @@ export class TransportShipExecution implements Execution {
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
const src = this.attacker.canBuild(UnitType.TransportShip, this.dst);
|
||||
if (src === false) {
|
||||
|
||||
const closestTileSrc = this.attacker.canBuild(
|
||||
UnitType.TransportShip,
|
||||
this.dst,
|
||||
);
|
||||
if (closestTileSrc == false) {
|
||||
consolex.warn(`can't build transport ship`);
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.src = src;
|
||||
if (this.src == null) {
|
||||
// Only update the src if it's not already set
|
||||
// because we assume that the src is set to the best spawn tile
|
||||
this.src = closestTileSrc;
|
||||
} else {
|
||||
if (
|
||||
this.mg.owner(this.src) != this.attacker ||
|
||||
!this.mg.isShore(this.src)
|
||||
) {
|
||||
console.warn(
|
||||
`src is not a shore tile or not owned by: ${this.attacker.name()}`,
|
||||
);
|
||||
this.src = closestTileSrc;
|
||||
}
|
||||
}
|
||||
|
||||
this.boat = this.attacker.buildUnit(
|
||||
UnitType.TransportShip,
|
||||
|
||||
@@ -26,12 +26,7 @@ export class WarshipExecution implements Execution {
|
||||
|
||||
private patrolTile: TileRef | null = null;
|
||||
|
||||
// TODO: put in config
|
||||
private searchRange = 100;
|
||||
|
||||
private shellAttackRate = 5;
|
||||
private lastShellAttack = 0;
|
||||
|
||||
private alreadySentShell = new Set<Unit>();
|
||||
|
||||
constructor(
|
||||
@@ -45,7 +40,7 @@ export class WarshipExecution implements Execution {
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
this.pathfinder = PathFinder.Mini(mg, 5000, false);
|
||||
this.pathfinder = PathFinder.Mini(mg, 5000);
|
||||
this._owner = mg.player(this.playerID);
|
||||
this.mg = mg;
|
||||
this.patrolTile = this.patrolCenterTile;
|
||||
@@ -75,10 +70,8 @@ export class WarshipExecution implements Execution {
|
||||
}
|
||||
|
||||
private shoot() {
|
||||
if (this.mg === null) throw new Error("Game not initialized");
|
||||
if (this.warship === null) throw new Error("Warship not initialized");
|
||||
if (this.target === null) throw new Error("Target not initialized");
|
||||
if (this.mg.ticks() - this.lastShellAttack > this.shellAttackRate) {
|
||||
const shellAttackRate = this.mg.config().warshipShellAttackRate();
|
||||
if (this.mg.ticks() - this.lastShellAttack > shellAttackRate) {
|
||||
this.lastShellAttack = this.mg.ticks();
|
||||
this.mg.addExecution(
|
||||
new ShellExecution(
|
||||
@@ -151,8 +144,8 @@ export class WarshipExecution implements Execution {
|
||||
if (warship === null) throw new Error("Warship not initialized");
|
||||
const ships = this.mg
|
||||
.nearbyUnits(
|
||||
warship.tile(),
|
||||
130, // Search range
|
||||
this.warship.tile(),
|
||||
this.mg.config().warshipTargettingRange(),
|
||||
[UnitType.TransportShip, UnitType.Warship, UnitType.TradeShip],
|
||||
)
|
||||
.filter(
|
||||
@@ -161,9 +154,11 @@ export class WarshipExecution implements Execution {
|
||||
unit !== warship &&
|
||||
!unit.owner().isFriendly(warship.owner()) &&
|
||||
!this.alreadySentShell.has(unit) &&
|
||||
(unit.type() !== UnitType.TradeShip || hasPort) &&
|
||||
(unit.type() !== UnitType.TradeShip ||
|
||||
unit.dstPort()?.owner() !== this._owner),
|
||||
(hasPort &&
|
||||
unit.dstPort()?.owner() !== this.warship.owner() &&
|
||||
!unit.dstPort()?.owner().isFriendly(this.warship.owner()) &&
|
||||
unit.isSafeFromPirates() !== true)),
|
||||
);
|
||||
|
||||
this.target =
|
||||
@@ -214,9 +209,10 @@ export class WarshipExecution implements Execution {
|
||||
if (
|
||||
this.target === null ||
|
||||
!this.target.isActive() ||
|
||||
this.target.owner() === this._owner
|
||||
this.target.owner() == this._owner ||
|
||||
this.target.isSafeFromPirates() == true
|
||||
) {
|
||||
// In case another destroyer captured or destroyed target
|
||||
// In case another warship captured or destroyed target, or the target escaped into safe waters
|
||||
this.target = null;
|
||||
return;
|
||||
}
|
||||
@@ -266,19 +262,29 @@ export class WarshipExecution implements Execution {
|
||||
}
|
||||
|
||||
randomTile(): TileRef {
|
||||
if (this.mg === null) throw new Error("Game not initialized");
|
||||
while (true) {
|
||||
let warshipPatrolRange = this.mg.config().warshipPatrolRange();
|
||||
const maxAttemptBeforeExpand: number = warshipPatrolRange * 2;
|
||||
let attemptCount: number = 0;
|
||||
let expandCount: number = 0;
|
||||
while (expandCount < 3) {
|
||||
const x =
|
||||
this.mg.x(this.patrolCenterTile) +
|
||||
this.random.nextInt(-this.searchRange / 2, this.searchRange / 2);
|
||||
this.random.nextInt(-warshipPatrolRange / 2, warshipPatrolRange / 2);
|
||||
const y =
|
||||
this.mg.y(this.patrolCenterTile) +
|
||||
this.random.nextInt(-this.searchRange / 2, this.searchRange / 2);
|
||||
this.random.nextInt(-warshipPatrolRange / 2, warshipPatrolRange / 2);
|
||||
if (!this.mg.isValidCoord(x, y)) {
|
||||
continue;
|
||||
}
|
||||
const tile = this.mg.ref(x, y);
|
||||
if (!this.mg.isOcean(tile)) {
|
||||
if (!this.mg.isOcean(tile) || this.mg.isShoreline(tile)) {
|
||||
attemptCount++;
|
||||
if (attemptCount === maxAttemptBeforeExpand) {
|
||||
expandCount++;
|
||||
attemptCount = 0;
|
||||
warshipPatrolRange =
|
||||
warshipPatrolRange + Math.floor(warshipPatrolRange / 2);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
return tile;
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { GameEvent } from "../EventBus";
|
||||
import { Execution, Game, GameMode, Player, Team } from "../game/Game";
|
||||
import {
|
||||
ColoredTeams,
|
||||
Execution,
|
||||
Game,
|
||||
GameMode,
|
||||
Player,
|
||||
Team,
|
||||
} from "../game/Game";
|
||||
|
||||
export class WinEvent implements GameEvent {
|
||||
constructor(public readonly winner: Player) {}
|
||||
@@ -72,7 +79,7 @@ export class WinCheckExecution implements Execution {
|
||||
this.mg.numLandTiles() - this.mg.numTilesWithFallout();
|
||||
const percentage = (max[1] / numTilesWithoutFallout) * 100;
|
||||
if (percentage > this.mg.config().percentageTilesOwnedToWin()) {
|
||||
if (max[0] === Team.Bot) return;
|
||||
if (max[0] == ColoredTeams.Bot) return;
|
||||
this.mg.setWinner(max[0], this.mg.stats().stats());
|
||||
console.log(`${max[0]} has won the game`);
|
||||
this.active = false;
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import {
|
||||
AllianceRequest,
|
||||
Game,
|
||||
Player,
|
||||
PlayerType,
|
||||
Relation,
|
||||
TerraNullius,
|
||||
Tick,
|
||||
} from "../../game/Game";
|
||||
import { PseudoRandom } from "../../PseudoRandom";
|
||||
import { flattenedEmojiTable } from "../../Util";
|
||||
import { AttackExecution } from "../AttackExecution";
|
||||
import { EmojiExecution } from "../EmojiExecution";
|
||||
|
||||
export class BotBehavior {
|
||||
private enemy: Player | null = null;
|
||||
private enemyUpdated: Tick;
|
||||
|
||||
private assistAcceptEmoji = flattenedEmojiTable.indexOf("👍");
|
||||
|
||||
private firstAttackSent = false;
|
||||
|
||||
constructor(
|
||||
private random: PseudoRandom,
|
||||
private game: Game,
|
||||
private player: Player,
|
||||
private triggerRatio: number,
|
||||
private reserveRatio: number,
|
||||
) {}
|
||||
|
||||
handleAllianceRequests() {
|
||||
for (const req of this.player.incomingAllianceRequests()) {
|
||||
if (shouldAcceptAllianceRequest(this.player, req)) {
|
||||
req.accept();
|
||||
} else {
|
||||
req.reject();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private emoji(player: Player, emoji: number) {
|
||||
if (player.type() !== PlayerType.Human) return;
|
||||
this.game.addExecution(
|
||||
new EmojiExecution(this.player.id(), player.id(), emoji),
|
||||
);
|
||||
}
|
||||
|
||||
forgetOldEnemies() {
|
||||
// Forget old enemies
|
||||
if (this.game.ticks() - this.enemyUpdated > 100) {
|
||||
this.enemy = null;
|
||||
}
|
||||
}
|
||||
|
||||
checkIncomingAttacks() {
|
||||
// Switch enemies if we're under attack
|
||||
const incomingAttacks = this.player.incomingAttacks();
|
||||
if (incomingAttacks.length > 0) {
|
||||
this.enemy = incomingAttacks
|
||||
.sort((a, b) => b.troops() - a.troops())[0]
|
||||
.attacker();
|
||||
this.enemyUpdated = this.game.ticks();
|
||||
}
|
||||
}
|
||||
|
||||
assistAllies() {
|
||||
outer: for (const ally of this.player.allies()) {
|
||||
if (ally.targets().length === 0) continue;
|
||||
if (this.player.relation(ally) < Relation.Friendly) {
|
||||
// this.emoji(ally, "🤦");
|
||||
continue;
|
||||
}
|
||||
for (const target of ally.targets()) {
|
||||
if (target === this.player) {
|
||||
// this.emoji(ally, "💀");
|
||||
continue;
|
||||
}
|
||||
if (this.player.isAlliedWith(target)) {
|
||||
// this.emoji(ally, "👎");
|
||||
continue;
|
||||
}
|
||||
// All checks passed, assist them
|
||||
this.player.updateRelation(ally, -20);
|
||||
this.enemy = target;
|
||||
this.enemyUpdated = this.game.ticks();
|
||||
this.emoji(ally, this.assistAcceptEmoji);
|
||||
break outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selectEnemy(): Player | null {
|
||||
if (this.enemy === null) {
|
||||
// Save up troops until we reach the trigger ratio
|
||||
const maxPop = this.game.config().maxPopulation(this.player);
|
||||
const ratio = this.player.population() / maxPop;
|
||||
if (ratio < this.triggerRatio) return null;
|
||||
}
|
||||
|
||||
// Prefer neighboring bots
|
||||
if (this.enemy === null) {
|
||||
const bots = this.player
|
||||
.neighbors()
|
||||
.filter((n) => n.isPlayer() && n.type() === PlayerType.Bot) as Player[];
|
||||
if (bots.length > 0) {
|
||||
const density = (p: Player) => p.troops() / p.numTilesOwned();
|
||||
this.enemy = bots.sort((a, b) => density(a) - density(b))[0];
|
||||
this.enemyUpdated = this.game.ticks();
|
||||
}
|
||||
}
|
||||
|
||||
// Select the most hated player
|
||||
if (this.enemy === null) {
|
||||
const mostHated = this.player.allRelationsSorted()[0] ?? null;
|
||||
if (mostHated != null && mostHated.relation === Relation.Hostile) {
|
||||
this.enemy = mostHated.player;
|
||||
this.enemyUpdated = this.game.ticks();
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity check, don't attack our allies or teammates
|
||||
if (this.enemy && this.player.isFriendly(this.enemy)) {
|
||||
this.enemy = null;
|
||||
}
|
||||
return this.enemy;
|
||||
}
|
||||
|
||||
selectRandomEnemy(): Player | TerraNullius | null {
|
||||
if (this.enemy === null) {
|
||||
// Save up troops until we reach the trigger ratio
|
||||
const maxPop = this.game.config().maxPopulation(this.player);
|
||||
const ratio = this.player.population() / maxPop;
|
||||
if (ratio < this.triggerRatio) return null;
|
||||
|
||||
// Choose a new enemy randomly
|
||||
const neighbors = this.player.neighbors();
|
||||
for (const neighbor of this.random.shuffleArray(neighbors)) {
|
||||
if (!neighbor.isPlayer()) continue;
|
||||
if (this.player.isFriendly(neighbor)) continue;
|
||||
if (neighbor.type() == PlayerType.FakeHuman) {
|
||||
if (this.random.chance(2)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
this.enemy = neighbor;
|
||||
this.enemyUpdated = this.game.ticks();
|
||||
}
|
||||
|
||||
// Select a traitor as an enemy
|
||||
const traitors = this.player
|
||||
.neighbors()
|
||||
.filter((n) => n.isPlayer() && n.isTraitor()) as Player[];
|
||||
if (traitors.length > 0) {
|
||||
const toAttack = this.random.randElement(traitors);
|
||||
const odds = this.player.isFriendly(toAttack) ? 6 : 3;
|
||||
if (this.random.chance(odds)) {
|
||||
this.enemy = toAttack;
|
||||
this.enemyUpdated = this.game.ticks();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity check, don't attack our allies or teammates
|
||||
if (this.enemy && this.player.isFriendly(this.enemy)) {
|
||||
this.enemy = null;
|
||||
}
|
||||
return this.enemy;
|
||||
}
|
||||
|
||||
sendAttack(target: Player | TerraNullius) {
|
||||
if (target.isPlayer() && this.player.isOnSameTeam(target)) return;
|
||||
const maxPop = this.game.config().maxPopulation(this.player);
|
||||
const maxTroops = maxPop * this.player.targetTroopRatio();
|
||||
const targetTroops = maxTroops * this.reserveRatio;
|
||||
// Don't wait until it has sufficient reserves to send the first attack
|
||||
// to prevent the bot from waiting too long at the start of the game.
|
||||
const troops = this.firstAttackSent
|
||||
? this.player.troops() - targetTroops
|
||||
: this.player.troops() / 5;
|
||||
if (troops < 1) return;
|
||||
this.firstAttackSent = true;
|
||||
this.game.addExecution(
|
||||
new AttackExecution(
|
||||
troops,
|
||||
this.player.id(),
|
||||
target.isPlayer() ? target.id() : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function shouldAcceptAllianceRequest(player: Player, request: AllianceRequest) {
|
||||
const notTraitor = !request.requestor().isTraitor();
|
||||
const noMalice = player.relation(request.requestor()) >= Relation.Neutral;
|
||||
const requestorIsMuchLarger =
|
||||
request.requestor().numTilesOwned() > player.numTilesOwned() * 3;
|
||||
const notTooManyAlliances =
|
||||
requestorIsMuchLarger || request.requestor().alliances().length < 3;
|
||||
return notTraitor && noMalice && notTooManyAlliances;
|
||||
}
|
||||
+59
-13
@@ -37,15 +37,25 @@ export enum Difficulty {
|
||||
Impossible = "Impossible",
|
||||
}
|
||||
|
||||
export enum Team {
|
||||
Red = "Red",
|
||||
Blue = "Blue",
|
||||
Bot = "Bot",
|
||||
}
|
||||
export type Team = string;
|
||||
|
||||
export const Duos = "Duos" as const;
|
||||
|
||||
export const ColoredTeams: Record<string, Team> = {
|
||||
Red: "Red",
|
||||
Blue: "Blue",
|
||||
Teal: "Teal",
|
||||
Purple: "Purple",
|
||||
Yellow: "Yellow",
|
||||
Orange: "Orange",
|
||||
Green: "Green",
|
||||
Bot: "Bot",
|
||||
} as const;
|
||||
|
||||
export enum GameMapType {
|
||||
World = "World",
|
||||
Europe = "Europe",
|
||||
EuropeClassic = "Europe Classic",
|
||||
Mena = "Mena",
|
||||
NorthAmerica = "North America",
|
||||
SouthAmerica = "South America",
|
||||
@@ -62,8 +72,40 @@ export enum GameMapType {
|
||||
Japan = "Japan",
|
||||
BetweenTwoSeas = "Between Two Seas",
|
||||
KnownWorld = "Known World",
|
||||
FaroeIslands = "FaroeIslands",
|
||||
DeglaciatedAntarctica = "Deglaciated Antarctica",
|
||||
}
|
||||
|
||||
export const mapCategories: Record<string, GameMapType[]> = {
|
||||
continental: [
|
||||
GameMapType.World,
|
||||
GameMapType.NorthAmerica,
|
||||
GameMapType.SouthAmerica,
|
||||
GameMapType.Europe,
|
||||
GameMapType.EuropeClassic,
|
||||
GameMapType.Asia,
|
||||
GameMapType.Africa,
|
||||
GameMapType.Oceania,
|
||||
],
|
||||
regional: [
|
||||
GameMapType.BlackSea,
|
||||
GameMapType.Britannia,
|
||||
GameMapType.GatewayToTheAtlantic,
|
||||
GameMapType.BetweenTwoSeas,
|
||||
GameMapType.Iceland,
|
||||
GameMapType.Japan,
|
||||
GameMapType.Mena,
|
||||
GameMapType.Australia,
|
||||
GameMapType.FaroeIslands,
|
||||
],
|
||||
fantasy: [
|
||||
GameMapType.Pangaea,
|
||||
GameMapType.Mars,
|
||||
GameMapType.KnownWorld,
|
||||
GameMapType.DeglaciatedAntarctica,
|
||||
],
|
||||
};
|
||||
|
||||
export enum GameType {
|
||||
Singleplayer = "Singleplayer",
|
||||
Public = "Public",
|
||||
@@ -120,10 +162,9 @@ export enum Relation {
|
||||
|
||||
export class Nation {
|
||||
constructor(
|
||||
public readonly flag: string,
|
||||
public readonly name: string,
|
||||
public readonly cell: Cell,
|
||||
public readonly spawnCell: Cell,
|
||||
public readonly strength: number,
|
||||
public readonly playerInfo: PlayerInfo,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -234,6 +275,7 @@ export class PlayerInfo {
|
||||
// Some units have info specific to them
|
||||
export interface UnitSpecificInfos {
|
||||
dstPort?: Unit; // Only for trade ships
|
||||
lastSetSafeFromPirates?: number; // Only for trade ships
|
||||
detonationDst?: TileRef; // Only for nukes
|
||||
warshipTarget?: Unit;
|
||||
cooldownDuration?: number;
|
||||
@@ -266,8 +308,10 @@ export interface Unit {
|
||||
ticksLeftInCooldown(cooldownDuration: number): Tick;
|
||||
isCooldown(): boolean;
|
||||
setDstPort(dstPort: Unit): void;
|
||||
dstPort(): Unit | null; // Only for trade ships
|
||||
detonationDst(): TileRef | null; // Only for nukes
|
||||
dstPort(): Unit; // Only for trade ships
|
||||
setSafeFromPirates(): void; // Only for trade ships
|
||||
isSafeFromPirates(): boolean; // Only for trade ships
|
||||
detonationDst(): TileRef; // Only for nukes
|
||||
|
||||
setMoveTarget(cell: TileRef | null): void;
|
||||
moveTarget(): TileRef | null;
|
||||
@@ -309,6 +353,7 @@ export interface Player {
|
||||
// State & Properties
|
||||
isAlive(): boolean;
|
||||
isTraitor(): boolean;
|
||||
markTraitor(): void;
|
||||
largestClusterBoundingBox: { min: Cell; max: Cell } | null;
|
||||
lastTileChange(): Tick;
|
||||
|
||||
@@ -340,6 +385,7 @@ export interface Player {
|
||||
// Units
|
||||
units(...types: UnitType[]): Unit[];
|
||||
unitsIncludingConstruction(type: UnitType): Unit[];
|
||||
buildableUnits(tile: TileRef): BuildableUnit[];
|
||||
canBuild(type: UnitType, targetTile: TileRef): TileRef | false;
|
||||
buildUnit(
|
||||
type: UnitType,
|
||||
@@ -409,8 +455,9 @@ export interface Player {
|
||||
// Misc
|
||||
toUpdate(): PlayerUpdate;
|
||||
playerProfile(): PlayerProfile;
|
||||
canBoat(tile: TileRef): boolean;
|
||||
tradingPorts(port: Unit): Unit[];
|
||||
// WARNING: this operation is expensive.
|
||||
bestTransportShipSpawn(tile: TileRef): TileRef | false;
|
||||
}
|
||||
|
||||
export interface Game extends GameMap {
|
||||
@@ -467,7 +514,6 @@ export interface Game extends GameMap {
|
||||
}
|
||||
|
||||
export interface PlayerActions {
|
||||
canBoat: boolean;
|
||||
canAttack: boolean;
|
||||
buildableUnits: BuildableUnit[];
|
||||
canSendEmojiAllPlayers: boolean;
|
||||
@@ -475,7 +521,7 @@ export interface PlayerActions {
|
||||
}
|
||||
|
||||
export interface BuildableUnit {
|
||||
canBuild: boolean;
|
||||
canBuild: TileRef | false;
|
||||
type: UnitType;
|
||||
cost: number;
|
||||
}
|
||||
|
||||
+45
-23
@@ -8,6 +8,8 @@ import {
|
||||
Alliance,
|
||||
AllianceRequest,
|
||||
Cell,
|
||||
ColoredTeams,
|
||||
Duos,
|
||||
EmojiMessage,
|
||||
Execution,
|
||||
Game,
|
||||
@@ -32,19 +34,18 @@ import { PlayerImpl } from "./PlayerImpl";
|
||||
import { Stats } from "./Stats";
|
||||
import { StatsImpl } from "./StatsImpl";
|
||||
import { assignTeams } from "./TeamAssignment";
|
||||
import { NationMap } from "./TerrainMapLoader";
|
||||
import { TerraNulliusImpl } from "./TerraNulliusImpl";
|
||||
import { UnitGrid } from "./UnitGrid";
|
||||
import { UnitImpl } from "./UnitImpl";
|
||||
|
||||
export function createGame(
|
||||
humans: PlayerInfo[],
|
||||
nations: Nation[],
|
||||
gameMap: GameMap,
|
||||
miniGameMap: GameMap,
|
||||
nationMap: NationMap,
|
||||
config: Config,
|
||||
): Game {
|
||||
return new GameImpl(humans, gameMap, miniGameMap, nationMap, config);
|
||||
return new GameImpl(humans, nations, gameMap, miniGameMap, config);
|
||||
}
|
||||
|
||||
export type CellString = string;
|
||||
@@ -54,8 +55,6 @@ export class GameImpl implements Game {
|
||||
|
||||
private unInitExecs: Execution[] = [];
|
||||
|
||||
private nations_: Nation[] = [];
|
||||
|
||||
_players: Map<PlayerID, PlayerImpl> = new Map<PlayerID, PlayerImpl>();
|
||||
_playersBySmallID: Player[] = [];
|
||||
|
||||
@@ -75,38 +74,61 @@ export class GameImpl implements Game {
|
||||
|
||||
private _stats: StatsImpl = new StatsImpl();
|
||||
|
||||
private playerTeams: Team[] = [Team.Red, Team.Blue];
|
||||
private botTeam: Team = Team.Bot;
|
||||
private playerTeams: Team[] = [ColoredTeams.Red, ColoredTeams.Blue];
|
||||
private botTeam: Team = ColoredTeams.Bot;
|
||||
|
||||
constructor(
|
||||
private _humans: PlayerInfo[],
|
||||
private _nations: Nation[],
|
||||
private _map: GameMap,
|
||||
private miniGameMap: GameMap,
|
||||
nationMap: NationMap,
|
||||
private _config: Config,
|
||||
) {
|
||||
this.addHumans();
|
||||
this._terraNullius = new TerraNulliusImpl();
|
||||
this._width = _map.width();
|
||||
this._height = _map.height();
|
||||
this.nations_ = nationMap.nations.map(
|
||||
(n) =>
|
||||
new Nation(
|
||||
n.flag || "",
|
||||
n.name,
|
||||
new Cell(n.coordinates[0], n.coordinates[1]),
|
||||
n.strength,
|
||||
),
|
||||
);
|
||||
this.unitGrid = new UnitGrid(this._map);
|
||||
|
||||
if (_config.gameConfig().gameMode === GameMode.Team) {
|
||||
this.populateTeams();
|
||||
}
|
||||
this.addPlayers();
|
||||
}
|
||||
|
||||
private addHumans() {
|
||||
if (this.config().gameConfig().gameMode !== GameMode.Team) {
|
||||
private populateTeams() {
|
||||
if (this._config.playerTeams() === Duos) {
|
||||
this.playerTeams = [];
|
||||
const numTeams = Math.ceil(
|
||||
(this._humans.length + this._nations.length) / 2,
|
||||
);
|
||||
for (let i = 0; i < numTeams; i++) {
|
||||
this.playerTeams.push("Team " + (i + 1));
|
||||
}
|
||||
} else {
|
||||
const numPlayerTeams = this._config.playerTeams() as number;
|
||||
if (numPlayerTeams < 2)
|
||||
throw new Error(`Too few teams: ${numPlayerTeams}`);
|
||||
if (numPlayerTeams >= 3) this.playerTeams.push(ColoredTeams.Yellow);
|
||||
if (numPlayerTeams >= 4) this.playerTeams.push(ColoredTeams.Green);
|
||||
if (numPlayerTeams >= 5) this.playerTeams.push(ColoredTeams.Purple);
|
||||
if (numPlayerTeams >= 6) this.playerTeams.push(ColoredTeams.Orange);
|
||||
if (numPlayerTeams >= 7) this.playerTeams.push(ColoredTeams.Teal);
|
||||
if (numPlayerTeams >= 8)
|
||||
throw new Error(`Too many teams: ${numPlayerTeams}`);
|
||||
}
|
||||
}
|
||||
|
||||
private addPlayers() {
|
||||
if (this.config().gameConfig().gameMode != GameMode.Team) {
|
||||
this._humans.forEach((p) => this.addPlayer(p));
|
||||
this._nations.forEach((n) => this.addPlayer(n.playerInfo));
|
||||
return;
|
||||
}
|
||||
const playerToTeam = assignTeams(this._humans);
|
||||
const allPlayers = [
|
||||
...this._humans,
|
||||
...this._nations.map((n) => n.playerInfo),
|
||||
];
|
||||
const playerToTeam = assignTeams(allPlayers, this.playerTeams);
|
||||
for (const [playerInfo, team] of playerToTeam.entries()) {
|
||||
if (team === "kicked") {
|
||||
console.warn(`Player ${playerInfo.name} was kicked from team`);
|
||||
@@ -167,7 +189,7 @@ export class GameImpl implements Game {
|
||||
return this.config().unitInfo(type);
|
||||
}
|
||||
nations(): Nation[] {
|
||||
return this.nations_;
|
||||
return this._nations;
|
||||
}
|
||||
|
||||
createAllianceRequest(
|
||||
@@ -515,7 +537,7 @@ export class GameImpl implements Game {
|
||||
);
|
||||
}
|
||||
if (!other.isTraitor()) {
|
||||
(breaker as PlayerImpl).isTraitor_ = true;
|
||||
breaker.markTraitor();
|
||||
}
|
||||
|
||||
const breakerSet = new Set(breaker.alliances());
|
||||
|
||||
+31
-14
@@ -58,6 +58,11 @@ export class GameMapImpl implements GameMap {
|
||||
private readonly width_: number;
|
||||
private readonly height_: number;
|
||||
|
||||
// Lookup tables (LUTs) contain pre-computed values to avoid performing division at runtime
|
||||
private readonly refToX: number[];
|
||||
private readonly refToY: number[];
|
||||
private readonly yToRef: number[];
|
||||
|
||||
// Terrain bits (Uint8Array)
|
||||
private static readonly IS_LAND_BIT = 7;
|
||||
private static readonly SHORELINE_BIT = 6;
|
||||
@@ -87,6 +92,19 @@ export class GameMapImpl implements GameMap {
|
||||
this.height_ = height;
|
||||
this.terrain = terrainData;
|
||||
this.state = new Uint16Array(width * height);
|
||||
// Precompute the LUTs
|
||||
let ref = 0;
|
||||
this.refToX = new Array(width * height);
|
||||
this.refToY = new Array(width * height);
|
||||
this.yToRef = new Array(height);
|
||||
for (let y = 0; y < height; y++) {
|
||||
this.yToRef[y] = ref;
|
||||
for (let x = 0; x < width; x++) {
|
||||
this.refToX[ref] = x;
|
||||
this.refToY[ref] = y;
|
||||
ref++;
|
||||
}
|
||||
}
|
||||
}
|
||||
numTilesWithFallout(): number {
|
||||
return this._numTilesWithFallout;
|
||||
@@ -96,15 +114,15 @@ export class GameMapImpl implements GameMap {
|
||||
if (!this.isValidCoord(x, y)) {
|
||||
throw new Error(`Invalid coordinates: ${x},${y}`);
|
||||
}
|
||||
return y * this.width_ + x;
|
||||
return this.yToRef[y] + x;
|
||||
}
|
||||
|
||||
x(ref: TileRef): number {
|
||||
return ref % this.width_;
|
||||
return this.refToX[ref];
|
||||
}
|
||||
|
||||
y(ref: TileRef): number {
|
||||
return Math.floor(ref / this.width_);
|
||||
return this.refToY[ref];
|
||||
}
|
||||
|
||||
cell(ref: TileRef): Cell {
|
||||
@@ -242,24 +260,19 @@ export class GameMapImpl implements GameMap {
|
||||
neighbors(ref: TileRef): TileRef[] {
|
||||
const neighbors: TileRef[] = [];
|
||||
const w = this.width_;
|
||||
const x = this.refToX[ref];
|
||||
|
||||
if (ref >= w) neighbors.push(ref - w);
|
||||
if (ref < (this.height_ - 1) * w) neighbors.push(ref + w);
|
||||
if (ref % w !== 0) neighbors.push(ref - 1);
|
||||
if (ref % w !== w - 1) neighbors.push(ref + 1);
|
||||
|
||||
for (const n of neighbors) {
|
||||
this.ref(this.x(n), this.y(n));
|
||||
}
|
||||
if (x !== 0) neighbors.push(ref - 1);
|
||||
if (x !== w - 1) neighbors.push(ref + 1);
|
||||
|
||||
return neighbors;
|
||||
}
|
||||
|
||||
forEachTile(fn: (tile: TileRef) => void): void {
|
||||
for (let x = 0; x < this.width_; x++) {
|
||||
for (let y = 0; y < this.height_; y++) {
|
||||
fn(this.ref(x, y));
|
||||
}
|
||||
for (let ref: TileRef = 0; ref < this.width_ * this.height_; ref++) {
|
||||
fn(ref);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,7 +292,11 @@ export class GameMapImpl implements GameMap {
|
||||
): Set<TileRef> {
|
||||
const seen = new Set<TileRef>();
|
||||
const q: TileRef[] = [];
|
||||
q.push(tile);
|
||||
if (filter(this, tile)) {
|
||||
seen.add(tile);
|
||||
q.push(tile);
|
||||
}
|
||||
|
||||
while (q.length > 0) {
|
||||
const curr = q.pop();
|
||||
if (typeof curr === "undefined") continue;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Config } from "../configuration/Config";
|
||||
import { ClientID, GameID, PlayerStats } from "../Schemas";
|
||||
import { createRandomName } from "../Util";
|
||||
import { WorkerClient } from "../worker/WorkerClient";
|
||||
import {
|
||||
Cell,
|
||||
@@ -124,11 +125,22 @@ export class UnitView {
|
||||
}
|
||||
|
||||
export class PlayerView {
|
||||
public anonymousName: string;
|
||||
|
||||
constructor(
|
||||
private game: GameView,
|
||||
public data: PlayerUpdate,
|
||||
public nameData: NameViewData,
|
||||
) {}
|
||||
) {
|
||||
if (data.clientID == game.myClientID()) {
|
||||
this.anonymousName = this.data.name;
|
||||
} else {
|
||||
this.anonymousName = createRandomName(
|
||||
this.data.name,
|
||||
this.data.playerType,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async actions(tile: TileRef): Promise<PlayerActions> {
|
||||
return this.game.worker.playerInteraction(
|
||||
@@ -167,19 +179,24 @@ export class PlayerView {
|
||||
return this.data.flag;
|
||||
}
|
||||
name(): string {
|
||||
return this.data.name;
|
||||
return userSettings.anonymousNames() && this.anonymousName !== null
|
||||
? this.anonymousName
|
||||
: this.data.name;
|
||||
}
|
||||
displayName(): string {
|
||||
return this.data.displayName;
|
||||
return userSettings.anonymousNames() && this.anonymousName !== null
|
||||
? this.anonymousName
|
||||
: this.data.name;
|
||||
}
|
||||
clientID(): ClientID | null {
|
||||
|
||||
clientID(): ClientID {
|
||||
return this.data.clientID;
|
||||
}
|
||||
id(): PlayerID {
|
||||
return this.data.id;
|
||||
}
|
||||
team(): Team | undefined {
|
||||
return this.data.team;
|
||||
team(): Team | null {
|
||||
return this.data.team ?? null;
|
||||
}
|
||||
type(): PlayerType {
|
||||
return this.data.playerType;
|
||||
@@ -243,6 +260,10 @@ export class PlayerView {
|
||||
return this.game.worker.playerProfile(this.smallID());
|
||||
}
|
||||
|
||||
bestTransportShipSpawn(targetTile: TileRef): Promise<TileRef | false> {
|
||||
return this.game.worker.transportShipSpawn(this.id(), targetTile);
|
||||
}
|
||||
|
||||
transitiveTargets(): PlayerView[] {
|
||||
return [...this.targets(), ...this.allies().flatMap((p) => p.targets())];
|
||||
}
|
||||
|
||||
+99
-100
@@ -4,12 +4,10 @@ import { PseudoRandom } from "../PseudoRandom";
|
||||
import { ClientID } from "../Schemas";
|
||||
import {
|
||||
assertNever,
|
||||
closestShoreFromPlayer,
|
||||
distSortUnit,
|
||||
maxInt,
|
||||
minInt,
|
||||
simpleHash,
|
||||
targetTransportTile,
|
||||
toInt,
|
||||
within,
|
||||
} from "../Util";
|
||||
@@ -20,7 +18,9 @@ import {
|
||||
AllianceRequest,
|
||||
AllPlayers,
|
||||
Attack,
|
||||
BuildableUnit,
|
||||
Cell,
|
||||
ColoredTeams,
|
||||
EmojiMessage,
|
||||
Gold,
|
||||
MessageType,
|
||||
@@ -42,6 +42,10 @@ import { GameImpl } from "./GameImpl";
|
||||
import { andFN, manhattanDistFN, TileRef } from "./GameMap";
|
||||
import { AttackUpdate, GameUpdateType, PlayerUpdate } from "./GameUpdates";
|
||||
import { TerraNulliusImpl } from "./TerraNulliusImpl";
|
||||
import {
|
||||
bestShoreDeploymentSource,
|
||||
canBuildTransportShip,
|
||||
} from "./TransportShipUtils";
|
||||
import { UnitImpl } from "./UnitImpl";
|
||||
|
||||
interface Target {
|
||||
@@ -67,7 +71,7 @@ export class PlayerImpl implements Player {
|
||||
// 0 to 100
|
||||
private _targetTroopRatio: bigint;
|
||||
|
||||
isTraitor_ = false;
|
||||
markedTraitorTick = -1;
|
||||
|
||||
private embargoes: Set<PlayerID> = new Set();
|
||||
|
||||
@@ -243,7 +247,7 @@ export class PlayerImpl implements Player {
|
||||
const ns: Set<Player | TerraNullius> = new Set();
|
||||
for (const border of this.borderTiles()) {
|
||||
for (const neighbor of this.mg.map().neighbors(border)) {
|
||||
if (this.mg.map().isLake(neighbor)) {
|
||||
if (this.mg.map().isLand(neighbor)) {
|
||||
const owner = this.mg.map().ownerID(neighbor);
|
||||
if (owner != this.smallID()) {
|
||||
ns.add(
|
||||
@@ -374,7 +378,14 @@ export class PlayerImpl implements Player {
|
||||
}
|
||||
|
||||
isTraitor(): boolean {
|
||||
return this.isTraitor_;
|
||||
return (
|
||||
this.markedTraitorTick >= 0 &&
|
||||
this.mg.ticks() - this.markedTraitorTick <
|
||||
this.mg.config().traitorDuration()
|
||||
);
|
||||
}
|
||||
markTraitor(): void {
|
||||
this.markedTraitorTick = this.mg.ticks();
|
||||
}
|
||||
|
||||
createAllianceRequest(recipient: Player): AllianceRequest | null {
|
||||
@@ -455,12 +466,12 @@ export class PlayerImpl implements Player {
|
||||
this.mg.target(this, other);
|
||||
}
|
||||
|
||||
targets(): PlayerImpl[] {
|
||||
targets(): Player[] {
|
||||
return this.targets_
|
||||
.filter(
|
||||
(t) => this.mg.ticks() - t.tick < this.mg.config().targetDuration(),
|
||||
)
|
||||
.map((t) => t.target as PlayerImpl);
|
||||
.map((t) => t.target);
|
||||
}
|
||||
|
||||
transitiveTargets(): Player[] {
|
||||
@@ -538,7 +549,7 @@ export class PlayerImpl implements Player {
|
||||
this.id(),
|
||||
);
|
||||
this.mg.displayMessage(
|
||||
`Recieved ${renderTroops(troops)} troops from ${this.name()}`,
|
||||
`Received ${renderTroops(troops)} troops from ${this.name()}`,
|
||||
MessageType.SUCCESS,
|
||||
recipient.id(),
|
||||
);
|
||||
@@ -552,7 +563,7 @@ export class PlayerImpl implements Player {
|
||||
this.id(),
|
||||
);
|
||||
this.mg.displayMessage(
|
||||
`Recieved ${renderNumber(gold)} gold from ${this.name()}`,
|
||||
`Received ${renderNumber(gold)} gold from ${this.name()}`,
|
||||
MessageType.SUCCESS,
|
||||
recipient.id(),
|
||||
);
|
||||
@@ -593,6 +604,9 @@ export class PlayerImpl implements Player {
|
||||
if (this.team() == null || other.team() == null) {
|
||||
return false;
|
||||
}
|
||||
if (this.team() == ColoredTeams.Bot || other.team() == ColoredTeams.Bot) {
|
||||
return false;
|
||||
}
|
||||
return this._team == other.team();
|
||||
}
|
||||
|
||||
@@ -711,7 +725,24 @@ export class PlayerImpl implements Player {
|
||||
return b;
|
||||
}
|
||||
|
||||
canBuild(unitType: UnitType, targetTile: TileRef): TileRef | false {
|
||||
public buildableUnits(tile: TileRef): BuildableUnit[] {
|
||||
const validTiles = this.validStructureSpawnTiles(tile);
|
||||
return Object.values(UnitType).map((u) => {
|
||||
return {
|
||||
type: u,
|
||||
canBuild: this.mg.inSpawnPhase()
|
||||
? false
|
||||
: this.canBuild(u, tile, validTiles),
|
||||
cost: this.mg.config().unitInfo(u).cost(this),
|
||||
} as BuildableUnit;
|
||||
});
|
||||
}
|
||||
|
||||
canBuild(
|
||||
unitType: UnitType,
|
||||
targetTile: TileRef,
|
||||
validTiles: TileRef[] | null = null,
|
||||
): TileRef | false {
|
||||
// prevent the building of nukes and nuke related buildings
|
||||
if (this.mg.config().disableNukes()) {
|
||||
if (
|
||||
@@ -743,14 +774,14 @@ export class PlayerImpl implements Player {
|
||||
case UnitType.MIRVWarhead:
|
||||
return targetTile;
|
||||
case UnitType.Port:
|
||||
return this.portSpawn(targetTile);
|
||||
return this.portSpawn(targetTile, validTiles);
|
||||
case UnitType.Warship:
|
||||
return this.warshipSpawn(targetTile);
|
||||
case UnitType.Shell:
|
||||
case UnitType.SAMMissile:
|
||||
return targetTile;
|
||||
case UnitType.TransportShip:
|
||||
return this.transportShipSpawn(targetTile);
|
||||
return canBuildTransportShip(this.mg, this, targetTile);
|
||||
case UnitType.TradeShip:
|
||||
return this.tradeShipSpawn(targetTile);
|
||||
case UnitType.MissileSilo:
|
||||
@@ -758,7 +789,7 @@ export class PlayerImpl implements Player {
|
||||
case UnitType.SAMLauncher:
|
||||
case UnitType.City:
|
||||
case UnitType.Construction:
|
||||
return this.landBasedStructureSpawn(targetTile);
|
||||
return this.landBasedStructureSpawn(targetTile, validTiles);
|
||||
default:
|
||||
assertNever(unitType);
|
||||
}
|
||||
@@ -773,7 +804,6 @@ export class PlayerImpl implements Player {
|
||||
}
|
||||
// only get missilesilos that are not on cooldown
|
||||
const spawns = this.units(UnitType.MissileSilo)
|
||||
.map((u) => u as Unit)
|
||||
.filter((silo) => {
|
||||
return !silo.isCooldown();
|
||||
})
|
||||
@@ -784,7 +814,7 @@ export class PlayerImpl implements Player {
|
||||
return spawns[0].tile();
|
||||
}
|
||||
|
||||
portSpawn(tile: TileRef): TileRef | false {
|
||||
portSpawn(tile: TileRef, validTiles: TileRef[]): TileRef | false {
|
||||
const spawns = Array.from(
|
||||
this.mg.bfs(
|
||||
tile,
|
||||
@@ -796,10 +826,15 @@ export class PlayerImpl implements Player {
|
||||
(a, b) =>
|
||||
this.mg.manhattanDist(a, tile) - this.mg.manhattanDist(b, tile),
|
||||
);
|
||||
if (spawns.length == 0) {
|
||||
return false;
|
||||
const validTileSet = new Set(
|
||||
validTiles ?? this.validStructureSpawnTiles(tile),
|
||||
);
|
||||
for (const t of spawns) {
|
||||
if (validTileSet.has(t)) {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
return spawns[0];
|
||||
return false;
|
||||
}
|
||||
|
||||
warshipSpawn(tile: TileRef): TileRef | false {
|
||||
@@ -817,22 +852,54 @@ export class PlayerImpl implements Player {
|
||||
return spawns[0].tile();
|
||||
}
|
||||
|
||||
landBasedStructureSpawn(tile: TileRef): TileRef | false {
|
||||
if (this.mg.owner(tile) != this) {
|
||||
landBasedStructureSpawn(
|
||||
tile: TileRef,
|
||||
validTiles: TileRef[] | null = null,
|
||||
): TileRef | false {
|
||||
const tiles = validTiles ?? this.validStructureSpawnTiles(tile);
|
||||
if (tiles.length == 0) {
|
||||
return false;
|
||||
}
|
||||
return tile;
|
||||
return tiles[0];
|
||||
}
|
||||
|
||||
transportShipSpawn(targetTile: TileRef): TileRef | false {
|
||||
if (!this.mg.isShore(targetTile)) {
|
||||
return false;
|
||||
private validStructureSpawnTiles(tile: TileRef): TileRef[] {
|
||||
if (this.mg.owner(tile) != this) {
|
||||
return [];
|
||||
}
|
||||
const spawn = closestShoreFromPlayer(this.mg, this, targetTile);
|
||||
if (spawn == null) {
|
||||
return false;
|
||||
const searchRadius = 15;
|
||||
const searchRadiusSquared = searchRadius ** 2;
|
||||
const types = Object.values(UnitType).filter((unitTypeValue) => {
|
||||
return this.mg.config().unitInfo(unitTypeValue).territoryBound;
|
||||
});
|
||||
|
||||
const nearbyUnits = this.mg
|
||||
.nearbyUnits(tile, searchRadius * 2, types)
|
||||
.map((u) => u.unit);
|
||||
const nearbyTiles = this.mg.bfs(tile, (gm, t) => {
|
||||
return (
|
||||
this.mg.euclideanDistSquared(tile, t) < searchRadiusSquared &&
|
||||
gm.ownerID(t) == this.smallID()
|
||||
);
|
||||
});
|
||||
const validSet: Set<TileRef> = new Set(nearbyTiles);
|
||||
|
||||
const minDistSquared = this.mg.config().structureMinDist() ** 2;
|
||||
for (const t of nearbyTiles) {
|
||||
for (const unit of nearbyUnits) {
|
||||
if (this.mg.euclideanDistSquared(unit.tile(), t) < minDistSquared) {
|
||||
validSet.delete(t);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return spawn;
|
||||
const valid = Array.from(validSet);
|
||||
valid.sort(
|
||||
(a, b) =>
|
||||
this.mg.euclideanDistSquared(a, tile) -
|
||||
this.mg.euclideanDistSquared(b, tile),
|
||||
);
|
||||
return valid;
|
||||
}
|
||||
|
||||
tradeShipSpawn(targetTile: TileRef): TileRef | false {
|
||||
@@ -875,78 +942,6 @@ export class PlayerImpl implements Player {
|
||||
return rel;
|
||||
}
|
||||
|
||||
public canBoat(tile: TileRef): boolean {
|
||||
if (
|
||||
this.units(UnitType.TransportShip).length >=
|
||||
this.mg.config().boatMaxNumber()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const dst = targetTransportTile(this.mg, tile);
|
||||
if (dst == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const other = this.mg.owner(tile);
|
||||
if (other == this) {
|
||||
return false;
|
||||
}
|
||||
if (other.isPlayer() && this.isFriendly(other)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.mg.isOceanShore(dst)) {
|
||||
let myPlayerBordersOcean = false;
|
||||
for (const bt of this.borderTiles()) {
|
||||
if (this.mg.isOceanShore(bt)) {
|
||||
myPlayerBordersOcean = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let otherPlayerBordersOcean = false;
|
||||
if (!this.mg.hasOwner(tile)) {
|
||||
otherPlayerBordersOcean = true;
|
||||
} else {
|
||||
for (const bt of (other as Player).borderTiles()) {
|
||||
if (this.mg.isOceanShore(bt)) {
|
||||
otherPlayerBordersOcean = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (myPlayerBordersOcean && otherPlayerBordersOcean) {
|
||||
return this.canBuild(UnitType.TransportShip, dst) != false;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Now we are boating in a lake, so do a bfs from target until we find
|
||||
// a border tile owned by the player
|
||||
|
||||
const tiles = this.mg.bfs(
|
||||
dst,
|
||||
andFN(
|
||||
manhattanDistFN(dst, 300),
|
||||
(_, t: TileRef) => this.mg.isLake(t) || this.mg.isShore(t),
|
||||
),
|
||||
);
|
||||
|
||||
const sorted = Array.from(tiles).sort(
|
||||
(a, b) => this.mg.manhattanDist(dst, a) - this.mg.manhattanDist(dst, b),
|
||||
);
|
||||
|
||||
for (const t of sorted) {
|
||||
if (this.mg.owner(t) == this) {
|
||||
return this.canBuild(UnitType.TransportShip, dst) != false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
createAttack(
|
||||
target: Player | TerraNullius,
|
||||
troops: number,
|
||||
@@ -1015,6 +1010,10 @@ export class PlayerImpl implements Player {
|
||||
}
|
||||
}
|
||||
|
||||
bestTransportShipSpawn(targetTile: TileRef): TileRef | false {
|
||||
return bestShoreDeploymentSource(this.mg, this, targetTile);
|
||||
}
|
||||
|
||||
// It's a probability list, so if an element appears twice it's because it's
|
||||
// twice more likely to be picked later.
|
||||
tradingPorts(port: Unit): Unit[] {
|
||||
|
||||
@@ -2,10 +2,10 @@ import { PlayerInfo, Team } from "./Game";
|
||||
|
||||
export function assignTeams(
|
||||
players: PlayerInfo[],
|
||||
teams: Team[],
|
||||
): Map<PlayerInfo, Team | "kicked"> {
|
||||
const result = new Map<PlayerInfo, Team | "kicked">();
|
||||
let redTeamCount = 0;
|
||||
let blueTeamCount = 0;
|
||||
const teamPlayerCount = new Map<Team, number>();
|
||||
|
||||
// Group players by clan
|
||||
const clanGroups = new Map<string, PlayerInfo[]>();
|
||||
@@ -23,7 +23,7 @@ export function assignTeams(
|
||||
}
|
||||
}
|
||||
|
||||
const maxTeamSize = Math.ceil(players.length / 2);
|
||||
const maxTeamSize = Math.ceil(players.length / teams.length);
|
||||
|
||||
// Sort clans by size (largest first)
|
||||
const sortedClans = Array.from(clanGroups.entries()).sort(
|
||||
@@ -33,38 +33,38 @@ export function assignTeams(
|
||||
// First, assign clan players
|
||||
for (const [_, clanPlayers] of sortedClans) {
|
||||
// Try to keep the clan together on the team with fewer players
|
||||
if (redTeamCount <= blueTeamCount) {
|
||||
// Assign to red team
|
||||
for (const player of clanPlayers) {
|
||||
if (redTeamCount < maxTeamSize) {
|
||||
redTeamCount++;
|
||||
result.set(player, Team.Red);
|
||||
} else {
|
||||
result.set(player, "kicked");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Assign to blue team
|
||||
for (const player of clanPlayers) {
|
||||
if (blueTeamCount < maxTeamSize) {
|
||||
blueTeamCount++;
|
||||
result.set(player, Team.Blue);
|
||||
} else {
|
||||
result.set(player, "kicked");
|
||||
}
|
||||
let team: Team | null = null;
|
||||
let teamSize = 0;
|
||||
for (const t of teams) {
|
||||
const p = teamPlayerCount.get(t) ?? 0;
|
||||
if (team !== null && teamSize <= p) continue;
|
||||
teamSize = p;
|
||||
team = t;
|
||||
}
|
||||
|
||||
for (const player of clanPlayers) {
|
||||
if (teamSize < maxTeamSize) {
|
||||
teamSize++;
|
||||
result.set(player, team);
|
||||
} else {
|
||||
result.set(player, "kicked");
|
||||
}
|
||||
}
|
||||
teamPlayerCount.set(team, teamSize);
|
||||
}
|
||||
|
||||
// Then, assign non-clan players to balance teams
|
||||
for (const player of noClanPlayers) {
|
||||
if (redTeamCount <= blueTeamCount) {
|
||||
redTeamCount++;
|
||||
result.set(player, Team.Red);
|
||||
} else {
|
||||
blueTeamCount++;
|
||||
result.set(player, Team.Blue);
|
||||
let team: Team | null = null;
|
||||
let teamSize = 0;
|
||||
for (const t of teams) {
|
||||
const p = teamPlayerCount.get(t) ?? 0;
|
||||
if (team !== null && teamSize <= p) continue;
|
||||
teamSize = p;
|
||||
team = t;
|
||||
}
|
||||
teamPlayerCount.set(team, teamSize + 1);
|
||||
result.set(player, team);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -41,6 +41,9 @@ const MAP_FILE_NAMES: Record<GameMapType, string> = {
|
||||
[GameMapType.Japan]: "Japan",
|
||||
[GameMapType.BetweenTwoSeas]: "BetweenTwoSeas",
|
||||
[GameMapType.KnownWorld]: "KnownWorld",
|
||||
[GameMapType.FaroeIslands]: "FaroeIslands",
|
||||
[GameMapType.DeglaciatedAntarctica]: "DeglaciatedAntarctica",
|
||||
[GameMapType.EuropeClassic]: "EuropeClassic",
|
||||
};
|
||||
|
||||
class GameMapLoader {
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import { PathFindResultType } from "../pathfinding/AStar";
|
||||
import { MiniAStar } from "../pathfinding/MiniAStar";
|
||||
import { Game, Player, UnitType } from "./Game";
|
||||
import { andFN, GameMap, manhattanDistFN, TileRef } from "./GameMap";
|
||||
|
||||
export function canBuildTransportShip(
|
||||
game: Game,
|
||||
player: Player,
|
||||
tile: TileRef,
|
||||
): TileRef | false {
|
||||
if (
|
||||
player.units(UnitType.TransportShip).length >= game.config().boatMaxNumber()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const dst = targetTransportTile(game, tile);
|
||||
if (dst == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const other = game.owner(tile);
|
||||
if (other == player) {
|
||||
return false;
|
||||
}
|
||||
if (other.isPlayer() && player.isFriendly(other)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (game.isOceanShore(dst)) {
|
||||
let myPlayerBordersOcean = false;
|
||||
for (const bt of player.borderTiles()) {
|
||||
if (game.isOceanShore(bt)) {
|
||||
myPlayerBordersOcean = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let otherPlayerBordersOcean = false;
|
||||
if (!game.hasOwner(tile)) {
|
||||
otherPlayerBordersOcean = true;
|
||||
} else {
|
||||
for (const bt of (other as Player).borderTiles()) {
|
||||
if (game.isOceanShore(bt)) {
|
||||
otherPlayerBordersOcean = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (myPlayerBordersOcean && otherPlayerBordersOcean) {
|
||||
return transportShipSpawn(game, player, dst);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Now we are boating in a lake, so do a bfs from target until we find
|
||||
// a border tile owned by the player
|
||||
|
||||
const tiles = game.bfs(
|
||||
dst,
|
||||
andFN(
|
||||
manhattanDistFN(dst, 300),
|
||||
(_, t: TileRef) => game.isLake(t) || game.isShore(t),
|
||||
),
|
||||
);
|
||||
|
||||
const sorted = Array.from(tiles).sort(
|
||||
(a, b) => game.manhattanDist(dst, a) - game.manhattanDist(dst, b),
|
||||
);
|
||||
|
||||
for (const t of sorted) {
|
||||
if (game.owner(t) == player) {
|
||||
return transportShipSpawn(game, player, t);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function transportShipSpawn(
|
||||
game: Game,
|
||||
player: Player,
|
||||
targetTile: TileRef,
|
||||
): TileRef | false {
|
||||
if (!game.isShore(targetTile)) {
|
||||
return false;
|
||||
}
|
||||
const spawn = closestShoreFromPlayer(game, player, targetTile);
|
||||
if (spawn == null) {
|
||||
return false;
|
||||
}
|
||||
return spawn;
|
||||
}
|
||||
|
||||
export function sourceDstOceanShore(
|
||||
gm: Game,
|
||||
src: Player,
|
||||
tile: TileRef,
|
||||
): [TileRef | null, TileRef | null] {
|
||||
const dst = gm.owner(tile);
|
||||
const srcTile = closestShoreFromPlayer(gm, src, tile);
|
||||
let dstTile: TileRef | null = null;
|
||||
if (dst.isPlayer()) {
|
||||
dstTile = closestShoreFromPlayer(gm, dst as Player, tile);
|
||||
} else {
|
||||
dstTile = closestShoreTN(gm, tile, 50);
|
||||
}
|
||||
return [srcTile, dstTile];
|
||||
}
|
||||
|
||||
export function targetTransportTile(gm: Game, tile: TileRef): TileRef | null {
|
||||
const dst = gm.playerBySmallID(gm.ownerID(tile));
|
||||
let dstTile: TileRef | null = null;
|
||||
if (dst.isPlayer()) {
|
||||
dstTile = closestShoreFromPlayer(gm, dst as Player, tile);
|
||||
} else {
|
||||
dstTile = closestShoreTN(gm, tile, 50);
|
||||
}
|
||||
return dstTile;
|
||||
}
|
||||
|
||||
export function closestShoreFromPlayer(
|
||||
gm: GameMap,
|
||||
player: Player,
|
||||
target: TileRef,
|
||||
): TileRef | null {
|
||||
const shoreTiles = Array.from(player.borderTiles()).filter((t) =>
|
||||
gm.isShore(t),
|
||||
);
|
||||
if (shoreTiles.length == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return shoreTiles.reduce((closest, current) => {
|
||||
const closestDistance = gm.manhattanDist(target, closest);
|
||||
const currentDistance = gm.manhattanDist(target, current);
|
||||
return currentDistance < closestDistance ? current : closest;
|
||||
});
|
||||
}
|
||||
|
||||
export function bestShoreDeploymentSource(
|
||||
gm: Game,
|
||||
player: Player,
|
||||
target: TileRef,
|
||||
): TileRef | false {
|
||||
target = targetTransportTile(gm, target);
|
||||
if (target == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidates = candidateShoreTiles(gm, player, target);
|
||||
const aStar = new MiniAStar(gm, gm.miniMap(), candidates, target, 500_000, 1);
|
||||
const result = aStar.compute();
|
||||
if (result != PathFindResultType.Completed) {
|
||||
console.warn(`bestShoreDeploymentSource: path not found: ${result}`);
|
||||
return false;
|
||||
}
|
||||
const path = aStar.reconstructPath();
|
||||
if (path.length == 0) {
|
||||
return false;
|
||||
}
|
||||
const potential = path[0];
|
||||
// Since mini a* downscales the map, we need to check the neighbors
|
||||
// of the potential tile to find a valid deployment point
|
||||
const neighbors = gm
|
||||
.neighbors(potential)
|
||||
.filter((n) => gm.isShore(n) && gm.owner(n) == player);
|
||||
if (neighbors.length == 0) {
|
||||
return false;
|
||||
}
|
||||
return neighbors[0];
|
||||
}
|
||||
|
||||
export function candidateShoreTiles(
|
||||
gm: Game,
|
||||
player: Player,
|
||||
target: TileRef,
|
||||
): TileRef[] {
|
||||
let closestManhattanDistance = Infinity;
|
||||
let minX = Infinity,
|
||||
minY = Infinity,
|
||||
maxX = -Infinity,
|
||||
maxY = -Infinity;
|
||||
|
||||
let bestByManhattan: TileRef = null;
|
||||
const extremumTiles: Record<string, TileRef> = {
|
||||
minX: null,
|
||||
minY: null,
|
||||
maxX: null,
|
||||
maxY: null,
|
||||
};
|
||||
|
||||
const borderShoreTiles = Array.from(player.borderTiles()).filter((t) =>
|
||||
gm.isShore(t),
|
||||
);
|
||||
|
||||
for (const tile of borderShoreTiles) {
|
||||
const distance = gm.manhattanDist(tile, target);
|
||||
const cell = gm.cell(tile);
|
||||
|
||||
// Manhattan-closest tile
|
||||
if (distance < closestManhattanDistance) {
|
||||
closestManhattanDistance = distance;
|
||||
bestByManhattan = tile;
|
||||
}
|
||||
|
||||
// Extremum tiles
|
||||
if (cell.x < minX) {
|
||||
minX = cell.x;
|
||||
extremumTiles.minX = tile;
|
||||
} else if (cell.y < minY) {
|
||||
minY = cell.y;
|
||||
extremumTiles.minY = tile;
|
||||
} else if (cell.x > maxX) {
|
||||
maxX = cell.x;
|
||||
extremumTiles.maxX = tile;
|
||||
} else if (cell.y > maxY) {
|
||||
maxY = cell.y;
|
||||
extremumTiles.maxY = tile;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate sampling interval to ensure we get at most 50 tiles
|
||||
const samplingInterval = Math.max(
|
||||
10,
|
||||
Math.ceil(borderShoreTiles.length / 50),
|
||||
);
|
||||
const sampledTiles = borderShoreTiles.filter(
|
||||
(_, index) => index % samplingInterval === 0,
|
||||
);
|
||||
|
||||
const candidates = [
|
||||
bestByManhattan,
|
||||
extremumTiles.minX,
|
||||
extremumTiles.minY,
|
||||
extremumTiles.maxX,
|
||||
extremumTiles.maxY,
|
||||
...sampledTiles,
|
||||
].filter(Boolean);
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function closestShoreTN(
|
||||
gm: GameMap,
|
||||
tile: TileRef,
|
||||
searchDist: number,
|
||||
): TileRef {
|
||||
const tn = Array.from(
|
||||
gm.bfs(
|
||||
tile,
|
||||
andFN((_, t) => !gm.hasOwner(t), manhattanDistFN(tile, searchDist)),
|
||||
),
|
||||
)
|
||||
.filter((t) => gm.isShore(t))
|
||||
.sort((a, b) => gm.manhattanDist(tile, a) - gm.manhattanDist(tile, b));
|
||||
if (tn.length == 0) {
|
||||
return null;
|
||||
}
|
||||
return tn[0];
|
||||
}
|
||||
@@ -16,13 +16,13 @@ import { PlayerImpl } from "./PlayerImpl";
|
||||
export class UnitImpl implements Unit {
|
||||
private _active = true;
|
||||
private _health: bigint;
|
||||
private _lastTile: TileRef | null = null;
|
||||
// Currently only warship use it
|
||||
private _target: Unit | null = null;
|
||||
private _moveTarget: TileRef | null = null;
|
||||
private _lastTile: TileRef = null;
|
||||
private _target: Unit = null;
|
||||
private _moveTarget: TileRef = null;
|
||||
private _targetedBySAM = false;
|
||||
|
||||
private _constructionType: UnitType | undefined = undefined;
|
||||
private _safeFromPiratesCooldown: number; // Only for trade ships
|
||||
private _lastSetSafeFromPirates: number; // Only for trade ships
|
||||
private _constructionType: UnitType = undefined;
|
||||
|
||||
private _cooldownTick: Tick | null = null;
|
||||
private _dstPort: Unit | undefined = undefined; // Only for trade ships
|
||||
@@ -45,6 +45,10 @@ export class UnitImpl implements Unit {
|
||||
this._detonationDst = unitsSpecificInfos.detonationDst;
|
||||
this._warshipTarget = unitsSpecificInfos.warshipTarget;
|
||||
this._cooldownDuration = unitsSpecificInfos.cooldownDuration;
|
||||
this._lastSetSafeFromPirates = unitsSpecificInfos.lastSetSafeFromPirates;
|
||||
this._safeFromPiratesCooldown = this.mg
|
||||
.config()
|
||||
.safeFromPiratesCooldownMax();
|
||||
}
|
||||
|
||||
id() {
|
||||
@@ -90,9 +94,9 @@ export class UnitImpl implements Unit {
|
||||
if (tile == null) {
|
||||
throw new Error("tile cannot be null");
|
||||
}
|
||||
this.mg.removeUnit(this);
|
||||
this._lastTile = this._tile;
|
||||
this._tile = tile;
|
||||
this.mg.removeUnit(this);
|
||||
this.mg.addUnit(this);
|
||||
this.mg.addUpdate(this.toUpdate());
|
||||
}
|
||||
@@ -147,7 +151,7 @@ export class UnitImpl implements Unit {
|
||||
this._active = false;
|
||||
this.mg.addUpdate(this.toUpdate());
|
||||
this.mg.removeUnit(this);
|
||||
if (displayMessage) {
|
||||
if (displayMessage && this.type() != UnitType.MIRVWarhead) {
|
||||
this.mg.displayMessage(
|
||||
`Your ${this.type()} was destroyed`,
|
||||
MessageType.ERROR,
|
||||
@@ -237,4 +241,15 @@ export class UnitImpl implements Unit {
|
||||
targetedBySAM(): boolean {
|
||||
return this._targetedBySAM;
|
||||
}
|
||||
|
||||
setSafeFromPirates(): void {
|
||||
this._lastSetSafeFromPirates = this.mg.ticks();
|
||||
}
|
||||
|
||||
isSafeFromPirates(): boolean {
|
||||
return (
|
||||
this.mg.ticks() - this._lastSetSafeFromPirates <
|
||||
this._safeFromPiratesCooldown
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ export class UserSettings {
|
||||
emojis() {
|
||||
return this.get("settings.emojis", true);
|
||||
}
|
||||
anonymousNames() {
|
||||
return this.get("settings.anonymousNames", false);
|
||||
}
|
||||
|
||||
darkMode() {
|
||||
return this.get("settings.darkMode", false);
|
||||
@@ -44,6 +47,10 @@ export class UserSettings {
|
||||
this.set("settings.emojis", !this.emojis());
|
||||
}
|
||||
|
||||
toggleRandomName() {
|
||||
this.set("settings.anonymousNames", !this.anonymousNames());
|
||||
}
|
||||
|
||||
toggleDarkMode() {
|
||||
this.set("settings.darkMode", !this.darkMode());
|
||||
if (this.darkMode()) {
|
||||
|
||||
@@ -3,31 +3,33 @@ import { GameMap, TileRef } from "../game/GameMap";
|
||||
import { AStar, PathFindResultType } from "./AStar";
|
||||
import { SerialAStar } from "./SerialAStar";
|
||||
|
||||
// TODO: test this, get it work
|
||||
export class MiniAStar implements AStar {
|
||||
private aStar: SerialAStar;
|
||||
private aStar: AStar;
|
||||
|
||||
constructor(
|
||||
private gameMap: GameMap,
|
||||
private miniMap: GameMap,
|
||||
private src: TileRef,
|
||||
src: TileRef | TileRef[],
|
||||
private dst: TileRef,
|
||||
private canMove: (t: TileRef) => boolean,
|
||||
private iterations: number,
|
||||
private maxTries: number,
|
||||
iterations: number,
|
||||
maxTries: number,
|
||||
) {
|
||||
const miniSrc = this.miniMap.ref(
|
||||
Math.floor(gameMap.x(src) / 2),
|
||||
Math.floor(gameMap.y(src) / 2),
|
||||
const srcArray: TileRef[] = Array.isArray(src) ? src : [src];
|
||||
const miniSrc = srcArray.map((srcPoint) =>
|
||||
this.miniMap.ref(
|
||||
Math.floor(gameMap.x(srcPoint) / 2),
|
||||
Math.floor(gameMap.y(srcPoint) / 2),
|
||||
),
|
||||
);
|
||||
|
||||
const miniDst = this.miniMap.ref(
|
||||
Math.floor(gameMap.x(dst) / 2),
|
||||
Math.floor(gameMap.y(dst) / 2),
|
||||
);
|
||||
|
||||
this.aStar = new SerialAStar(
|
||||
miniSrc,
|
||||
miniDst,
|
||||
canMove,
|
||||
iterations,
|
||||
maxTries,
|
||||
this.miniMap,
|
||||
|
||||
@@ -1,9 +1,46 @@
|
||||
import { consolex } from "../Consolex";
|
||||
import { Game } from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { GameMap, TileRef } from "../game/GameMap";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { AStar, PathFindResultType, TileResult } from "./AStar";
|
||||
import { MiniAStar } from "./MiniAStar";
|
||||
|
||||
export class AirPathFinder {
|
||||
constructor(
|
||||
private mg: GameMap,
|
||||
private random: PseudoRandom,
|
||||
) {}
|
||||
|
||||
nextTile(tile: TileRef, dst: TileRef): TileRef | true {
|
||||
const x = this.mg.x(tile);
|
||||
const y = this.mg.y(tile);
|
||||
const dstX = this.mg.x(dst);
|
||||
const dstY = this.mg.y(dst);
|
||||
|
||||
if (x === dstX && y === dstY) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Calculate next position
|
||||
let nextX = x;
|
||||
let nextY = y;
|
||||
|
||||
const ratio = Math.floor(1 + Math.abs(dstY - y) / (Math.abs(dstX - x) + 1));
|
||||
|
||||
if (this.random.chance(ratio) && x != dstX) {
|
||||
if (x < dstX) nextX++;
|
||||
else if (x > dstX) nextX--;
|
||||
} else {
|
||||
if (y < dstY) nextY++;
|
||||
else if (y > dstY) nextY--;
|
||||
}
|
||||
if (nextX == x && nextY == y) {
|
||||
return true;
|
||||
}
|
||||
return this.mg.ref(nextX, nextY);
|
||||
}
|
||||
}
|
||||
|
||||
export class PathFinder {
|
||||
private curr: TileRef | null = null;
|
||||
private dst: TileRef | null = null;
|
||||
@@ -16,24 +53,13 @@ export class PathFinder {
|
||||
private newAStar: (curr: TileRef, dst: TileRef) => AStar,
|
||||
) {}
|
||||
|
||||
public static Mini(
|
||||
game: Game,
|
||||
iterations: number,
|
||||
canMoveOnLand: boolean,
|
||||
maxTries: number = 20,
|
||||
) {
|
||||
public static Mini(game: Game, iterations: number, maxTries: number = 20) {
|
||||
return new PathFinder(game, (curr: TileRef, dst: TileRef) => {
|
||||
return new MiniAStar(
|
||||
game.map(),
|
||||
game.miniMap(),
|
||||
curr,
|
||||
dst,
|
||||
(tr: TileRef): boolean => {
|
||||
if (canMoveOnLand) {
|
||||
return true;
|
||||
}
|
||||
return game.miniMap().isWater(tr);
|
||||
},
|
||||
iterations,
|
||||
maxTries,
|
||||
);
|
||||
|
||||
@@ -4,29 +4,42 @@ import { GameMap, TileRef } from "../game/GameMap";
|
||||
import { AStar, PathFindResultType } from "./AStar";
|
||||
|
||||
export class SerialAStar implements AStar {
|
||||
private fwdOpenSet: PriorityQueue<{ tile: TileRef; fScore: number }>;
|
||||
private bwdOpenSet: PriorityQueue<{ tile: TileRef; fScore: number }>;
|
||||
private fwdOpenSet: PriorityQueue<{
|
||||
tile: TileRef;
|
||||
fScore: number;
|
||||
}>;
|
||||
|
||||
private bwdOpenSet: PriorityQueue<{
|
||||
tile: TileRef;
|
||||
fScore: number;
|
||||
}>;
|
||||
|
||||
private fwdCameFrom: Map<TileRef, TileRef>;
|
||||
private bwdCameFrom: Map<TileRef, TileRef>;
|
||||
private fwdGScore: Map<TileRef, number>;
|
||||
private bwdGScore: Map<TileRef, number>;
|
||||
private meetingPoint: TileRef | null;
|
||||
public completed: boolean;
|
||||
private sources: TileRef[];
|
||||
private closestSource: TileRef;
|
||||
|
||||
constructor(
|
||||
private src: TileRef,
|
||||
src: TileRef | TileRef[],
|
||||
private dst: TileRef,
|
||||
private canMove: (t: TileRef) => boolean,
|
||||
private iterations: number,
|
||||
private maxTries: number,
|
||||
private gameMap: GameMap,
|
||||
) {
|
||||
this.fwdOpenSet = new PriorityQueue<{ tile: TileRef; fScore: number }>(
|
||||
(a, b) => a.fScore - b.fScore,
|
||||
);
|
||||
this.bwdOpenSet = new PriorityQueue<{ tile: TileRef; fScore: number }>(
|
||||
(a, b) => a.fScore - b.fScore,
|
||||
);
|
||||
this.fwdOpenSet = new PriorityQueue<{
|
||||
tile: TileRef;
|
||||
fScore: number;
|
||||
}>((a, b) => a.fScore - b.fScore);
|
||||
|
||||
this.bwdOpenSet = new PriorityQueue<{
|
||||
tile: TileRef;
|
||||
fScore: number;
|
||||
}>((a, b) => a.fScore - b.fScore);
|
||||
|
||||
this.fwdCameFrom = new Map<TileRef, TileRef>();
|
||||
this.bwdCameFrom = new Map<TileRef, TileRef>();
|
||||
this.fwdGScore = new Map<TileRef, number>();
|
||||
@@ -34,13 +47,32 @@ export class SerialAStar implements AStar {
|
||||
this.meetingPoint = null;
|
||||
this.completed = false;
|
||||
|
||||
// Initialize forward search
|
||||
this.fwdGScore.set(src, 0);
|
||||
this.fwdOpenSet.enqueue({ tile: src, fScore: this.heuristic(src, dst) });
|
||||
this.sources = Array.isArray(src) ? src : [src];
|
||||
this.closestSource = this.findClosestSource(dst);
|
||||
|
||||
// Initialize backward search
|
||||
// Initialize forward search with source point(s)
|
||||
this.sources.forEach((startPoint) => {
|
||||
this.fwdGScore.set(startPoint, 0);
|
||||
this.fwdOpenSet.enqueue({
|
||||
tile: startPoint,
|
||||
fScore: this.heuristic(startPoint, dst),
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize backward search from destination
|
||||
this.bwdGScore.set(dst, 0);
|
||||
this.bwdOpenSet.enqueue({ tile: dst, fScore: this.heuristic(dst, src) });
|
||||
this.bwdOpenSet.enqueue({
|
||||
tile: dst,
|
||||
fScore: this.heuristic(dst, this.findClosestSource(dst)),
|
||||
});
|
||||
}
|
||||
|
||||
private findClosestSource(tile: TileRef): TileRef {
|
||||
return this.sources.reduce((closest, source) =>
|
||||
this.heuristic(tile, source) < this.heuristic(tile, closest)
|
||||
? source
|
||||
: closest,
|
||||
);
|
||||
}
|
||||
|
||||
compute(): PathFindResultType {
|
||||
@@ -60,8 +92,9 @@ export class SerialAStar implements AStar {
|
||||
|
||||
// Process forward search
|
||||
const fwdCurrent = this.fwdOpenSet.dequeue()!.tile;
|
||||
|
||||
// Check if we've found a meeting point
|
||||
if (this.bwdGScore.has(fwdCurrent)) {
|
||||
// We found a meeting point!
|
||||
this.meetingPoint = fwdCurrent;
|
||||
this.completed = true;
|
||||
return PathFindResultType.Completed;
|
||||
@@ -71,8 +104,9 @@ export class SerialAStar implements AStar {
|
||||
|
||||
// Process backward search
|
||||
const bwdCurrent = this.bwdOpenSet.dequeue()!.tile;
|
||||
|
||||
// Check if we've found a meeting point
|
||||
if (this.fwdGScore.has(bwdCurrent)) {
|
||||
// We found a meeting point!
|
||||
this.meetingPoint = bwdCurrent;
|
||||
this.completed = true;
|
||||
return PathFindResultType.Completed;
|
||||
@@ -89,8 +123,8 @@ export class SerialAStar implements AStar {
|
||||
private expandTileRef(current: TileRef, isForward: boolean) {
|
||||
for (const neighbor of this.gameMap.neighbors(current)) {
|
||||
if (
|
||||
neighbor != (isForward ? this.dst : this.src) &&
|
||||
!this.canMove(neighbor)
|
||||
neighbor != (isForward ? this.dst : this.closestSource) &&
|
||||
!this.gameMap.isWater(neighbor)
|
||||
)
|
||||
continue;
|
||||
|
||||
@@ -106,21 +140,21 @@ export class SerialAStar implements AStar {
|
||||
gScore.set(neighbor, tentativeGScore);
|
||||
const fScore =
|
||||
tentativeGScore +
|
||||
this.heuristic(neighbor, isForward ? this.dst : this.src);
|
||||
this.heuristic(neighbor, isForward ? this.dst : this.closestSource);
|
||||
openSet.enqueue({ tile: neighbor, fScore: fScore });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private heuristic(a: TileRef, b: TileRef): number {
|
||||
// TODO use wrapped
|
||||
try {
|
||||
return (
|
||||
1.1 * Math.abs(this.gameMap.x(a) - this.gameMap.x(b)) +
|
||||
Math.abs(this.gameMap.y(a) - this.gameMap.y(b))
|
||||
1.1 *
|
||||
(Math.abs(this.gameMap.x(a) - this.gameMap.x(b)) +
|
||||
Math.abs(this.gameMap.y(a) - this.gameMap.y(b)))
|
||||
);
|
||||
} catch (e) {
|
||||
consolex.log("uh oh", e);
|
||||
} catch {
|
||||
consolex.log("uh oh");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -131,6 +165,7 @@ export class SerialAStar implements AStar {
|
||||
// Reconstruct path from start to meeting point
|
||||
const fwdPath: TileRef[] = [this.meetingPoint];
|
||||
let current = this.meetingPoint;
|
||||
|
||||
while (this.fwdCameFrom.has(current)) {
|
||||
current = this.fwdCameFrom.get(current)!;
|
||||
fwdPath.unshift(current);
|
||||
@@ -138,6 +173,7 @@ export class SerialAStar implements AStar {
|
||||
|
||||
// Reconstruct path from meeting point to goal
|
||||
current = this.meetingPoint;
|
||||
|
||||
while (this.bwdCameFrom.has(current)) {
|
||||
current = this.bwdCameFrom.get(current)!;
|
||||
fwdPath.push(current);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
PlayerActionsResultMessage,
|
||||
PlayerBorderTilesResultMessage,
|
||||
PlayerProfileResultMessage,
|
||||
TransportShipSpawnResultMessage,
|
||||
WorkerMessage,
|
||||
} from "./WorkerMessages";
|
||||
|
||||
@@ -120,6 +121,25 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
|
||||
throw error;
|
||||
}
|
||||
break;
|
||||
case "transport_ship_spawn":
|
||||
if (!gameRunner) {
|
||||
throw new Error("Game runner not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
const spawnTile = (await gameRunner).bestTransportShipSpawn(
|
||||
message.playerID,
|
||||
message.targetTile,
|
||||
);
|
||||
sendMessage({
|
||||
type: "transport_ship_spawn_result",
|
||||
id: message.id,
|
||||
result: spawnTile,
|
||||
} as TransportShipSpawnResultMessage);
|
||||
} catch (error) {
|
||||
console.error("Failed to spawn transport ship:", error);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
console.warn("Unknown message :", message);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
PlayerID,
|
||||
PlayerProfile,
|
||||
} from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
|
||||
import { ClientID, GameStartInfo, Turn } from "../Schemas";
|
||||
import { generateID } from "../Util";
|
||||
@@ -188,6 +189,36 @@ export class WorkerClient {
|
||||
});
|
||||
}
|
||||
|
||||
transportShipSpawn(
|
||||
playerID: PlayerID,
|
||||
targetTile: TileRef,
|
||||
): Promise<TileRef | false> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.isInitialized) {
|
||||
reject(new Error("Worker not initialized"));
|
||||
return;
|
||||
}
|
||||
|
||||
const messageId = generateID();
|
||||
|
||||
this.messageHandlers.set(messageId, (message) => {
|
||||
if (
|
||||
message.type === "transport_ship_spawn_result" &&
|
||||
message.result !== undefined
|
||||
) {
|
||||
resolve(message.result);
|
||||
}
|
||||
});
|
||||
|
||||
this.worker.postMessage({
|
||||
type: "transport_ship_spawn",
|
||||
id: messageId,
|
||||
playerID: playerID,
|
||||
targetTile: targetTile,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
this.worker.terminate();
|
||||
this.messageHandlers.clear();
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
PlayerID,
|
||||
PlayerProfile,
|
||||
} from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { GameUpdateViewData } from "../game/GameUpdates";
|
||||
import { ClientID, GameStartInfo, Turn } from "../Schemas";
|
||||
|
||||
@@ -18,7 +19,9 @@ export type WorkerMessageType =
|
||||
| "player_profile"
|
||||
| "player_profile_result"
|
||||
| "player_border_tiles"
|
||||
| "player_border_tiles_result";
|
||||
| "player_border_tiles_result"
|
||||
| "transport_ship_spawn"
|
||||
| "transport_ship_spawn_result";
|
||||
|
||||
// Base interface for all messages
|
||||
interface BaseWorkerMessage {
|
||||
@@ -84,6 +87,17 @@ export interface PlayerBorderTilesResultMessage extends BaseWorkerMessage {
|
||||
result: PlayerBorderTiles;
|
||||
}
|
||||
|
||||
export interface TransportShipSpawnMessage extends BaseWorkerMessage {
|
||||
type: "transport_ship_spawn";
|
||||
playerID: PlayerID;
|
||||
targetTile: TileRef;
|
||||
}
|
||||
|
||||
export interface TransportShipSpawnResultMessage extends BaseWorkerMessage {
|
||||
type: "transport_ship_spawn_result";
|
||||
result: TileRef | false;
|
||||
}
|
||||
|
||||
// Union types for type safety
|
||||
export type MainThreadMessage =
|
||||
| HeartbeatMessage
|
||||
@@ -91,7 +105,8 @@ export type MainThreadMessage =
|
||||
| TurnMessage
|
||||
| PlayerActionsMessage
|
||||
| PlayerProfileMessage
|
||||
| PlayerBorderTilesMessage;
|
||||
| PlayerBorderTilesMessage
|
||||
| TransportShipSpawnMessage;
|
||||
|
||||
// Message send from worker
|
||||
export type WorkerMessage =
|
||||
@@ -99,4 +114,5 @@ export type WorkerMessage =
|
||||
| GameUpdateMessage
|
||||
| PlayerActionsResultMessage
|
||||
| PlayerProfileResultMessage
|
||||
| PlayerBorderTilesResultMessage;
|
||||
| PlayerBorderTilesResultMessage
|
||||
| TransportShipSpawnResultMessage;
|
||||
|
||||
@@ -9,6 +9,7 @@ const maps = [
|
||||
"WorldMap",
|
||||
"BlackSea",
|
||||
"Europe",
|
||||
"EuropeClassic",
|
||||
"Mars",
|
||||
"Mena",
|
||||
"Oceania",
|
||||
@@ -22,6 +23,8 @@ const maps = [
|
||||
"BetweenTwoSeas",
|
||||
"Japan",
|
||||
"KnownWorld",
|
||||
"FaroeIslands",
|
||||
"DeglaciatedAntarctica",
|
||||
];
|
||||
|
||||
const removeSmall = true;
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { SecretManagerServiceClient } from "@google-cloud/secret-manager";
|
||||
import { Client, Events, GatewayIntentBits } from "discord.js";
|
||||
|
||||
export class DiscordBot {
|
||||
private client: Client;
|
||||
private secretManager: SecretManagerServiceClient;
|
||||
|
||||
constructor() {
|
||||
this.client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.MessageContent,
|
||||
],
|
||||
});
|
||||
this.secretManager = new SecretManagerServiceClient();
|
||||
this.setupEventHandlers();
|
||||
}
|
||||
|
||||
private setupEventHandlers(): void {
|
||||
this.client.once(Events.ClientReady, (c) => {
|
||||
console.log(`Ready! Logged in as ${c.user.tag}`);
|
||||
});
|
||||
|
||||
this.client.on(Events.MessageCreate, async (message) => {
|
||||
if (message.author.bot) return;
|
||||
|
||||
if (message.content === "!ping") {
|
||||
await message.reply("Pong! 🏓");
|
||||
}
|
||||
|
||||
if (message.content === "!hello") {
|
||||
await message.reply(`Hello ${message.author.username}! 👋`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async getToken(): Promise<string | undefined> {
|
||||
const name =
|
||||
"projects/openfrontio/secrets/discord-bot-token/versions/latest";
|
||||
const [version] = await this.secretManager.accessSecretVersion({ name });
|
||||
return version.payload?.data?.toString().trim();
|
||||
}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
try {
|
||||
const token = await this.getToken();
|
||||
if (!token) {
|
||||
throw new Error("Failed to retrieve Discord token");
|
||||
}
|
||||
await this.client.login(token);
|
||||
} catch (error) {
|
||||
console.error("Failed to start bot:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
this.client.destroy();
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,9 @@ export class GameServer {
|
||||
if (typeof gameConfig.gameMode !== "undefined") {
|
||||
this.gameConfig.gameMode = gameConfig.gameMode;
|
||||
}
|
||||
if (gameConfig.playerTeams != null) {
|
||||
this.gameConfig.playerTeams = gameConfig.playerTeams;
|
||||
}
|
||||
}
|
||||
|
||||
public addClient(client: Client, lastTurn: number) {
|
||||
@@ -356,7 +359,11 @@ export class GameServer {
|
||||
client.ws.close(1000, "game has ended");
|
||||
}
|
||||
});
|
||||
this.log.info("ending game", { gameID: this.id, turns: this.turns.length });
|
||||
if (!this._hasPrestarted && !this._hasStarted) {
|
||||
this.log.info(`game not started, not archiving game`);
|
||||
return;
|
||||
}
|
||||
this.log.info(`ending game with ${this.turns.length} turns`);
|
||||
try {
|
||||
if (this.allClients.size > 0) {
|
||||
const playerRecords: PlayerRecord[] = Array.from(
|
||||
|
||||
+56
-1
@@ -1,4 +1,56 @@
|
||||
import * as logsAPI from "@opentelemetry/api-logs";
|
||||
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
|
||||
import {
|
||||
LoggerProvider,
|
||||
SimpleLogRecordProcessor,
|
||||
} from "@opentelemetry/sdk-logs";
|
||||
import { OpenTelemetryTransportV3 } from "@opentelemetry/winston-transport";
|
||||
import * as dotenv from "dotenv";
|
||||
import winston from "winston";
|
||||
import { GameEnv } from "../core/configuration/Config";
|
||||
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
|
||||
import { getOtelResource } from "./OtelResource";
|
||||
dotenv.config();
|
||||
|
||||
const config = getServerConfigFromServer();
|
||||
|
||||
const resource = getOtelResource();
|
||||
|
||||
// Initialize the OpenTelemetry Logger Provider
|
||||
const loggerProvider = new LoggerProvider({
|
||||
resource,
|
||||
});
|
||||
|
||||
if (config.env() == GameEnv.Prod && config.otelEnabled()) {
|
||||
console.log("OTEL enabled");
|
||||
// Configure OpenTelemetry endpoint with basic auth (if provided)
|
||||
const headers = {};
|
||||
if (config.otelUsername() && config.otelPassword()) {
|
||||
headers["Authorization"] =
|
||||
"Basic " +
|
||||
Buffer.from(`${config.otelUsername()}:${config.otelPassword()}`).toString(
|
||||
"base64",
|
||||
);
|
||||
}
|
||||
|
||||
// Add OTLP exporter for logs
|
||||
const logExporter = new OTLPLogExporter({
|
||||
url: `${config.otelEndpoint()}/v1/logs`,
|
||||
headers,
|
||||
});
|
||||
|
||||
// Add a log processor with the exporter
|
||||
loggerProvider.addLogRecordProcessor(
|
||||
new SimpleLogRecordProcessor(logExporter),
|
||||
);
|
||||
|
||||
// Set as the global logger provider
|
||||
logsAPI.logs.setGlobalLoggerProvider(loggerProvider);
|
||||
} else {
|
||||
console.log(
|
||||
"No OTLP endpoint and credentials provided, remote logging disabled",
|
||||
);
|
||||
}
|
||||
|
||||
// Custom format to add severity tag based on log level
|
||||
const addSeverityFormat = winston.format((info) => {
|
||||
@@ -20,7 +72,10 @@ const logger = winston.createLogger({
|
||||
service: "openfront",
|
||||
environment: process.env.NODE_ENV,
|
||||
},
|
||||
transports: [new winston.transports.Console()],
|
||||
transports: [
|
||||
new winston.transports.Console(),
|
||||
new OpenTelemetryTransportV3(),
|
||||
],
|
||||
});
|
||||
|
||||
// Export both the main logger and the child logger factory
|
||||
|
||||
+108
-92
@@ -1,117 +1,133 @@
|
||||
import { GameMapType, GameMode } from "../core/game/Game";
|
||||
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
|
||||
import { Difficulty, GameMapType, GameMode, GameType } from "../core/game/Game";
|
||||
import { PseudoRandom } from "../core/PseudoRandom";
|
||||
import { GameConfig } from "../core/Schemas";
|
||||
import { logger } from "./Logger";
|
||||
|
||||
enum PlaylistType {
|
||||
BigMaps,
|
||||
SmallMaps,
|
||||
const log = logger.child({});
|
||||
|
||||
const config = getServerConfigFromServer();
|
||||
|
||||
const frequency = {
|
||||
World: 3,
|
||||
Europe: 2,
|
||||
Africa: 2,
|
||||
Australia: 1,
|
||||
NorthAmerica: 1,
|
||||
Britannia: 1,
|
||||
GatewayToTheAtlantic: 1,
|
||||
Iceland: 1,
|
||||
SouthAmerica: 1,
|
||||
KnownWorld: 1,
|
||||
DeglaciatedAntarctica: 1,
|
||||
EuropeClassic: 1,
|
||||
Mena: 1,
|
||||
Pangaea: 1,
|
||||
Asia: 1,
|
||||
Mars: 1,
|
||||
BetweenTwoSeas: 1,
|
||||
Japan: 1,
|
||||
BlackSea: 1,
|
||||
FaroeIslands: 1,
|
||||
};
|
||||
|
||||
interface MapWithMode {
|
||||
map: GameMapType;
|
||||
mode: GameMode;
|
||||
}
|
||||
|
||||
const random = new PseudoRandom(123);
|
||||
|
||||
export class MapPlaylist {
|
||||
private gameModeRotation = [GameMode.FFA, GameMode.FFA, GameMode.Team];
|
||||
private currentGameModeIndex = 0;
|
||||
private mapsPlaylist: MapWithMode[] = [];
|
||||
|
||||
private mapsPlaylistBig: GameMapType[] = [];
|
||||
private mapsPlaylistSmall: GameMapType[] = [];
|
||||
private currentPlaylistCounter = 0;
|
||||
public gameConfig(): GameConfig {
|
||||
const { map, mode } = this.getNextMap();
|
||||
|
||||
// Get the next map in rotation
|
||||
public getNextMap(): GameMapType {
|
||||
const playlistType: PlaylistType = this.getNextPlaylistType();
|
||||
const mapsPlaylist: GameMapType[] = this.getNextMapsPlayList(playlistType);
|
||||
return mapsPlaylist.shift()!;
|
||||
const numPlayerTeams =
|
||||
mode === GameMode.Team ? 2 + Math.floor(Math.random() * 5) : undefined;
|
||||
|
||||
// Create the default public game config (from your GameManager)
|
||||
return {
|
||||
gameMap: map,
|
||||
maxPlayers: config.lobbyMaxPlayers(map, mode),
|
||||
gameType: GameType.Public,
|
||||
difficulty: Difficulty.Medium,
|
||||
infiniteGold: false,
|
||||
infiniteTroops: false,
|
||||
instantBuild: false,
|
||||
disableNPCs: mode == GameMode.Team,
|
||||
disableNukes: false,
|
||||
gameMode: mode,
|
||||
playerTeams: numPlayerTeams,
|
||||
bots: 400,
|
||||
} as GameConfig;
|
||||
}
|
||||
|
||||
public getNextGameMode(): GameMode {
|
||||
const nextGameMode = this.gameModeRotation[this.currentGameModeIndex];
|
||||
this.currentGameModeIndex =
|
||||
(this.currentGameModeIndex + 1) % this.gameModeRotation.length;
|
||||
return nextGameMode;
|
||||
}
|
||||
|
||||
private getNextMapsPlayList(playlistType: PlaylistType): GameMapType[] {
|
||||
switch (playlistType) {
|
||||
case PlaylistType.BigMaps:
|
||||
if (!(this.mapsPlaylistBig.length > 0)) {
|
||||
this.fillMapsPlaylist(playlistType, this.mapsPlaylistBig);
|
||||
private getNextMap(): MapWithMode {
|
||||
if (this.mapsPlaylist.length === 0) {
|
||||
const numAttempts = 10000;
|
||||
for (let i = 0; i < numAttempts; i++) {
|
||||
if (this.shuffleMapsPlaylist()) {
|
||||
log.info(`Generated map playlist in ${i} attempts`);
|
||||
return this.mapsPlaylist.shift()!;
|
||||
}
|
||||
return this.mapsPlaylistBig;
|
||||
|
||||
case PlaylistType.SmallMaps:
|
||||
if (!(this.mapsPlaylistSmall.length > 0)) {
|
||||
this.fillMapsPlaylist(playlistType, this.mapsPlaylistSmall);
|
||||
}
|
||||
return this.mapsPlaylistSmall;
|
||||
}
|
||||
log.error("Failed to generate a valid map playlist");
|
||||
}
|
||||
// Even if it failed, playlist will be partially populated.
|
||||
return this.mapsPlaylist.shift()!;
|
||||
}
|
||||
|
||||
private fillMapsPlaylist(
|
||||
playlistType: PlaylistType,
|
||||
mapsPlaylist: GameMapType[],
|
||||
): void {
|
||||
const frequency = this.getFrequency(playlistType);
|
||||
private shuffleMapsPlaylist(): boolean {
|
||||
const maps: GameMapType[] = [];
|
||||
Object.keys(GameMapType).forEach((key) => {
|
||||
let count = parseInt(frequency[key]);
|
||||
while (count > 0) {
|
||||
mapsPlaylist.push(GameMapType[key]);
|
||||
count--;
|
||||
for (let i = 0; i < parseInt(frequency[key]); i++) {
|
||||
maps.push(GameMapType[key]);
|
||||
}
|
||||
});
|
||||
while (!this.allNonConsecutive(mapsPlaylist)) {
|
||||
random.shuffleArray(mapsPlaylist);
|
||||
}
|
||||
}
|
||||
|
||||
// Specifically controls how the playlists rotate.
|
||||
private getNextPlaylistType(): PlaylistType {
|
||||
switch (this.currentPlaylistCounter) {
|
||||
case 0:
|
||||
case 1:
|
||||
this.currentPlaylistCounter++;
|
||||
return PlaylistType.BigMaps;
|
||||
default:
|
||||
this.currentPlaylistCounter = 0;
|
||||
return PlaylistType.SmallMaps;
|
||||
}
|
||||
}
|
||||
const rand = new PseudoRandom(Date.now());
|
||||
|
||||
private getFrequency(playlistType: PlaylistType) {
|
||||
switch (playlistType) {
|
||||
// Big Maps are those larger than ~2.5 mil pixels
|
||||
case PlaylistType.BigMaps:
|
||||
return {
|
||||
Europe: 3,
|
||||
NorthAmerica: 2,
|
||||
Africa: 2,
|
||||
Britannia: 1,
|
||||
GatewayToTheAtlantic: 2,
|
||||
Australia: 1,
|
||||
Iceland: 1,
|
||||
SouthAmerica: 3,
|
||||
KnownWorld: 2,
|
||||
};
|
||||
case PlaylistType.SmallMaps:
|
||||
return {
|
||||
World: 1,
|
||||
Mena: 2,
|
||||
Pangaea: 1,
|
||||
Asia: 1,
|
||||
Mars: 1,
|
||||
BetweenTwoSeas: 3,
|
||||
Japan: 3,
|
||||
BlackSea: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
const ffa1: GameMapType[] = rand.shuffleArray([...maps]);
|
||||
const ffa2: GameMapType[] = rand.shuffleArray([...maps]);
|
||||
const ffa3: GameMapType[] = rand.shuffleArray([...maps]);
|
||||
const team: GameMapType[] = rand.shuffleArray([...maps]);
|
||||
|
||||
// Check for consecutive duplicates in the maps array
|
||||
private allNonConsecutive(maps: GameMapType[]): boolean {
|
||||
for (let i = 0; i < maps.length - 1; i++) {
|
||||
if (maps[i] === maps[i + 1]) {
|
||||
this.mapsPlaylist = [];
|
||||
for (let i = 0; i < maps.length; i++) {
|
||||
if (!this.addNextMap(this.mapsPlaylist, ffa1, GameMode.FFA)) {
|
||||
return false;
|
||||
}
|
||||
if (!this.addNextMap(this.mapsPlaylist, ffa2, GameMode.FFA)) {
|
||||
return false;
|
||||
}
|
||||
if (!this.addNextMap(this.mapsPlaylist, ffa3, GameMode.FFA)) {
|
||||
return false;
|
||||
}
|
||||
if (!this.addNextMap(this.mapsPlaylist, team, GameMode.Team)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private addNextMap(
|
||||
playlist: MapWithMode[],
|
||||
nextEls: GameMapType[],
|
||||
mode: GameMode,
|
||||
): boolean {
|
||||
const nonConsecutiveNum = 5;
|
||||
const lastEls = playlist
|
||||
.slice(playlist.length - nonConsecutiveNum)
|
||||
.map((m) => m.map);
|
||||
for (let i = 0; i < nextEls.length; i++) {
|
||||
const next = nextEls[i];
|
||||
if (lastEls.includes(next)) {
|
||||
continue;
|
||||
}
|
||||
nextEls.splice(i, 1);
|
||||
playlist.push({ map: next, mode: mode });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
+2
-37
@@ -5,13 +5,11 @@ import http from "http";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
|
||||
import { Difficulty, GameMode, GameType } from "../core/game/Game";
|
||||
import { GameConfig, GameInfo } from "../core/Schemas";
|
||||
import { GameInfo } from "../core/Schemas";
|
||||
import { generateID } from "../core/Util";
|
||||
import { gatekeeper, LimiterType } from "./Gatekeeper";
|
||||
import { logger } from "./Logger";
|
||||
import { MapPlaylist } from "./MapPlaylist";
|
||||
import { setupMetricsServer } from "./MasterMetrics";
|
||||
|
||||
const config = getServerConfigFromServer();
|
||||
const playlist = new MapPlaylist();
|
||||
@@ -20,10 +18,6 @@ const readyWorkers = new Set();
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
|
||||
// Create a separate metrics server on port 9090
|
||||
const metricsApp = express();
|
||||
const metricsServer = http.createServer(metricsApp);
|
||||
|
||||
const log = logger.child({ comp: "m" });
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
@@ -146,9 +140,6 @@ export async function startMaster() {
|
||||
server.listen(PORT, () => {
|
||||
log.info(`Master HTTP server listening on port ${PORT}`);
|
||||
});
|
||||
|
||||
// Setup the metrics server
|
||||
setupMetricsServer();
|
||||
}
|
||||
|
||||
app.get(
|
||||
@@ -240,37 +231,11 @@ async function fetchLobbies(): Promise<number> {
|
||||
return publicLobbyIDs.size;
|
||||
}
|
||||
|
||||
let lastGameMode: GameMode = GameMode.FFA;
|
||||
|
||||
// Function to schedule a new public game
|
||||
async function schedulePublicGame(playlist: MapPlaylist) {
|
||||
const gameID = generateID();
|
||||
const map = playlist.getNextMap();
|
||||
publicLobbyIDs.add(gameID);
|
||||
|
||||
if (lastGameMode == GameMode.FFA) {
|
||||
lastGameMode = GameMode.Team;
|
||||
} else {
|
||||
lastGameMode = GameMode.FFA;
|
||||
}
|
||||
|
||||
const gameMode = playlist.getNextGameMode();
|
||||
|
||||
// Create the default public game config (from your GameManager)
|
||||
const defaultGameConfig = {
|
||||
gameMap: map,
|
||||
maxPlayers: config.lobbyMaxPlayers(map),
|
||||
gameType: GameType.Public,
|
||||
difficulty: Difficulty.Medium,
|
||||
infiniteGold: false,
|
||||
infiniteTroops: false,
|
||||
instantBuild: false,
|
||||
disableNPCs: gameMode == GameMode.Team,
|
||||
disableNukes: false,
|
||||
gameMode: gameMode,
|
||||
bots: 400,
|
||||
} as GameConfig;
|
||||
|
||||
const workerPath = config.workerPath(gameID);
|
||||
|
||||
// Send request to the worker to start the game
|
||||
@@ -284,7 +249,7 @@ async function schedulePublicGame(playlist: MapPlaylist) {
|
||||
[config.adminHeader()]: config.adminToken(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
gameConfig: defaultGameConfig,
|
||||
gameConfig: playlist.gameConfig(),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
import express from "express";
|
||||
import http from "http";
|
||||
import promClient from "prom-client";
|
||||
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
|
||||
|
||||
const config = getServerConfigFromServer();
|
||||
|
||||
// Create a separate metrics server on port 9090
|
||||
const metricsApp = express();
|
||||
const metricsServer = http.createServer(metricsApp);
|
||||
|
||||
// Initialize the Prometheus registry for the master's own metrics
|
||||
const register = new promClient.Registry();
|
||||
|
||||
// Default Prometheus metrics
|
||||
promClient.collectDefaultMetrics({ register });
|
||||
|
||||
// Prometheus metrics endpoint that gathers metrics from workers
|
||||
export function setupMetricsServer() {
|
||||
metricsApp.get("/metrics", async (req, res) => {
|
||||
// Set a timeout for the request to avoid hanging
|
||||
const timeout = setTimeout(() => {
|
||||
res.status(500).end("# Error: Request timed out after 30 seconds");
|
||||
}, 30000);
|
||||
console.log("Metrics requested");
|
||||
try {
|
||||
// Get the master's metrics
|
||||
const masterMetrics = await register.metrics();
|
||||
|
||||
// Track seen metric names to avoid duplicate metadata
|
||||
const seenMetrics = new Set();
|
||||
const processedLines: string[] = [];
|
||||
const allMetricValues: string[] = [];
|
||||
|
||||
// Process all metadata information in the master metrics first
|
||||
const masterLines = masterMetrics.split("\n");
|
||||
|
||||
for (let j = 0; j < masterLines.length; j++) {
|
||||
const line = masterLines[j];
|
||||
|
||||
if (line.startsWith("# HELP ")) {
|
||||
const metricName = line.split(" ")[2];
|
||||
seenMetrics.add(metricName);
|
||||
processedLines.push(line);
|
||||
} else if (line.startsWith("# TYPE ")) {
|
||||
const metricName = line.split(" ")[2];
|
||||
if (seenMetrics.has(metricName)) {
|
||||
processedLines.push(line);
|
||||
}
|
||||
} else if (line.trim() && !line.startsWith("#")) {
|
||||
// Add worker label to each metric line and collect for later
|
||||
const processedLine = line.replace(
|
||||
/^([a-z][a-z0-9_]*)(?:{([^}]*)})?(\s+[0-9.e+-]+.*)/,
|
||||
(match, metricName, existingLabels, valueAndRest) => {
|
||||
if (existingLabels) {
|
||||
return `${metricName}{${existingLabels},worker="master"}${valueAndRest}`;
|
||||
} else {
|
||||
return `${metricName}{worker="master"}${valueAndRest}`;
|
||||
}
|
||||
},
|
||||
);
|
||||
allMetricValues.push(processedLine);
|
||||
}
|
||||
}
|
||||
|
||||
// Collect metrics from all workers
|
||||
for (let i = 0; i < config.numWorkers(); i++) {
|
||||
const workerPort = config.workerPortByIndex(i);
|
||||
const workerUrl = `http://localhost:${workerPort}/metrics`;
|
||||
console.log(`Fetching metrics from worker ${i} at ${workerUrl}`);
|
||||
|
||||
try {
|
||||
const response = await fetch(workerUrl, {
|
||||
headers: {
|
||||
[config.adminHeader()]: config.adminToken(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Worker ${i} returned status ${response.status}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const metricsText = await response.text();
|
||||
const lines = metricsText.split("\n");
|
||||
|
||||
for (let j = 0; j < lines.length; j++) {
|
||||
const line = lines[j];
|
||||
|
||||
// Collect HELP and TYPE info if we haven't seen this metric before
|
||||
if (line.startsWith("# HELP ")) {
|
||||
const metricName = line.split(" ")[2];
|
||||
if (!seenMetrics.has(metricName)) {
|
||||
seenMetrics.add(metricName);
|
||||
processedLines.push(line);
|
||||
}
|
||||
} else if (line.startsWith("# TYPE ")) {
|
||||
const metricName = line.split(" ")[2];
|
||||
if (
|
||||
seenMetrics.has(metricName) &&
|
||||
!processedLines.some((l) =>
|
||||
l.startsWith(`# TYPE ${metricName}`),
|
||||
)
|
||||
) {
|
||||
processedLines.push(line);
|
||||
}
|
||||
} else if (line.trim() && !line.startsWith("#")) {
|
||||
// Process and collect actual metric values
|
||||
try {
|
||||
const processedLine = line.replace(
|
||||
/^([a-z][a-z0-9_]*)(?:{([^}]*)})?(\s+[0-9.e+-]+.*)/,
|
||||
(match, metricName, existingLabels, valueAndRest) => {
|
||||
if (existingLabels) {
|
||||
return `${metricName}{${existingLabels},worker="worker-${i}"}${valueAndRest}`;
|
||||
} else {
|
||||
return `${metricName}{worker="worker-${i}"}${valueAndRest}`;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Make sure the line was actually processed (regex matched)
|
||||
if (processedLine !== line) {
|
||||
allMetricValues.push(processedLine);
|
||||
} else if (
|
||||
line.match(/^[a-z][a-z0-9_]*(?:{[^}]*})?\s+[0-9.e+-]+.*/)
|
||||
) {
|
||||
// This looks like a metric line but didn't match our regex, try a more general approach
|
||||
const parts = line.split(/({|\s+)/);
|
||||
if (parts.length >= 3) {
|
||||
const metricName = parts[0];
|
||||
if (line.includes("{")) {
|
||||
// Has labels
|
||||
const labelEndIndex = line.indexOf("}");
|
||||
const valueStartIndex = labelEndIndex + 1;
|
||||
if (labelEndIndex > 0 && valueStartIndex < line.length) {
|
||||
const labels = line.substring(
|
||||
line.indexOf("{") + 1,
|
||||
labelEndIndex,
|
||||
);
|
||||
const valueAndRest = line.substring(valueStartIndex);
|
||||
allMetricValues.push(
|
||||
`${metricName}{${labels},worker="worker-${i}"}${valueAndRest}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// No labels
|
||||
const valueAndRest = line.substring(metricName.length);
|
||||
allMetricValues.push(
|
||||
`${metricName}{worker="worker-${i}"}${valueAndRest}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error processing metric line: ${line}`, error);
|
||||
// Skip this line if there's an error
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error fetching metrics from worker ${i}:`, error);
|
||||
allMetricValues.push(
|
||||
`# Error fetching metrics from worker ${i}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Combine metadata with all metric values and ensure it ends with a newline
|
||||
const combinedMetrics = [...processedLines, ...allMetricValues].join(
|
||||
"\n",
|
||||
);
|
||||
|
||||
// Send the combined response with a final newline to prevent unexpected end of input
|
||||
clearTimeout(timeout);
|
||||
res.set("Content-Type", register.contentType);
|
||||
res.end(combinedMetrics + "\n");
|
||||
} catch (error) {
|
||||
console.error("Error collecting metrics:", error);
|
||||
clearTimeout(timeout);
|
||||
res.status(500).end(`# Error collecting metrics: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Start the metrics server on port 9090
|
||||
const METRICS_PORT = 9090;
|
||||
metricsServer.listen(METRICS_PORT, () => {
|
||||
console.log(`Metrics server listening on port ${METRICS_PORT}`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { resourceFromAttributes } from "@opentelemetry/resources";
|
||||
import {
|
||||
ATTR_SERVICE_NAME,
|
||||
ATTR_SERVICE_VERSION,
|
||||
} from "@opentelemetry/semantic-conventions";
|
||||
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
|
||||
|
||||
const config = getServerConfigFromServer();
|
||||
|
||||
export function getOtelResource() {
|
||||
return resourceFromAttributes({
|
||||
[ATTR_SERVICE_NAME]: "openfront",
|
||||
[ATTR_SERVICE_VERSION]: "1.0.0",
|
||||
"service.instance.id": process.env.HOSTNAME,
|
||||
"openfront.environment": config.env(),
|
||||
"openfront.host": process.env.HOST,
|
||||
"openfront.domain": process.env.DOMAIN,
|
||||
"openfront.subdomain": process.env.SUBDOMAIN,
|
||||
"openfront.component": process.env.WORKER_ID
|
||||
? "Worker " + process.env.WORKER_ID
|
||||
: "Master",
|
||||
// The comma-separated list tells OpenTelemetry which resource attributes
|
||||
// should be converted to Loki labels
|
||||
"loki.resource.labels":
|
||||
"service.name,service.instance.id,openfront.environment,openfront.host,openfront.domain,openfront.subdomain,openfront.component",
|
||||
});
|
||||
}
|
||||
+5
-23
@@ -13,7 +13,7 @@ import { Client } from "./Client";
|
||||
import { GameManager } from "./GameManager";
|
||||
import { gatekeeper, LimiterType } from "./Gatekeeper";
|
||||
import { logger } from "./Logger";
|
||||
import { metrics } from "./WorkerMetrics";
|
||||
import { initWorkerMetrics } from "./WorkerMetrics";
|
||||
|
||||
const config = getServerConfigFromServer();
|
||||
|
||||
@@ -33,10 +33,9 @@ export function startWorker() {
|
||||
|
||||
const gm = new GameManager(config, log);
|
||||
|
||||
// Set up periodic metrics updates
|
||||
setInterval(() => {
|
||||
metrics.updateGameMetrics(gm);
|
||||
}, 15000); // Update every 15 seconds
|
||||
if (config.env() == GameEnv.Prod && config.otelEnabled()) {
|
||||
initWorkerMetrics(gm);
|
||||
}
|
||||
|
||||
// Middleware to handle /wX path prefix
|
||||
app.use((req, res, next) => {
|
||||
@@ -165,6 +164,7 @@ export function startWorker() {
|
||||
disableNPCs: req.body.disableNPCs,
|
||||
disableNukes: req.body.disableNukes,
|
||||
gameMode: req.body.gameMode,
|
||||
playerTeams: req.body.playerTeams,
|
||||
});
|
||||
res.status(200).json({ success: true });
|
||||
}),
|
||||
@@ -250,24 +250,6 @@ export function startWorker() {
|
||||
}),
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/metrics",
|
||||
gatekeeper.httpHandler(LimiterType.Get, async (req, res) => {
|
||||
if (req.headers[config.adminHeader()] !== config.adminToken()) {
|
||||
return res.status(403).end("Access denied");
|
||||
}
|
||||
log.info(`metrics requested on worker ${workerId}`);
|
||||
|
||||
try {
|
||||
const metricsData = await metrics.register.metrics();
|
||||
res.set("Content-Type", metrics.register.contentType);
|
||||
res.end(metricsData);
|
||||
} catch (error) {
|
||||
res.status(500).end(error.message);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// WebSocket handling
|
||||
wss.on("connection", (ws: WebSocket, req) => {
|
||||
ws.on(
|
||||
|
||||
+80
-40
@@ -1,52 +1,92 @@
|
||||
import promClient from "prom-client";
|
||||
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
|
||||
import {
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import * as dotenv from "dotenv";
|
||||
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
|
||||
import { GameManager } from "./GameManager";
|
||||
import { getOtelResource } from "./OtelResource";
|
||||
|
||||
const config = getServerConfigFromServer();
|
||||
const region = config.region();
|
||||
dotenv.config();
|
||||
|
||||
// Initialize the Prometheus registry
|
||||
const register = new promClient.Registry();
|
||||
export function initWorkerMetrics(gameManager: GameManager): void {
|
||||
// Get server configuration
|
||||
const config = getServerConfigFromServer();
|
||||
|
||||
// Enable default Node.js metrics collection
|
||||
promClient.collectDefaultMetrics({ register });
|
||||
// Create resource with worker information
|
||||
const resource = getOtelResource();
|
||||
|
||||
// Add worker-specific metrics
|
||||
const activeGamesGauge = new promClient.Gauge({
|
||||
name: "openfront_active_games_count",
|
||||
help: "Number of active games on this worker",
|
||||
labelNames: ["region"],
|
||||
registers: [register],
|
||||
});
|
||||
// Configure auth headers
|
||||
const headers = {};
|
||||
if (config.otelEnabled()) {
|
||||
headers["Authorization"] =
|
||||
"Basic " +
|
||||
Buffer.from(`${config.otelUsername()}:${config.otelPassword()}`).toString(
|
||||
"base64",
|
||||
);
|
||||
}
|
||||
|
||||
const connectedClientsGauge = new promClient.Gauge({
|
||||
name: "openfront_connected_clients_count",
|
||||
help: "Number of connected clients on this worker",
|
||||
labelNames: ["region"],
|
||||
registers: [register],
|
||||
});
|
||||
// Create metrics exporter
|
||||
const metricExporter = new OTLPMetricExporter({
|
||||
url: `${config.otelEndpoint()}/v1/metrics`,
|
||||
headers,
|
||||
});
|
||||
|
||||
const memoryUsageGauge = new promClient.Gauge({
|
||||
name: "openfront_memory_usage_bytes",
|
||||
help: "Current memory usage of the worker process in bytes",
|
||||
labelNames: ["region"],
|
||||
registers: [register],
|
||||
});
|
||||
// Configure the metric reader
|
||||
const metricReader = new PeriodicExportingMetricReader({
|
||||
exporter: metricExporter,
|
||||
exportIntervalMillis: 15000, // Export metrics every 15 seconds
|
||||
});
|
||||
|
||||
// Export the metrics for use in the worker
|
||||
export const metrics = {
|
||||
register,
|
||||
activeGamesGauge,
|
||||
connectedClientsGauge,
|
||||
memoryUsageGauge,
|
||||
// Create a meter provider
|
||||
const meterProvider = new MeterProvider({
|
||||
resource,
|
||||
readers: [metricReader],
|
||||
});
|
||||
|
||||
// Function to update game-related metrics
|
||||
updateGameMetrics: (gameManager: GameManager) => {
|
||||
activeGamesGauge.set({ region: region }, gameManager.activeGames());
|
||||
connectedClientsGauge.set({ region: region }, gameManager.activeClients());
|
||||
// Get meter for creating metrics
|
||||
const meter = meterProvider.getMeter("worker-metrics");
|
||||
|
||||
// Update memory usage metrics
|
||||
// Create observable gauges
|
||||
const activeGamesGauge = meter.createObservableGauge(
|
||||
"openfront.active_games.gauge",
|
||||
{
|
||||
description: "Number of active games on this worker",
|
||||
},
|
||||
);
|
||||
|
||||
const connectedClientsGauge = meter.createObservableGauge(
|
||||
"openfront.connected_clients.gauge",
|
||||
{
|
||||
description: "Number of connected clients on this worker",
|
||||
},
|
||||
);
|
||||
|
||||
const memoryUsageGauge = meter.createObservableGauge(
|
||||
"openfront.memory_usage.bytes",
|
||||
{
|
||||
description: "Current memory usage of the worker process in bytes",
|
||||
},
|
||||
);
|
||||
|
||||
// Register callback for active games metric
|
||||
activeGamesGauge.addCallback((result) => {
|
||||
const count = gameManager.activeGames();
|
||||
result.observe(count);
|
||||
});
|
||||
|
||||
// Register callback for connected clients metric
|
||||
connectedClientsGauge.addCallback((result) => {
|
||||
const count = gameManager.activeClients();
|
||||
result.observe(count);
|
||||
});
|
||||
|
||||
// Register callback for memory usage metric
|
||||
memoryUsageGauge.addCallback((result) => {
|
||||
const memoryUsage = process.memoryUsage();
|
||||
memoryUsageGauge.set({ region: region }, memoryUsage.heapUsed);
|
||||
},
|
||||
};
|
||||
result.observe(memoryUsage.heapUsed);
|
||||
});
|
||||
|
||||
console.log("Metrics initialized with GameManager");
|
||||
}
|
||||
|
||||
+1
-1
Submodule src/server/gatekeeper updated: 4d3fd72121...8324db9408
Reference in New Issue
Block a user