Merge branch 'main' into patterned-territory

This commit is contained in:
Aotumuri
2025-05-17 16:19:00 +09:00
committed by GitHub
773 changed files with 15719 additions and 12321 deletions
+37 -22
View File
@@ -44,7 +44,7 @@ export interface LobbyConfig {
playerName: string;
clientID: ClientID;
gameID: GameID;
persistentID: string;
token: string;
// GameStartInfo only exists when playing a singleplayer game.
gameStartInfo?: GameStartInfo;
// GameRecord exists when replaying an archived game.
@@ -60,11 +60,11 @@ export function joinLobby(
initRemoteSender(eventBus);
consolex.log(
`joinging lobby: gameID: ${lobbyConfig.gameID}, clientID: ${lobbyConfig.clientID}, persistentID: ${lobbyConfig.persistentID.slice(0, 5)}`,
`joinging lobby: gameID: ${lobbyConfig.gameID}, clientID: ${lobbyConfig.clientID}`,
);
const userSettings: UserSettings = new UserSettings();
startGame(lobbyConfig.gameID, lobbyConfig.gameStartInfo?.config);
startGame(lobbyConfig.gameID, lobbyConfig.gameStartInfo?.config ?? {});
const transport = new Transport(lobbyConfig, eventBus);
@@ -75,15 +75,15 @@ export function joinLobby(
let terrainLoad: Promise<TerrainMapData> | null = null;
const onmessage = (message: ServerMessage) => {
if (message.type == "prestart") {
if (message.type === "prestart") {
consolex.log(`lobby: game prestarting: ${JSON.stringify(message)}`);
terrainLoad = loadTerrainMap(message.gameMap);
onPrestart();
}
if (message.type == "start") {
if (message.type === "start") {
// Trigger prestart for singleplayer games
onPrestart();
consolex.log(`lobby: game started: ${JSON.stringify(message)}`);
consolex.log(`lobby: game started: ${JSON.stringify(message, null, 2)}`);
onJoin();
// For multiplayer games, GameStartInfo is not known until game starts.
lobbyConfig.gameStartInfo = message.gameStartInfo;
@@ -110,9 +110,13 @@ export async function createClientGame(
userSettings: UserSettings,
terrainLoad: Promise<TerrainMapData> | null,
): Promise<ClientGameRunner> {
if (lobbyConfig.gameStartInfo === undefined) {
throw new Error("missing gameStartInfo");
}
const config = await getConfig(
lobbyConfig.gameStartInfo.config,
userSettings,
lobbyConfig.gameRecord !== undefined,
);
let gameMap: TerrainMapData | null = null;
@@ -160,7 +164,7 @@ export async function createClientGame(
}
export class ClientGameRunner {
private myPlayer: PlayerView;
private myPlayer: PlayerView | null = null;
private isActive = false;
private turnsSeen = 0;
@@ -193,7 +197,7 @@ export class ClientGameRunner {
},
];
let winner: ClientID | Team | null = null;
if (update.winnerType == "player") {
if (update.winnerType === "player") {
winner = this.gameView
.playerBySmallID(update.winner as number)
.clientID();
@@ -201,6 +205,9 @@ export class ClientGameRunner {
winner = update.winner as Team;
}
if (this.lobby.gameStartInfo === undefined) {
throw new Error("missing gameStartInfo");
}
const record = createGameRecord(
this.lobby.gameStartInfo.gameID,
this.lobby.gameStartInfo,
@@ -233,16 +240,21 @@ export class ClientGameRunner {
this.renderer.initialize();
this.input.initialize();
this.worker.start((gu: GameUpdateViewData | ErrorUpdate) => {
if (this.lobby.gameStartInfo === undefined) {
throw new Error("missing gameStartInfo");
}
if ("errMsg" in gu) {
showErrorModal(
gu.errMsg,
gu.stack,
gu.stack ?? "missing",
this.lobby.gameStartInfo.gameID,
this.lobby.clientID,
);
console.error(gu.stack);
this.stop(true);
return;
}
this.transport.turnComplete();
gu.updates[GameUpdateType.Hash].forEach((hu: HashUpdate) => {
this.eventBus.emit(new SendHashEvent(hu.tick, hu.hash));
});
@@ -266,7 +278,7 @@ export class ClientGameRunner {
};
const onmessage = (message: ServerMessage) => {
this.lastMessageTime = Date.now();
if (message.type == "start") {
if (message.type === "start") {
this.hasJoined = true;
consolex.log("starting game!");
for (const turn of message.turns) {
@@ -276,7 +288,6 @@ export class ClientGameRunner {
while (turn.turnNumber - 1 > this.turnsSeen) {
this.worker.sendTurn({
turnNumber: this.turnsSeen,
gameID: turn.gameID,
intents: [],
});
this.turnsSeen++;
@@ -285,7 +296,10 @@ export class ClientGameRunner {
this.turnsSeen++;
}
}
if (message.type == "desync") {
if (message.type === "desync") {
if (this.lobby.gameStartInfo === undefined) {
throw new Error("missing gameStartInfo");
}
showErrorModal(
`desync from server: ${JSON.stringify(message)}`,
"",
@@ -295,12 +309,12 @@ export class ClientGameRunner {
"You are desynced from other players. What you see might differ from other players.",
);
}
if (message.type == "turn") {
if (message.type === "turn") {
if (!this.hasJoined) {
this.transport.joinGame(0);
return;
}
if (this.turnsSeen != message.turn.turnNumber) {
if (this.turnsSeen !== message.turn.turnNumber) {
consolex.error(
`got wrong turn have turns ${this.turnsSeen}, received turn ${message.turn.turnNumber}`,
);
@@ -347,17 +361,17 @@ export class ClientGameRunner {
if (this.gameView.inSpawnPhase()) {
return;
}
if (this.myPlayer == null) {
this.myPlayer = this.gameView.playerByClientID(this.lobby.clientID);
if (this.myPlayer == null) {
return;
}
if (this.myPlayer === null) {
const myPlayer = this.gameView.playerByClientID(this.lobby.clientID);
if (myPlayer === null) return;
this.myPlayer = myPlayer;
}
this.myPlayer.actions(tile).then((actions) => {
if (this.myPlayer === null) return;
const bu = actions.buildableUnits.find(
(bu) => bu.type == UnitType.TransportShip,
(bu) => bu.type === UnitType.TransportShip,
);
if (bu == null) {
if (bu === undefined) {
console.warn(`no transport ship buildable units`);
return;
}
@@ -376,7 +390,8 @@ export class ClientGameRunner {
this.myPlayer
.bestTransportShipSpawn(this.gameView.ref(cell.x, cell.y))
.then((spawn: number | false) => {
let spawnCell = null;
if (this.myPlayer === null) throw new Error("not initialized");
let spawnCell: Cell | null = null;
if (spawn !== false) {
spawnCell = new Cell(
this.gameView.x(spawn),
+1 -1
View File
@@ -26,7 +26,7 @@ export class FlagInput extends LitElement {
}
private setFlag(flag: string) {
if (flag == "xx") {
if (flag === "xx") {
flag = "";
}
this.flag = flag;
+2 -2
View File
@@ -87,7 +87,7 @@ export class GoogleAdElement extends LitElement {
const isElectron = () => {
// Renderer process
if (
typeof window !== "undefined" &&
window !== undefined &&
typeof window.process === "object" &&
// @ts-expect-error hidden
window.process.type === "renderer"
@@ -97,7 +97,7 @@ const isElectron = () => {
// Main process
if (
typeof process !== "undefined" &&
process !== undefined &&
typeof process.versions === "object" &&
!!process.versions.electron
) {
+79 -30
View File
@@ -6,8 +6,10 @@ import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { consolex } from "../core/Consolex";
import {
Difficulty,
Duos,
GameMapType,
GameMode,
UnitType,
mapCategories,
} from "../core/game/Game";
import { GameConfig, GameInfo } from "../core/Schemas";
@@ -28,8 +30,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 = 2;
@state() private disableNukes: boolean = false;
@state() private teamCount: number | typeof Duos = 2;
@state() private bots: number = 400;
@state() private infiniteGold: boolean = false;
@state() private infiniteTroops: boolean = false;
@@ -38,8 +39,9 @@ export class HostLobbyModal extends LitElement {
@state() private copySuccess = false;
@state() private players: string[] = [];
@state() private useRandomMap: boolean = false;
@state() private disabledUnits: UnitType[] = [];
private playersInterval = null;
private playersInterval: NodeJS.Timeout | null = null;
// Add a new timer for debouncing bot changes
private botsUpdateTimer: number | null = null;
@@ -103,7 +105,7 @@ export class HostLobbyModal extends LitElement {
.selected=${!this.useRandomMap &&
this.selectedMap === mapValue}
.translation=${translateText(
`map.${mapKey.toLowerCase()}`,
`map.${mapKey?.toLowerCase()}`,
)}
></map-display>
</div>
@@ -194,7 +196,7 @@ export class HostLobbyModal extends LitElement {
${translateText("host_modal.team_count")}
</div>
<div class="option-cards">
${[2, 3, 4, 5, 6, 7].map(
${[Duos, 2, 3, 4, 5, 6, 7].map(
(o) => html`
<div
class="option-card ${this.teamCount === o
@@ -230,7 +232,7 @@ export class HostLobbyModal extends LitElement {
/>
<div class="option-card-title">
<span>${translateText("host_modal.bots")}</span>${
this.bots == 0
this.bots === 0
? translateText("host_modal.bots_disabled")
: this.bots
}
@@ -301,21 +303,72 @@ export class HostLobbyModal extends LitElement {
</div>
</label>
<label
for="disable-nukes"
class="option-card ${this.disableNukes ? "selected" : ""}"
<hr style="width: 100%; border-top: 1px solid #444; margin: 16px 0;" />
<!-- Individual disables for structures/weapons -->
<div
style="margin: 8px 0 12px 0; font-weight: bold; color: #ccc; text-align: center;"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
id="disable-nukes"
@change=${this.handleDisableNukesChange}
.checked=${this.disableNukes}
/>
<div class="option-card-title">
${translateText("host_modal.disable_nukes")}
${translateText("host_modal.enables_title")}
</div>
<div
style="display: flex; flex-wrap: wrap; justify-content: center; gap: 12px;"
>
${[
[UnitType.City, "unit_type.city"],
[UnitType.DefensePost, "unit_type.defense_post"],
[UnitType.Port, "unit_type.port"],
[UnitType.Warship, "unit_type.warship"],
[UnitType.MissileSilo, "unit_type.missile_silo"],
[UnitType.SAMLauncher, "unit_type.sam_launcher"],
[UnitType.AtomBomb, "unit_type.atom_bomb"],
[UnitType.HydrogenBomb, "unit_type.hydrogen_bomb"],
[UnitType.MIRV, "unit_type.mirv"],
].map(
([unitType, translationKey]: [UnitType, string]) => html`
<label
class="option-card ${this.disabledUnits.includes(
unitType,
)
? ""
: "selected"}"
style="width: 140px;"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
@change=${(e: Event) => {
const checked = (e.target as HTMLInputElement)
.checked;
const parsedUnitType =
UnitType[unitType as keyof typeof UnitType];
if (parsedUnitType) {
if (checked) {
this.disabledUnits = [
...this.disabledUnits,
parsedUnitType,
];
} else {
this.disabledUnits = this.disabledUnits.filter(
(u) => u !== parsedUnitType,
);
}
this.putGameConfig();
}
}}
.checked=${this.disabledUnits.includes(unitType)}
/>
<div
class="option-card-title"
style="text-align: center;"
>
${translateText(translationKey)}
</div>
</label>
`,
)}
</div>
</label>
</div>
</div>
</div>
</div>
@@ -351,7 +404,7 @@ export class HostLobbyModal extends LitElement {
}
</button>
</div>
</div>
</o-modal>
`;
@@ -449,10 +502,6 @@ export class HostLobbyModal extends LitElement {
this.infiniteTroops = Boolean((e.target as HTMLInputElement).checked);
this.putGameConfig();
}
private handleDisableNukesChange(e: Event) {
this.disableNukes = Boolean((e.target as HTMLInputElement).checked);
this.putGameConfig();
}
private async handleDisableNPCsChange(e: Event) {
this.disableNPCs = Boolean((e.target as HTMLInputElement).checked);
@@ -465,8 +514,8 @@ export class HostLobbyModal extends LitElement {
this.putGameConfig();
}
private async handleTeamCountSelection(value: number) {
this.teamCount = value;
private async handleTeamCountSelection(value: number | typeof Duos) {
this.teamCount = value === Duos ? Duos : Number(value);
this.putGameConfig();
}
@@ -483,14 +532,14 @@ export class HostLobbyModal extends LitElement {
gameMap: this.selectedMap,
difficulty: this.selectedDifficulty,
disableNPCs: this.disableNPCs,
disableNukes: this.disableNukes,
bots: this.bots,
infiniteGold: this.infiniteGold,
infiniteTroops: this.infiniteTroops,
instantBuild: this.instantBuild,
gameMode: this.gameMode,
numPlayerTeams: this.teamCount,
} as GameConfig),
disabledUnits: this.disabledUnits,
playerTeams: this.teamCount,
} satisfies Partial<GameConfig>),
},
);
return response;
@@ -551,7 +600,7 @@ export class HostLobbyModal extends LitElement {
.then((response) => response.json())
.then((data: GameInfo) => {
console.log(`got game info response: ${JSON.stringify(data)}`);
this.players = data.clients.map((p) => p.username);
this.players = data.clients?.map((p) => p.username) ?? [];
});
}
}
+61 -66
View File
@@ -99,7 +99,7 @@ export class InputHandler {
private alternateView = false;
private moveInterval: NodeJS.Timeout = null;
private moveInterval: NodeJS.Timeout | null = null;
private activeKeys = new Set<string>();
private readonly PAN_SPEED = 5;
@@ -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);
});
}
@@ -307,7 +300,7 @@ export class InputHandler {
Math.abs(event.x - this.lastPointerDownX) +
Math.abs(event.y - this.lastPointerDownY);
if (dist < 10) {
if (event.pointerType == "touch") {
if (event.pointerType === "touch") {
this.eventBus.emit(new ContextMenuEvent(event.clientX, event.clientY));
event.preventDefault();
return;
@@ -392,7 +385,9 @@ export class InputHandler {
}
destroy() {
clearInterval(this.moveInterval);
if (this.moveInterval !== null) {
clearInterval(this.moveInterval);
}
this.activeKeys.clear();
}
}
+3 -3
View File
@@ -19,7 +19,7 @@ export class JoinPrivateLobbyModal extends LitElement {
@state() private hasJoined = false;
@state() private players: string[] = [];
private playersInterval = null;
private playersInterval: NodeJS.Timeout | null = null;
render() {
return html`
@@ -98,7 +98,7 @@ export class JoinPrivateLobbyModal extends LitElement {
}
public close() {
this.lobbyIdInput.value = null;
this.lobbyIdInput.value = "";
this.modalEl?.close();
if (this.playersInterval) {
clearInterval(this.playersInterval);
@@ -263,7 +263,7 @@ export class JoinPrivateLobbyModal extends LitElement {
)
.then((response) => response.json())
.then((data: GameInfo) => {
this.players = data.clients.map((p) => p.username);
this.players = data.clients?.map((p) => p.username) ?? [];
})
.catch((error) => {
consolex.error("Error polling players:", error);
+5
View File
@@ -2,6 +2,7 @@ import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import "./LanguageModal";
import ar from "../../resources/lang/ar.json";
import bg from "../../resources/lang/bg.json";
import bn from "../../resources/lang/bn.json";
import de from "../../resources/lang/de.json";
@@ -17,6 +18,7 @@ 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 tp from "../../resources/lang/tp.json";
import tr from "../../resources/lang/tr.json";
import uk from "../../resources/lang/uk.json";
@@ -32,6 +34,7 @@ export class LangSelector extends LitElement {
private dKeyPressed: boolean = false;
private languageMap: Record<string, any> = {
ar,
bg,
bn,
de,
@@ -48,6 +51,7 @@ export class LangSelector extends LitElement {
ru,
sh,
tr,
tp,
uk,
};
@@ -173,6 +177,7 @@ export class LangSelector extends LitElement {
"help-modal",
"username-input",
"public-lobby",
"user-setting",
"o-modal",
"o-button",
];
+4 -4
View File
@@ -3,7 +3,7 @@ import { GameConfig, GameID, GameRecord } from "../core/Schemas";
export interface LocalStatsData {
[key: GameID]: {
lobby: GameConfig;
lobby: Partial<GameConfig>;
// Only once the game is over
gameRecord?: GameRecord;
};
@@ -26,8 +26,8 @@ function save(stats: LocalStatsData) {
// The user can quit the game anytime so better save the lobby as soon as the
// game starts.
export function startGame(id: GameID, lobby: GameConfig) {
if (typeof localStorage === "undefined") {
export function startGame(id: GameID, lobby: Partial<GameConfig>) {
if (localStorage === undefined) {
return;
}
@@ -42,7 +42,7 @@ export function startTime() {
}
export function endGame(gameRecord: GameRecord) {
if (typeof localStorage === "undefined") {
if (localStorage === undefined) {
return;
}
+51 -22
View File
@@ -16,42 +16,61 @@ import { LobbyConfig } from "./ClientGameRunner";
import { getPersistentIDFromCookie } from "./Main";
export class LocalServer {
// All turns from the game record on replay.
private replayTurns: Turn[] = [];
private turns: Turn[] = [];
private intents: Intent[] = [];
private startedAt: number;
private endTurnIntervalID;
private paused = false;
private winner: ClientSendWinnerMessage = null;
private winner: ClientSendWinnerMessage | null = null;
private allPlayersStats: AllPlayersStats = {};
private turnsExecuted = 0;
private lastTurnCompletedTime = 0;
private turnCheckInterval: NodeJS.Timeout;
constructor(
private lobbyConfig: LobbyConfig,
private clientConnect: () => void,
private clientMessage: (message: ServerMessage) => void,
private isReplay: boolean,
) {}
start() {
this.turnCheckInterval = setInterval(() => {
if (this.turnsExecuted === this.turns.length) {
if (
this.isReplay ||
Date.now() >
this.lastTurnCompletedTime +
this.lobbyConfig.serverConfig.turnIntervalMs()
) {
this.endTurn();
}
}
}, 5);
this.startedAt = Date.now();
if (!this.lobbyConfig.gameRecord) {
this.endTurnIntervalID = setInterval(
() => this.endTurn(),
this.lobbyConfig.serverConfig.turnIntervalMs(),
);
}
this.clientConnect();
if (this.lobbyConfig.gameRecord) {
this.turns = decompressGameRecord(this.lobbyConfig.gameRecord).turns;
console.log(`loaded turns: ${JSON.stringify(this.turns)}`);
this.replayTurns = decompressGameRecord(
this.lobbyConfig.gameRecord,
).turns;
}
if (this.lobbyConfig.gameStartInfo === undefined) {
throw new Error("missing gameStartInfo");
}
this.clientMessage(
ServerStartGameMessageSchema.parse({
type: "start",
gameID: this.lobbyConfig.gameStartInfo.gameID,
gameStartInfo: this.lobbyConfig.gameStartInfo,
turns: this.turns,
turns: [],
}),
);
}
@@ -68,13 +87,13 @@ export class LocalServer {
const clientMsg: ClientMessage = ClientMessageSchema.parse(
JSON.parse(message),
);
if (clientMsg.type == "intent") {
if (clientMsg.type === "intent") {
if (this.lobbyConfig.gameRecord) {
// If we are replaying a game, we don't want to process intents
return;
}
if (this.paused) {
if (clientMsg.intent.type == "troop_ratio") {
if (clientMsg.intent.type === "troop_ratio") {
// Store troop change events because otherwise they are
// not registered when game is paused.
this.intents.push(clientMsg.intent);
@@ -83,21 +102,21 @@ export class LocalServer {
}
this.intents.push(clientMsg.intent);
}
if (clientMsg.type == "hash") {
if (clientMsg.type === "hash") {
if (!this.lobbyConfig.gameRecord) {
// If we are playing a singleplayer then store hash.
this.turns[clientMsg.turnNumber].hash = clientMsg.hash;
return;
}
// If we are replaying a game then verify hash.
const archivedHash = this.turns[clientMsg.turnNumber].hash;
const archivedHash = this.replayTurns[clientMsg.turnNumber].hash;
if (!archivedHash) {
console.warn(
`no archived hash found for turn ${clientMsg.turnNumber}, client hash: ${clientMsg.hash}`,
);
return;
}
if (archivedHash != clientMsg.hash) {
if (archivedHash !== clientMsg.hash) {
console.error(
`desync detected on turn ${clientMsg.turnNumber}, client hash: ${clientMsg.hash}, server hash: ${archivedHash}`,
);
@@ -115,19 +134,26 @@ export class LocalServer {
);
}
}
if (clientMsg.type == "winner") {
if (clientMsg.type === "winner") {
this.winner = clientMsg;
this.allPlayersStats = clientMsg.allPlayersStats;
}
}
public turnComplete() {
this.turnsExecuted++;
this.lastTurnCompletedTime = Date.now();
}
private endTurn() {
if (this.paused) {
return;
}
if (this.replayTurns.length > 0) {
this.intents = this.replayTurns[this.turns.length].intents;
}
const pastTurn: Turn = {
turnNumber: this.turns.length,
gameID: this.lobbyConfig.gameStartInfo.gameID,
intents: this.intents,
};
this.turns.push(pastTurn);
@@ -140,7 +166,7 @@ export class LocalServer {
public endGame(saveFullGame: boolean = false) {
consolex.log("local server ending game");
clearInterval(this.endTurnIntervalID);
clearInterval(this.turnCheckInterval);
const players: PlayerRecord[] = [
{
ip: null,
@@ -149,6 +175,9 @@ export class LocalServer {
clientID: this.lobbyConfig.clientID,
},
];
if (this.lobbyConfig.gameStartInfo === undefined) {
throw new Error("missing gameStartInfo");
}
const record = createGameRecord(
this.lobbyConfig.gameStartInfo.gameID,
this.lobbyConfig.gameStartInfo,
@@ -156,8 +185,8 @@ export class LocalServer {
this.turns,
this.startedAt,
Date.now(),
this.winner?.winner,
this.winner?.winnerType,
this.winner?.winner ?? null,
this.winner?.winnerType ?? null,
this.allPlayersStats,
);
if (!saveFullGame) {
+113 -39
View File
@@ -19,18 +19,21 @@ import { JoinPrivateLobbyModal } from "./JoinPrivateLobbyModal";
import "./LangSelector";
import { LangSelector } from "./LangSelector";
import { LanguageModal } from "./LanguageModal";
import { NewsModal } from "./NewsModal";
import "./PublicLobby";
import { PublicLobby } from "./PublicLobby";
import "./RandomNameButton";
import { RandomNameButton } from "./RandomNameButton";
import { SinglePlayerModal } from "./SinglePlayerModal";
import { territoryPatternsModal } from "./TerritoryPatternsModal";
import { UserSettingModal } from "./UserSettingModal";
import "./UsernameInput";
import { UsernameInput } from "./UsernameInput";
import { generateCryptoRandomUUID } from "./Utils";
import "./components/NewsButton";
import { NewsButton } from "./components/NewsButton";
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 {
@@ -44,12 +47,11 @@ export interface JoinLobbyEvent {
}
class Client {
private gameStop: () => void;
private gameStop: (() => void) | null = null;
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;
@@ -59,6 +61,23 @@ class Client {
constructor() {}
initialize(): void {
const newsModal = document.querySelector("news-modal") as NewsModal;
if (!newsModal) {
consolex.warn("News modal element not found");
} else {
consolex.log("News modal element found");
}
newsModal instanceof NewsModal;
const newsButton = document.querySelector("news-button") as NewsButton;
if (!newsButton) {
consolex.warn("News button element not found");
} else {
consolex.log("News button element found");
}
// Comment out to show news button.
newsButton.hidden = true;
const langSelector = document.querySelector(
"lang-selector",
) as LangSelector;
@@ -84,12 +103,12 @@ 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",
@@ -105,7 +124,7 @@ class Client {
window.addEventListener("beforeunload", () => {
consolex.log("Browser is closing");
if (this.gameStop != null) {
if (this.gameStop !== null) {
this.gameStop();
}
});
@@ -118,15 +137,25 @@ class Client {
"single-player-modal",
) as SinglePlayerModal;
spModal instanceof SinglePlayerModal;
document.getElementById("single-player").addEventListener("click", () => {
if (this.usernameInput.isValid()) {
const singlePlayer = document.getElementById("single-player");
if (singlePlayer === null) throw new Error("Missing single-player");
singlePlayer.addEventListener("click", () => {
if (this.usernameInput?.isValid()) {
spModal.open();
}
});
// const ctModal = document.querySelector("chat-modal") as ChatModal;
// ctModal instanceof ChatModal;
// document.getElementById("chat-button").addEventListener("click", () => {
// ctModal.open();
// });
const hlpModal = document.querySelector("help-modal") as HelpModal;
hlpModal instanceof HelpModal;
document.getElementById("help-button").addEventListener("click", () => {
const helpButton = document.getElementById("help-button");
if (helpButton === null) throw new Error("Missing help-button");
helpButton.addEventListener("click", () => {
hlpModal.open();
});
@@ -140,38 +169,78 @@ class Client {
TerritoryModal.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();
});
document
.getElementById("settings-button")
?.addEventListener("click", () => {
settingsModal.open();
});
const hostModal = document.querySelector(
"host-lobby-modal",
) as HostPrivateLobbyModal;
hostModal instanceof HostPrivateLobbyModal;
document
.getElementById("host-lobby-button")
.addEventListener("click", () => {
if (this.usernameInput.isValid()) {
hostModal.open();
this.publicLobby.leaveLobby();
}
});
const hostLobbyButton = document.getElementById("host-lobby-button");
if (hostLobbyButton === null) throw new Error("Missing host-lobby-button");
hostLobbyButton.addEventListener("click", () => {
if (this.usernameInput?.isValid()) {
hostModal.open();
this.publicLobby.leaveLobby();
}
});
this.joinModal = document.querySelector(
"join-private-lobby-modal",
) as JoinPrivateLobbyModal;
this.joinModal instanceof JoinPrivateLobbyModal;
document
.getElementById("join-private-lobby-button")
.addEventListener("click", () => {
if (this.usernameInput.isValid()) {
this.joinModal.open();
}
});
const joinPrivateLobbyButton = document.getElementById(
"join-private-lobby-button",
);
if (joinPrivateLobbyButton === null)
throw new Error("Missing join-private-lobby-button");
joinPrivateLobbyButton.addEventListener("click", () => {
if (this.usernameInput?.isValid()) {
this.joinModal.open();
}
});
if (this.userSettings.darkMode()) {
document.documentElement.classList.add("dark");
@@ -209,7 +278,7 @@ class Client {
private async handleJoinLobby(event: CustomEvent) {
const lobby = event.detail as JoinLobbyEvent;
consolex.log(`joining lobby ${lobby.gameID}`);
if (this.gameStop != null) {
if (this.gameStop !== null) {
consolex.log("joining lobby, stopping existing game");
this.gameStop();
}
@@ -221,18 +290,18 @@ class Client {
serverConfig: config,
pattern: localStorage.getItem("territoryPattern"),
flag:
this.flagInput.getCurrentFlag() == "xx"
this.flagInput === null || this.flagInput.getCurrentFlag() === "xx"
? ""
: this.flagInput.getCurrentFlag(),
playerName: this.usernameInput.getCurrentUsername(),
persistentID: getPersistentIDFromCookie(),
playerName: this.usernameInput?.getCurrentUsername() ?? "",
token: localStorage.getItem("token") ?? getPersistentIDFromCookie(),
clientID: lobby.clientID,
gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.gameStartInfo,
gameRecord: lobby.gameRecord,
},
() => {
console.log("Closing modals");
document.getElementById("settings-button").classList.add("hidden");
document.getElementById("settings-button")?.classList.add("hidden");
[
"single-player-modal",
"host-lobby-modal",
@@ -271,7 +340,7 @@ class Client {
(ad as HTMLElement).style.display = "none";
});
if (event.detail.gameConfig?.gameType != GameType.Singleplayer) {
if (event.detail.gameConfig?.gameType !== GameType.Singleplayer) {
window.history.pushState({}, "", `/join/${lobby.gameID}`);
sessionStorage.setItem("inLobby", "true");
}
@@ -280,7 +349,7 @@ class Client {
}
private async handleLeaveLobby(/* event: CustomEvent */) {
if (this.gameStop == null) {
if (this.gameStop === null) {
return;
}
consolex.log("leaving lobby, cancelling game");
@@ -305,6 +374,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
+77 -99
View File
@@ -1,130 +1,108 @@
export class MultiTabDetector {
private focusChanges: number[] = [];
private readonly maxFocusChanges: number = 10;
private readonly timeWindow: number = 60_000;
private readonly punishmentDelays: number[] = [
2_000, 3_000, 5_000, 10_000, 30_000, 60_000,
];
private lastFocusChangeTime: number = 0;
private isPunished: boolean = false;
private isMonitoring: boolean = false;
private startPenaltyCallback?: (duration: number) => void;
private readonly tabId = `${Date.now()}-${Math.random()}`;
private readonly lockKey = "multi-tab-lock";
private readonly heartbeatIntervalMs = 1_000;
private readonly staleThresholdMs = 3_000;
private numPunishmentsGiven = 0;
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));
}
/**
* Start monitoring for multi-tabbing behavior
*
* @param startPenalty Callback function when punishment starts
*/
public startMonitoring(startPenalty: (duration: number) => void): void {
if (this.isMonitoring) return;
this.isMonitoring = true;
this.startPenaltyCallback = startPenalty;
// Event listeners for window focus/blur
window.addEventListener("blur", this.handleFocusChange.bind(this));
window.addEventListener("focus", this.handleFocusChange.bind(this));
// Also track visibility changes for tab switching
document.addEventListener(
"visibilitychange",
this.handleVisibilityChange.bind(this),
this.writeLock();
this.heartbeatTimer = window.setInterval(
() => this.heartbeat(),
this.heartbeatIntervalMs,
);
}
public stopMonitoring(): void {
if (!this.isMonitoring) return;
this.isMonitoring = false;
// Remove event listeners
window.removeEventListener("blur", this.handleFocusChange.bind(this));
window.removeEventListener("focus", this.handleFocusChange.bind(this));
document.removeEventListener(
"visibilitychange",
this.handleVisibilityChange.bind(this),
);
// Clear data
this.focusChanges = [];
this.isPunished = false;
}
private handleFocusChange(): void {
const currentTime = Date.now();
this.recordFocusChange(currentTime);
// Check for multi-tabbing when focus is gained
if (document.hasFocus() && !this.isPunished) {
this.checkForMultiTabbing(currentTime);
if (this.heartbeatTimer !== null) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
private handleVisibilityChange(): void {
const currentTime = Date.now();
// Record and check regardless of current focus state
this.recordFocusChange(currentTime);
// Only check when tab becomes visible
if (document.visibilityState === "visible" && !this.isPunished) {
this.checkForMultiTabbing(currentTime);
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 recordFocusChange(timestamp: number): void {
if (Math.abs(this.lastFocusChangeTime - timestamp) < 100) {
// Don't count multiple triggers at same time
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;
}
this.focusChanges.push(timestamp);
console.log(`pushing focus change at ${timestamp}`);
this.lastFocusChangeTime = timestamp;
// Keep only recent changes
if (this.focusChanges.length > this.maxFocusChanges) {
this.focusChanges.shift();
if (!this.isPunished) {
this.applyPunishment();
}
}
private checkForMultiTabbing(currentTime: number): void {
// Only if we have enough data points
if (this.focusChanges.length >= this.maxFocusChanges) {
const oldestChange = this.focusChanges[0];
const timeSpan = currentTime - oldestChange;
// If changes happened within detection window
if (timeSpan <= this.timeWindow) {
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 {
// Prevent multiple punishments
if (this.isPunished) return;
this.isPunished = true;
let punishmentDelay = 0;
if (this.numPunishmentsGiven >= this.punishmentDelays.length) {
punishmentDelay = this.punishmentDelays[this.punishmentDelays.length - 1];
} else {
punishmentDelay = this.punishmentDelays[this.numPunishmentsGiven];
}
this.numPunishmentsGiven++;
// Call the start penalty callback
if (this.startPenaltyCallback) {
this.startPenaltyCallback(punishmentDelay);
}
// Remove penalty after delay
this.punishmentCount++;
const delay = 10_000;
this.startPenaltyCallback(delay);
setTimeout(() => {
this.isPunished = false;
}, punishmentDelay);
}, 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;
}
}
}
+65
View File
@@ -0,0 +1,65 @@
import { LitElement, css, html } from "lit";
import { customElement, query } from "lit/decorators.js";
import { translateText } from "../client/Utils";
import "./components/baseComponents/Button";
import "./components/baseComponents/Modal";
@customElement("news-modal")
export class NewsModal extends LitElement {
@query("o-modal") private modalEl!: HTMLElement & {
open: () => void;
close: () => void;
};
static styles = css`
.news-container {
max-height: 60vh;
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.news-content {
color: #ddd;
line-height: 1.5;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 1rem;
}
`;
render() {
return html`
<o-modal title=${translateText("news.title")}>
<div class="options-layout">
<div class="options-section">
<div class="news-container">
<div class="news-content">INSERT NEWS HERE</div>
</div>
</div>
</div>
<o-button
title=${translateText("common.close")}
@click=${this.close}
blockDesktop
></o-button>
</o-modal>
`;
}
public open() {
this.requestUpdate();
this.modalEl?.open();
}
private close() {
this.modalEl?.close();
}
createRenderRoot() {
return this; // light DOM
}
}
+13 -11
View File
@@ -14,7 +14,7 @@ export class PublicLobby extends LitElement {
@state() public isLobbyHighlighted: boolean = false;
@state() private isButtonDebounced: boolean = false;
private lobbiesInterval: number | null = null;
private currLobby: GameInfo = null;
private currLobby: GameInfo | null = null;
private debounceDelay: number = 750;
private lobbyIDToStart = new Map<GameID, number>();
@@ -46,7 +46,8 @@ export class PublicLobby extends LitElement {
// Store the start time on first fetch because endpoint is cached, causing
// the time to appear irregular.
if (!this.lobbyIDToStart.has(l.gameID)) {
this.lobbyIDToStart.set(l.gameID, l.msUntilStart + Date.now());
const msUntilStart = l.msUntilStart ?? 0;
this.lobbyIDToStart.set(l.gameID, msUntilStart + Date.now());
}
});
} catch (error) {
@@ -82,17 +83,18 @@ export class PublicLobby extends LitElement {
if (!lobby?.gameConfig) {
return;
}
const timeRemaining = Math.max(
0,
Math.floor((this.lobbyIDToStart.get(lobby.gameID) - Date.now()) / 1000),
);
const start = this.lobbyIDToStart.get(lobby.gameID) ?? 0;
const timeRemaining = Math.max(0, Math.floor((start - Date.now()) / 1000));
// Format time to show minutes and seconds
const minutes = Math.floor(timeRemaining / 60);
const seconds = timeRemaining % 60;
const timeDisplay = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
const playersRemainingBeforeMax =
lobby.gameConfig.maxPlayers - lobby.numClients;
const teamCount =
lobby.gameConfig.gameMode === GameMode.Team
? lobby.gameConfig.playerTeams || 0
: null;
return html`
<button
@@ -126,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 ?? 0 })
: translateText("game_mode.ffa")}
</div>
</div>
@@ -163,7 +165,7 @@ export class PublicLobby extends LitElement {
this.isButtonDebounced = false;
}, this.debounceDelay);
if (this.currLobby == null) {
if (this.currLobby === null) {
this.isLobbyHighlighted = true;
this.currLobby = lobby;
this.dispatchEvent(
-30
View File
@@ -1,30 +0,0 @@
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>
`;
}
}
+67 -23
View File
@@ -5,9 +5,11 @@ import { translateText } from "../client/Utils";
import { consolex } from "../core/Consolex";
import {
Difficulty,
Duos,
GameMapType,
GameMode,
GameType,
UnitType,
mapCategories,
} from "../core/game/Game";
import { generateID } from "../core/Util";
@@ -36,7 +38,9 @@ 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 = 2;
@state() private teamCount: number | typeof Duos = 2;
@state() private disabledUnits: string[] = [];
render() {
return html`
@@ -69,7 +73,7 @@ export class SinglePlayerModal extends LitElement {
.selected=${!this.useRandomMap &&
this.selectedMap === mapValue}
.translation=${translateText(
`map.${mapKey.toLowerCase()}`,
`map.${mapKey?.toLowerCase()}`,
)}
></map-display>
</div>
@@ -165,7 +169,7 @@ export class SinglePlayerModal extends LitElement {
${translateText("host_modal.team_count")}
</div>
<div class="option-cards">
${[2, 3, 4, 5, 6, 7].map(
${["Duos", 2, 3, 4, 5, 6, 7].map(
(o) => html`
<div
class="option-card ${this.teamCount === o
@@ -200,7 +204,7 @@ export class SinglePlayerModal extends LitElement {
/>
<div class="option-card-title">
<span>${translateText("single_modal.bots")}</span>${this
.bots == 0
.bots === 0
? translateText("single_modal.bots_disabled")
: this.bots}
</div>
@@ -268,22 +272,61 @@ export class SinglePlayerModal extends LitElement {
${translateText("single_modal.infinite_troops")}
</div>
</label>
</div>
<label
for="singleplayer-modal-disable-nukes"
class="option-card ${this.disableNukes ? "selected" : ""}"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
id="singleplayer-modal-disable-nukes"
@change=${this.handleDisableNukesChange}
.checked=${this.disableNukes}
/>
<div class="option-card-title">
${translateText("single_modal.disable_nukes")}
</div>
</label>
<hr
style="width: 100%; border-top: 1px solid #444; margin: 16px 0;"
/>
<div
style="margin: 8px 0 12px 0; font-weight: bold; color: #ccc; text-align: center;"
>
${translateText("single_modal.enables_title")}
</div>
<div
style="display: flex; flex-wrap: wrap; justify-content: center; gap: 12px;"
>
${[
[UnitType.City, "unit_type.city"],
[UnitType.DefensePost, "unit_type.defense_post"],
[UnitType.Port, "unit_type.port"],
[UnitType.Warship, "unit_type.warship"],
[UnitType.MissileSilo, "unit_type.missile_silo"],
[UnitType.SAMLauncher, "unit_type.sam_launcher"],
[UnitType.AtomBomb, "unit_type.atom_bomb"],
[UnitType.HydrogenBomb, "unit_type.hydrogen_bomb"],
[UnitType.MIRV, "unit_type.mirv"],
].map(
([unitType, translationKey]) => html`
<label
class="option-card ${this.disabledUnits.includes(unitType)
? ""
: "selected"}"
style="width: 140px;"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
@change=${(e: Event) => {
const checked = (e.target as HTMLInputElement).checked;
if (checked) {
this.disabledUnits = [
...this.disabledUnits,
unitType,
];
} else {
this.disabledUnits = this.disabledUnits.filter(
(u) => u !== unitType,
);
}
}}
.checked=${this.disabledUnits.includes(unitType)}
/>
<div class="option-card-title" style="text-align: center;">
${translateText(translationKey)}
</div>
</label>
`,
)}
</div>
</div>
</div>
@@ -355,8 +398,8 @@ export class SinglePlayerModal extends LitElement {
this.gameMode = value;
}
private handleTeamCountSelection(value: number) {
this.teamCount = value;
private handleTeamCountSelection(value: number | string) {
this.teamCount = value === "Duos" ? Duos : Number(value);
}
private getRandomMap(): GameMapType {
@@ -401,7 +444,7 @@ export class SinglePlayerModal extends LitElement {
clientID,
username: usernameInput.getCurrentUsername(),
flag:
flagInput.getCurrentFlag() == "xx"
flagInput.getCurrentFlag() === "xx"
? ""
: flagInput.getCurrentFlag(),
pattern: localStorage.getItem("territoryPattern"),
@@ -411,7 +454,7 @@ export class SinglePlayerModal extends LitElement {
gameMap: this.selectedMap,
gameType: GameType.Singleplayer,
gameMode: this.gameMode,
numPlayerTeams: this.teamCount,
playerTeams: this.teamCount,
difficulty: this.selectedDifficulty,
disableNPCs: this.disableNPCs,
disableNukes: this.disableNukes,
@@ -419,6 +462,7 @@ export class SinglePlayerModal extends LitElement {
infiniteGold: this.infiniteGold,
infiniteTroops: this.infiniteTroops,
instantBuild: this.instantBuild,
disabledUnits: this.disabledUnits,
},
},
} as JoinLobbyEvent,
+96 -76
View File
@@ -13,13 +13,13 @@ import {
import { PlayerView } from "../core/game/GameView";
import {
AllPlayersStats,
ClientHashMessage,
ClientID,
ClientIntentMessageSchema,
ClientJoinMessageSchema,
ClientLogMessageSchema,
ClientMessageSchema,
ClientPingMessageSchema,
ClientSendWinnerSchema,
ClientIntentMessage,
ClientJoinMessage,
ClientLogMessage,
ClientPingMessage,
ClientSendWinnerMessage,
Intent,
ServerMessage,
ServerMessageSchema,
@@ -60,14 +60,14 @@ export class SendSpawnIntentEvent implements GameEvent {
export class SendAttackIntentEvent implements GameEvent {
constructor(
public readonly targetID: PlayerID,
public readonly targetID: PlayerID | null,
public readonly troops: number,
) {}
}
export class SendBoatAttackIntentEvent implements GameEvent {
constructor(
public readonly targetID: PlayerID,
public readonly targetID: PlayerID | null,
public readonly dst: Cell,
public readonly troops: number,
public readonly src: Cell | null = null,
@@ -88,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,
) {}
}
@@ -108,6 +108,15 @@ export class SendDonateTroopsIntentEvent implements GameEvent {
) {}
}
export class SendQuickChatEvent implements GameEvent {
constructor(
public readonly sender: PlayerView,
public readonly recipient: PlayerView,
public readonly quickChatKey: string,
public readonly variables: { [key: string]: string },
) {}
}
export class SendEmbargoIntentEvent implements GameEvent {
constructor(
public readonly sender: PlayerView,
@@ -149,7 +158,7 @@ export class MoveWarshipIntentEvent implements GameEvent {
}
export class Transport {
private socket: WebSocket;
private socket: WebSocket | null = null;
private localServer: LocalServer;
@@ -167,8 +176,8 @@ export class Transport {
// If gameRecord is not null, we are replaying an archived game.
// For multiplayer games, GameConfig is not known until game starts.
this.isLocal =
lobbyConfig.gameRecord != null ||
lobbyConfig.gameStartInfo?.config.gameType == GameType.Singleplayer;
lobbyConfig.gameRecord !== undefined ||
lobbyConfig.gameStartInfo?.config.gameType === GameType.Singleplayer;
this.eventBus.on(SendAllianceRequestIntentEvent, (e) =>
this.onSendAllianceRequest(e),
@@ -196,6 +205,7 @@ export class Transport {
this.eventBus.on(SendDonateTroopsIntentEvent, (e) =>
this.onSendDonateTroopIntent(e),
);
this.eventBus.on(SendQuickChatEvent, (e) => this.onSendQuickChatIntent(e));
this.eventBus.on(SendEmbargoIntentEvent, (e) =>
this.onSendEmbargoIntent(e),
);
@@ -218,18 +228,13 @@ export class Transport {
private startPing() {
if (this.isLocal || this.pingInterval) return;
if (this.pingInterval == null) {
if (this.pingInterval === null) {
this.pingInterval = window.setInterval(() => {
if (this.socket != null && this.socket.readyState === WebSocket.OPEN) {
if (this.socket !== null && this.socket.readyState === WebSocket.OPEN) {
this.sendMsg(
JSON.stringify(
ClientPingMessageSchema.parse({
type: "ping",
clientID: this.lobbyConfig.clientID,
persistentID: this.lobbyConfig.persistentID,
gameID: this.lobbyConfig.gameID,
}),
),
JSON.stringify({
type: "ping",
} satisfies ClientPingMessage),
);
}
}, 5 * 1000);
@@ -258,7 +263,12 @@ export class Transport {
onconnect: () => void,
onmessage: (message: ServerMessage) => void,
) {
this.localServer = new LocalServer(this.lobbyConfig, onconnect, onmessage);
this.localServer = new LocalServer(
this.lobbyConfig,
onconnect,
onmessage,
this.lobbyConfig.gameRecord !== undefined,
);
this.localServer.start();
}
@@ -280,7 +290,12 @@ export class Transport {
console.log("Connected to game server!");
while (this.buffer.length > 0) {
console.log("sending dropped message");
this.sendMsg(this.buffer.pop());
const msg = this.buffer.pop();
if (msg === undefined) {
console.warn("msg is undefined");
continue;
}
this.sendMsg(msg);
}
onconnect();
};
@@ -296,13 +311,14 @@ export class Transport {
};
this.socket.onerror = (err) => {
console.error("Socket encountered error: ", err, "Closing socket");
if (this.socket === null) return;
this.socket.close();
};
this.socket.onclose = (event: CloseEvent) => {
console.log(
`WebSocket closed. Code: ${event.code}, Reason: ${event.reason}`,
);
if (event.code != 1000) {
if (event.code !== 1000) {
console.log(`reconnecting`);
this.reconnect();
}
@@ -313,35 +329,34 @@ export class Transport {
this.connect(this.onconnect, this.onmessage);
}
public turnComplete() {
if (this.isLocal) {
this.localServer.turnComplete();
}
}
private onSendLogEvent(event: SendLogEvent) {
this.sendMsg(
JSON.stringify(
ClientLogMessageSchema.parse({
type: "log",
gameID: this.lobbyConfig.gameID,
clientID: this.lobbyConfig.clientID,
persistentID: this.lobbyConfig.persistentID,
log: event.log,
severity: event.severity,
}),
),
JSON.stringify({
type: "log",
log: event.log,
severity: event.severity,
} satisfies ClientLogMessage),
);
}
joinGame(numTurns: number) {
this.sendMsg(
JSON.stringify(
ClientJoinMessageSchema.parse({
type: "join",
gameID: this.lobbyConfig.gameID,
clientID: this.lobbyConfig.clientID,
lastTurn: numTurns,
persistentID: this.lobbyConfig.persistentID,
username: this.lobbyConfig.playerName,
flag: this.lobbyConfig.flag,
pattern: this.lobbyConfig.pattern,
}),
),
JSON.stringify({
type: "join",
gameID: this.lobbyConfig.gameID,
clientID: this.lobbyConfig.clientID,
lastTurn: numTurns,
token: this.lobbyConfig.token,
username: this.lobbyConfig.playerName,
flag: this.lobbyConfig.flag,
pattern: this.lobbyConfig.pattern,
} satisfies ClientJoinMessage),
);
}
@@ -351,6 +366,7 @@ export class Transport {
return;
}
this.stopPing();
if (this.socket === null) return;
if (this.socket.readyState === WebSocket.OPEN) {
console.log("on stop: leaving game");
this.socket.close();
@@ -419,8 +435,8 @@ export class Transport {
troops: event.troops,
dstX: event.dst.x,
dstY: event.dst.y,
srcX: event.src?.x,
srcY: event.src?.y,
srcX: event.src?.x ?? null,
srcY: event.src?.y ?? null,
});
}
@@ -437,7 +453,7 @@ export class Transport {
type: "emoji",
clientID: this.lobbyConfig.clientID,
recipient:
event.recipient == AllPlayers ? AllPlayers : event.recipient.id(),
event.recipient === AllPlayers ? AllPlayers : event.recipient.id(),
emoji: event.emoji,
});
}
@@ -460,6 +476,16 @@ export class Transport {
});
}
private onSendQuickChatIntent(event: SendQuickChatEvent) {
this.sendIntent({
type: "quick_chat",
clientID: this.lobbyConfig.clientID,
recipient: event.recipient.id(),
quickChatKey: event.quickChatKey,
variables: event.variables,
});
}
private onSendEmbargoIntent(event: SendEmbargoIntentEvent) {
this.sendIntent({
type: "embargo",
@@ -500,37 +526,33 @@ export class Transport {
}
private onSendWinnerEvent(event: SendWinnerEvent) {
if (this.isLocal || this.socket.readyState === WebSocket.OPEN) {
const msg = ClientSendWinnerSchema.parse({
if (this.isLocal || this.socket?.readyState === WebSocket.OPEN) {
const msg = {
type: "winner",
clientID: this.lobbyConfig.clientID,
persistentID: this.lobbyConfig.persistentID,
gameID: this.lobbyConfig.gameID,
winner: event.winner,
allPlayersStats: event.allPlayersStats,
winnerType: event.winnerType,
});
} satisfies ClientSendWinnerMessage;
this.sendMsg(JSON.stringify(msg));
} else {
console.log(
"WebSocket is not open. Current state:",
this.socket.readyState,
this.socket?.readyState,
);
console.log("attempting reconnect");
}
}
private onSendHashEvent(event: SendHashEvent) {
if (this.socket === null) return;
if (this.isLocal || this.socket.readyState === WebSocket.OPEN) {
const msg = ClientMessageSchema.parse({
type: "hash",
clientID: this.lobbyConfig.clientID,
persistentID: this.lobbyConfig.persistentID,
gameID: this.lobbyConfig.gameID,
turnNumber: event.tick,
hash: event.hash,
});
this.sendMsg(JSON.stringify(msg));
this.sendMsg(
JSON.stringify({
type: "hash",
turnNumber: event.tick,
hash: event.hash,
} satisfies ClientHashMessage),
);
} else {
console.log(
"WebSocket is not open. Current state:",
@@ -558,19 +580,16 @@ export class Transport {
}
private sendIntent(intent: Intent) {
if (this.isLocal || this.socket.readyState === WebSocket.OPEN) {
const msg = ClientIntentMessageSchema.parse({
if (this.isLocal || this.socket?.readyState === WebSocket.OPEN) {
const msg = {
type: "intent",
clientID: this.lobbyConfig.clientID,
persistentID: this.lobbyConfig.persistentID,
gameID: this.lobbyConfig.gameID,
intent: intent,
});
} satisfies ClientIntentMessage;
this.sendMsg(JSON.stringify(msg));
} else {
console.log(
"WebSocket is not open. Current state:",
this.socket.readyState,
this.socket?.readyState,
);
console.log("attempting reconnect");
}
@@ -580,9 +599,10 @@ export class Transport {
if (this.isLocal) {
this.localServer.onMessage(msg);
} else {
if (this.socket === null) return;
if (
this.socket.readyState == WebSocket.CLOSED ||
this.socket.readyState == WebSocket.CLOSED
this.socket.readyState === WebSocket.CLOSED ||
this.socket.readyState === WebSocket.CLOSED
) {
console.warn("socket not ready, closing and trying later");
this.socket.close();
@@ -596,7 +616,7 @@ export class Transport {
}
private killExistingSocket(): void {
if (this.socket == null) {
if (this.socket === null) {
return;
}
// Remove all event listeners
+271 -86
View File
@@ -1,6 +1,9 @@
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";
@@ -9,7 +12,8 @@ import "./components/baseComponents/setting/SettingToggle";
export class UserSettingModal extends LitElement {
private userSettings: UserSettings = new UserSettings();
@state() private darkMode: boolean = this.userSettings.darkMode();
@state() private settingsMode: "basic" | "keybinds" = "basic";
@state() private keybinds: Record<string, string> = {};
@state() private keySequence: string[] = [];
@state() private showEasterEggSettings = false;
@@ -17,6 +21,15 @@ export class UserSettingModal extends LitElement {
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 & {
@@ -89,6 +102,15 @@ export class UserSettingModal extends LitElement {
console.log("🤡 Emojis:", enabled ? "ON" : "OFF");
}
private toggleAnonymousNames(e: CustomEvent<{ checked: boolean }>) {
const enabled = e.detail?.checked;
if (typeof enabled !== "boolean") return;
this.userSettings.set("settings.anonymousNames", enabled);
console.log("🙈 Anonymous Names:", enabled ? "ON" : "OFF");
}
private toggleLeftClickOpensMenu(e: CustomEvent<{ checked: boolean }>) {
const enabled = e.detail?.checked;
if (typeof enabled !== "boolean") return;
@@ -119,96 +141,63 @@ export class UserSettingModal extends LitElement {
}
}
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="User Settings">
<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">
<setting-toggle
label="🌙 Dark Mode"
description="Toggle the sites appearance between light and dark themes"
id="dark-mode-toggle"
.checked=${this.userSettings.darkMode()}
@change=${(e: CustomEvent<{ checked: boolean }>) =>
this.toggleDarkMode(e)}
></setting-toggle>
<setting-toggle
label="😊 Emojis"
description="Toggle whether emojis are shown in game"
id="emoji-toggle"
.checked=${this.userSettings.emojis()}
@change=${this.toggleEmojis}
></setting-toggle>
<setting-toggle
label="🖱️ Left Click to Open Menu"
description="When ON, left-click opens menu and sword button attacks. When OFF, right-click attacks directly."
id="left-click-toggle"
.checked=${this.userSettings.leftClickOpensMenu()}
@change=${this.toggleLeftClickOpensMenu}
></setting-toggle>
<setting-slider
label="⚔️ Attack Ratio"
description="What percentage of your troops to send in an attack (1100%)"
min="1"
max="100"
.value=${Number(
localStorage.getItem("settings.attackRatio") ?? "0.2",
) * 100}
@change=${this.sliderAttackRatio}
></setting-slider>
<setting-slider
label="🪖🛠️ Troops and Workers Ratio"
description="Adjust the balance between troops (for combat) and workers (for gold production) (1100%)"
min="1"
max="100"
.value=${Number(
localStorage.getItem("settings.troopRatio") ?? "0.95",
) * 100}
@change=${this.sliderTroopRatio}
></setting-slider>
${this.showEasterEggSettings
? html`
<setting-slider
label="Writing Speed Multiplier"
description="Adjust how fast you pretend to code (x1x100)"
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="Bug Count"
description="How many bugs you're okay with (01000, emotionally)"
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}
${this.settingsMode === "basic"
? this.renderBasicSettings()
: this.renderKeybindSettings()}
</div>
</div>
</div>
@@ -216,7 +205,203 @@ export class UserSettingModal extends LitElement {
`;
}
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>
<!-- 🙈 Anonymous Names -->
<setting-toggle
label="${translateText("user_setting.anonymous_names_label")}"
description="${translateText("user_setting.anonymous_names_desc")}"
id="anonymous-names-toggle"
.checked=${this.userSettings.anonymousNames()}
@change=${this.toggleAnonymousNames}
></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 (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 (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();
}
+1 -1
View File
@@ -64,7 +64,7 @@ export class UsernameInput extends LitElement {
this.storeUsername(this.username);
this.validationError = "";
} else {
this.validationError = result.error;
this.validationError = result.error ?? "";
}
}
+2 -2
View File
@@ -45,12 +45,12 @@ export function createCanvas(): HTMLCanvasElement {
*/
export function generateCryptoRandomUUID(): string {
// Type guard to check if randomUUID is available
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
if (crypto !== undefined && "randomUUID" in crypto) {
return crypto.randomUUID();
}
// Fallback using crypto.getRandomValues
if (typeof crypto !== "undefined" && "getRandomValues" in crypto) {
if (crypto !== undefined && "getRandomValues" in crypto) {
return (([1e7] as any) + -1e3 + -4e3 + -8e3 + -1e11).replace(
/[018]/g,
(c: number): string =>
+4
View File
@@ -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",
@@ -24,6 +25,9 @@ export const MapDescription: Record<keyof typeof GameMapType, string> = {
BetweenTwoSeas: "Between Two Seas",
KnownWorld: "Known World",
FaroeIslands: "Faroe Islands",
DeglaciatedAntarctica: "Deglaciated Antarctica",
FalklandIslands: "Falkland Islands",
Baikal: "Baikal",
};
@customElement("map-display")
+64
View File
@@ -0,0 +1,64 @@
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import megaphone from "../../../resources/images/Megaphone.svg";
import { NewsModal } from "../NewsModal";
import { translateText } from "../Utils";
@customElement("news-button")
export class NewsButton extends LitElement {
@property({ type: Boolean })
hidden = false;
static styles = css`
.news-button {
opacity: 0.75;
transition: opacity 0.2s ease;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
margin: 0;
border: none;
background: none;
cursor: pointer;
}
.news-button:hover {
opacity: 1;
}
.news-button img {
width: 24px;
height: 24px;
display: block;
margin-left: 12px;
}
.hidden {
display: none !important;
}
`;
private handleClick() {
const newsModal = document.querySelector("news-modal") as NewsModal;
if (newsModal) {
newsModal.open();
}
}
render() {
return html`
<div class="text-center mb-0.5 ${this.hidden ? "hidden" : ""}">
<button class="news-button" @click=${this.handleClick}>
<img src="${megaphone}" alt=${translateText("news.title")} />
</button>
</div>
`;
}
createRenderRoot() {
return this;
}
}
@@ -12,7 +12,7 @@ export class OModal extends LitElement {
.c-modal {
position: fixed;
padding: 1rem;
z-index: 1000;
z-index: 9999;
left: 0;
bottom: 0;
right: 0;
@@ -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();
}
}
@@ -30,7 +30,7 @@ export class SettingSlider extends LitElement {
private handleSliderChange(e: Event) {
const detail = (e as CustomEvent)?.detail;
if (!detail || typeof detail.value === "undefined") {
if (!detail || detail.value === undefined) {
console.warn("Invalid slider change event", e);
return;
}
File diff suppressed because it is too large Load Diff
+31 -1
View File
@@ -7,6 +7,8 @@ import { RefreshGraphicsEvent as RedrawGraphicsEvent } from "../InputHandler";
import { TransformHandler } from "./TransformHandler";
import { UIState } from "./UIState";
import { BuildMenu } from "./layers/BuildMenu";
import { ChatDisplay } from "./layers/ChatDisplay";
import { ChatModal } from "./layers/ChatModal";
import { ControlPanel } from "./layers/ControlPanel";
import { EmojiTable } from "./layers/EmojiTable";
import { EventsDisplay } from "./layers/EventsDisplay";
@@ -20,6 +22,7 @@ import { PlayerPanel } from "./layers/PlayerPanel";
import { RadialMenu } from "./layers/RadialMenu";
import { SpawnTimer } from "./layers/SpawnTimer";
import { StructureLayer } from "./layers/StructureLayer";
import { TeamStats } from "./layers/TeamStats";
import { TerrainLayer } from "./layers/TerrainLayer";
import { TerritoryLayer } from "./layers/TerritoryLayer";
import { TopBar } from "./layers/TopBar";
@@ -68,6 +71,14 @@ export function createRenderer(
leaderboard.eventBus = eventBus;
leaderboard.game = game;
const teamStats = document.querySelector("team-stats") as TeamStats;
if (!emojiTable || !(teamStats instanceof TeamStats)) {
consolex.error("EmojiTable element not found in the DOM");
}
teamStats.clientID = clientID;
teamStats.eventBus = eventBus;
teamStats.game = game;
const controlPanel = document.querySelector("control-panel") as ControlPanel;
if (!(controlPanel instanceof ControlPanel)) {
consolex.error("ControlPanel element not found in the DOM");
@@ -87,6 +98,14 @@ export function createRenderer(
eventsDisplay.game = game;
eventsDisplay.clientID = clientID;
const chatDisplay = document.querySelector("chat-display") as ChatDisplay;
if (!(chatDisplay instanceof ChatDisplay)) {
consolex.error("chat display not found");
}
chatDisplay.eventBus = eventBus;
chatDisplay.game = game;
chatDisplay.clientID = clientID;
const playerInfo = document.querySelector(
"player-info-overlay",
) as PlayerInfoOverlay;
@@ -126,6 +145,13 @@ export function createRenderer(
playerPanel.eventBus = eventBus;
playerPanel.emojiTable = emojiTable;
const chatModal = document.querySelector("chat-modal") as ChatModal;
if (!(chatModal instanceof ChatModal)) {
console.error("chat modal not found");
}
chatModal.g = game;
chatModal.eventBus = eventBus;
const multiTabModal = document.querySelector(
"multi-tab-modal",
) as MultiTabModal;
@@ -142,6 +168,7 @@ export function createRenderer(
new UILayer(game, eventBus, clientID, transformHandler),
new NameLayer(game, transformHandler, clientID),
eventsDisplay,
chatDisplay,
buildMenu,
new RadialMenu(
eventBus,
@@ -160,6 +187,7 @@ export function createRenderer(
playerInfo,
winModel,
optionsMenu,
teamStats,
topBar,
playerPanel,
multiTabModal,
@@ -186,7 +214,9 @@ export class GameRenderer {
public uiState: UIState,
private layers: Layer[],
) {
this.context = canvas.getContext("2d");
const context = canvas.getContext("2d");
if (context === null) throw new Error("2d context not supported");
this.context = context;
}
initialize() {
+3
View File
@@ -90,6 +90,9 @@ export const getColoredSprite = (
}
const sprite = getSpriteForUnit(unit.type());
if (sprite === null) {
throw new Error(`Failed to load sprite for ${unit.type()}`);
}
const territoryRgb = territoryColor.toRgb();
const borderRgb = borderColor.toRgb();
+5 -3
View File
@@ -9,8 +9,8 @@ export class TransformHandler {
private offsetX: number = -350;
private offsetY: number = -200;
private target: Cell;
private intervalID = null;
private target: Cell | null;
private intervalID: NodeJS.Timeout | null = null;
private changed = false;
constructor(
@@ -170,6 +170,8 @@ export class TransformHandler {
const { screenX, screenY } = this.screenCenter();
const screenMapCenter = new Cell(screenX, screenY);
if (this.target === null) throw new Error("null target");
if (
this.game.manhattanDist(
this.game.ref(screenX, screenY),
@@ -234,7 +236,7 @@ export class TransformHandler {
}
private clearTarget() {
if (this.intervalID != null) {
if (this.intervalID !== null) {
clearInterval(this.intervalID);
this.intervalID = null;
}
+39 -34
View File
@@ -10,6 +10,7 @@ import atomBombIcon from "../../../../resources/images/NukeIconWhite.svg";
import portIcon from "../../../../resources/images/PortIcon.svg";
import samlauncherIcon from "../../../../resources/images/SamLauncherIconWhite.svg";
import shieldIcon from "../../../../resources/images/ShieldIconWhite.svg";
import { translateText } from "../../../client/Utils";
import { EventBus } from "../../../core/EventBus";
import { Cell, PlayerActions, UnitType } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
@@ -22,6 +23,7 @@ interface BuildItemDisplay {
unitType: UnitType;
icon: string;
description?: string;
key?: string;
countable?: boolean;
}
@@ -30,56 +32,65 @@ const buildTable: BuildItemDisplay[][] = [
{
unitType: UnitType.AtomBomb,
icon: atomBombIcon,
description: "Small explosion",
description: "build_menu.desc.atom_bomb",
key: "unit_type.atom_bomb",
countable: false,
},
{
unitType: UnitType.MIRV,
icon: mirvIcon,
description: "Huge explosion, only targets selected player",
description: "build_menu.desc.mirv",
key: "unit_type.mirv",
countable: false,
},
{
unitType: UnitType.HydrogenBomb,
icon: hydrogenBombIcon,
description: "Large explosion",
description: "build_menu.desc.hydrogen_bomb",
key: "unit_type.hydrogen_bomb",
countable: false,
},
{
unitType: UnitType.Warship,
icon: warshipIcon,
description: "Captures trade ships, destroys ships and boats",
description: "build_menu.desc.warship",
key: "unit_type.warship",
countable: true,
},
{
unitType: UnitType.Port,
icon: portIcon,
description: "Sends trade ships to allies to generate gold",
description: "build_menu.desc.port",
key: "unit_type.port",
countable: true,
},
{
unitType: UnitType.MissileSilo,
icon: missileSiloIcon,
description: "Used to launch nukes",
description: "build_menu.desc.missile_silo",
key: "unit_type.missile_silo",
countable: true,
},
// needs new icon
{
unitType: UnitType.SAMLauncher,
icon: samlauncherIcon,
description: "Defends against incoming nukes",
description: "build_menu.desc.sam_launcher",
key: "unit_type.sam_launcher",
countable: true,
},
{
unitType: UnitType.DefensePost,
icon: shieldIcon,
description: "Increase defenses of nearby borders",
description: "build_menu.desc.defense_post",
key: "unit_type.defense_post",
countable: true,
},
{
unitType: UnitType.City,
icon: cityIcon,
description: "Increase max population",
description: "build_menu.desc.city",
key: "unit_type.city",
countable: true,
},
],
@@ -292,13 +303,12 @@ export class BuildMenu extends LitElement implements Layer {
private _hidden = true;
private canBuild(item: BuildItemDisplay): boolean {
if (this.game?.myPlayer() == null || this.playerActions == null) {
if (this.game?.myPlayer() === null || this.playerActions === null) {
return false;
}
const unit = this.playerActions.buildableUnits.filter(
(u) => u.type == item.unitType,
);
if (!unit) {
const buildableUnits = this.playerActions?.buildableUnits ?? [];
const unit = buildableUnits.filter((u) => u.type === item.unitType);
if (unit.length === 0) {
return false;
}
return unit[0].canBuild !== false;
@@ -306,7 +316,7 @@ export class BuildMenu extends LitElement implements Layer {
private cost(item: BuildItemDisplay): number {
for (const bu of this.playerActions?.buildableUnits ?? []) {
if (bu.type == item.unitType) {
if (bu.type === item.unitType) {
return bu.cost;
}
}
@@ -347,7 +357,9 @@ export class BuildMenu extends LitElement implements Layer {
class="build-button"
@click=${() => this.onBuildSelected(item)}
?disabled=${!this.canBuild(item)}
title=${!this.canBuild(item) ? "Not enough money" : ""}
title=${!this.canBuild(item)
? translateText("build_menu.not_enough_money")
: ""}
>
<img
src=${item.icon}
@@ -355,8 +367,13 @@ export class BuildMenu extends LitElement implements Layer {
width="40"
height="40"
/>
<span class="build-name">${item.unitType}</span>
<span class="build-description">${item.description}</span>
<span class="build-name"
>${item.key && translateText(item.key)}</span
>
<span class="build-description"
>${item.description &&
translateText(item.description)}</span
>
<span class="build-cost" translate="no">
${renderNumber(
this.game && this.game.myPlayer() ? this.cost(item) : 0,
@@ -398,7 +415,7 @@ export class BuildMenu extends LitElement implements Layer {
private refresh() {
this.game
.myPlayer()
.actions(this.clickedTile)
?.actions(this.clickedTile)
.then((actions) => {
this.playerActions = actions;
this.requestUpdate();
@@ -409,21 +426,9 @@ export class BuildMenu extends LitElement implements Layer {
}
private getBuildableUnits(): BuildItemDisplay[][] {
if (this.game?.config()?.disableNukes()) {
return buildTable.map((row) =>
row.filter(
(item) =>
![
UnitType.AtomBomb,
UnitType.MIRV,
UnitType.HydrogenBomb,
UnitType.MissileSilo,
UnitType.SAMLauncher,
].includes(item.unitType),
),
);
}
return buildTable;
return buildTable.map((row) =>
row.filter((item) => !this.game?.config()?.isUnitDisabled(item.unitType)),
);
}
get isVisible() {
+194
View File
@@ -0,0 +1,194 @@
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { DirectiveResult } from "lit/directive.js";
import { unsafeHTML, UnsafeHTMLDirective } from "lit/directives/unsafe-html.js";
import { EventBus } from "../../../core/EventBus";
import { MessageType } from "../../../core/game/Game";
import {
DisplayMessageUpdate,
GameUpdateType,
} from "../../../core/game/GameUpdates";
import { GameView } from "../../../core/game/GameView";
import { ClientID } from "../../../core/Schemas";
import { onlyImages } from "../../../core/Util";
import { Layer } from "./Layer";
interface ChatEvent {
description: string;
unsafeDescription?: boolean;
createdAt: number;
highlight?: boolean;
}
@customElement("chat-display")
export class ChatDisplay extends LitElement implements Layer {
public eventBus: EventBus;
public game: GameView;
public clientID: ClientID;
private active: boolean = false;
private updateMap = new Map([
[GameUpdateType.DisplayEvent, (u) => this.onDisplayMessageEvent(u)],
]);
@state() private _hidden: boolean = false;
@state() private newEvents: number = 0;
@state() private chatEvents: ChatEvent[] = [];
private toggleHidden() {
this._hidden = !this._hidden;
if (this._hidden) {
this.newEvents = 0;
}
this.requestUpdate();
}
private addEvent(event: ChatEvent) {
this.chatEvents = [...this.chatEvents, event];
if (this._hidden) {
this.newEvents++;
}
this.requestUpdate();
}
private removeEvent(index: number) {
this.chatEvents = [
...this.chatEvents.slice(0, index),
...this.chatEvents.slice(index + 1),
];
}
onDisplayMessageEvent(event: DisplayMessageUpdate) {
if (event.messageType !== MessageType.CHAT) return;
const myPlayer = this.game.playerByClientID(this.clientID);
if (
event.playerID !== null &&
(!myPlayer || myPlayer.smallID() !== event.playerID)
) {
return;
}
this.addEvent({
description: event.message,
createdAt: this.game.ticks(),
highlight: true,
unsafeDescription: true,
});
}
init() {}
tick() {
// this.active = true;
const updates = this.game.updatesSinceLastTick();
if (updates === null) throw new Error("null updates");
const messages = updates[GameUpdateType.DisplayEvent] as
| DisplayMessageUpdate[]
| undefined;
if (messages) {
for (const msg of messages) {
if (msg.messageType === MessageType.CHAT) {
const myPlayer = this.game.playerByClientID(this.clientID);
if (
msg.playerID !== null &&
(!myPlayer || myPlayer.smallID() !== msg.playerID)
) {
continue;
}
this.chatEvents = [
...this.chatEvents,
{
description: msg.message,
unsafeDescription: true,
createdAt: this.game.ticks(),
},
];
}
}
}
if (this.chatEvents.length > 100) {
this.chatEvents = this.chatEvents.slice(-100);
}
this.requestUpdate();
}
private getChatContent(
chat: ChatEvent,
): string | DirectiveResult<typeof UnsafeHTMLDirective> {
return chat.unsafeDescription
? unsafeHTML(onlyImages(chat.description))
: chat.description;
}
render() {
if (!this.active) {
return html``;
}
return html`
<div
class="${this._hidden
? "w-fit px-[10px] py-[5px]"
: ""} rounded-md bg-black bg-opacity-60 relative max-h-[30vh] flex flex-col-reverse overflow-y-auto w-full lg:bottom-2.5 lg:right-2.5 z-50 lg:max-w-[30vw] lg:w-full lg:w-auto"
style="pointer-events: auto"
>
<div>
<div class="w-full bg-black/80 sticky top-0 px-[10px]">
<button
class="text-white cursor-pointer pointer-events-auto ${this
._hidden
? "hidden"
: ""}"
@click=${this.toggleHidden}
>
Hide
</button>
</div>
<button
class="text-white cursor-pointer pointer-events-auto ${this._hidden
? ""
: "hidden"}"
@click=${this.toggleHidden}
>
Chat
<span
class="${this.newEvents
? ""
: "hidden"} inline-block px-2 bg-red-500 rounded-sm"
>${this.newEvents}</span
>
</button>
<table
class="w-full border-collapse text-white shadow-lg lg:text-xl text-xs ${this
._hidden
? "hidden"
: ""}"
style="pointer-events: auto;"
>
<tbody>
${this.chatEvents.map(
(chat) => html`
<tr class="border-b border-opacity-0">
<td class="lg:p-3 p-1 text-left">
${this.getChatContent(chat)}
</td>
</tr>
`,
)}
</tbody>
</table>
</div>
</div>
`;
}
createRenderRoot() {
return this;
}
}
+310
View File
@@ -0,0 +1,310 @@
import { LitElement, html } from "lit";
import { customElement, query } from "lit/decorators.js";
import { PlayerType } from "../../../core/game/Game";
import { GameView, PlayerView } from "../../../core/game/GameView";
import quickChatData from "../../../../resources/QuickChat.json";
import { EventBus } from "../../../core/EventBus";
import { SendQuickChatEvent } from "../../Transport";
import { translateText } from "../../Utils";
type QuickChatPhrase = {
key: string;
requiresPlayer: boolean;
};
type QuickChatPhrases = Record<string, QuickChatPhrase[]>;
const quickChatPhrases: QuickChatPhrases = quickChatData;
@customElement("chat-modal")
export class ChatModal extends LitElement {
@query("o-modal") private modalEl!: HTMLElement & {
open: () => void;
close: () => void;
};
createRenderRoot() {
return this;
}
private players: string[] = [];
private playerSearchQuery: string = "";
private previewText: string | null = null;
private requiresPlayerSelection: boolean = false;
private selectedCategory: string | null = null;
private selectedPhraseText: string | null = null;
private selectedPlayer: string | null = null;
private selectedPhraseTemplate: string | null = null;
private selectedQuickChatKey: string | null = null;
private recipient: PlayerView;
private sender: PlayerView;
public eventBus: EventBus;
public g: GameView;
quickChatPhrases: Record<
string,
Array<{ text: string; requiresPlayer: boolean }>
> = {
help: [{ text: "Please give me troops!", requiresPlayer: false }],
attack: [{ text: "Attack [P1]!", requiresPlayer: true }],
defend: [{ text: "Defend [P1]!", requiresPlayer: true }],
greet: [{ text: "Hello!", requiresPlayer: false }],
misc: [{ text: "Let's go!", requiresPlayer: false }],
};
private categories = [
{ id: "help" },
{ id: "attack" },
{ id: "defend" },
{ id: "greet" },
{ id: "misc" },
{ id: "warnings" },
];
private getPhrasesForCategory(categoryId: string) {
return quickChatPhrases[categoryId] ?? [];
}
render() {
const sortedPlayers = [...this.players].sort((a, b) => a.localeCompare(b));
const filteredPlayers = sortedPlayers.filter((player) =>
player.toLowerCase().includes(this.playerSearchQuery),
);
const otherPlayers = sortedPlayers.filter(
(player) => !player.toLowerCase().includes(this.playerSearchQuery),
);
const displayPlayers = [...filteredPlayers, ...otherPlayers];
return html`
<o-modal title="${translateText("chat.title")}">
<div class="chat-columns">
<div class="chat-column">
<div class="column-title">${translateText("chat.category")}</div>
${this.categories.map(
(category) => html`
<button
class="chat-option-button ${this.selectedCategory ===
category.id
? "selected"
: ""}"
@click=${() => this.selectCategory(category.id)}
>
${translateText(`chat.cat.${category.id}`)}
</button>
`,
)}
</div>
${this.selectedCategory
? html`
<div class="chat-column">
<div class="column-title">
${translateText("chat.phrase")}
</div>
<div class="phrase-scroll-area">
${this.getPhrasesForCategory(this.selectedCategory).map(
(phrase) => html`
<button
class="chat-option-button ${this
.selectedPhraseText ===
translateText(
`chat.${this.selectedCategory}.${phrase.key}`,
)
? "selected"
: ""}"
@click=${() => this.selectPhrase(phrase)}
>
${this.renderPhrasePreview(phrase)}
</button>
`,
)}
</div>
</div>
`
: null}
${this.requiresPlayerSelection || this.selectedPlayer
? html`
<div class="chat-column">
<div class="column-title">
${translateText("chat.player")}
</div>
<input
class="player-search-input"
type="text"
placeholder="${translateText("chat.search")}"
.value=${this.playerSearchQuery}
@input=${this.onPlayerSearchInput}
/>
<div class="player-scroll-area">
${this.getSortedFilteredPlayers().map(
(player) => html`
<button
class="chat-option-button ${this.selectedPlayer ===
player
? "selected"
: ""}"
@click=${() => this.selectPlayer(player)}
>
${player}
</button>
`,
)}
</div>
</div>
`
: null}
</div>
<div class="chat-preview">
${this.previewText
? translateText(this.previewText)
: translateText("chat.build")}
</div>
<div class="chat-send">
<button
class="chat-send-button"
@click=${this.sendChatMessage}
?disabled=${!this.previewText ||
(this.requiresPlayerSelection && !this.selectedPlayer)}
>
${translateText("chat.send")}
</button>
</div>
</o-modal>
`;
}
private selectCategory(categoryId: string) {
this.selectedCategory = categoryId;
this.selectedPhraseText = null;
this.previewText = null;
this.requiresPlayerSelection = false;
this.selectedPlayer = null;
this.requestUpdate();
}
private selectPhrase(phrase: QuickChatPhrase) {
this.selectedQuickChatKey = this.getFullQuickChatKey(
this.selectedCategory!,
phrase.key,
);
this.selectedPhraseTemplate = translateText(
`chat.${this.selectedCategory}.${phrase.key}`,
);
this.selectedPhraseText = translateText(
`chat.${this.selectedCategory}.${phrase.key}`,
);
this.previewText = `chat.${this.selectedCategory}.${phrase.key}`;
this.requiresPlayerSelection = phrase.requiresPlayer;
this.selectedPlayer = null;
this.requestUpdate();
}
private renderPhrasePreview(phrase: { key: string }) {
return translateText(`chat.${this.selectedCategory}.${phrase.key}`);
}
private selectPlayer(player: string) {
if (this.previewText) {
this.previewText =
this.selectedPhraseTemplate?.replace("[P1]", player) ?? null;
this.selectedPlayer = player;
this.requiresPlayerSelection = false;
this.requestUpdate();
}
}
private sendChatMessage() {
console.log("Sent message:", this.previewText);
console.log("Sender:", this.sender);
console.log("Recipient:", this.recipient);
console.log("Key:", this.selectedQuickChatKey);
if (this.sender && this.recipient && this.selectedQuickChatKey) {
const variables: Record<string, string> = this.selectedPlayer
? { P1: this.selectedPlayer }
: {};
this.eventBus.emit(
new SendQuickChatEvent(
this.sender,
this.recipient,
this.selectedQuickChatKey,
variables,
),
);
}
this.previewText = null;
this.selectedCategory = null;
this.requiresPlayerSelection = false;
this.close();
this.requestUpdate();
}
private onPlayerSearchInput(e: Event) {
const target = e.target as HTMLInputElement;
this.playerSearchQuery = target.value.toLowerCase();
this.requestUpdate();
}
private getSortedFilteredPlayers(): string[] {
const sorted = [...this.players].sort((a, b) => a.localeCompare(b));
const filtered = sorted.filter((p) =>
p.toLowerCase().includes(this.playerSearchQuery),
);
const others = sorted.filter(
(p) => !p.toLowerCase().includes(this.playerSearchQuery),
);
return [...filtered, ...others];
}
private getFullQuickChatKey(category: string, phraseKey: string): string {
return `${category}.${phraseKey}`;
}
public open(sender?: PlayerView, recipient?: PlayerView) {
if (sender && recipient) {
console.log("Sent message:", recipient);
console.log("Sent message:", sender);
const alivePlayerNames = this.g
.players()
.filter((p) => p.isAlive() && !(p.data.playerType === PlayerType.Bot))
.map((p) => p.data.name);
console.log("Alive player names:", alivePlayerNames);
this.players = alivePlayerNames;
this.recipient = recipient;
this.sender = sender;
}
this.requestUpdate();
this.modalEl?.open();
}
public close() {
this.selectedCategory = null;
this.selectedPhraseText = null;
this.previewText = null;
this.requiresPlayerSelection = false;
this.selectedPlayer = null;
this.modalEl?.close();
}
public setRecipient(value: PlayerView) {
this.recipient = value;
}
public setSender(value: PlayerView) {
this.sender = value;
}
}
+16 -9
View File
@@ -1,5 +1,6 @@
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { translateText } from "../../../client/Utils";
import { EventBus } from "../../../core/EventBus";
import { GameView } from "../../../core/game/GameView";
import { ClientID } from "../../../core/Schemas";
@@ -84,7 +85,7 @@ export class ControlPanel extends LitElement implements Layer {
newAttackRatio = 1;
}
if (newAttackRatio == 0.11 && this.attackRatio == 0.01) {
if (newAttackRatio === 0.11 && this.attackRatio === 0.01) {
// If we're changing the ratio from 1%, then set it to 10% instead of 11% to keep a consistency
newAttackRatio = 0.1;
}
@@ -107,13 +108,13 @@ export class ControlPanel extends LitElement implements Layer {
}
const player = this.game.myPlayer();
if (player == null || !player.isAlive()) {
if (player === null || !player.isAlive()) {
this.setVisibile(false);
return;
}
const popIncreaseRate = player.population() - this._population;
if (this.game.ticks() % 5 == 0) {
if (this.game.ticks() % 5 === 0) {
this._popRateIsIncreasing =
popIncreaseRate >= this._lastPopulationIncreaseRate;
this._lastPopulationIncreaseRate = popIncreaseRate;
@@ -210,7 +211,9 @@ export class ControlPanel extends LitElement implements Layer {
>
<div class="hidden lg:block bg-black/30 text-white mb-4 p-2 rounded">
<div class="flex justify-between mb-1">
<span class="font-bold">Pop:</span>
<span class="font-bold"
>${translateText("control_panel.pop")}:</span
>
<span translate="no"
>${renderTroops(this._population)} /
${renderTroops(this._maxPopulation)}
@@ -224,7 +227,9 @@ export class ControlPanel extends LitElement implements Layer {
>
</div>
<div class="flex justify-between">
<span class="font-bold">Gold:</span>
<span class="font-bold"
>${translateText("control_panel.gold")}:</span
>
<span translate="no"
>${renderNumber(this._gold)}
(+${renderNumber(this._goldPerSecond)})</span
@@ -234,8 +239,9 @@ export class ControlPanel extends LitElement implements Layer {
<div class="relative mb-4 lg:mb-4">
<label class="block text-white mb-1" translate="no"
>Troops: <span translate="no">${renderTroops(this._troops)}</span> |
Workers:
>${translateText("control_panel.troops")}:
<span translate="no">${renderTroops(this._troops)}</span> |
${translateText("control_panel.workers")}:
<span translate="no">${renderTroops(this._workers)}</span></label
>
<div class="relative h-8">
@@ -266,9 +272,10 @@ export class ControlPanel extends LitElement implements Layer {
<div class="relative mb-0 lg:mb-4">
<label class="block text-white mb-1" translate="no"
>Attack Ratio: ${(this.attackRatio * 100).toFixed(0)}%
>${translateText("control_panel.attack_ratio")}:
${(this.attackRatio * 100).toFixed(0)}%
(${renderTroops(
this.game?.myPlayer()?.troops() * this.attackRatio,
(this.game?.myPlayer()?.troops() ?? 0) * this.attackRatio,
)})</label
>
<div class="relative h-8">
+8 -16
View File
@@ -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;
@@ -127,10 +114,15 @@ export class EmojiTable extends LitElement {
this.showTable((emoji) => {
const recipient =
targetPlayer == this.game.myPlayer()
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();
});
});
+93 -15
View File
@@ -16,10 +16,12 @@ import {
AllianceRequestUpdate,
AttackUpdate,
BrokeAllianceUpdate,
DisplayChatMessageUpdate,
DisplayMessageUpdate,
EmojiUpdate,
GameUpdateType,
TargetPlayerUpdate,
UnitIncomingUpdate,
} from "../../../core/game/GameUpdates";
import { ClientID } from "../../../core/Schemas";
import {
@@ -33,6 +35,8 @@ import { onlyImages } from "../../../core/Util";
import { renderTroops } from "../../Utils";
import { GoToPlayerEvent, GoToUnitEvent } from "./Leaderboard";
import { translateText } from "../../Utils";
interface Event {
description: string;
unsafeDescription?: boolean;
@@ -50,6 +54,7 @@ interface Event {
priority?: number;
duration?: Tick;
focusID?: number;
unitView?: UnitView;
}
@customElement("events-display")
@@ -77,6 +82,7 @@ export class EventsDisplay extends LitElement implements Layer {
private updateMap = new Map([
[GameUpdateType.DisplayEvent, (u) => this.onDisplayMessageEvent(u)],
[GameUpdateType.DisplayChatEvent, (u) => this.onDisplayChatEvent(u)],
[GameUpdateType.AllianceRequest, (u) => this.onAllianceRequestEvent(u)],
[
GameUpdateType.AllianceRequestReply,
@@ -85,6 +91,7 @@ export class EventsDisplay extends LitElement implements Layer {
[GameUpdateType.BrokeAlliance, (u) => this.onBrokeAllianceEvent(u)],
[GameUpdateType.TargetPlayer, (u) => this.onTargetPlayerEvent(u)],
[GameUpdateType.Emoji, (u) => this.onEmojiMessageEvent(u)],
[GameUpdateType.UnitIncoming, (u) => this.onUnitIncomingEvent(u)],
]);
constructor() {
@@ -100,8 +107,10 @@ export class EventsDisplay extends LitElement implements Layer {
tick() {
this.active = true;
const updates = this.game.updatesSinceLastTick();
for (const [ut, fn] of this.updateMap) {
updates[ut]?.forEach((u) => fn(u));
if (updates) {
for (const [ut, fn] of this.updateMap) {
updates[ut]?.forEach(fn);
}
}
let remainingEvents = this.events.filter((event) => {
@@ -130,16 +139,16 @@ export class EventsDisplay extends LitElement implements Layer {
// Update attacks
this.incomingAttacks = myPlayer.incomingAttacks().filter((a) => {
const t = (this.game.playerBySmallID(a.attackerID) as PlayerView).type();
return t != PlayerType.Bot;
return t !== PlayerType.Bot;
});
this.outgoingAttacks = myPlayer
.outgoingAttacks()
.filter((a) => a.targetID != 0);
.filter((a) => a.targetID !== 0);
this.outgoingLandAttacks = myPlayer
.outgoingAttacks()
.filter((a) => a.targetID == 0);
.filter((a) => a.targetID === 0);
this.outgoingBoats = myPlayer
.units()
@@ -150,7 +159,7 @@ export class EventsDisplay extends LitElement implements Layer {
private addEvent(event: Event) {
this.events = [...this.events, event];
if (this._hidden == true) {
if (this._hidden === true) {
this.newEvents++;
}
this.requestUpdate();
@@ -172,7 +181,7 @@ export class EventsDisplay extends LitElement implements Layer {
onDisplayMessageEvent(event: DisplayMessageUpdate) {
const myPlayer = this.game.playerByClientID(this.clientID);
if (
event.playerID != null &&
event.playerID !== null &&
(!myPlayer || myPlayer.smallID() !== event.playerID)
) {
return;
@@ -187,6 +196,34 @@ export class EventsDisplay extends LitElement implements Layer {
});
}
onDisplayChatEvent(event: DisplayChatMessageUpdate) {
const myPlayer = this.game.playerByClientID(this.clientID);
if (
event.playerID === null ||
!myPlayer ||
myPlayer.smallID() !== event.playerID
) {
return;
}
const baseMessage = translateText(`chat.${event.category}.${event.key}`);
const translatedMessage = baseMessage.replace(
/\[([^\]]+)\]/g,
(_, key) => event.variables?.[key] || `[${key}]`,
);
this.addEvent({
description: translateText(event.isFrom ? "chat.from" : "chat.to", {
user: event.recipient,
msg: translatedMessage,
}),
createdAt: this.game.ticks(),
highlight: true,
type: MessageType.CHAT,
unsafeDescription: false,
});
}
onAllianceRequestEvent(update: AllianceRequestUpdate) {
const myPlayer = this.game.playerByClientID(this.clientID);
if (!myPlayer || update.recipientID !== myPlayer.smallID()) {
@@ -271,10 +308,17 @@ export class EventsDisplay extends LitElement implements Layer {
const malusPercent = Math.round(
(1 - this.game.config().traitorDefenseDebuff()) * 100,
);
const traitorDuration = Math.floor(
this.game.config().traitorDuration() * 0.1,
);
const durationText =
traitorDuration === 1 ? "1 second" : `${traitorDuration} 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(),
@@ -301,6 +345,7 @@ export class EventsDisplay extends LitElement implements Layer {
: update.player2ID === myPlayer.smallID()
? update.player1ID
: null;
if (otherID === null) return;
const other = this.game.playerBySmallID(otherID) as PlayerView;
if (!other || !myPlayer.isAlive() || !other.isAlive()) return;
@@ -350,14 +395,14 @@ export class EventsDisplay extends LitElement implements Layer {
if (!myPlayer) return;
const recipient =
update.emoji.recipientID == AllPlayers
update.emoji.recipientID === AllPlayers
? AllPlayers
: this.game.playerBySmallID(update.emoji.recipientID);
const sender = this.game.playerBySmallID(
update.emoji.senderID,
) as PlayerView;
if (recipient == myPlayer) {
if (recipient === myPlayer) {
this.addEvent({
description: `${sender.displayName()}:${update.emoji.message}`,
unsafeDescription: true,
@@ -380,12 +425,33 @@ export class EventsDisplay extends LitElement implements Layer {
}
}
onUnitIncomingEvent(event: UnitIncomingUpdate) {
const myPlayer = this.game.playerByClientID(this.clientID);
if (!myPlayer || myPlayer.smallID() !== event.playerID) {
return;
}
const unitView = this.game.unit(event.unitID);
this.addEvent({
description: event.message,
type: event.messageType,
unsafeDescription: false,
highlight: true,
createdAt: this.game.ticks(),
unitView: unitView,
});
}
private getMessageTypeClasses(type: MessageType): string {
switch (type) {
case MessageType.SUCCESS:
return "text-green-300";
case MessageType.INFO:
return "text-gray-200";
case MessageType.CHAT:
return "text-gray-200";
case MessageType.WARN:
return "text-yellow-300";
case MessageType.ERROR:
@@ -414,8 +480,10 @@ export class EventsDisplay extends LitElement implements Layer {
<button
translate="no"
class="ml-2"
@click=${() =>
this.emitGoToPlayerEvent(attack.attackerID)}
@click=${() => {
attack.attackerID &&
this.emitGoToPlayerEvent(attack.attackerID);
}}
>
${renderTroops(attack.troops)}
${(
@@ -540,7 +608,7 @@ export class EventsDisplay extends LitElement implements Layer {
this.events.sort((a, b) => {
const aPrior = a.priority ?? 100000;
const bPrior = b.priority ?? 100000;
if (aPrior == bPrior) {
if (aPrior === bPrior) {
return a.createdAt - b.createdAt;
}
return bPrior - aPrior;
@@ -597,12 +665,22 @@ export class EventsDisplay extends LitElement implements Layer {
${event.focusID
? html`<button
@click=${() => {
this.emitGoToPlayerEvent(event.focusID);
event.focusID &&
this.emitGoToPlayerEvent(event.focusID);
}}
>
${this.getEventDescription(event)}
</button>`
: this.getEventDescription(event)}
: event.unitView
? html`<button
@click=${() => {
event.unitView &&
this.emitGoToUnitEvent(event.unitView);
}}
>
${this.getEventDescription(event)}
</button>`
: this.getEventDescription(event)}
${event.buttons
? html`
<div class="flex flex-wrap gap-1.5 mt-1">
+27 -20
View File
@@ -1,6 +1,7 @@
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { translateText } from "../../../client/Utils";
import { EventBus, GameEvent } from "../../../core/EventBus";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import { ClientID } from "../../../core/Schemas";
@@ -27,9 +28,9 @@ export class GoToUnitEvent implements GameEvent {
@customElement("leader-board")
export class Leaderboard extends LitElement implements Layer {
public game: GameView;
public clientID: ClientID;
public eventBus: EventBus;
public game: GameView | null = null;
public clientID: ClientID | null = null;
public eventBus: EventBus | null = null;
players: Entry[] = [];
@@ -41,6 +42,7 @@ export class Leaderboard extends LitElement implements Layer {
init() {}
tick() {
if (this.game === null) throw new Error("Not initialized");
if (!this._shownOnInit && !this.game.inSpawnPhase()) {
this._shownOnInit = true;
this.showLeaderboard();
@@ -50,18 +52,19 @@ export class Leaderboard extends LitElement implements Layer {
return;
}
if (this.game.ticks() % 10 == 0) {
if (this.game.ticks() % 10 === 0) {
this.updateLeaderboard();
}
}
private updateLeaderboard() {
if (this.clientID == null) {
if (this.game === null) throw new Error("Not initialized");
if (this.clientID === null) {
return;
}
const myPlayer = this.game
.playerViews()
.find((p) => p.clientID() == this.clientID);
const myPlayer =
this.game.playerViews().find((p) => p.clientID() === this.clientID) ??
null;
const sorted = this.game
.playerViews()
@@ -85,16 +88,19 @@ export class Leaderboard extends LitElement implements Layer {
),
gold: renderNumber(player.gold()),
troops: renderNumber(troops),
isMyPlayer: player == myPlayer,
isMyPlayer: player === myPlayer,
player: player,
};
});
if (myPlayer != null && this.players.find((p) => p.isMyPlayer) == null) {
if (
myPlayer !== null &&
this.players.find((p) => p.isMyPlayer) === undefined
) {
let place = 0;
for (const p of sorted) {
place++;
if (p == myPlayer) {
if (p === myPlayer) {
break;
}
}
@@ -121,6 +127,7 @@ export class Leaderboard extends LitElement implements Layer {
}
private handleRowClickPlayer(player: PlayerView) {
if (this.eventBus === null) return;
this.eventBus.emit(new GoToPlayerEvent(player));
}
@@ -141,7 +148,7 @@ export class Leaderboard extends LitElement implements Layer {
position: fixed;
top: 10px;
left: 10px;
z-index: 9999;
z-index: 9998;
background-color: rgb(31 41 55 / 0.7);
padding: 10px;
padding-top: 0px;
@@ -192,7 +199,7 @@ export class Leaderboard extends LitElement implements Layer {
position: fixed;
left: 10px;
top: 10px;
z-index: 9999;
z-index: 9998;
background-color: rgb(31 41 55 / 0.7);
color: white;
border: none;
@@ -242,7 +249,7 @@ export class Leaderboard extends LitElement implements Layer {
? ""
: "hidden"}"
>
Leaderboard
${translateText("leaderboard.title")}
</button>
<div
class="leaderboard ${this._leaderboardHidden ? "hidden" : ""}"
@@ -252,7 +259,7 @@ export class Leaderboard extends LitElement implements Layer {
class="leaderboard-close-button"
@click=${() => this.hideLeaderboard()}
>
Hide
${translateText("leaderboard.hide")}
</button>
<button
class="leaderboard-top-five-button"
@@ -266,11 +273,11 @@ export class Leaderboard extends LitElement implements Layer {
<table>
<thead>
<tr>
<th>Rank</th>
<th>Player</th>
<th>Owned</th>
<th>Gold</th>
<th>Troops</th>
<th>${translateText("leaderboard.rank")}</th>
<th>${translateText("leaderboard.player")}</th>
<th>${translateText("leaderboard.owned")}</th>
<th>${translateText("leaderboard.gold")}</th>
<th>${translateText("leaderboard.troops")}</th>
</tr>
</thead>
<tbody>
+63 -4
View File
@@ -1,5 +1,6 @@
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";
@@ -15,6 +16,9 @@ export class MultiTabModal extends LitElement implements Layer {
@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;
@@ -26,7 +30,8 @@ export class MultiTabModal extends LitElement implements Layer {
tick() {
if (
this.game.inSpawnPhase() ||
this.game.config().gameConfig().gameType == GameType.Singleplayer
this.game.config().gameConfig().gameType === GameType.Singleplayer ||
this.game.config().serverConfig().env() === GameEnv.Dev
) {
return;
}
@@ -38,6 +43,26 @@ export class MultiTabModal extends LitElement implements Layer {
}
}
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()) {
@@ -98,14 +123,44 @@ export class MultiTabModal extends LitElement implements Layer {
<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"
>
<h2 class="text-2xl font-bold mb-4 text-red-600 dark:text-red-400">
${translateText("multi_tab.warning")}
</h2>
<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>
@@ -124,6 +179,10 @@ export class MultiTabModal extends LitElement implements Layer {
<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>
`;
+55 -50
View File
@@ -12,7 +12,7 @@ import { ClientID } from "../../../core/Schemas";
import { Theme } from "../../../core/configuration/Config";
import { AllPlayers, Cell, nukeTypes } from "../../../core/game/Game";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { createCanvas, renderNumber, renderTroops } from "../../Utils";
import { createCanvas, renderTroops } from "../../Utils";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
@@ -22,7 +22,7 @@ class RenderInfo {
constructor(
public player: PlayerView,
public lastRenderCalc: number,
public location: Cell,
public location: Cell | null,
public fontSize: number,
public fontColor: string,
public element: HTMLElement,
@@ -104,7 +104,7 @@ export class NameLayer implements Layer {
}
public tick() {
if (this.game.ticks() % 10 != 0) {
if (this.game.ticks() % 10 !== 0) {
return;
}
const sorted = this.game
@@ -214,18 +214,20 @@ export class NameLayer implements Layer {
troopsDiv.style.marginTop = "-5%";
element.appendChild(troopsDiv);
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);
// 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";
@@ -236,7 +238,7 @@ export class NameLayer implements Layer {
renderPlayerInfo(render: RenderInfo) {
if (!render.player.nameLocation() || !render.player.isAlive()) {
this.renders = this.renders.filter((r) => r != render);
this.renders = this.renders.filter((r) => r !== render);
render.element.remove();
return;
}
@@ -291,23 +293,25 @@ export class NameLayer implements Layer {
troopsDiv.style.color = render.fontColor;
troopsDiv.textContent = renderTroops(render.player.troops());
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;
}
// 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(
@@ -351,7 +355,7 @@ export class NameLayer implements Layer {
// Alliance icon
const existingAlliance = iconsDiv.querySelector('[data-icon="alliance"]');
if (myPlayer != null && myPlayer.isAlliedWith(render.player)) {
if (myPlayer !== null && myPlayer.isAlliedWith(render.player)) {
if (!existingAlliance) {
iconsDiv.appendChild(
this.createIconElement(
@@ -368,7 +372,7 @@ export class NameLayer implements Layer {
// Alliance request icon
const data = '[data-icon="alliance-request"]';
const existingRequestAlliance = iconsDiv.querySelector(data);
if (myPlayer != null && render.player.isRequestingAllianceWith(myPlayer)) {
if (myPlayer !== null && render.player.isRequestingAllianceWith(myPlayer)) {
if (!existingRequestAlliance) {
iconsDiv.appendChild(
this.createIconElement(
@@ -385,7 +389,7 @@ export class NameLayer implements Layer {
// Target icon
const existingTarget = iconsDiv.querySelector('[data-icon="target"]');
if (
myPlayer != null &&
myPlayer !== null &&
new Set(myPlayer.transitiveTargets()).has(render.player)
) {
if (!existingTarget) {
@@ -408,11 +412,11 @@ export class NameLayer implements Layer {
.outgoingEmojis()
.filter(
(emoji) =>
emoji.recipientID == AllPlayers ||
emoji.recipientID == myPlayer?.smallID(),
emoji.recipientID === AllPlayers ||
emoji.recipientID === myPlayer?.smallID(),
);
if (this.game.config().userSettings().emojis() && emojis.length > 0) {
if (this.game.config().userSettings()?.emojis() && emojis.length > 0) {
if (!existingEmoji) {
const emojiDiv = document.createElement("div");
emojiDiv.setAttribute("data-icon", "emoji");
@@ -447,8 +451,8 @@ export class NameLayer implements Layer {
}
const nukesSentByOtherPlayer = this.game.units().filter((unit) => {
const isSendingNuke = render.player.id() == unit.owner().id();
const notMyPlayer = !myPlayer || unit.owner().id() != myPlayer.id();
const isSendingNuke = render.player.id() === unit.owner().id();
const notMyPlayer = !myPlayer || unit.owner().id() !== myPlayer.id();
return (
nukeTypes.includes(unit.type()) &&
isSendingNuke &&
@@ -458,24 +462,25 @@ export class NameLayer implements Layer {
});
const isMyPlayerTarget = nukesSentByOtherPlayer.find((unit) => {
const detonationDst = unit.detonationDst();
if (detonationDst === undefined) return false;
const targetId = this.game.owner(detonationDst).id();
return myPlayer && targetId == this.myPlayer.id();
return myPlayer && targetId === myPlayer.id();
});
const existingNuke = iconsDiv.querySelector(
'[data-icon="nuke"]',
) as HTMLImageElement;
if (existingNuke) {
if (nukesSentByOtherPlayer.length == 0) {
if (nukesSentByOtherPlayer.length === 0) {
existingNuke.remove();
} else if (
isMyPlayerTarget &&
existingNuke.src != this.nukeRedIconImage.src
existingNuke.src !== this.nukeRedIconImage.src
) {
existingNuke.src = this.nukeRedIconImage.src;
} else if (
!isMyPlayerTarget &&
existingNuke.src != this.nukeWhiteIconImage.src
existingNuke.src !== this.nukeWhiteIconImage.src
) {
existingNuke.src = this.nukeWhiteIconImage.src;
}
@@ -495,7 +500,7 @@ export class NameLayer implements Layer {
}
// Position element with scale
if (render.location && render.location != oldLocation) {
if (render.location && render.location !== oldLocation) {
const scale = Math.min(baseSize * 0.25, 3);
render.element.style.transform = `translate(${render.location.x}px, ${render.location.y}px) translate(-50%, -50%) scale(${scale})`;
}
@@ -521,12 +526,12 @@ export class NameLayer implements Layer {
}
private getPlayer(): PlayerView | null {
if (this.myPlayer != null) {
if (this.myPlayer !== null) {
return this.myPlayer;
}
this.myPlayer = this.game
.playerViews()
.find((p) => p.clientID() == this.clientID);
this.myPlayer =
this.game.playerViews().find((p) => p.clientID() === this.clientID) ??
null;
return this.myPlayer;
}
}
+7 -5
View File
@@ -122,18 +122,20 @@ export class OptionsMenu extends LitElement implements Layer {
init() {
console.log("init called from OptionsMenu");
this.showPauseButton =
this.game.config().gameConfig().gameType == GameType.Singleplayer;
this.game.config().gameConfig().gameType === GameType.Singleplayer ||
this.game.config().isReplay();
this.isVisible = true;
this.requestUpdate();
}
tick() {
this.hasWinner =
this.hasWinner ||
this.game.updatesSinceLastTick()[GameUpdateType.Win].length > 0;
const updates = this.game.updatesSinceLastTick();
if (updates) {
this.hasWinner = this.hasWinner || updates[GameUpdateType.Win].length > 0;
}
if (this.game.inSpawnPhase()) {
this.timer = 0;
} else if (!this.hasWinner && this.game.ticks() % 10 == 0) {
} else if (!this.hasWinner && this.game.ticks() % 10 === 0) {
this.timer++;
}
this.isVisible = true;
+58 -20
View File
@@ -1,5 +1,6 @@
import { LitElement, html } from "lit";
import { LitElement, TemplateResult, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { translateText } from "../../../client/Utils";
import { EventBus } from "../../../core/EventBus";
import {
PlayerProfile,
@@ -158,37 +159,53 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
}
}
private getRelationName(relation: Relation): string {
switch (relation) {
case Relation.Hostile:
return translateText("relation.hostile");
case Relation.Distrustful:
return translateText("relation.distrustful");
case Relation.Neutral:
return translateText("relation.neutral");
case Relation.Friendly:
return translateText("relation.friendly");
default:
return translateText("relation.default");
}
}
private renderPlayerInfo(player: PlayerView) {
const myPlayer = this.myPlayer();
const isFriendly = myPlayer?.isFriendly(player);
let relationHtml = null;
let relationHtml: TemplateResult | null = null;
const attackingTroops = player
.outgoingAttacks()
.map((a) => a.troops)
.reduce((a, b) => a + b, 0);
if (player.type() == PlayerType.FakeHuman && myPlayer != null) {
if (player.type() === PlayerType.FakeHuman && myPlayer !== null) {
const relation =
this.playerProfile?.relations[myPlayer.smallID()] ?? Relation.Neutral;
const relationClass = this.getRelationClass(relation);
const relationName = Relation[relation];
const relationName = this.getRelationName(relation);
relationHtml = html`
<div class="text-sm opacity-80">
Attitude: <span class="${relationClass}">${relationName}</span>
${translateText("player_info_overlay.attitude")}:
<span class="${relationClass}">${relationName}</span>
</div>
`;
}
let playerType = "";
switch (player.type()) {
case PlayerType.Bot:
playerType = "Bot";
playerType = translateText("player_info_overlay.bot");
break;
case PlayerType.FakeHuman:
playerType = "Nation";
playerType = translateText("player_info_overlay.nation");
break;
case PlayerType.Human:
playerType = "Player";
playerType = translateText("player_info_overlay.player");
break;
}
@@ -207,31 +224,49 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
: ""}
${player.name()}
</div>
<div class="text-sm opacity-80">Type: ${playerType}</div>
${player.team() !== null
? html`<div class="text-sm opacity-80">
${translateText("player_info_overlay.team")}: ${player.team()}
</div>`
: ""}
<div class="text-sm opacity-80">
${translateText("player_info_overlay.type")}: ${playerType}
</div>
${player.troops() >= 1
? html`<div class="text-sm opacity-80" translate="no">
Defending troops: ${renderTroops(player.troops())}
${translateText("player_info_overlay.d_troops")}:
${renderTroops(player.troops())}
</div>`
: ""}
${attackingTroops >= 1
? html`<div class="text-sm opacity-80" translate="no">
Attacking troops: ${renderTroops(attackingTroops)}
${translateText("player_info_overlay.a_troops")}:
${renderTroops(attackingTroops)}
</div>`
: ""}
<div class="text-sm opacity-80" translate="no">
Gold: ${renderNumber(player.gold())}
${translateText("player_info_overlay.gold")}:
${renderNumber(player.gold())}
</div>
<div class="text-sm opacity-80" translate="no">
Ports: ${player.units(UnitType.Port).length}
${translateText("player_info_overlay.ports")}:
${player.units(UnitType.Port).length}
</div>
<div class="text-sm opacity-80" translate="no">
Cities: ${player.units(UnitType.City).length}
${translateText("player_info_overlay.cities")}:
${player.units(UnitType.City).length}
</div>
<div class="text-sm opacity-80" translate="no">
Missile launchers: ${player.units(UnitType.MissileSilo).length}
${translateText("player_info_overlay.missile_launchers")}:
${player.units(UnitType.MissileSilo).length}
</div>
<div class="text-sm opacity-80" translate="no">
SAMs: ${player.units(UnitType.SAMLauncher).length}
${translateText("player_info_overlay.sams")}:
${player.units(UnitType.SAMLauncher).length}
</div>
<div class="text-sm opacity-80" translate="no">
${translateText("player_info_overlay.warships")}:
${player.units(UnitType.Warship).length}
</div>
${relationHtml}
</div>
@@ -240,7 +275,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
private renderUnitInfo(unit: UnitView) {
const isAlly =
(unit.owner() == this.myPlayer() ||
(unit.owner() === this.myPlayer() ||
this.myPlayer()?.isFriendly(unit.owner())) ??
false;
@@ -253,7 +288,10 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
<div class="text-sm opacity-80">${unit.type()}</div>
${unit.hasHealth()
? html`
<div class="text-sm opacity-80">Health: ${unit.health()}</div>
<div class="text-sm opacity-80">
${translateText("player_info_overlay.health")}:
${unit.health()}
</div>
`
: ""}
</div>
@@ -278,8 +316,8 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
<div
class="bg-opacity-60 bg-gray-900 rounded-lg shadow-lg backdrop-blur-sm transition-all duration-300 text-white text-lg md:text-base ${containerClasses}"
>
${this.player != null ? this.renderPlayerInfo(this.player) : ""}
${this.unit != null ? this.renderUnitInfo(this.unit) : ""}
${this.player !== null ? this.renderPlayerInfo(this.player) : ""}
${this.unit !== null ? this.renderUnitInfo(this.unit) : ""}
</div>
</div>
`;
+71 -31
View File
@@ -1,11 +1,13 @@
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import allianceIcon from "../../../../resources/images/AllianceIconWhite.svg";
import chatIcon from "../../../../resources/images/ChatIconWhite.svg";
import donateGoldIcon from "../../../../resources/images/DonateGoldIconWhite.svg";
import donateTroopIcon from "../../../../resources/images/DonateTroopIconWhite.svg";
import emojiIcon from "../../../../resources/images/EmojiIconWhite.svg";
import targetIcon from "../../../../resources/images/TargetIconWhite.svg";
import traitorIcon from "../../../../resources/images/TraitorIconWhite.svg";
import { translateText } from "../../../client/Utils";
import { EventBus } from "../../../core/EventBus";
import {
AllPlayers,
@@ -15,6 +17,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,
@@ -26,6 +29,7 @@ import {
SendTargetPlayerIntentEvent,
} from "../../Transport";
import { renderNumber, renderTroops } from "../../Utils";
import { ChatModal } from "./ChatModal";
import { EmojiTable } from "./EmojiTable";
import { Layer } from "./Layer";
@@ -35,8 +39,8 @@ export class PlayerPanel extends LitElement implements Layer {
public eventBus: EventBus;
public emojiTable: EmojiTable;
private actions: PlayerActions = null;
private tile: TileRef = null;
private actions: PlayerActions | null = null;
private tile: TileRef | null = null;
@state()
private isVisible: boolean = false;
@@ -121,16 +125,28 @@ 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();
});
}
private handleChat(e: Event, sender: PlayerView, other: PlayerView) {
this.ctModal.open(sender, other);
this.hide();
}
private handleTargetClick(e: Event, other: PlayerView) {
e.stopPropagation();
this.eventBus.emit(new SendTargetPlayerIntentEvent(other.id()));
@@ -141,8 +157,12 @@ export class PlayerPanel extends LitElement implements Layer {
return this;
}
private ctModal;
init() {
this.eventBus.on(MouseUpEvent, (e: MouseEvent) => this.hide());
this.ctModal = document.querySelector("chat-modal") as ChatModal;
}
async tick() {
@@ -161,12 +181,16 @@ export class PlayerPanel extends LitElement implements Layer {
return 0;
}
let sum = 0;
const nukes = stats.sentNukes[this.g.myPlayer().id()];
const player = this.g.myPlayer();
if (player === null) {
return 0;
}
const nukes = stats.sentNukes[player.id()];
if (!nukes) {
return 0;
}
for (const nukeType in nukes) {
if (nukeType != UnitType.MIRVWarhead) {
if (nukeType !== UnitType.MIRVWarhead) {
sum += nukes[nukeType];
}
}
@@ -178,26 +202,26 @@ export class PlayerPanel extends LitElement implements Layer {
return html``;
}
const myPlayer = this.g.myPlayer();
if (myPlayer == null) {
return;
}
if (myPlayer === null) return;
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;
const canDonate = this.actions.interaction?.canDonate;
const canDonate = this.actions?.interaction?.canDonate;
const canSendAllianceRequest =
this.actions.interaction?.canSendAllianceRequest;
this.actions?.interaction?.canSendAllianceRequest;
const canSendEmoji =
other == myPlayer
? this.actions.canSendEmojiAllPlayers
: this.actions.interaction?.canSendEmoji;
const canBreakAlliance = this.actions.interaction?.canBreakAlliance;
const canTarget = this.actions.interaction?.canTarget;
const canEmbargo = this.actions.interaction?.canEmbargo;
other === myPlayer
? this.actions?.canSendEmojiAllPlayers
: this.actions?.interaction?.canSendEmoji;
const canBreakAlliance = this.actions?.interaction?.canBreakAlliance;
const canTarget = this.actions?.interaction?.canTarget;
const canEmbargo = this.actions?.interaction?.canEmbargo;
return html`
<div
@@ -233,7 +257,9 @@ export class PlayerPanel extends LitElement implements Layer {
<div class="grid grid-cols-2 gap-2">
<div class="flex flex-col gap-1">
<!-- Gold -->
<div class="text-white text-opacity-80 text-sm px-2">Gold</div>
<div class="text-white text-opacity-80 text-sm px-2">
${translateText("player_panel.gold")}
</div>
<div
class="bg-opacity-50 bg-gray-700 rounded p-2 text-white"
translate="no"
@@ -244,7 +270,7 @@ export class PlayerPanel extends LitElement implements Layer {
<div class="flex flex-col gap-1">
<!-- Troops -->
<div class="text-white text-opacity-80 text-sm px-2">
Troops
${translateText("player_panel.troops")}
</div>
<div
class="bg-opacity-50 bg-gray-700 rounded p-2 text-white"
@@ -257,26 +283,32 @@ export class PlayerPanel extends LitElement implements Layer {
<!-- Attitude section -->
<div class="flex flex-col gap-1">
<div class="text-white text-opacity-80 text-sm px-2">Traitor</div>
<div class="text-white text-opacity-80 text-sm px-2">
${translateText("player_panel.traitor")}
</div>
<div class="bg-opacity-50 bg-gray-700 rounded p-2 text-white">
${other.isTraitor() ? "Yes" : "No"}
${other.isTraitor()
? translateText("player_panel.yes")
: translateText("player_panel.no")}
</div>
</div>
<!-- Embargo -->
<div class="flex flex-col gap-1">
<div class="text-white text-opacity-80 text-sm px-2">
Embargo against you
${translateText("player_panel.embargo")}
</div>
<div class="bg-opacity-50 bg-gray-700 rounded p-2 text-white">
${other.hasEmbargoAgainst(myPlayer) ? "Yes" : "No"}
${other.hasEmbargoAgainst(myPlayer)
? translateText("player_panel.yes")
: translateText("player_panel.no")}
</div>
</div>
<!-- Stats -->
<div class="flex flex-col gap-1">
<div class="text-white text-opacity-80 text-sm px-2">
Nukes sent by them to you
${translateText("player_panel.nuke")}
</div>
<div class="bg-opacity-50 bg-gray-700 rounded p-2 text-white">
${this.getTotalNukesSent(other.id())}
@@ -285,6 +317,14 @@ export class PlayerPanel extends LitElement implements Layer {
<!-- Action buttons -->
<div class="flex justify-center gap-2">
<button
@click=${(e) => this.handleChat(e, myPlayer, other)}
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
text-white rounded-lg transition-colors"
>
<img src=${chatIcon} alt="Target" class="w-6 h-6" />
</button>
${canTarget
? html`<button
@click=${(e) => this.handleTargetClick(e, other)}
@@ -354,17 +394,17 @@ export class PlayerPanel extends LitElement implements Layer {
</button>`
: ""}
</div>
${canEmbargo && other != myPlayer
${canEmbargo && other !== myPlayer
? html`<button
@click=${(e) => this.handleEmbargoClick(e, myPlayer, other)}
class="w-100 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
text-white rounded-lg transition-colors"
>
Stop trading
${translateText("player_panel.stop_trade")}
</button>`
: ""}
${!canEmbargo && other != myPlayer
${!canEmbargo && other !== myPlayer
? html`<button
@click=${(e) =>
this.handleStopEmbargoClick(e, myPlayer, other)}
@@ -372,7 +412,7 @@ export class PlayerPanel extends LitElement implements Layer {
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
text-white rounded-lg transition-colors"
>
Start trading
${translateText("player_panel.start_trade")}
</button>`
: ""}
</div>
+25 -10
View File
@@ -52,7 +52,16 @@ export class RadialMenu implements Layer {
private originalTileOwner: PlayerView | TerraNullius;
private menuElement: d3.Selection<HTMLDivElement, unknown, null, undefined>;
private isVisible: boolean = false;
private readonly menuItems = new Map([
private readonly menuItems: Map<
Slot,
{
name: string;
disabled: boolean;
action: () => void;
color?: string | null;
icon?: string | null;
}
> = new Map([
[
Slot.Boat,
{
@@ -105,7 +114,7 @@ export class RadialMenu implements Layer {
e.x,
e.y,
);
if (clickedCell == null) {
if (clickedCell === null) {
return;
}
if (!this.g.isValidCoord(clickedCell.x, clickedCell.y)) {
@@ -113,7 +122,7 @@ export class RadialMenu implements Layer {
}
const tile = this.g.ref(clickedCell.x, clickedCell.y);
const p = this.g.playerByClientID(this.clientID);
if (p == null) {
if (p === null) {
return;
}
this.buildMenu.showMenu(tile);
@@ -280,12 +289,12 @@ export class RadialMenu implements Layer {
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) {
if (this.g.owner(tile) !== this.originalTileOwner) {
this.closeMenu();
return;
}
} else {
if (this.g.owner(tile).isPlayer() || this.g.owner(tile) == myPlayer) {
if (this.g.owner(tile).isPlayer() || this.g.owner(tile) === myPlayer) {
this.closeMenu();
return;
}
@@ -350,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);
@@ -380,7 +392,7 @@ export class RadialMenu implements Layer {
});
}
if (
actions.buildableUnits.find((bu) => bu.type == UnitType.TransportShip)
actions.buildableUnits.find((bu) => bu.type === UnitType.TransportShip)
?.canBuild
) {
this.activateMenuElement(Slot.Boat, "#3f6ab1", boatIcon, () => {
@@ -392,6 +404,7 @@ export class RadialMenu implements Layer {
spawnTile = new Cell(this.g.x(spawn), this.g.y(spawn));
}
if (this.clickedCell === null) return;
this.eventBus.emit(
new SendBoatAttackIntentEvent(
this.g.owner(tile).id(),
@@ -443,12 +456,13 @@ export class RadialMenu implements Layer {
return;
}
consolex.log("Center button clicked");
if (this.clickedCell === null) return;
const clicked = this.g.ref(this.clickedCell.x, this.clickedCell.y);
if (this.g.inSpawnPhase()) {
this.eventBus.emit(new SendSpawnIntentEvent(this.clickedCell));
} else {
const myPlayer = this.g.myPlayer();
if (myPlayer != null && this.g.owner(clicked) != myPlayer) {
if (myPlayer !== null && this.g.owner(clicked) !== myPlayer) {
this.eventBus.emit(
new SendAttackIntentEvent(
this.g.owner(clicked).id(),
@@ -475,6 +489,7 @@ export class RadialMenu implements Layer {
action: () => void,
) {
const menuItem = this.menuItems.get(slot);
if (menuItem === undefined) return;
menuItem.action = action;
menuItem.disabled = false;
menuItem.color = color;
+2 -1
View File
@@ -24,13 +24,14 @@ export class SpawnTimer implements Layer {
this.ratios = [];
this.colors = [];
if (this.game.config().gameConfig().gameMode != GameMode.Team) {
if (this.game.config().gameConfig().gameMode !== GameMode.Team) {
return;
}
const teamTiles: Map<Team, number> = new Map();
for (const player of this.game.players()) {
const team = player.team();
if (team === null) throw new Error("Team is null");
const tiles = teamTiles.get(team) ?? 0;
const sum = tiles + player.numTilesOwned();
teamTiles.set(team, sum);
+21 -16
View File
@@ -43,7 +43,7 @@ export class StructureLayer implements Layer {
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
private unitIcons: Map<string, ImageData> = new Map();
private theme: Theme = null;
private theme: Theme;
// Configuration for supported unit types only
private readonly unitConfigs: Partial<Record<UnitType, UnitRenderConfig>> = {
@@ -106,6 +106,7 @@ export class StructureLayer implements Layer {
// Create temporary canvas for icon processing
const tempCanvas = document.createElement("canvas");
const tempContext = tempCanvas.getContext("2d");
if (tempContext === null) throw new Error("2d context not supported");
tempCanvas.width = image.width;
tempCanvas.height = image.height;
@@ -135,11 +136,13 @@ export class StructureLayer implements Layer {
}
tick() {
this.game
.updatesSinceLastTick()
[
GameUpdateType.Unit
].forEach((u) => this.handleUnitRendering(this.game.unit(u.id)));
const updates = this.game.updatesSinceLastTick();
const unitUpdates = updates !== null ? updates[GameUpdateType.Unit] : [];
for (const u of unitUpdates) {
const unit = this.game.unit(u.id);
if (unit === undefined) continue;
this.handleUnitRendering(unit);
}
}
init() {
@@ -149,7 +152,9 @@ export class StructureLayer implements Layer {
redraw() {
console.log("structure layer redrawing");
this.canvas = document.createElement("canvas");
this.context = this.canvas.getContext("2d", { alpha: true });
const context = this.canvas.getContext("2d", { alpha: true });
if (context === null) throw new Error("2d context not supported");
this.context = context;
this.canvas.width = this.game.width();
this.canvas.height = this.game.height();
this.game.units().forEach((u) => this.handleUnitRendering(u));
@@ -193,7 +198,7 @@ export class StructureLayer implements Layer {
)) {
this.paintCell(
new Cell(this.game.x(tile), this.game.y(tile)),
unit.type() == UnitType.Construction
unit.type() === UnitType.Construction
? underConstructionColor
: this.theme.territoryColor(unit.owner()),
130,
@@ -220,15 +225,15 @@ export class StructureLayer implements Layer {
if (!this.isUnitTypeSupported(unitType)) return;
const config = this.unitConfigs[unitType];
let icon: ImageData;
let icon: ImageData | undefined;
if (unitType == UnitType.SAMLauncher && unit.isCooldown()) {
if (unitType === UnitType.SAMLauncher && unit.isCooldown()) {
icon = this.unitIcons.get("reloadingSam");
} else {
icon = this.unitIcons.get(iconType);
}
if (unitType == UnitType.MissileSilo && unit.isCooldown()) {
if (unitType === UnitType.MissileSilo && unit.isCooldown()) {
icon = this.unitIcons.get("reloadingSilo");
} else {
icon = this.unitIcons.get(iconType);
@@ -248,15 +253,15 @@ export class StructureLayer implements Layer {
if (!unit.isActive()) return;
let borderColor = this.theme.borderColor(unit.owner());
if (unitType == UnitType.SAMLauncher && unit.isCooldown()) {
if (unitType === UnitType.SAMLauncher && unit.isCooldown()) {
borderColor = reloadingColor;
} else if (unit.type() == UnitType.Construction) {
} else if (unit.type() === UnitType.Construction) {
borderColor = underConstructionColor;
}
if (unitType == UnitType.MissileSilo && unit.isCooldown()) {
if (unitType === UnitType.MissileSilo && unit.isCooldown()) {
borderColor = reloadingColor;
} else if (unit.type() == UnitType.Construction) {
} else if (unit.type() === UnitType.Construction) {
borderColor = underConstructionColor;
}
@@ -277,7 +282,7 @@ export class StructureLayer implements Layer {
unit: UnitView,
) {
let color = this.theme.borderColor(unit.owner());
if (unit.type() == UnitType.Construction) {
if (unit.type() === UnitType.Construction) {
color = underConstructionColor;
}
for (let y = 0; y < height; y++) {
+238
View File
@@ -0,0 +1,238 @@
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { GameMode } from "../../../core/game/Game";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { ClientID } from "../../../core/Schemas";
import { renderNumber } from "../../Utils";
import { Layer } from "./Layer";
interface TeamEntry {
teamName: string;
totalScoreStr: string;
totalGold: string;
totalTroops: string;
players: PlayerView[];
}
@customElement("team-stats")
export class TeamStats extends LitElement implements Layer {
public game: GameView;
public clientID: ClientID;
public eventBus: EventBus;
teams: TeamEntry[] = [];
@state()
private _teamStatsHidden = true;
private _shownOnInit = false;
init() {}
tick() {
if (this.game.config().gameConfig().gameMode !== GameMode.Team) {
return;
}
if (!this._shownOnInit && !this.game.inSpawnPhase()) {
this._shownOnInit = true;
this._teamStatsHidden = false;
this.updateTeamStats();
}
if (this._teamStatsHidden) return;
if (this.game.ticks() % 10 === 0) {
this.updateTeamStats();
}
}
private updateTeamStats() {
const players = this.game.playerViews();
const grouped: Record<number, PlayerView[]> = {};
for (const player of players) {
const team = player.team();
if (team === null) continue;
if (!grouped[team]) grouped[team] = [];
grouped[team].push(player);
}
this.teams = Object.entries(grouped)
.map(([teamStr, teamPlayers]) => {
let totalGold = 0;
let totalTroops = 0;
let totalScoreSort = 0;
for (const p of teamPlayers) {
totalGold += p.gold();
if (p.isAlive()) {
totalTroops += p.troops();
totalGold += p.gold();
totalScoreSort += p.numTilesOwned();
}
}
const totalScorePercent = totalScoreSort / this.game.numLandTiles();
return {
teamName: teamStr,
totalScoreStr: formatPercentage(totalScorePercent),
totalScoreSort,
totalGold: renderNumber(totalGold),
totalTroops: renderNumber(totalTroops / 10),
players: teamPlayers,
};
})
.sort((a, b) => b.totalScoreSort - a.totalScoreSort);
this.requestUpdate();
}
renderLayer(context: CanvasRenderingContext2D) {}
shouldTransform(): boolean {
return false;
}
static styles = css`
:host {
display: block;
}
.team-stats {
position: fixed;
top: 10px;
left: 450px;
z-index: 9999;
background-color: rgb(31 41 55 / 0.7);
padding: 10px;
padding-top: 0px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
border-radius: 10px;
max-width: 250px;
max-height: 30vh;
overflow-y: auto;
width: 400px;
backdrop-filter: blur(5px);
}
.teamStats-close-button {
background: none;
border: none;
color: white;
cursor: pointer;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 5px;
text-align: center;
border-bottom: 1px solid rgba(51, 51, 51, 0.2);
color: var(--text-color, white);
}
th {
background-color: rgb(31 41 55 / 0.5);
color: white;
}
.hidden {
display: none !important;
}
.team-stats-button {
position: fixed;
left: 450px;
top: 10px;
z-index: 9999;
background-color: rgb(31 41 55 / 0.7);
color: white;
border: none;
border-radius: 4px;
padding: 5px 10px;
cursor: pointer;
}
`;
render() {
return html`
<button
@click=${() => this.toggleTeamStats()}
class="team-stats-button ${this._shownOnInit && this._teamStatsHidden
? ""
: "hidden"}"
>
Team Stats
</button>
<div
class="team-stats ${this._teamStatsHidden ? "hidden" : ""}"
@contextmenu=${(e) => e.preventDefault()}
>
<button
class="teamStats-close-button"
@click=${() => this.hideTeamStats()}
>
Hide
</button>
<table>
<thead>
<tr>
<th>Team</th>
<th>Owned</th>
<th>Gold</th>
<th>Troops</th>
</tr>
</thead>
<tbody>
${this.teams.map(
(team) => html`
<tr class="">
<td>${team.teamName}</td>
<td>${team.totalScoreStr}</td>
<td>${team.totalGold}</td>
<td>${team.totalTroops}</td>
</tr>
`,
)}
</tbody>
</table>
</div>
`;
}
toggleTeamStats() {
this._teamStatsHidden = !this._teamStatsHidden;
}
hideTeamStats() {
this._teamStatsHidden = true;
this.requestUpdate();
}
showTeamStats() {
this._teamStatsHidden = true;
this.requestUpdate();
}
get isVisible() {
return !this._teamStatsHidden;
}
}
function formatPercentage(value: number): string {
const perc = value * 100;
if (perc > 99.5) {
return "100%";
}
if (perc < 0.01) {
return "0%";
}
if (perc < 0.1) {
return perc.toPrecision(1) + "%";
}
return perc.toPrecision(2) + "%";
}
+3 -1
View File
@@ -29,7 +29,9 @@ export class TerrainLayer implements Layer {
redraw(): void {
this.canvas = document.createElement("canvas");
this.context = this.canvas.getContext("2d");
const context = this.canvas.getContext("2d");
if (context === null) throw new Error("2d context not supported");
this.context = context;
this.imageData = this.context.getImageData(
0,
+47 -41
View File
@@ -4,12 +4,8 @@ import territory_patterns from "../../../../resources/territory_patterns.json";
import { Theme } from "../../../core/configuration/Config";
import { EventBus } from "../../../core/EventBus";
import { Cell, PlayerType, UnitType } from "../../../core/game/Game";
import {
euclDistFN,
manhattanDistFN,
TileRef,
} from "../../../core/game/GameMap";
import { GameUpdateType, UnitUpdate } from "../../../core/game/GameUpdates";
import { euclDistFN, TileRef } from "../../../core/game/GameMap";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { PseudoRandom } from "../../../core/PseudoRandom";
import { AlternateViewEvent, DragEvent } from "../../InputHandler";
@@ -27,7 +23,7 @@ export class TerritoryLayer implements Layer {
return a.lastUpdate - b.lastUpdate;
});
private random = new PseudoRandom(123);
private theme: Theme = null;
private theme: Theme;
// Used for spawn highlighting
private highlightCanvas: HTMLCanvasElement;
@@ -63,19 +59,18 @@ export class TerritoryLayer implements Layer {
tick() {
this.game.recentlyUpdatedTiles().forEach((t) => this.enqueueTile(t));
this.game.updatesSinceLastTick()[GameUpdateType.Unit].forEach((u) => {
const update = u as UnitUpdate;
if (update.unitType == UnitType.DefensePost && update.isActive) {
const updates = this.game.updatesSinceLastTick();
const unitUpdates = updates !== null ? updates[GameUpdateType.Unit] : [];
unitUpdates.forEach((update) => {
if (update.unitType === UnitType.DefensePost) {
const tile = update.pos;
this.game
.bfs(
tile,
manhattanDistFN(tile, this.game.config().defensePostRange()),
)
.bfs(tile, euclDistFN(tile, this.game.config().defensePostRange()))
.forEach((t) => {
if (
this.game.isBorder(t) &&
this.game.ownerID(t) == update.ownerID
(this.game.ownerID(t) === update.ownerID ||
this.game.ownerID(t) === update.lastOwnerID)
) {
this.enqueueTile(t);
}
@@ -97,7 +92,7 @@ export class TerritoryLayer implements Layer {
if (!this.game.inSpawnPhase()) {
return;
}
if (this.game.ticks() % 5 == 0) {
if (this.game.ticks() % 5 === 0) {
return;
}
@@ -109,7 +104,7 @@ export class TerritoryLayer implements Layer {
);
const humans = this.game
.playerViews()
.filter((p) => p.type() == PlayerType.Human);
.filter((p) => p.type() === PlayerType.Human);
for (const human of humans) {
const center = human.nameLocation();
@@ -120,6 +115,15 @@ export class TerritoryLayer implements Layer {
if (!centerTile) {
continue;
}
let color = this.theme.spawnHighlightColor();
const myPlayer = this.game.myPlayer();
if (
myPlayer !== null &&
myPlayer !== human &&
myPlayer.isFriendly(human)
) {
color = this.theme.selfColor();
}
for (const tile of this.game.bfs(
centerTile,
euclDistFN(centerTile, 9, true),
@@ -127,7 +131,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,
);
}
@@ -149,7 +153,9 @@ export class TerritoryLayer implements Layer {
redraw() {
console.log("redrew territory layer");
this.canvas = document.createElement("canvas");
this.context = this.canvas.getContext("2d");
const context = this.canvas.getContext("2d");
if (context === null) throw new Error("2d context not supported");
this.context = context;
this.imageData = this.context.getImageData(
0,
@@ -164,9 +170,11 @@ export class TerritoryLayer implements Layer {
// Add a second canvas for highlights
this.highlightCanvas = document.createElement("canvas");
this.highlightContext = this.highlightCanvas.getContext("2d", {
const highlightContext = this.highlightCanvas.getContext("2d", {
alpha: true,
});
if (highlightContext === null) throw new Error("2d context not supported");
this.highlightContext = highlightContext;
this.highlightCanvas.width = this.game.width();
this.highlightCanvas.height = this.game.height();
@@ -185,11 +193,12 @@ export class TerritoryLayer implements Layer {
}
renderLayer(context: CanvasRenderingContext2D) {
const now = Date.now();
if (
Date.now() > this.lastDragTime + this.nodrawDragDuration &&
Date.now() > this.lastRefresh + this.refreshRate
now > this.lastDragTime + this.nodrawDragDuration &&
now > this.lastRefresh + this.refreshRate
) {
this.lastRefresh = Date.now();
this.lastRefresh = now;
this.renderTerritory();
this.context.putImageData(this.imageData, 0, 0);
}
@@ -217,7 +226,7 @@ export class TerritoryLayer implements Layer {
renderTerritory() {
let numToRender = Math.floor(this.tileToRenderQueue.size() / 10);
if (numToRender == 0 || this.game.inSpawnPhase()) {
if (numToRender === 0 || this.game.inSpawnPhase()) {
numToRender = this.tileToRenderQueue.size();
}
@@ -250,25 +259,22 @@ export class TerritoryLayer implements Layer {
}
const owner = this.game.owner(tile) as PlayerView;
if (this.game.isBorder(tile)) {
const playerIsFocused = owner && this.game.focusedPlayer() == owner;
const playerIsFocused = owner && this.game.focusedPlayer() === owner;
if (
this.game
.nearbyUnits(
tile,
this.game.config().defensePostRange(),
UnitType.DefensePost,
)
.filter((u) => u.unit.owner() == owner).length > 0
this.game.hasUnitNearby(
tile,
this.game.config().defensePostRange(),
UnitType.DefensePost,
owner.id(),
)
) {
const useDefendedBorderColor = playerIsFocused
? this.theme.focusedDefendedBorderColor()
: this.theme.defendedBorderColor(owner);
this.paintCell(
this.game.x(tile),
this.game.y(tile),
useDefendedBorderColor,
255,
);
const borderColors = this.theme.defendedBorderColors(owner);
const x = this.game.x(tile);
const y = this.game.y(tile);
const lightTile =
(x % 2 === 0 && y % 2 === 0) || (y % 2 === 1 && x % 2 === 1);
const borderColor = lightTile ? borderColors.light : borderColors.dark;
this.paintCell(x, y, borderColor, 255);
} else {
const useBorderColor = playerIsFocused
? this.theme.focusedBorderColor()
+19 -11
View File
@@ -1,5 +1,6 @@
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import { translateText } from "../../../client/Utils";
import { GameView } from "../../../core/game/GameView";
import { renderNumber, renderTroops } from "../../Utils";
import { Layer } from "./Layer";
@@ -22,18 +23,21 @@ export class TopBar extends LitElement implements Layer {
}
tick() {
if (this.game?.myPlayer() !== null) {
const popIncreaseRate =
this.game.myPlayer().population() - this._population;
if (this.game.ticks() % 5 == 0) {
this._popRateIsIncreasing =
popIncreaseRate >= this._lastPopulationIncreaseRate;
this._lastPopulationIncreaseRate = popIncreaseRate;
}
}
this.updatePopulationIncrease();
this.requestUpdate();
}
private updatePopulationIncrease() {
const player = this.game?.myPlayer();
if (player === null) return;
const popIncreaseRate = player.population() - this._population;
if (this.game.ticks() % 5 === 0) {
this._popRateIsIncreasing =
popIncreaseRate >= this._lastPopulationIncreaseRate;
this._lastPopulationIncreaseRate = popIncreaseRate;
}
}
render() {
if (!this.isVisible) {
return html``;
@@ -56,7 +60,9 @@ export class TopBar extends LitElement implements Layer {
<div
class="sm:col-span-1 flex items-center space-x-1 overflow-x-auto whitespace-nowrap"
>
<span class="font-bold shrink-0">Pop:</span>
<span class="font-bold shrink-0"
>${translateText("control_panel.pop")}:</span
>
<span translate="no"
>${renderTroops(myPlayer.population())} /
${renderTroops(maxPop)}</span
@@ -73,7 +79,9 @@ export class TopBar extends LitElement implements Layer {
<div
class="flex items-center space-x-2 overflow-x-auto whitespace-nowrap"
>
<span class="font-bold shrink-0">Gold:</span>
<span class="font-bold shrink-0"
>${translateText("control_panel.gold")}:</span
>
<span translate="no"
>${renderNumber(myPlayer.gold())}
(+${renderNumber(goldPerSecond)})</span
+5 -2
View File
@@ -14,9 +14,9 @@ import { Layer } from "./Layer";
*/
export class UILayer implements Layer {
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
private context: CanvasRenderingContext2D | null;
private theme: Theme = null;
private theme: Theme | null = null;
private selectionAnimTime = 0;
// Keep track of currently selected unit
@@ -136,6 +136,7 @@ export class UILayer implements Layer {
baseOpacity + Math.sin(this.selectionAnimTime * 0.1) * pulseAmount;
// Get the unit's owner color for the box
if (this.theme === null) throw new Error("missing theme");
const ownerColor = this.theme.territoryColor(unit.owner());
// Create a brighter version of the owner color for the selection
@@ -196,12 +197,14 @@ export class UILayer implements Layer {
}
paintCell(x: number, y: number, color: Colord, alpha: number) {
if (this.context === null) throw new Error("null context");
this.clearCell(x, y);
this.context.fillStyle = color.alpha(alpha / 255).toRgbString();
this.context.fillRect(x, y, 1, 1);
}
clearCell(x: number, y: number) {
if (this.context === null) throw new Error("null context");
this.context.clearRect(x, y, 1, 1);
}
}
+152 -79
View File
@@ -6,6 +6,7 @@ import { UnitType } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import { BezenhamLine } from "../../../core/utilities/Line";
import {
AlternateViewEvent,
MouseUpEvent,
@@ -15,7 +16,11 @@ import { MoveWarshipIntentEvent } from "../../Transport";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
import { getColoredSprite, loadAllSprites } from "../SpriteLoader";
import {
getColoredSprite,
isSpriteReady,
loadAllSprites,
} from "../SpriteLoader";
enum Relationship {
Self,
@@ -27,11 +32,11 @@ export class UnitLayer implements Layer {
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
private transportShipTrailCanvas: HTMLCanvasElement;
private transportShipTrailContext: CanvasRenderingContext2D;
private unitTrailContext: CanvasRenderingContext2D;
private boatToTrail = new Map<UnitView, TileRef[]>();
private unitToTrail = new Map<UnitView, TileRef[]>();
private theme: Theme = null;
private theme: Theme;
private alternateView = false;
@@ -62,12 +67,11 @@ export class UnitLayer implements Layer {
}
tick() {
if (this.myPlayer == null) {
if (this.myPlayer === null) {
this.myPlayer = this.game.playerByClientID(this.clientID);
}
this.game.updatesSinceLastTick()?.[GameUpdateType.Unit]?.forEach((unit) => {
this.onUnitEvent(this.game.unit(unit.id));
});
this.updateUnitsSprites();
}
init() {
@@ -92,7 +96,7 @@ export class UnitLayer implements Layer {
const clickRef = this.game.ref(cell.x, cell.y);
// Make sure we have the current player
if (this.myPlayer == null) {
if (this.myPlayer === null) {
this.myPlayer = this.game.playerByClientID(this.clientID);
}
@@ -185,21 +189,22 @@ export class UnitLayer implements Layer {
redraw() {
this.canvas = document.createElement("canvas");
this.context = this.canvas.getContext("2d");
const context = this.canvas.getContext("2d");
if (context === null) throw new Error("2d context not supported");
this.context = context;
this.transportShipTrailCanvas = document.createElement("canvas");
this.transportShipTrailContext =
this.transportShipTrailCanvas.getContext("2d");
const trailContext = this.transportShipTrailCanvas.getContext("2d");
if (trailContext === null) throw new Error("2d context not supported");
this.unitTrailContext = trailContext;
this.canvas.width = this.game.width();
this.canvas.height = this.game.height();
this.transportShipTrailCanvas.width = this.game.width();
this.transportShipTrailCanvas.height = this.game.height();
this.game
?.updatesSinceLastTick()
?.[GameUpdateType.Unit]?.forEach((unit) => {
this.onUnitEvent(this.game.unit(unit.id));
});
this.boatToTrail.forEach((trail, unit) => {
this.updateUnitsSprites();
this.unitToTrail.forEach((trail, unit) => {
for (const t of trail) {
this.paintCell(
this.game.x(t),
@@ -207,17 +212,43 @@ export class UnitLayer implements Layer {
this.relationship(unit),
this.theme.territoryColor(unit.owner()),
150,
this.transportShipTrailContext,
this.unitTrailContext,
);
}
});
}
private updateUnitsSprites() {
const unitsToUpdate = this.game
.updatesSinceLastTick()
?.[GameUpdateType.Unit]?.map((unit) => this.game.unit(unit.id))
?.forEach((unitView) => {
if (unitView === undefined) return;
const ready = isSpriteReady(unitView.type());
if (ready) this.clearUnitCells(unitView);
this.onUnitEvent(unitView);
});
}
private clearUnitCells(unit: UnitView) {
const sprite = getColoredSprite(unit, this.theme);
const clearsize = sprite.width + 1;
const lastX = this.game.x(unit.lastTile());
const lastY = this.game.y(unit.lastTile());
this.context.clearRect(
lastX - clearsize / 2,
lastY - clearsize / 2,
clearsize,
clearsize,
);
}
private relationship(unit: UnitView): Relationship {
if (this.myPlayer == null) {
if (this.myPlayer === null) {
return Relationship.Enemy;
}
if (this.myPlayer == unit.owner()) {
if (this.myPlayer === unit.owner()) {
return Relationship.Self;
}
if (this.myPlayer.isFriendly(unit.owner())) {
@@ -272,8 +303,8 @@ export class UnitLayer implements Layer {
// Clear current and previous positions
this.clearCell(this.game.x(unit.lastTile()), this.game.y(unit.lastTile()));
if (this.oldShellTile.has(unit)) {
const oldTile = this.oldShellTile.get(unit);
const oldTile = this.oldShellTile.get(unit);
if (oldTile !== undefined) {
this.clearCell(this.game.x(oldTile), this.game.y(oldTile));
}
@@ -304,8 +335,85 @@ export class UnitLayer implements Layer {
this.drawSprite(unit);
}
private drawTrail(trail: number[], color: Colord, rel: Relationship) {
// Paint new trail
for (const t of trail) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
color,
150,
this.unitTrailContext,
);
}
}
private clearTrail(unit: UnitView) {
const trail = this.unitToTrail.get(unit) ?? [];
const rel = this.relationship(unit);
for (const t of trail) {
this.clearCell(this.game.x(t), this.game.y(t), this.unitTrailContext);
}
this.unitToTrail.delete(unit);
// Repaint overlapping trails
const trailSet = new Set(trail);
for (const [other, trail] of this.unitToTrail) {
for (const t of trail) {
if (trailSet.has(t)) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.territoryColor(other.owner()),
150,
this.unitTrailContext,
);
}
}
}
}
private handleNuke(unit: UnitView) {
const rel = this.relationship(unit);
if (!this.unitToTrail.has(unit)) {
this.unitToTrail.set(unit, []);
}
let newTrailSize = 1;
const trail = this.unitToTrail.get(unit) ?? [];
// It can move faster than 1 pixel, draw a line for the trail or else it will be dotted
if (trail.length >= 1) {
const cur = {
x: this.game.x(unit.lastTile()),
y: this.game.y(unit.lastTile()),
};
const prev = {
x: this.game.x(trail[trail.length - 1]),
y: this.game.y(trail[trail.length - 1]),
};
const line = new BezenhamLine(prev, cur);
let point = line.increment();
while (point !== true) {
trail.push(this.game.ref(point.x, point.y));
point = line.increment();
}
newTrailSize = line.size();
} else {
trail.push(unit.lastTile());
}
this.drawTrail(
trail.slice(-newTrailSize),
this.theme.territoryColor(unit.owner()),
rel,
);
this.drawSprite(unit);
if (!unit.isActive()) {
this.clearTrail(unit);
}
}
private handleMIRVWarhead(unit: UnitView) {
@@ -332,52 +440,22 @@ export class UnitLayer implements Layer {
private handleBoatEvent(unit: UnitView) {
const rel = this.relationship(unit);
if (!this.boatToTrail.has(unit)) {
this.boatToTrail.set(unit, []);
if (!this.unitToTrail.has(unit)) {
this.unitToTrail.set(unit, []);
}
const trail = this.boatToTrail.get(unit);
const trail = this.unitToTrail.get(unit) ?? [];
trail.push(unit.lastTile());
// 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.drawTrail(
trail.slice(-1),
this.theme.territoryColor(unit.owner()),
rel,
);
this.drawSprite(unit);
if (!unit.isActive()) {
for (const t of trail) {
this.clearCell(
this.game.x(t),
this.game.y(t),
this.transportShipTrailContext,
);
}
this.boatToTrail.delete(unit);
// Repaint overlapping trails
const trailSet = new Set(trail);
for (const [other, trail] of this.boatToTrail) {
for (const t of trail) {
if (trailSet.has(t)) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.territoryColor(other.owner()),
150,
this.transportShipTrailContext,
);
}
}
}
this.clearTrail(unit);
}
}
@@ -419,17 +497,21 @@ export class UnitLayer implements Layer {
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;
let alternateViewColor: Colord | null = 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();
if (this.game.myPlayer() != null && this.game.myPlayer() == target) {
rel = Relationship.Self;
const dstPortId = unit.dstPortId();
if (unit.type() === UnitType.TradeShip && dstPortId !== undefined) {
const target = this.game.unit(dstPortId)?.owner();
const myPlayer = this.game.myPlayer();
if (myPlayer !== null && target !== undefined) {
if (myPlayer === target) {
rel = Relationship.Self;
} else if (myPlayer.isFriendly(target)) {
rel = Relationship.Ally;
}
}
}
switch (rel) {
@@ -449,16 +531,7 @@ export class UnitLayer implements Layer {
unit,
this.theme,
alternateViewColor ?? customTerritoryColor,
alternateViewColor,
);
const clearsize = sprite.width + 1;
this.context.clearRect(
lastX - clearsize / 2,
lastY - clearsize / 2,
clearsize,
clearsize,
alternateViewColor ?? undefined,
);
if (unit.isActive()) {
+32 -42
View File
@@ -1,38 +1,24 @@
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { translateText } from "../../../client/Utils";
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 {
@@ -151,17 +137,19 @@ export class WinModal extends LitElement implements Layer {
<h2>${this._title || ""}</h2>
${this.innerHtml()}
<div class="button-container">
<button @click=${this._handleExit}>Exit Game</button>
<button @click=${this.hide}>Keep Playing</button>
<button @click=${this._handleExit}>
${translateText("win_modal.exit")}
</button>
<button @click=${this.hide}>
${translateText("win_modal.keep")}
</button>
</div>
</div>
`;
}
innerHtml() {
return html`
<div style="text-align: center; margin: 15px 0; line-height: 1.5;"></div>
`;
return html``;
}
show() {
@@ -179,9 +167,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();
@@ -193,36 +179,40 @@ export class WinModal extends LitElement implements Layer {
myPlayer.hasSpawned()
) {
this.hasShownDeathModal = true;
this._title = "You died";
this.won = false;
this._title = translateText("win_modal.died");
this.show();
}
this.game.updatesSinceLastTick()[GameUpdateType.Win].forEach((wu) => {
const updates = this.game.updatesSinceLastTick();
const winUpdates = updates !== null ? updates[GameUpdateType.Win] : [];
winUpdates.forEach((wu) => {
if (wu.winnerType === "team") {
this.eventBus.emit(
new SendWinnerEvent(wu.winner as Team, wu.allPlayersStats, "team"),
);
if (wu.winner == this.game.myPlayer()?.team()) {
this._title = "Your team won!";
this.won = true;
if (wu.winner === this.game.myPlayer()?.team()) {
this._title = translateText("win_modal.your_team");
} else {
this._title = `${wu.winner} team has won!`;
this.won = false;
this._title = translateText("win_modal.other_team", {
team: wu.winner,
});
}
this.show();
} else {
const winner = this.game.playerBySmallID(
wu.winner as number,
) as PlayerView;
this.eventBus.emit(
new SendWinnerEvent(winner.clientID(), wu.allPlayersStats, "player"),
);
if (winner == this.game.myPlayer()) {
this._title = "You Won!";
this.won = true;
const winnerClient = winner.clientID();
if (winnerClient !== null) {
this.eventBus.emit(
new SendWinnerEvent(winnerClient, wu.allPlayersStats, "player"),
);
}
if (winner === this.game.myPlayer()) {
this._title = translateText("win_modal.you_won");
} else {
this._title = `${winner.name()} has won!`;
this.won = false;
this._title = translateText("win_modal.you_won", {
player: winner.name(),
});
}
this.show();
}
+76 -10
View File
@@ -121,11 +121,6 @@
gtag("js", new Date());
gtag("config", "AW-16702609763");
</script>
<script
async
src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-7035513310742290"
crossorigin="anonymous"
></script>
<!-- Google tag (gtag.js) -->
<script
async
@@ -140,6 +135,25 @@
gtag("config", "G-WQGQQ8RDN4");
</script>
<!-- AdinPlay Ads -->
<script>
var aiptag = aiptag || {};
aiptag.cmd = aiptag.cmd || [];
aiptag.cmd.display = aiptag.cmd.display || [];
aiptag.cmd.player = aiptag.cmd.player || [];
//CMP tool settings
aiptag.cmp = {
show: true,
button: true,
buttonText: "Privacy settings",
buttonPosition: "top-right", //bottom-left, bottom-right, top-left, top-right
};
</script>
<script
async
src="//api.adinplay.com/libs/aiptag/pub/OFI/openfront.io/tag.min.js"
></script>
</head>
<body
@@ -208,12 +222,44 @@
</header>
<div class="bg-image"></div>
<random-name-button></random-name-button>
<!-- Left gutter ad placement - full height, no empty space -->
<div class="left-gutter-ad ad">
<div id="openfront-io_300x600">
<script type="text/javascript">
aiptag.cmd.display.push(function () {
aipDisplayTag.display("openfront-io_300x600");
});
</script>
</div>
</div>
<div class="right-gutter-ad ad">
<div id="openfront-io_300x600_2">
<script type="text/javascript">
aiptag.cmd.display.push(function () {
aipDisplayTag.display("openfront-io_300x600_2");
});
</script>
</div>
</div>
<dark-mode-button></dark-mode-button>
<!-- Main container with responsive padding -->
<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>
<territory-patterns-input class="w-[50%] md:w-[12%]">
@@ -224,6 +270,7 @@
></button>
</territory-patterns-input>
<username-input class="w-full"></username-input>
<news-button class="mt-3"></news-button>
</div>
<div></div>
<div>
@@ -244,6 +291,12 @@
block
secondary
></o-button>
<!-- <o-button
id="chat-button"
title="Chat Test"
block
secondary
></o-button> -->
</div>
<o-button
@@ -301,6 +354,7 @@
class="w-full sm:w-2/3 sm:fixed sm:right-0 sm:bottom-0 sm:flex justify-end"
style="pointer-events: none"
>
<chat-display></chat-display>
<events-display></events-display>
</div>
<div class="w-full sm:w-1/3 md:max-w-72" style="pointer-events: auto">
@@ -328,18 +382,27 @@
>
Wiki
</a>
<a target="_blank" href="https://discord.gg/openfront" class="t-link">
<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™
©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>
@@ -354,11 +417,14 @@
<win-modal></win-modal>
<game-starting-modal></game-starting-modal>
<top-bar></top-bar>
<team-stats></team-stats>
<player-panel></player-panel>
<help-modal></help-modal>
<dark-mode-button></dark-mode-button>
<chat-modal></chat-modal>
<user-setting></user-setting>
<multi-tab-modal></multi-tab-modal>
<news-modal></news-modal>
<div
id="language-modal"
class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden flex justify-center items-center"
+210
View File
@@ -0,0 +1,210 @@
import { decodeJwt } from "jose";
import {
RefreshResponseSchema,
TokenPayload,
TokenPayloadSchema,
UserMeResponse,
UserMeResponseSchema,
} from "../core/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;
}
}
+6
View File
@@ -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/modal/chat.css");
@import url("./styles/components/setting.css");
@import url("./styles/components/controls.css");
* {
@@ -194,6 +195,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;
+11
View File
@@ -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 */
}
+78 -9
View File
@@ -22,6 +22,10 @@
gap: 12px;
}
.setting-item.column {
flex-direction: column;
}
@keyframes rainbow-background {
0% {
background-position: 0% 50%;
@@ -64,6 +68,20 @@
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;
@@ -82,6 +100,25 @@
}
}
@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;
}
@@ -158,17 +195,14 @@
cursor: pointer;
}
.setting-input.slider::-moz-range-thumb {
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-track {
background: linear-gradient(to right, #2196f3 50%, #444 50%);
.setting-input.slider::-moz-range-progress {
background-color: #2196f3;
height: 10px;
border-radius: 5px;
}
@@ -255,3 +289,38 @@
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;
}
+99
View File
@@ -0,0 +1,99 @@
/* .w. */
.chat-columns {
display: flex;
gap: 16px;
padding: 12px;
overflow-x: auto;
}
.chat-column {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 120px;
}
.column-title {
font-weight: bold;
margin-bottom: 4px;
}
.chat-option-button {
background: #333;
color: white;
border: none;
padding: 8px 12px;
border-radius: 4px;
text-align: left;
cursor: pointer;
}
.chat-option-button.selected {
background-color: #66c;
}
.chat-preview {
margin: 10px 12px;
padding: 10px;
background: #222;
color: white;
border-radius: 6px;
text-align: center;
}
.chat-send {
display: flex;
justify-content: flex-end;
padding: 0 12px 12px;
}
.chat-send-button {
background: #4caf50;
color: white;
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
}
.chat-send-button:disabled {
background: #666;
cursor: not-allowed;
}
.chat-column {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 140px;
}
.player-search-input {
padding: 6px 8px;
border-radius: 4px;
border: 1px solid #666;
font-size: 14px;
outline: none;
background-color: #fff;
color: #000;
}
.player-scroll-area {
max-height: 240px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
padding-right: 4px;
}
.phrase-scroll-area {
max-height: 280px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
padding-right: 4px;
}
+12
View File
@@ -1,10 +1,14 @@
import africa from "../../../resources/maps/AfricaThumb.webp";
import asia from "../../../resources/maps/AsiaThumb.webp";
import australia from "../../../resources/maps/AustraliaThumb.webp";
import baikal from "../../../resources/maps/BaikalThumb.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 falklandislands from "../../../resources/maps/FalklandIslandsThumb.webp";
import faroeislands from "../../../resources/maps/FaroeIslandsThumb.webp";
import gatewayToTheAtlantic from "../../../resources/maps/GatewayToTheAtlanticThumb.webp";
import iceland from "../../../resources/maps/IcelandThumb.webp";
@@ -28,6 +32,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:
@@ -60,6 +66,12 @@ export function getMapsImage(map: GameMapType): string {
return knownworld;
case GameMapType.FaroeIslands:
return faroeislands;
case GameMapType.DeglaciatedAntarctica:
return deglaciatedAntarctica;
case GameMapType.FalklandIslands:
return falklandislands;
case GameMapType.Baikal:
return baikal;
default:
return "";
}
+48
View File
@@ -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;
},
{
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>;
+37
View File
@@ -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("-");
}
+49 -21
View File
@@ -4,9 +4,11 @@ import { Executor } from "./execution/ExecutionManager";
import { WinCheckExecution } from "./execution/WinCheckExecution";
import {
AllPlayers,
Cell,
Game,
GameUpdates,
NameViewData,
Nation,
Player,
PlayerActions,
PlayerBorderTiles,
@@ -23,8 +25,9 @@ import {
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(
@@ -34,27 +37,50 @@ 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.pattern,
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.pattern,
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(
null,
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,
);
@@ -116,23 +142,25 @@ export class GameRunner {
errMsg: error.message,
stack: error.stack,
} as ErrorUpdate);
return;
} else {
console.error("Game tick error:", error);
}
return;
}
if (this.game.inSpawnPhase() && this.game.ticks() % 2 == 0) {
if (this.game.inSpawnPhase() && this.game.ticks() % 2 === 0) {
this.game
.players()
.filter(
(p) =>
p.type() == PlayerType.Human || p.type() == PlayerType.FakeHuman,
p.type() === PlayerType.Human || p.type() === PlayerType.FakeHuman,
)
.forEach(
(p) => (this.playerViewData[p.id()] = placeName(this.game, p)),
);
}
if (this.game.ticks() < 3 || this.game.ticks() % 30 == 0) {
if (this.game.ticks() < 3 || this.game.ticks() % 30 === 0) {
this.game.players().forEach((p) => {
this.playerViewData[p.id()] = placeName(this.game, p);
});
+70 -8
View File
@@ -1,11 +1,51 @@
export class PseudoRandom {
// Internal state (two 32-bit integers)
private state0: number;
private state1: number;
// Keep these variables to maintain the exact same interface
private m: number = 0x80000000; // 2**31
private a: number = 1103515245;
private c: number = 12345;
private state: number;
constructor(seed: number) {
// Initialize the XorShift state with seed
this.state0 = seed | 0; // Force to 32-bit integer with bitwise OR
this.state1 = 0x6e2d786c; // Fixed value as second seed (arbitrary prime)
// Ensure non-zero state
if (this.state0 === 0) this.state0 = 1;
// Also set the LCG state variable to maintain interface
this.state = seed % this.m;
if (this.state < 0) this.state += this.m;
// Warm up the generator to improve initial distribution
for (let i = 0; i < 20; i++) {
this._nextIntInternal();
}
}
/**
* Internal function that implements XorShift algorithm
* @returns A 32-bit integer
*/
private _nextIntInternal(): number {
// Get current state
let s1 = this.state0;
const s0 = this.state1;
// Update state using XorShift algorithm (all operations are bitwise)
this.state0 = s0;
s1 ^= s1 << 23;
s1 ^= s1 >>> 17;
s1 ^= s0;
s1 ^= s0 >>> 26;
this.state1 = s1;
// Generate output (force 32-bit integer)
return (this.state0 + this.state1) | 0;
}
/**
@@ -13,7 +53,14 @@ export class PseudoRandom {
* @returns A number between 0 (inclusive) and 1 (exclusive).
*/
next(): number {
this.state = (this.a * this.state + this.c) % this.m;
// Get a 32-bit integer and convert to [0,1) range
// Using >>> 0 to get unsigned interpretation (positive number)
const int = this._nextIntInternal() >>> 0;
// Update the state variable to maintain compatibility with original interface
this.state = int % this.m;
// Convert to [0,1) range - using division for same interface
return this.state / this.m;
}
@@ -31,29 +78,44 @@ export class PseudoRandom {
return this.next() * (max - min) + min;
}
/**
* Generates a random ID (8 characters, alphanumeric).
*/
nextID(): string {
return this.nextInt(0, Math.pow(36, 8)) // 36^8 possibilities
.toString(36) // Convert to base36 (0-9 and a-z)
.padStart(8, "0"); // Ensure 8 chars by padding with zeros
}
/**
* Selects a random element from an array.
*/
randElement<T>(arr: T[]): T {
if (arr.length == 0) {
if (arr.length === 0) {
throw new Error("array must not be empty");
}
return arr[this.nextInt(0, arr.length)];
}
/**
* Returns true with probability 1/odds.
*/
chance(odds: number): boolean {
return this.nextInt(0, odds) == 0;
return this.nextInt(0, odds) === 0;
}
shuffleArray(array: 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]];
/**
* Returns a shuffled copy of the array using Fisher-Yates algorithm.
*/
shuffleArray<T>(array: T[]): T[] {
// Create a copy to avoid modifying the original array
const arrayCopy = [...array];
for (let i = arrayCopy.length - 1; i >= 0; i--) {
const j = this.nextInt(0, i + 1);
[arrayCopy[i], arrayCopy[j]] = [arrayCopy[j], arrayCopy[i]];
}
return array;
return arrayCopy;
}
}
+71 -36
View File
@@ -1,14 +1,16 @@
import { z } from "zod";
import quickChatData from "../../resources/QuickChat.json" with { type: "json" };
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;
@@ -28,6 +30,7 @@ export type Intent =
| TargetTroopRatioIntent
| BuildUnitIntent
| EmbargoIntent
| QuickChatIntent
| MoveWarshipIntent;
export type AttackIntent = z.infer<typeof AttackIntentSchema>;
@@ -49,6 +52,7 @@ export type TargetTroopRatioIntent = z.infer<
>;
export type BuildUnitIntent = z.infer<typeof BuildUnitIntentSchema>;
export type MoveWarshipIntent = z.infer<typeof MoveWarshipIntentSchema>;
export type QuickChatIntent = z.infer<typeof QuickChatIntentSchema>;
export type Turn = z.infer<typeof TurnSchema>;
export type GameConfig = z.infer<typeof GameConfigSchema>;
@@ -115,15 +119,17 @@ const GameConfigSchema = z.object({
gameType: z.nativeEnum(GameType),
gameMode: z.nativeEnum(GameMode),
disableNPCs: z.boolean(),
disableNukes: z.boolean(),
bots: z.number().int().min(0).max(400),
infiniteGold: z.boolean(),
infiniteTroops: z.boolean(),
instantBuild: z.boolean(),
maxPlayers: z.number().optional(),
numPlayerTeams: z.number().optional(),
disabledUnits: z.array(z.nativeEnum(UnitType)).optional(),
playerTeams: z.union([z.number().optional(), z.literal(Duos)]),
});
export const TeamSchema = z.string();
const SafeString = z
.string()
.regex(
@@ -131,14 +137,38 @@ 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 jwtRegex = /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$/;
// Copied from zod, modified to remove their erroneous `typ` header requirement
function isValidJWT(jwt: string, alg?: string): boolean {
if (!jwtRegex.test(jwt)) return false;
try {
const [header] = jwt.split(".");
// Convert base64url to base64
const base64 = header
.replace(/-/g, "+")
.replace(/_/g, "/")
.padEnd(header.length + ((4 - (header.length % 4)) % 4), "=");
const decoded = JSON.parse(atob(base64));
if (typeof decoded !== "object" || decoded === null) return false;
if (!decoded.alg) return false;
if (alg && decoded.alg !== alg) return false;
return true;
} catch {
return false;
}
}
const PersistentIdSchema = z.string().uuid();
const TokenSchema = z
.string()
.refine((v) => PersistentIdSchema.safeParse(v).success || isValidJWT(v), {
message: "Token must be a valid UUID or JWT",
});
const EmojiSchema = z
.number()
.nonnegative()
.max(flattenedEmojiTable.length - 1);
const ID = z
.string()
.regex(/^[a-zA-Z0-9]+$/)
@@ -196,11 +226,11 @@ export const SpawnIntentSchema = BaseIntentSchema.extend({
export const BoatAttackIntentSchema = BaseIntentSchema.extend({
type: z.literal("boat"),
targetID: ID.nullable(),
troops: z.number().nullable(),
troops: z.number(),
dstX: z.number(),
dstY: z.number(),
srcX: z.number().nullable().optional(),
srcY: z.number().nullable().optional(),
srcX: z.number().nullable(),
srcY: z.number().nullable(),
});
export const AllianceRequestIntentSchema = BaseIntentSchema.extend({
@@ -271,6 +301,19 @@ export const MoveWarshipIntentSchema = BaseIntentSchema.extend({
tile: z.number(),
});
export const QuickChatKeySchema = z.enum(
Object.entries(quickChatData).flatMap(([category, entries]) =>
entries.map((entry) => `${category}.${entry.key}`),
) as [string, ...string[]],
);
export const QuickChatIntentSchema = BaseIntentSchema.extend({
type: z.literal("quick_chat"),
recipient: ID,
quickChatKey: QuickChatKeySchema,
variables: z.record(SafeString).optional(),
});
const IntentSchema = z.union([
AttackIntentSchema,
CancelAttackIntentSchema,
@@ -287,11 +330,11 @@ const IntentSchema = z.union([
BuildUnitIntentSchema,
EmbargoIntentSchema,
MoveWarshipIntentSchema,
QuickChatIntentSchema,
]);
export const TurnSchema = z.object({
turnNumber: z.number(),
gameID: ID,
intents: z.array(IntentSchema),
// The hash of the game state at the end of the turn.
hash: z.number().nullable().optional(),
@@ -357,48 +400,43 @@ export const ServerMessageSchema = z.union([
// Client
const ClientBaseMessageSchema = z.object({
type: z.enum(["winner", "join", "intent", "ping", "log", "hash"]),
clientID: ID,
persistentID: SafeString.nullable(), // WARNING: persistent id is private.
gameID: ID,
});
export const ClientSendWinnerSchema = ClientBaseMessageSchema.extend({
export const ClientSendWinnerSchema = z.object({
type: z.literal("winner"),
winner: ID.or(z.nativeEnum(Team)).nullable(),
winner: z.union([ID, TeamSchema]).nullable(),
allPlayersStats: AllPlayersStatsSchema,
winnerType: z.enum(["player", "team"]),
});
export const ClientHashSchema = ClientBaseMessageSchema.extend({
export const ClientHashSchema = z.object({
type: z.literal("hash"),
hash: z.number(),
turnNumber: z.number(),
});
export const ClientLogMessageSchema = ClientBaseMessageSchema.extend({
export const ClientLogMessageSchema = z.object({
type: z.literal("log"),
severity: z.nativeEnum(LogSeverity),
log: ID,
persistentID: SafeString,
});
export const ClientPingMessageSchema = ClientBaseMessageSchema.extend({
export const ClientPingMessageSchema = z.object({
type: z.literal("ping"),
});
export const ClientIntentMessageSchema = ClientBaseMessageSchema.extend({
export const ClientIntentMessageSchema = z.object({
type: z.literal("intent"),
intent: IntentSchema,
});
// WARNING: never send this message to clients.
export const ClientJoinMessageSchema = ClientBaseMessageSchema.extend({
export const ClientJoinMessageSchema = z.object({
type: z.literal("join"),
clientID: ID,
token: TokenSchema, // WARNING: PII
gameID: ID,
lastTurn: z.number(), // The last turn the client saw.
username: SafeString,
flag: SafeString.nullable().optional(),
flag: SafeString.nullable(),
pattern: SafeString.nullable().optional(),
});
@@ -415,7 +453,7 @@ export const PlayerRecordSchema = z.object({
clientID: ID,
username: SafeString,
ip: SafeString.nullable(), // WARNING: PII
persistentID: SafeString, // WARNING: PII
persistentID: PersistentIdSchema, // WARNING: PII
});
export const GameRecordSchema = z.object({
@@ -428,10 +466,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"]),
+26 -13
View File
@@ -199,21 +199,26 @@ export function createGameRecord(
const record: GameRecord = {
id: id,
gameStartInfo: gameStart,
players,
startTimestampMS: start,
endTimestampMS: end,
durationSeconds: Math.floor((end - start) / 1000),
date: new Date().toISOString().split("T")[0],
num_turns: 0,
turns: [],
allPlayersStats,
version: "v0.0.1",
winner,
winnerType,
};
for (const turn of turns) {
if (turn.intents.length != 0 || turn.hash != undefined) {
if (turn.intents.length !== 0 || turn.hash !== undefined) {
record.turns.push(turn);
for (const intent of turn.intents) {
if (intent.type == "spawn") {
if (intent.type === "spawn") {
for (const playerRecord of players) {
if (playerRecord.clientID == intent.clientID) {
if (playerRecord.clientID === intent.clientID) {
playerRecord.username = intent.name;
}
}
@@ -221,24 +226,17 @@ export function createGameRecord(
}
}
}
record.players = players;
record.durationSeconds = Math.floor(
(record.endTimestampMS - record.startTimestampMS) / 1000,
);
record.num_turns = turns.length;
record.winner = winner;
record.winnerType = winnerType;
return record;
}
export function decompressGameRecord(gameRecord: GameRecord) {
const turns = [];
const turns: Turn[] = [];
let lastTurnNum = -1;
for (const turn of gameRecord.turns) {
while (lastTurnNum < turn.turnNumber - 1) {
lastTurnNum++;
turns.push({
gameID: gameRecord.id,
turnNumber: lastTurnNum,
intents: [],
});
@@ -249,7 +247,6 @@ export function decompressGameRecord(gameRecord: GameRecord) {
const turnLength = turns.length;
for (let i = turnLength; i < gameRecord.num_turns; i++) {
turns.push({
gameID: gameRecord.id,
turnNumber: i,
intents: [],
});
@@ -296,7 +293,7 @@ export function createRandomName(
name: string,
playerType: string,
): string | null {
let randomName = null;
let randomName: string | null = null;
if (playerType === "HUMAN") {
const hash = simpleHash(name);
const prefixIndex = hash % BOT_NAME_PREFIXES.length;
@@ -307,3 +304,19 @@ export function createRandomName(
}
return randomName;
}
export const emojiTable: string[][] = [
["😀", "😊", "🥰", "😇", "😎"],
["😞", "🥺", "😭", "😱", "😡"],
["😈", "🤡", "🖕", "🥱", "🤦‍♂️"],
["👋", "👏", "🤌", "💪", "🫡"],
["👍", "👎", "❓", "🐔", "🐀"],
["🤝", "🆘", "🕊️", "🏳️", "⏳"],
["🔥", "💥", "💀", "☢️", "⚠️"],
["↖️", "⬆️", "↗️", "👑", "🥇"],
["⬅️", "🎯", "➡️", "🥈", "🥉"],
["↙️", "⬇️", "↘️", "❤️", "💔"],
["💰", "⚓", "⛵", "🏡", "🛡️"],
];
// 2d to 1d array
export const flattenedEmojiTable: string[] = emojiTable.flat();
+16 -6
View File
@@ -1,9 +1,12 @@
import { Colord } from "colord";
import { JWK } from "jose";
import { GameConfig, GameID } from "../Schemas";
import {
Difficulty,
Duos,
Game,
GameMapType,
GameMode,
Gold,
Player,
PlayerInfo,
@@ -26,8 +29,7 @@ export enum GameEnv {
export interface ServerConfig {
turnIntervalMs(): number;
gameCreationRate(): number;
lobbyMaxPlayers(map: GameMapType): number;
discordRedirectURI(): string;
lobbyMaxPlayers(map: GameMapType, mode: GameMode): number;
numWorkers(): number;
workerIndex(gameID: GameID): number;
workerPath(gameID: GameID): string;
@@ -43,6 +45,13 @@ export interface ServerConfig {
r2Endpoint(): string;
r2AccessKey(): string;
r2SecretKey(): string;
otelEndpoint(): string;
otelUsername(): string;
otelPassword(): string;
otelEnabled(): boolean;
jwtAudience(): string;
jwtIssuer(): string;
jwkPublicKey(): Promise<JWK>;
}
export interface NukeMagnitude {
@@ -60,14 +69,14 @@ export interface Config {
percentageTilesOwnedToWin(): number;
numBots(): number;
spawnNPCs(): boolean;
disableNukes(): boolean;
isUnitDisabled(unitType: UnitType): boolean;
bots(): number;
infiniteGold(): boolean;
infiniteTroops(): boolean;
instantBuild(): boolean;
numSpawnPhaseTurns(): number;
userSettings(): UserSettings;
numPlayerTeams(): number;
playerTeams(): number | typeof Duos;
startManpower(playerInfo: PlayerInfo): number;
populationIncreaseRate(player: Player | PlayerView): number;
@@ -102,6 +111,7 @@ export interface Config {
boatMaxNumber(): number;
allianceDuration(): Tick;
allianceRequestCooldown(): Tick;
temporaryEmbargoDuration(): Tick;
targetDuration(): Tick;
targetCooldown(): Tick;
emojiMessageCooldown(): Tick;
@@ -130,6 +140,7 @@ export interface Config {
defaultNukeSpeed(): number;
nukeDeathFactor(humans: number, tilesOwned: number): number;
structureMinDist(): number;
isReplay(): boolean;
}
export interface Theme {
@@ -137,9 +148,8 @@ export interface Theme {
territoryColor(playerInfo: PlayerView): Colord;
specialBuildingColor(playerInfo: PlayerView): Colord;
borderColor(playerInfo: PlayerView): Colord;
defendedBorderColor(playerInfo: PlayerView): Colord;
defendedBorderColors(playerInfo: PlayerView): { light: Colord; dark: Colord };
focusedBorderColor(): Colord;
focusedDefendedBorderColor(): Colord;
terrainColor(gm: GameMap, tile: TileRef): Colord;
backgroundColor(): Colord;
falloutColor(): Colord;
+6 -5
View File
@@ -7,20 +7,21 @@ import { DevConfig, DevServerConfig } from "./DevConfig";
import { preprodConfig } from "./PreprodConfig";
import { prodConfig } from "./ProdConfig";
export let cachedSC: ServerConfig = null;
export let cachedSC: ServerConfig | null = null;
export async function getConfig(
gameConfig: GameConfig,
userSettings: UserSettings | null = null,
userSettings: UserSettings | null,
isReplay: boolean = false,
): Promise<Config> {
const sc = await getServerConfigFromClient();
switch (sc.env()) {
case GameEnv.Dev:
return new DevConfig(sc, gameConfig, userSettings);
return new DevConfig(sc, gameConfig, userSettings, isReplay);
case GameEnv.Preprod:
case GameEnv.Prod:
consolex.log("using prod config");
return new DefaultConfig(sc, gameConfig, userSettings);
return new DefaultConfig(sc, gameConfig, userSettings, isReplay);
default:
throw Error(`unsupported server configuration: ${process.env.GAME_ENV}`);
}
@@ -44,7 +45,7 @@ export async function getServerConfigFromClient(): Promise<ServerConfig> {
return cachedSC;
}
export function getServerConfigFromServer(): ServerConfig {
const gameEnv = process.env.GAME_ENV;
const gameEnv = process.env.GAME_ENV ?? "dev";
return getServerConfig(gameEnv);
}
export function getServerConfig(gameEnv: string) {
+182 -117
View File
@@ -1,5 +1,8 @@
import { JWK } from "jose";
import { z } from "zod";
import {
Difficulty,
Duos,
Game,
GameMapType,
GameMode,
@@ -23,94 +26,149 @@ import { Config, GameEnv, NukeMagnitude, ServerConfig, Theme } from "./Config";
import { pastelTheme } from "./PastelTheme";
import { pastelThemeDark } from "./PastelThemeDark";
const JwksSchema = z.object({
keys: z
.object({
alg: z.literal("EdDSA"),
crv: z.literal("Ed25519"),
kty: z.literal("OKP"),
x: z.string(),
})
.array()
.min(1),
});
export abstract class DefaultServerConfig implements ServerConfig {
private publicKey: JWK;
abstract jwtAudience(): string;
jwtIssuer(): string {
const audience = this.jwtAudience();
return audience === "localhost"
? "http://localhost:8787"
: `https://api.${audience}`;
}
async jwkPublicKey(): Promise<JWK> {
if (this.publicKey) return this.publicKey;
const jwksUrl = this.jwtIssuer() + "/.well-known/jwks.json";
console.log(`Fetching JWKS from ${jwksUrl}`);
const response = await fetch(jwksUrl);
const jwks = JwksSchema.parse(await response.json());
this.publicKey = jwks.keys[0];
return this.publicKey;
}
otelEnabled(): boolean {
return Boolean(
this.otelEndpoint() && this.otelUsername() && this.otelPassword(),
);
}
otelEndpoint(): string {
return process.env.OTEL_ENDPOINT ?? "undefined";
}
otelUsername(): string {
return process.env.OTEL_USERNAME ?? "undefined";
}
otelPassword(): string {
return process.env.OTEL_PASSWORD ?? "undefined";
}
region(): string {
if (this.env() == GameEnv.Dev) {
if (this.env() === GameEnv.Dev) {
return "dev";
}
return process.env.REGION;
return process.env.REGION ?? "undefined";
}
gitCommit(): string {
return process.env.GIT_COMMIT;
return process.env.GIT_COMMIT ?? "undefined";
}
r2Endpoint(): string {
return `https://${process.env.CF_ACCOUNT_ID}.r2.cloudflarestorage.com`;
}
r2AccessKey(): string {
return process.env.R2_ACCESS_KEY;
return process.env.R2_ACCESS_KEY ?? "undefined";
}
r2SecretKey(): string {
return process.env.R2_SECRET_KEY;
return process.env.R2_SECRET_KEY ?? "undefined";
}
abstract r2Bucket(): string;
r2Bucket(): string {
return process.env.R2_BUCKET ?? "undefined";
}
adminHeader(): string {
return "x-admin-key";
}
adminToken(): string {
return process.env.ADMIN_TOKEN;
return process.env.ADMIN_TOKEN ?? "undefined";
}
abstract numWorkers(): number;
abstract env(): GameEnv;
abstract discordRedirectURI(): string;
turnIntervalMs(): number {
return 100;
}
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 ? 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,
].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;
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,
GameMapType.FalklandIslands,
GameMapType.Baikal,
].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();
}
@@ -129,8 +187,12 @@ export class DefaultConfig implements Config {
constructor(
private _serverConfig: ServerConfig,
private _gameConfig: GameConfig,
private _userSettings: UserSettings,
private _userSettings: UserSettings | null,
private _isReplay: boolean,
) {}
isReplay(): boolean {
return this._isReplay;
}
samHittingChance(): number {
return 0.8;
@@ -144,7 +206,7 @@ export class DefaultConfig implements Config {
return 0.5;
}
traitorDuration(): number {
return 15 * 10; // 15 seconds
return 30 * 10; // 30 seconds
}
spawnImmunityDuration(): Tick {
return 5 * 10;
@@ -158,7 +220,10 @@ export class DefaultConfig implements Config {
return this._serverConfig;
}
userSettings(): UserSettings | null {
userSettings(): UserSettings {
if (this._userSettings === null) {
throw new Error("userSettings is null");
}
return this._userSettings;
}
@@ -197,15 +262,18 @@ export class DefaultConfig implements Config {
defensePostDefenseBonus(): number {
return 5;
}
numPlayerTeams(): number {
return this._gameConfig.numPlayerTeams ?? 0;
playerTeams(): number | typeof Duos {
return this._gameConfig.playerTeams ?? 0;
}
spawnNPCs(): boolean {
return !this._gameConfig.disableNPCs;
}
disableNukes(): boolean {
return this._gameConfig.disableNukes;
isUnitDisabled(unitType: UnitType): boolean {
return this._gameConfig.disabledUnits?.includes(unitType) ?? false;
}
bots(): number {
return this._gameConfig.bots;
}
@@ -222,12 +290,7 @@ export class DefaultConfig implements Config {
return 10000 + 150 * Math.pow(dist, 1.1);
}
tradeShipSpawnRate(numberOfPorts: number): number {
if (numberOfPorts <= 3) return 18;
if (numberOfPorts <= 5) return 25;
if (numberOfPorts <= 8) return 35;
if (numberOfPorts <= 10) return 40;
if (numberOfPorts <= 12) return 45;
return 50;
return Math.round(10 * Math.pow(numberOfPorts, 0.6));
}
unitInfo(type: UnitType): UnitInfo {
@@ -240,7 +303,7 @@ export class DefaultConfig implements Config {
case UnitType.Warship:
return {
cost: (p: Player) =>
p.type() == PlayerType.Human && this.infiniteGold()
p.type() === PlayerType.Human && this.infiniteGold()
? 0
: Math.min(
1_000_000,
@@ -264,7 +327,7 @@ export class DefaultConfig implements Config {
case UnitType.Port:
return {
cost: (p: Player) =>
p.type() == PlayerType.Human && this.infiniteGold()
p.type() === PlayerType.Human && this.infiniteGold()
? 0
: Math.min(
1_000_000,
@@ -279,19 +342,21 @@ export class DefaultConfig implements Config {
case UnitType.AtomBomb:
return {
cost: (p: Player) =>
p.type() == PlayerType.Human && this.infiniteGold() ? 0 : 750_000,
p.type() === PlayerType.Human && this.infiniteGold() ? 0 : 750_000,
territoryBound: false,
};
case UnitType.HydrogenBomb:
return {
cost: (p: Player) =>
p.type() == PlayerType.Human && this.infiniteGold() ? 0 : 5_000_000,
p.type() === PlayerType.Human && this.infiniteGold()
? 0
: 5_000_000,
territoryBound: false,
};
case UnitType.MIRV:
return {
cost: (p: Player) =>
p.type() == PlayerType.Human && this.infiniteGold()
p.type() === PlayerType.Human && this.infiniteGold()
? 0
: 25_000_000,
territoryBound: false,
@@ -309,14 +374,16 @@ export class DefaultConfig implements Config {
case UnitType.MissileSilo:
return {
cost: (p: Player) =>
p.type() == PlayerType.Human && this.infiniteGold() ? 0 : 1_000_000,
p.type() === PlayerType.Human && this.infiniteGold()
? 0
: 1_000_000,
territoryBound: true,
constructionDuration: this.instantBuild() ? 0 : 10 * 10,
};
case UnitType.DefensePost:
return {
cost: (p: Player) =>
p.type() == PlayerType.Human && this.infiniteGold()
p.type() === PlayerType.Human && this.infiniteGold()
? 0
: Math.min(
250_000,
@@ -330,7 +397,7 @@ export class DefaultConfig implements Config {
case UnitType.SAMLauncher:
return {
cost: (p: Player) =>
p.type() == PlayerType.Human && this.infiniteGold()
p.type() === PlayerType.Human && this.infiniteGold()
? 0
: Math.min(
3_000_000,
@@ -344,7 +411,7 @@ export class DefaultConfig implements Config {
case UnitType.City:
return {
cost: (p: Player) =>
p.type() == PlayerType.Human && this.infiniteGold()
p.type() === PlayerType.Human && this.infiniteGold()
? 0
: Math.min(
1_000_000,
@@ -389,23 +456,27 @@ export class DefaultConfig implements Config {
allianceDuration(): Tick {
return 600 * 10; // 10 minutes.
}
temporaryEmbargoDuration(): Tick {
return 300 * 10; // 5 minutes.
}
percentageTilesOwnedToWin(): number {
if (this._gameConfig.gameMode == GameMode.Team) {
if (this._gameConfig.gameMode === GameMode.Team) {
return 95;
}
return 80;
}
boatMaxNumber(): number {
return 9;
return 3;
}
numSpawnPhaseTurns(): number {
return this._gameConfig.gameType == GameType.Singleplayer ? 100 : 300;
return this._gameConfig.gameType === GameType.Singleplayer ? 100 : 300;
}
numBots(): number {
return this.bots();
}
theme(): Theme {
return this.userSettings().darkMode() ? pastelThemeDark : pastelTheme;
return this.userSettings()?.darkMode() ? pastelThemeDark : pastelTheme;
}
attackLogic(
@@ -444,7 +515,7 @@ export class DefaultConfig implements Config {
gm.config().defensePostRange(),
UnitType.DefensePost,
)) {
if (dp.unit.owner() == defender) {
if (dp.unit.owner() === defender) {
mag *= this.defensePostDefenseBonus();
speed *= this.defensePostDefenseBonus();
break;
@@ -460,14 +531,14 @@ export class DefaultConfig implements Config {
if (attacker.isPlayer() && defender.isPlayer()) {
if (
attacker.type() == PlayerType.Human &&
defender.type() == PlayerType.Bot
attacker.type() === PlayerType.Human &&
defender.type() === PlayerType.Bot
) {
mag *= 0.8;
}
if (
attacker.type() == PlayerType.FakeHuman &&
defender.type() == PlayerType.Bot
attacker.type() === PlayerType.FakeHuman &&
defender.type() === PlayerType.Bot
) {
mag *= 0.8;
}
@@ -484,30 +555,23 @@ 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 {
attackerTroopLoss:
attacker.type() == PlayerType.Bot ? mag / 10 : mag / 5,
attacker.type() === PlayerType.Bot ? mag / 10 : mag / 5,
defenderTroopLoss: 0,
tilesPerTickUsed: within(
(2000 * Math.max(10, speed)) / attackTroops,
@@ -552,7 +616,7 @@ export class DefaultConfig implements Config {
}
attackAmount(attacker: Player, defender: Player | TerraNullius) {
if (attacker.type() == PlayerType.Bot) {
if (attacker.type() === PlayerType.Bot) {
return attacker.troops() / 20;
} else {
return attacker.troops() / 5;
@@ -560,10 +624,10 @@ export class DefaultConfig implements Config {
}
startManpower(playerInfo: PlayerInfo): number {
if (playerInfo.playerType == PlayerType.Bot) {
if (playerInfo.playerType === PlayerType.Bot) {
return 10_000;
}
if (playerInfo.playerType == PlayerType.FakeHuman) {
if (playerInfo.playerType === PlayerType.FakeHuman) {
switch (this._gameConfig.difficulty) {
case Difficulty.Easy:
return 2_500 * (playerInfo?.nation?.strength ?? 1);
@@ -580,16 +644,16 @@ export class DefaultConfig implements Config {
maxPopulation(player: Player | PlayerView): number {
const maxPop =
player.type() == PlayerType.Human && this.infiniteTroops()
player.type() === PlayerType.Human && this.infiniteTroops()
? 1_000_000_000
: 2 * (Math.pow(player.numTilesOwned(), 0.6) * 1000 + 50000) +
player.units(UnitType.City).length * this.cityPopulationIncrease();
if (player.type() == PlayerType.Bot) {
if (player.type() === PlayerType.Bot) {
return maxPop / 2;
}
if (player.type() == PlayerType.Human) {
if (player.type() === PlayerType.Human) {
return maxPop;
}
@@ -613,11 +677,11 @@ export class DefaultConfig implements Config {
const ratio = 1 - player.population() / max;
toAdd *= ratio;
if (player.type() == PlayerType.Bot) {
if (player.type() === PlayerType.Bot) {
toAdd *= 0.7;
}
if (player.type() == PlayerType.FakeHuman) {
if (player.type() === PlayerType.FakeHuman) {
switch (this._gameConfig.difficulty) {
case Difficulty.Easy:
toAdd *= 0.9;
@@ -638,8 +702,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 {
@@ -666,6 +729,7 @@ export class DefaultConfig implements Config {
case UnitType.HydrogenBomb:
return { inner: 80, outer: 100 };
}
throw new Error(`Unknown nuke type: ${unitType}`);
}
defaultNukeSpeed(): number {
@@ -678,7 +742,8 @@ export class DefaultConfig implements Config {
}
structureMinDist(): number {
return 18;
// TODO: Increase this to ~15 once upgradable structures are implemented.
return 1;
}
shellLifetime(): number {
+10 -8
View File
@@ -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";
}
@@ -32,20 +29,25 @@ export class DevServerConfig extends DefaultServerConfig {
return 1;
}
discordRedirectURI(): string {
return "http://localhost:3000/auth/callback";
}
numWorkers(): number {
return 2;
}
jwtAudience(): string {
return "localhost";
}
gitCommit(): string {
return "DEV";
}
}
export class DevConfig extends DefaultConfig {
constructor(sc: ServerConfig, gc: GameConfig, us: UserSettings) {
super(sc, gc, us);
constructor(
sc: ServerConfig,
gc: GameConfig,
us: UserSettings | null,
isReplay: boolean,
) {
super(sc, gc, us, isReplay);
}
// numSpawnPhaseTurns(): number {
+25 -25
View File
@@ -1,7 +1,7 @@
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 {
@@ -43,41 +43,45 @@ export const pastelTheme = new (class implements Theme {
teamColor(team: Team): Colord {
switch (team) {
case Team.Blue:
case ColoredTeams.Blue:
return blue;
case Team.Red:
case ColoredTeams.Red:
return red;
case Team.Teal:
case ColoredTeams.Teal:
return teal;
case Team.Purple:
case ColoredTeams.Purple:
return purple;
case Team.Yellow:
case ColoredTeams.Yellow:
return yellow;
case Team.Orange:
case ColoredTeams.Orange:
return orange;
case Team.Green:
case ColoredTeams.Green:
return green;
case Team.Bot:
case ColoredTeams.Bot:
return botColor;
default:
return humanColors[simpleHash(team) % humanColors.length];
}
throw new Error(`Missing color for ${team}`);
}
territoryColor(player: PlayerView): Colord {
if (player.team() !== null) {
return this.teamColor(player.team());
const team = player.team();
if (team !== null) {
return this.teamColor(team);
}
if (player.info().playerType == PlayerType.Human) {
if (player.info().playerType === PlayerType.Human) {
return humanColors[simpleHash(player.id()) % humanColors.length];
}
if (player.info().playerType == PlayerType.Bot) {
if (player.info().playerType === PlayerType.Bot) {
return botColors[simpleHash(player.id()) % botColors.length];
}
return territoryColors[simpleHash(player.id()) % territoryColors.length];
}
textColor(player: PlayerView): string {
return player.info().playerType == PlayerType.Human ? "#000000" : "#4D4D4D";
return player.info().playerType === PlayerType.Human
? "#000000"
: "#4D4D4D";
}
specialBuildingColor(player: PlayerView): Colord {
@@ -97,21 +101,17 @@ export const pastelTheme = new (class implements Theme {
b: Math.max(tc.b - 40, 0),
});
}
defendedBorderColor(player: PlayerView): Colord {
const bc = this.borderColor(player).rgba;
return colord({
r: Math.max(bc.r - 40, 0),
g: Math.max(bc.g - 40, 0),
b: Math.max(bc.b - 40, 0),
});
defendedBorderColors(player: PlayerView): { light: Colord; dark: Colord } {
return {
light: this.territoryColor(player).darken(0.2),
dark: this.territoryColor(player).darken(0.4),
};
}
focusedBorderColor(): Colord {
return colord({ r: 230, g: 230, b: 230 });
}
focusedDefendedBorderColor(): Colord {
return colord({ r: 200, g: 200, b: 200 });
}
terrainColor(gm: GameMap, tile: TileRef): Colord {
const mag = gm.magnitude(tile);
+25 -25
View File
@@ -1,7 +1,7 @@
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 {
@@ -43,41 +43,45 @@ export const pastelThemeDark = new (class implements Theme {
teamColor(team: Team): Colord {
switch (team) {
case Team.Blue:
case ColoredTeams.Blue:
return blue;
case Team.Red:
case ColoredTeams.Red:
return red;
case Team.Teal:
case ColoredTeams.Teal:
return teal;
case Team.Purple:
case ColoredTeams.Purple:
return purple;
case Team.Yellow:
case ColoredTeams.Yellow:
return yellow;
case Team.Orange:
case ColoredTeams.Orange:
return orange;
case Team.Green:
case ColoredTeams.Green:
return green;
case Team.Bot:
case ColoredTeams.Bot:
return botColor;
default:
return humanColors[simpleHash(team) % humanColors.length];
}
throw new Error(`Missing color for ${team}`);
}
territoryColor(player: PlayerView): Colord {
if (player.team() !== null) {
return this.teamColor(player.team());
const team = player.team();
if (team !== null) {
return this.teamColor(team);
}
if (player.info().playerType == PlayerType.Human) {
if (player.info().playerType === PlayerType.Human) {
return humanColors[simpleHash(player.id()) % humanColors.length];
}
if (player.info().playerType == PlayerType.Bot) {
if (player.info().playerType === PlayerType.Bot) {
return botColors[simpleHash(player.id()) % botColors.length];
}
return territoryColors[simpleHash(player.id()) % territoryColors.length];
}
textColor(player: PlayerView): string {
return player.info().playerType == PlayerType.Human ? "#ffffff" : "#e6e6e6";
return player.info().playerType === PlayerType.Human
? "#ffffff"
: "#e6e6e6";
}
specialBuildingColor(player: PlayerView): Colord {
@@ -97,21 +101,17 @@ export const pastelThemeDark = new (class implements Theme {
b: Math.max(tc.b - 40, 0),
});
}
defendedBorderColor(player: PlayerView): Colord {
const bc = this.borderColor(player).rgba;
return colord({
r: Math.max(bc.r - 40, 0),
g: Math.max(bc.g - 40, 0),
b: Math.max(bc.b - 40, 0),
});
defendedBorderColors(player: PlayerView): { light: Colord; dark: Colord } {
return {
light: this.territoryColor(player).darken(0.2),
dark: this.territoryColor(player).darken(0.4),
};
}
focusedBorderColor(): Colord {
return colord({ r: 255, g: 255, b: 255 });
}
focusedDefendedBorderColor(): Colord {
return colord({ r: 215, g: 215, b: 215 });
}
terrainColor(gm: GameMap, tile: TileRef): Colord {
const mag = gm.magnitude(tile);
+4 -7
View File
@@ -2,16 +2,13 @@ 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;
}
discordRedirectURI(): string {
return "https://openfront.dev/auth/callback";
}
numWorkers(): number {
return 3;
return 2;
}
jwtAudience(): string {
return "openfront.dev";
}
})();
+3 -6
View File
@@ -2,16 +2,13 @@ import { GameEnv } from "./Config";
import { DefaultServerConfig } from "./DefaultConfig";
export const prodConfig = new (class extends DefaultServerConfig {
r2Bucket(): string {
return "openfront-prod";
}
numWorkers(): number {
return 6;
return 20;
}
env(): GameEnv {
return GameEnv.Prod;
}
discordRedirectURI(): string {
return "https://openfront.io/auth/callback";
jwtAudience(): string {
return "openfront.io";
}
})();
+38 -40
View File
@@ -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);
@@ -39,7 +32,7 @@ export class AttackExecution implements Execution {
private border = new Set<TileRef>();
private attack: Attack = null;
private attack: Attack | null = null;
constructor(
private startTroops: number | null = null,
@@ -49,7 +42,7 @@ export class AttackExecution implements Execution {
private removeTroops: boolean = true,
) {}
public targetID(): PlayerID {
public targetID(): PlayerID | null {
return this._targetID;
}
@@ -68,7 +61,7 @@ export class AttackExecution implements Execution {
this.active = false;
return;
}
if (this._targetID != null && !mg.hasPlayer(this._targetID)) {
if (this._targetID !== null && !mg.hasPlayer(this._targetID)) {
console.warn(`target ${this._targetID} not found`);
this.active = false;
return;
@@ -76,22 +69,22 @@ export class AttackExecution implements Execution {
this._owner = mg.player(this._ownerID);
this.target =
this._targetID == this.mg.terraNullius().id()
this._targetID === this.mg.terraNullius().id()
? mg.terraNullius()
: mg.player(this._targetID);
if (this.target && this.target.isPlayer()) {
const targetPlayer = this.target as Player;
if (
targetPlayer.type() != PlayerType.Bot &&
this._owner.type() != PlayerType.Bot
targetPlayer.type() !== PlayerType.Bot &&
this._owner.type() !== PlayerType.Bot
) {
// Don't let bots embargo since they can't trade anyways.
targetPlayer.addEmbargo(this._owner.id());
// Don't let bots embargo since they can't trade anyway.
targetPlayer.addEmbargo(this._owner.id(), true);
}
}
if (this._owner == this.target) {
if (this._owner === this.target) {
console.error(`Player ${this._owner} cannot attack itself`);
this.active = false;
return;
@@ -108,7 +101,7 @@ export class AttackExecution implements Execution {
return;
}
if (this.startTroops == null) {
if (this.startTroops === null) {
this.startTroops = this.mg
.config()
.attackAmount(this._owner, this.target);
@@ -124,7 +117,7 @@ export class AttackExecution implements Execution {
);
for (const incoming of this._owner.incomingAttacks()) {
if (incoming.attacker() == this.target) {
if (incoming.attacker() === this.target) {
// Target has opposing attack, cancel them out
if (incoming.troops() > this.attack.troops()) {
incoming.setTroops(incoming.troops() - this.attack.troops());
@@ -139,9 +132,9 @@ export class AttackExecution implements Execution {
}
for (const outgoing of this._owner.outgoingAttacks()) {
if (
outgoing != this.attack &&
outgoing.target() == this.attack.target() &&
outgoing.sourceTile() == this.attack.sourceTile()
outgoing !== this.attack &&
outgoing.target() === this.attack.target() &&
outgoing.sourceTile() === this.attack.sourceTile()
) {
// Existing attack on same target, add troops
outgoing.setTroops(outgoing.troops() + this.attack.troops());
@@ -151,7 +144,7 @@ export class AttackExecution implements Execution {
}
}
if (this.sourceTile != null) {
if (this.sourceTile !== null) {
this.addNeighbors(this.sourceTile);
} else {
this.refreshToConquer();
@@ -175,6 +168,10 @@ export class AttackExecution implements Execution {
}
private retreat(malusPercent = 0) {
if (this.attack === null) {
throw new Error("Attack not initialized");
}
const deaths = this.attack.troops() * (malusPercent / 100);
if (deaths) {
this.mg.displayMessage(
@@ -189,6 +186,10 @@ export class AttackExecution implements Execution {
}
tick(ticks: number) {
if (this.attack === null) {
throw new Error("Attack not initialized");
}
if (this.attack.retreated()) {
if (this.attack.target().isPlayer()) {
this.retreat(malusForRetreat);
@@ -209,7 +210,7 @@ export class AttackExecution implements Execution {
}
const alliance = this._owner.allianceWith(this.target as Player);
if (this.breakAlliance && alliance != null) {
if (this.breakAlliance && alliance !== null) {
this.breakAlliance = false;
this._owner.breakAlliance(alliance);
}
@@ -227,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) {
@@ -237,7 +236,7 @@ export class AttackExecution implements Execution {
return;
}
if (this.toConquer.size() == 0) {
if (this.toConquer.size() === 0) {
this.refreshToConquer();
this.retreat();
return;
@@ -249,8 +248,8 @@ export class AttackExecution implements Execution {
const onBorder =
this.mg
.neighbors(tileToConquer)
.filter((t) => this.mg.owner(t) == this._owner).length > 0;
if (this.mg.owner(tileToConquer) != this.target || !onBorder) {
.filter((t) => this.mg.owner(t) === this._owner).length > 0;
if (this.mg.owner(tileToConquer) !== this.target || !onBorder) {
continue;
}
this.addNeighbors(tileToConquer);
@@ -275,17 +274,16 @@ export class AttackExecution implements Execution {
private addNeighbors(tile: TileRef) {
for (const neighbor of this.mg.neighbors(tile)) {
if (this.mg.isWater(neighbor) || this.mg.owner(neighbor) != this.target) {
if (
this.mg.isWater(neighbor) ||
this.mg.owner(neighbor) !== this.target
) {
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:
@@ -301,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(),
),
);
}
@@ -326,13 +325,13 @@ export class AttackExecution implements Execution {
for (const tile of this.target.tiles()) {
const borders = this.mg
.neighbors(tile)
.some((t) => this.mg.owner(t) == this._owner);
.some((t) => this.mg.owner(t) === this._owner);
if (borders) {
this._owner.conquer(tile);
} else {
for (const neighbor of this.mg.neighbors(tile)) {
const no = this.mg.owner(neighbor);
if (no.isPlayer() && no != this.target) {
if (no.isPlayer() && no !== this.target) {
this.mg.player(no.id()).conquer(tile);
break;
}
@@ -355,6 +354,5 @@ class TileContainer {
constructor(
public readonly tile: TileRef,
public readonly priority: number,
public readonly tick: number,
) {}
}
+23 -11
View File
@@ -6,15 +6,21 @@ 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 {
@@ -27,17 +33,21 @@ export class BotExecution implements Execution {
}
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, 1 / 20);
this.behavior = new BotBehavior(
this.random,
this.mg,
this.bot,
this.triggerRatio,
this.reserveRatio,
);
}
this.behavior.handleAllianceRequests();
@@ -45,6 +55,9 @@ export class BotExecution implements Execution {
}
private maybeAttack() {
if (this.behavior === null) {
throw new Error("not initialized");
}
const traitors = this.bot
.neighbors()
.filter((n) => n.isPlayer() && n.isTraitor()) as Player[];
@@ -65,15 +78,14 @@ export class BotExecution implements Execution {
this.neighborsTerraNullius = false;
}
this.behavior.forgetOldEnemies();
this.behavior.checkIncomingAttacks();
const enemy = this.behavior.selectRandomEnemy();
if (!enemy) return;
if (!this.bot.sharesBorderWith(enemy)) return;
this.behavior.sendAttack(enemy);
}
owner(): Player {
return this.bot;
}
isActive(): boolean {
return this.active;
}
+1 -1
View File
@@ -27,7 +27,7 @@ export class BotSpawner {
}
const botName = this.randomBotName();
const spawn = this.spawnBot(botName);
if (spawn != null) {
if (spawn !== null) {
this.bots.push(spawn);
} else {
tries++;
+5 -5
View File
@@ -12,7 +12,7 @@ import { TileRef } from "../game/GameMap";
export class CityExecution implements Execution {
private player: Player;
private mg: Game;
private city: Unit;
private city: Unit | null = null;
private active: boolean = true;
constructor(
@@ -31,21 +31,21 @@ export class CityExecution implements Execution {
}
tick(ticks: number): void {
if (this.city == null) {
if (this.city === null) {
const spawnTile = this.player.canBuild(UnitType.City, this.tile);
if (spawnTile == false) {
if (spawnTile === false) {
consolex.warn("cannot build city");
this.active = false;
return;
}
this.city = this.player.buildUnit(UnitType.City, 0, spawnTile);
this.city = this.player.buildUnit(UnitType.City, spawnTile, {});
}
if (!this.city.isActive()) {
this.active = false;
return;
}
if (this.player != this.city.owner()) {
if (this.player !== this.city.owner()) {
this.player = this.city.owner();
}
}
+7 -7
View File
@@ -20,7 +20,7 @@ import { WarshipExecution } from "./WarshipExecution";
export class ConstructionExecution implements Execution {
private player: Player;
private construction: Unit;
private construction: Unit | null = null;
private active: boolean = true;
private mg: Game;
@@ -45,23 +45,23 @@ export class ConstructionExecution implements Execution {
}
tick(ticks: number): void {
if (this.construction == null) {
if (this.construction === null) {
const info = this.mg.unitInfo(this.constructionType);
if (info.constructionDuration == null) {
if (info.constructionDuration === undefined) {
this.completeConstruction();
this.active = false;
return;
}
const spawnTile = this.player.canBuild(this.constructionType, this.tile);
if (spawnTile == false) {
if (spawnTile === false) {
consolex.warn(`cannot build ${this.constructionType}`);
this.active = false;
return;
}
this.construction = this.player.buildUnit(
UnitType.Construction,
0,
spawnTile,
{},
);
this.cost = this.mg.unitInfo(this.constructionType).cost(this.player);
this.player.removeGold(this.cost);
@@ -75,11 +75,11 @@ export class ConstructionExecution implements Execution {
return;
}
if (this.player != this.construction.owner()) {
if (this.player !== this.construction.owner()) {
this.player = this.construction.owner();
}
if (this.ticksUntilComplete == 0) {
if (this.ticksUntilComplete === 0) {
this.player = this.construction.owner();
this.construction.delete(false);
// refund the cost so player has the gold to build the unit
+52 -48
View File
@@ -13,10 +13,10 @@ import { ShellExecution } from "./ShellExecution";
export class DefensePostExecution implements Execution {
private player: Player;
private mg: Game;
private post: Unit;
private post: Unit | null = null;
private active: boolean = true;
private target: Unit = null;
private target: Unit | null = null;
private lastShellAttack = 0;
private alreadySentShell = new Set<Unit>();
@@ -37,6 +37,8 @@ export class DefensePostExecution implements Execution {
}
private shoot() {
if (this.post === null) return;
if (this.target === null) return;
const shellAttackRate = this.mg.config().defensePostShellAttackRate();
if (this.mg.ticks() - this.lastShellAttack > shellAttackRate) {
this.lastShellAttack = this.mg.ticks();
@@ -58,69 +60,71 @@ export class DefensePostExecution implements Execution {
}
tick(ticks: number): void {
if (this.post == null) {
if (this.post === null) {
const spawnTile = this.player.canBuild(UnitType.DefensePost, this.tile);
if (spawnTile == false) {
if (spawnTile === false) {
consolex.warn("cannot build Defense Post");
this.active = false;
return;
}
this.post = this.player.buildUnit(UnitType.DefensePost, 0, spawnTile);
this.post = this.player.buildUnit(UnitType.DefensePost, spawnTile, {});
}
if (!this.post.isActive()) {
this.active = false;
return;
}
if (this.player != this.post.owner()) {
if (this.player !== this.post.owner()) {
this.player = this.post.owner();
}
if (this.target != null && !this.target.isActive()) {
if (this.target !== null && !this.target.isActive()) {
this.target = null;
}
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;
}
// TODO: Reconsider how/if defense posts target ships.
// const ships = this.mg
// .nearbyUnits(
// this.post.tile(),
// this.mg.config().defensePostTargettingRange(),
// [UnitType.TransportShip, UnitType.Warship],
// )
// .filter(
// ({ unit }) =>
// this.post !== null &&
// 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 {
+2 -5
View File
@@ -27,12 +27,13 @@ export class DonateGoldExecution implements Execution {
this.sender = mg.player(this.senderID);
this.recipient = mg.player(this.recipientID);
if (this.gold == null) {
if (this.gold === null) {
this.gold = Math.round(this.sender.gold() / 3);
}
}
tick(ticks: number): void {
if (this.gold === null) throw new Error("not initialized");
if (this.sender.canDonate(this.recipient)) {
this.sender.donateGold(this.recipient, this.gold);
this.recipient.updateRelation(this.sender, 50);
@@ -44,10 +45,6 @@ export class DonateGoldExecution implements Execution {
this.active = false;
}
owner(): Player {
return null;
}
isActive(): boolean {
return this.active;
}
+2 -5
View File
@@ -27,12 +27,13 @@ export class DonateTroopsExecution implements Execution {
this.sender = mg.player(this.senderID);
this.recipient = mg.player(this.recipientID);
if (this.troops == null) {
if (this.troops === null) {
this.troops = mg.config().defaultDonationAmount(this.sender);
}
}
tick(ticks: number): void {
if (this.troops === null) throw new Error("not initialized");
if (this.sender.canDonate(this.recipient)) {
this.sender.donateTroops(this.recipient, this.troops);
this.recipient.updateRelation(this.sender, 50);
@@ -44,10 +45,6 @@ export class DonateTroopsExecution implements Execution {
this.active = false;
}
owner(): Player {
return null;
}
isActive(): boolean {
return this.active;
}
+1 -5
View File
@@ -23,16 +23,12 @@ export class EmbargoExecution implements Execution {
}
tick(_: number): void {
if (this.action == "start") this.player.addEmbargo(this.targetID);
if (this.action === "start") this.player.addEmbargo(this.targetID, false);
else this.player.stopEmbargo(this.targetID);
this.active = false;
}
owner(): Player {
return null;
}
isActive(): boolean {
return this.active;
}
+16 -12
View File
@@ -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 {
@@ -26,7 +27,7 @@ export class EmojiExecution implements Execution {
this.active = false;
return;
}
if (this.recipientID != AllPlayers && !mg.hasPlayer(this.recipientID)) {
if (this.recipientID !== AllPlayers && !mg.hasPlayer(this.recipientID)) {
console.warn(`EmojiExecution: recipient ${this.recipientID} not found`);
this.active = false;
return;
@@ -34,16 +35,23 @@ export class EmojiExecution implements Execution {
this.requestor = mg.player(this.senderID);
this.recipient =
this.recipientID == AllPlayers ? AllPlayers : mg.player(this.recipientID);
this.recipientID === AllPlayers
? AllPlayers
: mg.player(this.recipientID);
}
tick(ticks: number): void {
if (this.requestor.canSendEmoji(this.recipient)) {
this.requestor.sendEmoji(this.recipient, this.emoji);
const emojiString = flattenedEmojiTable.at(this.emoji);
if (emojiString === undefined) {
consolex.warn(
`cannot send emoji ${this.emoji} from ${this.requestor} to ${this.recipient}`,
);
} else if (this.requestor.canSendEmoji(this.recipient)) {
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);
}
@@ -55,10 +63,6 @@ export class EmojiExecution implements Execution {
this.active = false;
}
owner(): Player {
return null;
}
isActive(): boolean {
return this.active;
}
+15 -19
View File
@@ -1,4 +1,5 @@
import { Execution, Game, PlayerInfo, PlayerType } from "../game/Game";
import { Execution, Game } from "../game/Game";
import { TileRef } from "../game/GameMap";
import { PseudoRandom } from "../PseudoRandom";
import { ClientID, GameID, Intent, Turn } from "../Schemas";
import { simpleHash } from "../Util";
@@ -15,6 +16,7 @@ import { EmojiExecution } from "./EmojiExecution";
import { FakeHumanExecution } from "./FakeHumanExecution";
import { MoveWarshipExecution } from "./MoveWarshipExecution";
import { NoOpExecution } from "./NoOpExecution";
import { QuickChatExecution } from "./QuickChatExecution";
import { RetreatExecution } from "./RetreatExecution";
import { SetTargetTroopRatioExecution } from "./SetTargetTroopRatioExecution";
import { SpawnExecution } from "./SpawnExecution";
@@ -23,7 +25,7 @@ import { TransportShipExecution } from "./TransportShipExecution";
export class Executor {
// private random = new PseudoRandom(999)
private random: PseudoRandom = null;
private random: PseudoRandom;
constructor(
private mg: Game,
@@ -65,8 +67,8 @@ export class Executor {
this.mg.ref(intent.x, intent.y),
);
case "boat":
let src = null;
if (intent.srcX != null || intent.srcY != null) {
let src: TileRef | null = null;
if (intent.srcX !== null && intent.srcY !== null) {
src = this.mg.ref(intent.srcX, intent.srcY);
}
return new TransportShipExecution(
@@ -108,6 +110,13 @@ export class Executor {
this.mg.ref(intent.x, intent.y),
intent.unit,
);
case "quick_chat":
return new QuickChatExecution(
playerID,
intent.recipient,
intent.quickChatKey,
intent.variables ?? {},
);
default:
throw new Error(`intent type ${intent} not found`);
}
@@ -118,22 +127,9 @@ export class Executor {
}
fakeHumanExecutions(): Execution[] {
const execs = [];
const execs: Execution[] = [];
for (const nation of this.mg.nations()) {
execs.push(
new FakeHumanExecution(
this.gameID,
new PlayerInfo(
null,
nation.flag || "",
nation.name,
PlayerType.FakeHuman,
null,
this.random.nextID(),
nation,
),
),
);
execs.push(new FakeHumanExecution(this.gameID, nation));
}
return execs;
}
+121 -92
View File
@@ -4,9 +4,9 @@ import {
Difficulty,
Execution,
Game,
Nation,
Player,
PlayerID,
PlayerInfo,
PlayerType,
Relation,
TerrainType,
@@ -17,7 +17,7 @@ import {
import { euclDistFN, manhattanDistFN, TileRef } from "../game/GameMap";
import { PseudoRandom } from "../PseudoRandom";
import { GameID } from "../Schemas";
import { calculateBoundingBox, simpleHash } from "../Util";
import { calculateBoundingBox, flattenedEmojiTable, simpleHash } from "../Util";
import { ConstructionExecution } from "./ConstructionExecution";
import { EmojiExecution } from "./EmojiExecution";
import { NukeExecution } from "./NukeExecution";
@@ -33,19 +33,30 @@ export class FakeHumanExecution implements Execution {
private random: PseudoRandom;
private behavior: BotBehavior | null = null;
private mg: Game;
private player: Player = null;
private player: Player | null = null;
private attackRate: number;
private attackTick: number;
private triggerRatio: number;
private reserveRatio: number;
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) {
@@ -56,60 +67,67 @@ export class FakeHumanExecution implements Execution {
}
private updateRelationsFromEmbargos() {
const others = this.mg.players().filter((p) => p.id() != this.player.id());
const player = this.player;
if (player === null) return;
const others = this.mg.players().filter((p) => p.id() !== player.id());
others.forEach((other: Player) => {
const embargoMalus = -20;
if (
other.hasEmbargoAgainst(this.player) &&
other.hasEmbargoAgainst(player) &&
!this.embargoMalusApplied.has(other.id())
) {
this.player.updateRelation(other, embargoMalus);
player.updateRelation(other, embargoMalus);
this.embargoMalusApplied.add(other.id());
} else if (
!other.hasEmbargoAgainst(this.player) &&
!other.hasEmbargoAgainst(player) &&
this.embargoMalusApplied.has(other.id())
) {
this.player.updateRelation(other, -embargoMalus);
player.updateRelation(other, -embargoMalus);
this.embargoMalusApplied.delete(other.id());
}
});
}
private handleEmbargoesToHostileNations() {
const others = this.mg.players().filter((p) => p.id() != this.player.id());
const player = this.player;
if (player === null) return;
const others = this.mg.players().filter((p) => p.id() !== player.id());
others.forEach((other: Player) => {
/* When player is hostile starts embargo. Do not stop until neutral again */
if (
this.player.relation(other) <= Relation.Hostile &&
!this.player.hasEmbargoAgainst(other)
player.relation(other) <= Relation.Hostile &&
!player.hasEmbargoAgainst(other)
) {
this.player.addEmbargo(other.id());
player.addEmbargo(other.id(), false);
} else if (
this.player.relation(other) >= Relation.Neutral &&
this.player.hasEmbargoAgainst(other)
player.relation(other) >= Relation.Neutral &&
player.hasEmbargoAgainst(other)
) {
this.player.stopEmbargo(other.id());
player.stopEmbargo(other.id());
}
});
}
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);
if (this.player == null) {
if (this.player === null) {
this.player =
this.mg.players().find((p) => p.id() === this.nation.playerInfo.id) ??
null;
if (this.player === null) {
return;
}
}
@@ -121,7 +139,13 @@ export class FakeHumanExecution implements Execution {
if (this.behavior === null) {
// Player is unavailable during init()
this.behavior = new BotBehavior(this.random, this.mg, this.player, 1 / 5);
this.behavior = new BotBehavior(
this.random,
this.mg,
this.player,
this.triggerRatio,
this.reserveRatio,
);
}
if (this.firstMove) {
@@ -130,10 +154,6 @@ export class FakeHumanExecution implements Execution {
return;
}
if (ticks % this.random.nextInt(40, 80) != 0) {
return;
}
if (
this.player.troops() > 100_000 &&
this.player.targetTroopRatio() > 0.7
@@ -146,14 +166,21 @@ export class FakeHumanExecution implements Execution {
this.handleEnemies();
this.handleUnits();
this.handleEmbargoesToHostileNations();
this.maybeAttack();
}
private maybeAttack() {
if (this.player === null || this.behavior === null) {
throw new Error("not initialized");
}
const enemyborder = Array.from(this.player.borderTiles())
.flatMap((t) => this.mg.neighbors(t))
.filter(
(t) => this.mg.isLand(t) && this.mg.ownerID(t) != this.player.smallID(),
(t) =>
this.mg.isLand(t) && this.mg.ownerID(t) !== this.player?.smallID(),
);
if (enemyborder.length == 0) {
if (enemyborder.length === 0) {
if (this.random.chance(10)) {
this.sendBoatRandomly();
}
@@ -174,9 +201,9 @@ export class FakeHumanExecution implements Execution {
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)) {
@@ -195,6 +222,7 @@ export class FakeHumanExecution implements Execution {
}
private shouldAttack(other: Player): boolean {
if (this.player === null) throw new Error("not initialized");
if (this.player.isOnSameTeam(other)) {
return false;
}
@@ -211,15 +239,18 @@ export class FakeHumanExecution implements Execution {
}
}
shouldDiscourageAttack(other: Player) {
private shouldDiscourageAttack(other: Player) {
if (other.isTraitor()) {
return false;
}
const difficulty = this.mg.config().gameConfig().difficulty;
if (difficulty == Difficulty.Hard || difficulty == Difficulty.Impossible) {
if (
difficulty === Difficulty.Hard ||
difficulty === Difficulty.Impossible
) {
return false;
}
if (other.type() != PlayerType.Human) {
if (other.type() !== PlayerType.Human) {
return false;
}
// Only discourage attacks on Humans who are not traitors on easy or medium difficulty.
@@ -227,6 +258,11 @@ export class FakeHumanExecution implements Execution {
}
handleEnemies() {
if (this.player === null || this.behavior === null) {
throw new Error("not initialized");
}
this.behavior.forgetOldEnemies();
this.behavior.checkIncomingAttacks();
this.behavior.assistAllies();
const enemy = this.behavior.selectEnemy();
if (!enemy) return;
@@ -240,7 +276,8 @@ export class FakeHumanExecution implements Execution {
}
private maybeSendEmoji(enemy: Player) {
if (enemy.type() != PlayerType.Human) return;
if (this.player === null) throw new Error("not initialized");
if (enemy.type() !== PlayerType.Human) return;
const lastSent = this.lastEmojiSent.get(enemy) ?? -300;
if (this.mg.ticks() - lastSent <= 300) return;
this.lastEmojiSent.set(enemy, this.mg.ticks());
@@ -248,18 +285,18 @@ export class FakeHumanExecution implements Execution {
new EmojiExecution(
this.player.id(),
enemy.id(),
this.random.randElement(["🤡", "😡"]),
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 (
silos.length == 0 ||
this.player.gold() <
this.mg.config().unitInfo(UnitType.AtomBomb).cost(this.player) ||
other.type() == PlayerType.Bot ||
silos.length === 0 ||
this.player.gold() < this.cost(UnitType.AtomBomb) ||
other.type() === PlayerType.Bot ||
this.player.isOnSameTeam(other)
) {
return;
@@ -273,31 +310,31 @@ export class FakeHumanExecution implements Execution {
UnitType.SAMLauncher,
);
const structureTiles = structures.map((u) => u.tile());
const randomTiles: TileRef[] = new Array(10);
const randomTiles: (TileRef | null)[] = 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 bestTile: TileRef | null = null;
let bestValue = 0;
this.removeOldNukeEvents();
outer: for (const tile of new Set(allTiles)) {
if (tile == null) continue;
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) {
if (this.mg.owner(t) !== other) {
continue outer;
}
}
if (!this.player.canBuild(UnitType.AtomBomb, tile)) continue;
const value = this.nukeTileScore(tile, silos, structures);
if (value > bestTile) {
if (value > bestValue) {
bestTile = tile;
bestValue = value;
}
}
if (bestTile != null) {
if (bestTile !== null) {
this.sendNuke(bestTile);
}
}
@@ -314,6 +351,7 @@ export class FakeHumanExecution implements Execution {
}
private sendNuke(tile: TileRef) {
if (this.player === null) throw new Error("not initialized");
const tick = this.mg.ticks();
this.lastNukeSent.push([tick, tile]);
this.mg.addExecution(
@@ -346,7 +384,9 @@ export class FakeHumanExecution implements Execution {
// Prefer tiles that are closer to a silo
const siloTiles = silos.map((u) => u.tile());
const { x: closestSilo } = closestTwoTiles(this.mg, siloTiles, [tile]);
const result = closestTwoTiles(this.mg, siloTiles, [tile]);
if (result === null) throw new Error("Missing result");
const { x: closestSilo } = result;
const distanceSquared = this.mg.euclideanDistSquared(tile, closestSilo);
const distanceToClosestSilo = Math.sqrt(distanceSquared);
tileValue -= distanceToClosestSilo * 30;
@@ -361,6 +401,7 @@ export class FakeHumanExecution implements Execution {
}
private maybeSendBoatAttack(other: Player) {
if (this.player === null) throw new Error("not initialized");
if (this.player.isOnSameTeam(other)) return;
const closest = closestTwoTiles(
this.mg,
@@ -369,7 +410,7 @@ export class FakeHumanExecution implements Execution {
),
Array.from(other.borderTiles()).filter((t) => this.mg.isOceanShore(t)),
);
if (closest == null) {
if (closest === null) {
return;
}
this.mg.addExecution(
@@ -384,63 +425,52 @@ export class FakeHumanExecution implements Execution {
}
private handleUnits() {
const ports = this.player.units(UnitType.Port);
if (ports.length == 0 && this.player.gold() > this.cost(UnitType.Port)) {
const oceanTiles = Array.from(this.player.borderTiles()).filter((t) =>
const player = this.player;
if (player === null) return;
const ports = player.units(UnitType.Port);
if (ports.length === 0 && player.gold() > this.cost(UnitType.Port)) {
const oceanTiles = Array.from(player.borderTiles()).filter((t) =>
this.mg.isOceanShore(t),
);
if (oceanTiles.length > 0) {
const buildTile = this.random.randElement(oceanTiles);
this.mg.addExecution(
new ConstructionExecution(this.player.id(), buildTile, UnitType.Port),
new ConstructionExecution(player.id(), buildTile, UnitType.Port),
);
}
return;
}
this.maybeSpawnStructure(
UnitType.City,
2,
(t) => new ConstructionExecution(this.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(this.player.id(), t, UnitType.MissileSilo),
);
}
this.maybeSpawnStructure(UnitType.MissileSilo, 1);
}
private maybeSpawnStructure(
type: UnitType,
maxNum: number,
build: (tile: TileRef) => Execution,
) {
private maybeSpawnStructure(type: UnitType, maxNum: number) {
if (this.player === null) throw new Error("not initialized");
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);
if (tile == null) {
if (tile === null) {
return;
}
const canBuild = this.player.canBuild(type, tile);
if (canBuild == false) {
if (canBuild === false) {
return;
}
this.mg.addExecution(build(tile));
this.mg.addExecution(
new ConstructionExecution(this.player.id(), tile, type),
);
}
private maybeSpawnWarship(): boolean {
if (this.player === null) throw new Error("not initialized");
if (!this.random.chance(50)) {
return false;
}
@@ -448,16 +478,16 @@ export class FakeHumanExecution implements Execution {
const ships = this.player.units(UnitType.Warship);
if (
ports.length > 0 &&
ships.length == 0 &&
ships.length === 0 &&
this.player.gold() > this.cost(UnitType.Warship)
) {
const port = this.random.randElement(ports);
const targetTile = this.warshipSpawnTile(port.tile());
if (targetTile == null) {
if (targetTile === null) {
return false;
}
const canBuild = this.player.canBuild(UnitType.Warship, targetTile);
if (canBuild == false) {
if (canBuild === false) {
consolex.warn("cannot spawn destroyer");
return false;
}
@@ -483,7 +513,7 @@ export class FakeHumanExecution implements Execution {
continue;
}
const randTile = this.mg.ref(randX, randY);
if (this.mg.owner(randTile) == p) {
if (this.mg.owner(randTile) === p) {
return randTile;
}
}
@@ -515,21 +545,23 @@ export class FakeHumanExecution implements Execution {
}
private cost(type: UnitType): number {
if (this.player === null) throw new Error("not initialized");
return this.mg.unitInfo(type).cost(this.player);
}
sendBoatRandomly() {
if (this.player === null) throw new Error("not initialized");
const oceanShore = Array.from(this.player.borderTiles()).filter((t) =>
this.mg.isOceanShore(t),
);
if (oceanShore.length == 0) {
if (oceanShore.length === 0) {
return;
}
const src = this.random.randElement(oceanShore);
const dst = this.randOceanShoreTile(src, 150);
if (dst == null) {
if (dst === null) {
return;
}
@@ -550,7 +582,7 @@ export class FakeHumanExecution implements Execution {
let tries = 0;
while (tries < 50) {
tries++;
const cell = this.playerInfo.nation.cell;
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)) {
@@ -559,7 +591,7 @@ export class FakeHumanExecution implements Execution {
const tile = this.mg.ref(x, y);
if (this.mg.isLand(tile) && !this.mg.hasOwner(tile)) {
if (
this.mg.terrainType(tile) == TerrainType.Mountain &&
this.mg.terrainType(tile) === TerrainType.Mountain &&
this.random.chance(2)
) {
continue;
@@ -571,6 +603,7 @@ export class FakeHumanExecution implements Execution {
}
private randOceanShoreTile(tile: TileRef, dist: number): TileRef | null {
if (this.player === null) throw new Error("not initialized");
const x = this.mg.x(tile);
const y = this.mg.y(tile);
for (let i = 0; i < 500; i++) {
@@ -594,10 +627,6 @@ export class FakeHumanExecution implements Execution {
return null;
}
owner(): Player {
return null;
}
isActive(): boolean {
return this.active;
}
+25 -24
View File
@@ -10,7 +10,7 @@ import {
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { AirPathFinder } from "../pathfinding/PathFinding";
import { ParabolaPathFinder } from "../pathfinding/PathFinding";
import { PseudoRandom } from "../PseudoRandom";
import { simpleHash } from "../Util";
import { NukeExecution } from "./NukeExecution";
@@ -22,19 +22,21 @@ export class MirvExecution implements Execution {
private mg: Game;
private nuke: Unit;
private nuke: Unit | null = null;
private mirvRange = 1500;
private warheadCount = 350;
private random: PseudoRandom;
private pathFinder: AirPathFinder;
private pathFinder: ParabolaPathFinder;
private targetPlayer: Player | TerraNullius;
private separateDst: TileRef;
private speed: number = -1;
constructor(
private senderID: PlayerID,
private dst: TileRef,
@@ -49,9 +51,10 @@ export class MirvExecution implements Execution {
this.random = new PseudoRandom(mg.ticks() + simpleHash(this.senderID));
this.mg = mg;
this.pathFinder = new AirPathFinder(mg, this.random);
this.pathFinder = new ParabolaPathFinder(mg);
this.player = mg.player(this.senderID);
this.targetPlayer = this.mg.owner(this.dst);
this.speed = this.mg.config().defaultNukeSpeed();
this.mg
.stats()
@@ -63,49 +66,47 @@ export class MirvExecution implements Execution {
}
tick(ticks: number): void {
if (this.nuke == null) {
if (this.nuke === null) {
const spawn = this.player.canBuild(UnitType.MIRV, this.dst);
if (spawn == false) {
if (spawn === false) {
consolex.warn(`cannot build MIRV`);
this.active = false;
return;
}
this.nuke = this.player.buildUnit(UnitType.MIRV, 0, spawn);
this.nuke = this.player.buildUnit(UnitType.MIRV, spawn, {});
const x = Math.floor(
(this.mg.x(this.dst) + this.mg.x(this.mg.x(this.nuke.tile()))) / 2,
);
const y = Math.max(0, this.mg.y(this.dst) - 500) + 50;
this.separateDst = this.mg.ref(x, y);
this.pathFinder.computeControlPoints(spawn, this.separateDst);
this.mg.displayMessage(
this.mg.displayIncomingUnit(
this.nuke.id(),
`⚠️⚠️⚠️ ${this.player.name()} - MIRV INBOUND ⚠️⚠️⚠️`,
MessageType.ERROR,
this.targetPlayer.id(),
);
}
for (let i = 0; i < 4; i++) {
const result = this.pathFinder.nextTile(
this.nuke.tile(),
this.separateDst,
);
if (result === true) {
this.separate();
this.active = false;
return;
} else {
this.nuke.move(result);
}
const result = this.pathFinder.nextTile(this.speed);
if (result === true) {
this.separate();
this.active = false;
return;
} else {
this.nuke.move(result);
}
}
private separate() {
if (this.nuke === null) throw new Error("uninitialized");
const dsts: TileRef[] = [this.dst];
let attempts = 1000;
while (attempts > 0 && dsts.length < this.warheadCount) {
attempts--;
const potential = this.randomLand(this.dst, dsts);
if (potential == null) {
if (potential === null) {
continue;
}
dsts.push(potential);
@@ -132,10 +133,10 @@ export class MirvExecution implements Execution {
}
if (this.targetPlayer.isPlayer()) {
const alliance = this.player.allianceWith(this.targetPlayer);
if (alliance != null) {
if (alliance !== null) {
this.player.breakAlliance(alliance);
}
if (this.targetPlayer != this.player) {
if (this.targetPlayer !== this.player) {
this.targetPlayer.updateRelation(this.player, -100);
}
}
@@ -165,7 +166,7 @@ export class MirvExecution implements Execution {
if (this.mg.euclideanDistSquared(tile, ref) > mirvRange2) {
continue;
}
if (this.mg.owner(tile) != this.targetPlayer) {
if (this.mg.owner(tile) !== this.targetPlayer) {
continue;
}
for (const t of taken) {
+10 -7
View File
@@ -11,9 +11,9 @@ import { TileRef } from "../game/GameMap";
export class MissileSiloExecution implements Execution {
private active = true;
private mg: Game;
private player: Player;
private silo: Unit;
private mg: Game | null = null;
private player: Player | null = null;
private silo: Unit | null = null;
constructor(
private _owner: PlayerID,
@@ -32,7 +32,10 @@ export class MissileSiloExecution implements Execution {
}
tick(ticks: number): void {
if (this.silo == null) {
if (this.player === null || this.mg === null) {
throw new Error("Not initialized");
}
if (this.silo === null) {
const spawn = this.player.canBuild(UnitType.MissileSilo, this.tile);
if (spawn === false) {
consolex.warn(
@@ -41,18 +44,18 @@ export class MissileSiloExecution implements Execution {
this.active = false;
return;
}
this.silo = this.player.buildUnit(UnitType.MissileSilo, 0, spawn, {
this.silo = this.player.buildUnit(UnitType.MissileSilo, spawn, {
cooldownDuration: this.mg.config().SiloCooldown(),
});
if (this.player != this.silo.owner()) {
if (this.player !== this.silo.owner()) {
this.player = this.silo.owner();
}
}
if (
this.silo.isCooldown() &&
this.silo.ticksLeftInCooldown(this.mg.config().SiloCooldown()) == 0
this.silo.ticksLeftInCooldown(this.mg.config().SiloCooldown()) === 0
) {
this.silo.setCooldown(false);
}
+5 -2
View File
@@ -4,7 +4,7 @@ const cancelDelay = 2;
export class MoveWarshipExecution implements Execution {
private active = true;
private mg: Game;
private mg: Game | null = null;
constructor(
public readonly unitId: number,
@@ -16,7 +16,10 @@ export class MoveWarshipExecution implements Execution {
}
tick(ticks: number): void {
const warship = this.mg.units().find((u) => u.id() == this.unitId);
if (this.mg === null) {
throw new Error("Not initialized");
}
const warship = this.mg.units().find((u) => u.id() === this.unitId);
if (!warship) {
console.log("MoveWarshipExecution: warship is already dead");
return;
+1 -4
View File
@@ -1,4 +1,4 @@
import { Execution, Game, Player } from "../game/Game";
import { Execution, Game } from "../game/Game";
export class NoOpExecution implements Execution {
isActive(): boolean {
@@ -9,7 +9,4 @@ export class NoOpExecution implements Execution {
}
init(mg: Game, ticks: number): void {}
tick(ticks: number): void {}
owner(): Player {
return null;
}
}
+73 -40
View File
@@ -11,23 +11,24 @@ import {
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { AirPathFinder } from "../pathfinding/PathFinding";
import { ParabolaPathFinder } from "../pathfinding/PathFinding";
import { PseudoRandom } from "../PseudoRandom";
export class NukeExecution implements Execution {
private player: Player;
private active = true;
private mg: Game;
private nuke: Unit;
private player: Player | null = null;
private mg: Game | null = null;
private nuke: Unit | null = null;
private tilesToDestroyCache: Set<TileRef> | undefined;
private random: PseudoRandom;
private pathFinder: AirPathFinder;
private pathFinder: ParabolaPathFinder;
constructor(
private type: NukeType,
private senderID: PlayerID,
private dst: TileRef,
private src?: TileRef,
private src?: TileRef | null,
private speed: number = -1,
private waitTicks = 0,
) {}
@@ -42,28 +43,41 @@ export class NukeExecution implements Execution {
this.mg = mg;
this.player = mg.player(this.senderID);
this.random = new PseudoRandom(ticks);
if (this.speed == -1) {
if (this.speed === -1) {
this.speed = this.mg.config().defaultNukeSpeed();
}
this.pathFinder = new AirPathFinder(mg, this.random);
this.pathFinder = new ParabolaPathFinder(mg);
}
public target(): Player | TerraNullius {
if (this.mg === null) {
throw new Error("Not initialized");
}
return this.mg.owner(this.dst);
}
private tilesToDestroy(): Set<TileRef> {
if (this.tilesToDestroyCache !== undefined) {
return this.tilesToDestroyCache;
}
if (this.mg === null || this.nuke === null) {
throw new Error("Not initialized");
}
const magnitude = this.mg.config().nukeMagnitudes(this.nuke.type());
const rand = new PseudoRandom(this.mg.ticks());
const inner2 = magnitude.inner * magnitude.inner;
const outer2 = magnitude.outer * magnitude.outer;
return this.mg.bfs(this.dst, (_, n: TileRef) => {
const d2 = this.mg.euclideanDistSquared(this.dst, n);
this.tilesToDestroyCache = this.mg.bfs(this.dst, (_, n: TileRef) => {
const d2 = this.mg?.euclideanDistSquared(this.dst, n) ?? 0;
return d2 <= outer2 && (d2 <= inner2 || rand.chance(2));
});
return this.tilesToDestroyCache;
}
private breakAlliances(toDestroy: Set<TileRef>) {
if (this.mg === null || this.player === null || this.nuke === null) {
throw new Error("Not initialized");
}
const attacked = new Map<Player, number>();
for (const tile of toDestroy) {
const owner = this.mg.owner(tile);
@@ -74,13 +88,13 @@ export class NukeExecution implements Execution {
}
for (const [other, tilesDestroyed] of attacked) {
if (tilesDestroyed > 100 && this.nuke.type() != UnitType.MIRVWarhead) {
if (tilesDestroyed > 100 && this.nuke.type() !== UnitType.MIRVWarhead) {
// Mirv warheads shouldn't break alliances
const alliance = this.player.allianceWith(other);
if (alliance != null) {
if (alliance !== null) {
this.player.breakAlliance(alliance);
}
if (other != this.player) {
if (other !== this.player) {
other.updateRelation(this.player, -100);
}
}
@@ -88,31 +102,44 @@ export class NukeExecution implements Execution {
}
tick(ticks: number): void {
if (this.nuke == null) {
if (this.mg === null || this.player === null) {
throw new Error("Not initialized");
}
if (this.nuke === null) {
const spawn = this.src ?? this.player.canBuild(this.type, this.dst);
if (spawn == false) {
if (spawn === false) {
consolex.warn(`cannot build Nuke`);
this.active = false;
return;
}
this.nuke = this.player.buildUnit(this.type, 0, spawn, {
this.pathFinder.computeControlPoints(
spawn,
this.dst,
this.type !== UnitType.MIRVWarhead,
);
this.nuke = this.player.buildUnit(this.type, spawn, {
detonationDst: this.dst,
});
if (this.mg.hasOwner(this.dst)) {
const target = this.mg.owner(this.dst) as Player;
if (this.type == UnitType.AtomBomb) {
this.mg.displayMessage(
if (this.type === UnitType.AtomBomb) {
this.mg.displayIncomingUnit(
this.nuke.id(),
`${this.player.name()} - atom bomb inbound`,
MessageType.ERROR,
target.id(),
);
this.breakAlliances(this.tilesToDestroy());
}
if (this.type == UnitType.HydrogenBomb) {
this.mg.displayMessage(
if (this.type === UnitType.HydrogenBomb) {
this.mg.displayIncomingUnit(
this.nuke.id(),
`${this.player.name()} - hydrogen bomb inbound`,
MessageType.ERROR,
target.id(),
);
this.breakAlliances(this.tilesToDestroy());
}
this.mg
@@ -124,7 +151,7 @@ export class NukeExecution implements Execution {
);
}
// after sending an nuke set the missilesilo on cooldown
// after sending a nuke set the missilesilo on cooldown
const silo = this.player
.units(UnitType.MissileSilo)
.find((silo) => silo.tile() === spawn);
@@ -146,19 +173,20 @@ export class NukeExecution implements Execution {
return;
}
for (let i = 0; i < this.speed; i++) {
// Move to next tile
const nextTile = this.pathFinder.nextTile(this.nuke.tile(), this.dst);
if (nextTile === true) {
this.detonate();
return;
} else {
this.nuke.move(nextTile);
}
// Move to next tile
const nextTile = this.pathFinder.nextTile(this.speed);
if (nextTile === true) {
this.detonate();
return;
} else {
this.nuke.move(nextTile);
}
}
private detonate() {
if (this.mg === null || this.nuke === null) {
throw new Error("Not initialized");
}
const magnitude = this.mg.config().nukeMagnitudes(this.nuke.type());
const toDestroy = this.tilesToDestroy();
this.breakAlliances(toDestroy);
@@ -178,15 +206,17 @@ export class NukeExecution implements Execution {
.nukeDeathFactor(owner.workers(), owner.numTilesOwned()),
);
owner.outgoingAttacks().forEach((attack) => {
const deaths = this.mg
.config()
.nukeDeathFactor(attack.troops(), owner.numTilesOwned());
const deaths =
this.mg
?.config()
.nukeDeathFactor(attack.troops(), owner.numTilesOwned()) ?? 0;
attack.setTroops(attack.troops() - deaths);
});
owner.units(UnitType.TransportShip).forEach((attack) => {
const deaths = this.mg
.config()
.nukeDeathFactor(attack.troops(), owner.numTilesOwned());
const deaths =
this.mg
?.config()
.nukeDeathFactor(attack.troops(), owner.numTilesOwned()) ?? 0;
attack.setTroops(attack.troops() - deaths);
});
}
@@ -199,10 +229,10 @@ export class NukeExecution implements Execution {
const outer2 = magnitude.outer * magnitude.outer;
for (const unit of this.mg.units()) {
if (
unit.type() != UnitType.AtomBomb &&
unit.type() != UnitType.HydrogenBomb &&
unit.type() != UnitType.MIRVWarhead &&
unit.type() != UnitType.MIRV
unit.type() !== UnitType.AtomBomb &&
unit.type() !== UnitType.HydrogenBomb &&
unit.type() !== UnitType.MIRVWarhead &&
unit.type() !== UnitType.MIRV
) {
if (this.mg.euclideanDistSquared(this.dst, unit.tile()) < outer2) {
unit.delete();
@@ -214,6 +244,9 @@ export class NukeExecution implements Execution {
}
owner(): Player {
if (this.player === null) {
throw new Error("Not initialized");
}
return this.player;
}
+67 -25
View File
@@ -16,10 +16,10 @@ import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util";
export class PlayerExecution implements Execution {
private readonly ticksPerClusterCalc = 20;
private player: Player;
private config: Config;
private player: Player | null = null;
private config: Config | null = null;
private lastCalc = 0;
private mg: Game;
private mg: Game | null = null;
private active = true;
constructor(private playerID: PlayerID) {}
@@ -42,6 +42,9 @@ export class PlayerExecution implements Execution {
}
tick(ticks: number) {
if (this.mg === null || this.config === null || this.player === null) {
throw new Error("Not initialized");
}
this.player.decayRelations();
const hasPort = this.player.units(UnitType.Port).length > 0;
this.player.units().forEach((u) => {
@@ -49,13 +52,14 @@ export class PlayerExecution implements Execution {
u.delete();
return;
}
if (hasPort && u.type() == UnitType.Warship) {
if (hasPort && u.type() === UnitType.Warship) {
u.modifyHealth(1);
}
if (this.mg === null) return;
const tileOwner = this.mg.owner(u.tile());
if (u.info().territoryBound) {
if (tileOwner.isPlayer()) {
if (tileOwner != this.player) {
if (tileOwner !== this.player) {
this.mg.player(tileOwner.id()).captureUnit(u);
}
} else {
@@ -67,10 +71,10 @@ export class PlayerExecution implements Execution {
if (!this.player.isAlive()) {
this.player.units().forEach((u) => {
if (
u.type() != UnitType.AtomBomb &&
u.type() != UnitType.HydrogenBomb &&
u.type() != UnitType.MIRVWarhead &&
u.type() != UnitType.MIRV
u.type() !== UnitType.AtomBomb &&
u.type() !== UnitType.HydrogenBomb &&
u.type() !== UnitType.MIRVWarhead &&
u.type() !== UnitType.MIRV
) {
u.delete();
}
@@ -97,6 +101,17 @@ export class PlayerExecution implements Execution {
}
}
const embargoes = this.player.getEmbargoes();
for (const embargo of embargoes) {
if (
embargo.isTemporary &&
this.mg.ticks() - embargo.createdAt >
this.mg.config().temporaryEmbargoDuration()
) {
this.player.stopEmbargo(embargo.target);
}
}
if (ticks - this.lastCalc > this.ticksPerClusterCalc) {
if (this.player.lastTileChange() > this.lastCalc) {
this.lastCalc = ticks;
@@ -111,10 +126,14 @@ export class PlayerExecution implements Execution {
}
private removeClusters() {
if (this.mg === null || this.player === null) {
throw new Error("Not initialized");
}
const clusters = this.calculateClusters();
clusters.sort((a, b) => b.size - a.size);
const main = clusters.shift();
if (main === undefined) throw new Error("No clusters");
this.player.largestClusterBoundingBox = calculateBoundingBox(this.mg, main);
const surroundedBy = this.surroundedBySamePlayer(main);
if (surroundedBy && !this.player.isFriendly(surroundedBy)) {
@@ -129,6 +148,9 @@ export class PlayerExecution implements Execution {
}
private surroundedBySamePlayer(cluster: Set<TileRef>): false | Player {
if (this.mg === null || this.player === null) {
throw new Error("Not initialized");
}
const enemies = new Set<number>();
for (const tile of cluster) {
const isOceanShore = this.mg.isOceanShore(tile);
@@ -138,19 +160,19 @@ export class PlayerExecution implements Execution {
if (
isOceanShore ||
this.mg.isOnEdgeOfMap(tile) ||
this.mg.neighbors(tile).some((n) => !this.mg.hasOwner(n))
this.mg.neighbors(tile).some((n) => !this.mg?.hasOwner(n))
) {
return false;
}
this.mg
.neighbors(tile)
.filter((n) => this.mg.ownerID(n) != this.player.smallID())
.forEach((p) => enemies.add(this.mg.ownerID(p)));
if (enemies.size != 1) {
.filter((n) => this.mg?.ownerID(n) !== this.player?.smallID())
.forEach((p) => this.mg && enemies.add(this.mg.ownerID(p)));
if (enemies.size !== 1) {
return false;
}
}
if (enemies.size != 1) {
if (enemies.size !== 1) {
return false;
}
const enemy = this.mg.playerBySmallID(Array.from(enemies)[0]) as Player;
@@ -163,6 +185,9 @@ export class PlayerExecution implements Execution {
}
private isSurrounded(cluster: Set<TileRef>): boolean {
if (this.mg === null || this.player === null) {
throw new Error("Not initialized");
}
const enemyTiles = new Set<TileRef>();
for (const tr of cluster) {
if (this.mg.isShore(tr) || this.mg.isOnEdgeOfMap(tr)) {
@@ -170,10 +195,14 @@ export class PlayerExecution implements Execution {
}
this.mg
.neighbors(tr)
.filter((n) => this.mg.ownerID(n) != this.player.smallID())
.filter(
(n) =>
this.mg?.owner(n).isPlayer() &&
this.mg?.ownerID(n) !== this.player?.smallID(),
)
.forEach((n) => enemyTiles.add(n));
}
if (enemyTiles.size == 0) {
if (enemyTiles.size === 0) {
return false;
}
const enemyBox = calculateBoundingBox(this.mg, enemyTiles);
@@ -182,9 +211,12 @@ export class PlayerExecution implements Execution {
}
private removeCluster(cluster: Set<TileRef>) {
if (this.mg === null || this.player === null) {
throw new Error("Not initialized");
}
if (
Array.from(cluster).some(
(t) => this.mg.ownerID(t) != this.player.smallID(),
(t) => this.mg?.ownerID(t) !== this.player?.smallID(),
)
) {
// Other removeCluster operations could change tile owners,
@@ -193,16 +225,16 @@ export class PlayerExecution implements Execution {
}
const capturing = this.getCapturingPlayer(cluster);
if (capturing == null) {
if (capturing === null) {
return;
}
const firstTile = cluster.values().next().value;
const filter = (_, t: TileRef): boolean =>
this.mg.ownerID(t) == this.player.smallID();
this.mg?.ownerID(t) === this.player?.smallID();
const tiles = this.mg.bfs(firstTile, filter);
if (this.player.numTilesOwned() == tiles.size) {
if (this.player.numTilesOwned() === tiles.size) {
const gold = this.player.gold();
this.mg.displayMessage(
`Conquered ${this.player.displayName()} received ${renderNumber(
@@ -221,10 +253,13 @@ export class PlayerExecution implements Execution {
}
private getCapturingPlayer(cluster: Set<TileRef>): Player | null {
if (this.mg === null || this.player === null) {
throw new Error("Not initialized");
}
const neighborsIDs = new Set<number>();
for (const t of cluster) {
for (const neighbor of this.mg.neighbors(t)) {
if (this.mg.ownerID(neighbor) != this.player.smallID()) {
if (this.mg.ownerID(neighbor) !== this.player.smallID()) {
neighborsIDs.add(this.mg.ownerID(neighbor));
}
}
@@ -234,11 +269,11 @@ export class PlayerExecution implements Execution {
let largestTroopCount: number = 0;
for (const id of neighborsIDs) {
const neighbor = this.mg.playerBySmallID(id);
if (!neighbor.isPlayer()) {
if (!neighbor.isPlayer() || this.player.isFriendly(neighbor)) {
continue;
}
for (const attack of neighbor.outgoingAttacks()) {
if (attack.target() == this.player) {
if (attack.target() === this.player) {
if (attack.troops() > largestTroopCount) {
largestTroopCount = attack.troops();
largestNeighborAttack = neighbor;
@@ -246,7 +281,7 @@ export class PlayerExecution implements Execution {
}
}
}
if (largestNeighborAttack != null) {
if (largestNeighborAttack !== null) {
return largestNeighborAttack;
}
@@ -259,10 +294,13 @@ export class PlayerExecution implements Execution {
if (!capturing.isPlayer()) {
return null;
}
return capturing as Player;
return capturing;
}
private calculateClusters(): Set<TileRef>[] {
if (this.mg === null || this.player === null) {
throw new Error("Not initialized");
}
const seen = new Set<TileRef>();
const border = this.player.borderTiles();
const clusters: Set<TileRef>[] = [];
@@ -276,6 +314,7 @@ export class PlayerExecution implements Execution {
seen.add(tile);
while (queue.length > 0) {
const curr = queue.shift();
if (curr === undefined) throw new Error("curr is undefined");
cluster.add(curr);
const neighbors = (this.mg as GameImpl).neighborsWithDiag(curr);
@@ -292,6 +331,9 @@ export class PlayerExecution implements Execution {
}
owner(): Player {
if (this.player === null) {
throw new Error("Not initialized");
}
return this.player;
}
+15 -9
View File
@@ -14,10 +14,10 @@ import { TradeShipExecution } from "./TradeShipExecution";
export class PortExecution implements Execution {
private active = true;
private mg: Game;
private port: Unit;
private random: PseudoRandom;
private checkOffset: number;
private mg: Game | null = null;
private port: Unit | null = null;
private random: PseudoRandom | null = null;
private checkOffset: number | null = null;
constructor(
private _owner: PlayerID,
@@ -36,7 +36,10 @@ export class PortExecution implements Execution {
}
tick(ticks: number): void {
if (this.port == null) {
if (this.mg === null || this.random === null || this.checkOffset === null) {
throw new Error("Not initialized");
}
if (this.port === null) {
const tile = this.tile;
const player = this.mg.player(this._owner);
const spawn = player.canBuild(UnitType.Port, tile);
@@ -45,7 +48,7 @@ export class PortExecution implements Execution {
this.active = false;
return;
}
this.port = player.buildUnit(UnitType.Port, 0, spawn);
this.port = player.buildUnit(UnitType.Port, spawn, {});
}
if (!this.port.isActive()) {
@@ -53,12 +56,12 @@ export class PortExecution implements Execution {
return;
}
if (this._owner != this.port.owner().id()) {
if (this._owner !== this.port.owner().id()) {
this._owner = this.port.owner().id();
}
// Only check every 10 ticks for performance.
if ((this.mg.ticks() + this.checkOffset) % 10 != 0) {
if ((this.mg.ticks() + this.checkOffset) % 10 !== 0) {
return;
}
@@ -71,7 +74,7 @@ export class PortExecution implements Execution {
const ports = this.player().tradingPorts(this.port);
if (ports.length == 0) {
if (ports.length === 0) {
return;
}
@@ -91,6 +94,9 @@ export class PortExecution implements Execution {
}
player(): Player {
if (this.port === null) {
throw new Error("Not initialized");
}
return this.port.owner();
}
}
+84
View File
@@ -0,0 +1,84 @@
import { consolex } from "../Consolex";
import { Execution, Game, Player, PlayerID } from "../game/Game";
export class QuickChatExecution implements Execution {
private sender: Player;
private recipient: Player;
private mg: Game;
private active = true;
constructor(
private senderID: PlayerID,
private recipientID: PlayerID,
private quickChatKey: string,
private variables: Record<string, string>,
) {}
init(mg: Game, ticks: number): void {
this.mg = mg;
if (!mg.hasPlayer(this.senderID)) {
consolex.warn(`QuickChatExecution: sender ${this.senderID} not found`);
this.active = false;
return;
}
if (!mg.hasPlayer(this.recipientID)) {
consolex.warn(
`QuickChatExecution: recipient ${this.recipientID} not found`,
);
this.active = false;
return;
}
this.sender = mg.player(this.senderID);
this.recipient = mg.player(this.recipientID);
}
tick(ticks: number): void {
const message = this.getMessageFromKey(this.quickChatKey, this.variables);
this.mg.displayChat(
message[1],
message[0],
this.variables,
this.recipient.id(),
true,
this.recipient.name(),
);
this.mg.displayChat(
message[1],
message[0],
this.variables,
this.sender.id(),
false,
this.recipient.name(),
);
consolex.log(
`[QuickChat] ${this.sender.name}${this.recipient.name}: ${message}`,
);
this.active = false;
}
owner(): Player {
return this.sender;
}
isActive(): boolean {
return this.active;
}
activeDuringSpawnPhase(): boolean {
return false;
}
private getMessageFromKey(
fullKey: string,
vars: Record<string, string>,
): string[] {
const translated = fullKey.split(".");
return translated;
}
}
+43 -34
View File
@@ -17,23 +17,20 @@ export class SAMLauncherExecution implements Execution {
private mg: Game;
private active: boolean = true;
private target: Unit = null;
private warheadTargets: Unit[] = [];
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;
private pseudoRandom: PseudoRandom | undefined;
constructor(
private ownerId: PlayerID,
private tile: TileRef,
private tile: TileRef | null,
private sam: Unit | null = null,
) {
if (sam != null) {
if (sam !== null) {
this.tile = sam.tile();
}
}
@@ -49,6 +46,7 @@ export class SAMLauncherExecution implements Execution {
}
private getSingleTarget(): Unit | null {
if (this.sam === null) return null;
const nukes = this.mg
.nearbyUnits(this.sam.tile(), this.searchRangeRadius, [
UnitType.AtomBomb,
@@ -83,11 +81,11 @@ export class SAMLauncherExecution implements Execution {
}
private isHit(type: UnitType, random: number): boolean {
if (type == UnitType.AtomBomb) {
if (type === UnitType.AtomBomb) {
return true;
}
if (type == UnitType.MIRVWarhead) {
if (type === UnitType.MIRVWarhead) {
return random < this.mg.config().samWarheadHittingChance();
}
@@ -95,14 +93,20 @@ export class SAMLauncherExecution implements Execution {
}
tick(ticks: number): void {
if (this.sam == null) {
if (this.mg === null || this.player === null) {
throw new Error("Not initialized");
}
if (this.sam === null) {
if (this.tile === null) {
throw new Error("tile is null");
}
const spawnTile = this.player.canBuild(UnitType.SAMLauncher, this.tile);
if (spawnTile == false) {
if (spawnTile === false) {
consolex.warn("cannot build SAM Launcher");
this.active = false;
return;
}
this.sam = this.player.buildUnit(UnitType.SAMLauncher, 0, spawnTile, {
this.sam = this.player.buildUnit(UnitType.SAMLauncher, spawnTile, {
cooldownDuration: this.mg.config().SAMCooldown(),
});
}
@@ -111,15 +115,15 @@ export class SAMLauncherExecution implements Execution {
return;
}
if (this.player != this.sam.owner()) {
if (this.player !== this.sam.owner()) {
this.player = this.sam.owner();
}
if (!this.pseudoRandom) {
if (this.pseudoRandom === undefined) {
this.pseudoRandom = new PseudoRandom(this.sam.id());
}
this.warheadTargets = this.mg
const mirvWarheadTargets = this.mg
.nearbyUnits(
this.sam.tile(),
this.MIRVWarheadSearchRadius,
@@ -130,33 +134,37 @@ export class SAMLauncherExecution implements Execution {
(unit) =>
unit.owner() !== this.player && !this.player.isFriendly(unit.owner()),
)
.filter(
(unit) =>
this.mg.manhattanDist(unit.detonationDst(), this.sam.tile()) <
this.MIRVWarheadProtectionRadius,
);
.filter((unit) => {
const dst = unit.detonationDst();
return (
this.sam !== null &&
dst !== null &&
this.mg.manhattanDist(dst, this.sam.tile()) <
this.MIRVWarheadProtectionRadius
);
});
if (this.warheadTargets.length == 0) {
this.target = this.getSingleTarget();
let target: Unit | null = null;
if (mirvWarheadTargets.length === 0) {
target = this.getSingleTarget();
}
if (
this.sam.isCooldown() &&
this.sam.ticksLeftInCooldown(this.mg.config().SAMCooldown()) == 0
this.sam.ticksLeftInCooldown(this.mg.config().SAMCooldown()) === 0
) {
this.sam.setCooldown(false);
}
const isSingleTarget = this.target && !this.target.targetedBySAM();
const isSingleTarget = target && !target.targetedBySAM();
if (
(isSingleTarget || this.warheadTargets.length > 0) &&
(isSingleTarget || mirvWarheadTargets.length > 0) &&
!this.sam.isCooldown()
) {
this.sam.setCooldown(true);
const type =
this.warheadTargets.length > 0
? UnitType.MIRVWarhead
: this.target.type();
mirvWarheadTargets.length > 0 ? UnitType.MIRVWarhead : target?.type();
if (type === undefined) throw new Error("Unknown unit type");
const random = this.pseudoRandom.next();
const hit = this.isHit(type, random);
if (!hit) {
@@ -166,26 +174,27 @@ export class SAMLauncherExecution implements Execution {
this.sam.owner().id(),
);
} else {
if (this.warheadTargets.length > 0) {
if (mirvWarheadTargets.length > 0) {
// Message
this.mg.displayMessage(
`${this.warheadTargets.length} MIRV warheads intercepted`,
`${mirvWarheadTargets.length} MIRV warheads intercepted`,
MessageType.SUCCESS,
this.sam.owner().id(),
);
// Delete warheads
this.warheadTargets.forEach((u) => u.delete());
} else {
this.target.setTargetedBySAM(true);
mirvWarheadTargets.forEach((u) => u.delete());
} else if (target !== null) {
target.setTargetedBySAM(true);
this.mg.addExecution(
new SAMMissileExecution(
this.sam.tile(),
this.sam.owner(),
this.sam,
this.target,
target,
),
);
this.warheadTargets = [];
} else {
throw new Error("target is null");
}
}
}
+4 -4
View File
@@ -13,7 +13,7 @@ import { PseudoRandom } from "../PseudoRandom";
export class SAMMissileExecution implements Execution {
private active = true;
private pathFinder: AirPathFinder;
private SAMMissile: Unit;
private SAMMissile: Unit | undefined;
private mg: Game;
constructor(
@@ -30,11 +30,11 @@ export class SAMMissileExecution implements Execution {
}
tick(ticks: number): void {
if (this.SAMMissile == null) {
if (this.SAMMissile === undefined) {
this.SAMMissile = this._owner.buildUnit(
UnitType.SAMMissile,
0,
this.spawn,
{},
);
}
if (!this.SAMMissile.isActive()) {
@@ -46,7 +46,7 @@ export class SAMMissileExecution implements Execution {
if (
!this.target.isActive() ||
!this.ownerUnit.isActive() ||
this.target.owner() == this.SAMMissile.owner() ||
this.target.owner() === this.SAMMissile.owner() ||
!nukesWhitelist.includes(this.target.type())
) {
this.SAMMissile.delete(false);
@@ -31,10 +31,6 @@ export class SetTargetTroopRatioExecution implements Execution {
this.active = false;
}
owner(): Player {
return null;
}
isActive(): boolean {
return this.active;
}
+8 -8
View File
@@ -6,7 +6,7 @@ import { PseudoRandom } from "../PseudoRandom";
export class ShellExecution implements Execution {
private active = true;
private pathFinder: AirPathFinder;
private shell: Unit;
private shell: Unit | undefined;
private mg: Game;
private destroyAtTick: number = -1;
@@ -23,8 +23,8 @@ export class ShellExecution implements Execution {
}
tick(ticks: number): void {
if (this.shell == null) {
this.shell = this._owner.buildUnit(UnitType.Shell, 0, this.spawn);
if (this.shell === undefined) {
this.shell = this._owner.buildUnit(UnitType.Shell, this.spawn, {});
}
if (!this.shell.isActive()) {
this.active = false;
@@ -32,15 +32,15 @@ export class ShellExecution implements Execution {
}
if (
!this.target.isActive() ||
this.target.owner() == this.shell.owner() ||
(this.destroyAtTick != -1 && this.mg.ticks() >= this.destroyAtTick)
this.target.owner() === this.shell.owner() ||
(this.destroyAtTick !== -1 && this.mg.ticks() >= this.destroyAtTick)
) {
this.shell.delete(false);
this.active = false;
return;
}
if (this.destroyAtTick == -1 && !this.ownerUnit.isActive()) {
if (this.destroyAtTick === -1 && !this.ownerUnit.isActive()) {
this.destroyAtTick = this.mg.ticks() + this.mg.config().shellLifetime();
}
@@ -61,8 +61,8 @@ export class ShellExecution implements Execution {
}
private effectOnTarget(): number {
const baseDamage: number = this.mg.config().unitInfo(UnitType.Shell).damage;
return baseDamage;
const { damage } = this.mg.config().unitInfo(UnitType.Shell);
return damage ?? 0;
}
isActive(): boolean {
+2 -5
View File
@@ -25,7 +25,7 @@ export class SpawnExecution implements Execution {
return;
}
let player: Player = null;
let player: Player | null = null;
if (this.mg.hasPlayer(this.playerInfo.id)) {
player = this.mg.player(this.playerInfo.id);
} else {
@@ -39,16 +39,13 @@ export class SpawnExecution implements Execution {
if (!player.hasSpawned()) {
this.mg.addExecution(new PlayerExecution(player.id()));
if (player.type() == PlayerType.Bot) {
if (player.type() === PlayerType.Bot) {
this.mg.addExecution(new BotExecution(player));
}
}
player.setHasSpawned(true);
}
owner(): Player {
return null;
}
isActive(): boolean {
return this.active;
}
@@ -37,10 +37,6 @@ export class TargetPlayerExecution implements Execution {
this.active = false;
}
owner(): Player {
return null;
}
isActive(): boolean {
return this.active;
}
+33 -14
View File
@@ -16,11 +16,12 @@ import { distSortUnit } from "../Util";
export class TradeShipExecution implements Execution {
private active = true;
private mg: Game;
private origOwner: Player;
private tradeShip: Unit;
private mg: Game | null = null;
private origOwner: Player | null = null;
private tradeShip: Unit | null = null;
private index = 0;
private wasCaptured = false;
private tilesTraveled = 0;
constructor(
private _owner: PlayerID,
@@ -35,17 +36,20 @@ export class TradeShipExecution implements Execution {
}
tick(ticks: number): void {
if (this.tradeShip == null) {
if (this.mg === null || this.origOwner === null) {
throw new Error("Not initialized");
}
if (this.tradeShip === null) {
const spawn = this.origOwner.canBuild(
UnitType.TradeShip,
this.srcPort.tile(),
);
if (spawn == false) {
if (spawn === false) {
consolex.warn(`cannot build trade ship`);
this.active = false;
return;
}
this.tradeShip = this.origOwner.buildUnit(UnitType.TradeShip, 0, spawn, {
this.tradeShip = this.origOwner.buildUnit(UnitType.TradeShip, spawn, {
dstPort: this._dstPort,
lastSetSafeFromPirates: ticks,
});
@@ -56,14 +60,14 @@ export class TradeShipExecution implements Execution {
return;
}
if (this.origOwner != this.tradeShip.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 another player's port while trading we should delete
// the ship.
if (this._dstPort.owner().id() == this.srcPort.owner().id()) {
if (this._dstPort.owner().id() === this.srcPort.owner().id()) {
this.tradeShip.delete(false);
this.active = false;
return;
@@ -84,7 +88,7 @@ export class TradeShipExecution implements Execution {
.owner()
.units(UnitType.Port)
.sort(distSortUnit(this.mg, this.tradeShip));
if (ports.length == 0) {
if (ports.length === 0) {
this.tradeShip.delete(false);
this.active = false;
return;
@@ -94,6 +98,19 @@ export class TradeShipExecution implements Execution {
}
}
const cachedNextTile = this._dstPort.cacheGet(this.tradeShip.tile());
if (cachedNextTile !== undefined) {
if (
this.mg.isWater(cachedNextTile) &&
this.mg.isShoreline(cachedNextTile)
) {
this.tradeShip.setSafeFromPirates();
}
this.tradeShip.move(cachedNextTile);
this.tilesTraveled++;
return;
}
const result = this.pathFinder.nextTile(
this.tradeShip.tile(),
this._dstPort.tile(),
@@ -108,11 +125,13 @@ export class TradeShipExecution implements Execution {
this.tradeShip.move(this.tradeShip.tile());
break;
case PathFindResultType.NextTile:
this._dstPort.cachePut(this.tradeShip.tile(), result.tile);
// Update safeFromPirates status
if (this.mg.isWater(result.tile) && this.mg.isShoreline(result.tile)) {
this.tradeShip.setSafeFromPirates();
}
this.tradeShip.move(result.tile);
this.tilesTraveled++;
break;
case PathFindResultType.PathNotFound:
consolex.warn("captured trade ship cannot find route");
@@ -125,13 +144,13 @@ export class TradeShipExecution implements Execution {
}
private complete() {
if (this.mg === null || this.origOwner === null) {
throw new Error("Not initialized");
}
if (this.tradeShip === null) return;
this.active = false;
this.tradeShip.delete(false);
const gold = this.mg
.config()
.tradeShipGold(
this.mg.manhattanDist(this.srcPort.tile(), this._dstPort.tile()),
);
const gold = this.mg.config().tradeShipGold(this.tilesTraveled);
if (this.wasCaptured) {
this.tradeShip.owner().addGold(gold);
+29 -28
View File
@@ -26,7 +26,6 @@ export class TransportShipExecution implements Execution {
private mg: Game;
private attacker: Player;
private target: Player | TerraNullius;
private embarkDelay = 10;
// TODO make private
public path: TileRef[];
@@ -40,7 +39,7 @@ export class TransportShipExecution implements Execution {
private attackerID: PlayerID,
private targetID: PlayerID | null,
private ref: TileRef,
private troops: number | null,
private troops: number,
private src: TileRef | null,
) {}
@@ -56,7 +55,7 @@ export class TransportShipExecution implements Execution {
this.active = false;
return;
}
if (this.targetID != null && !mg.hasPlayer(this.targetID)) {
if (this.targetID !== null && !mg.hasPlayer(this.targetID)) {
console.warn(`TransportShipExecution: target ${this.targetID} not found`);
this.active = false;
return;
@@ -68,15 +67,6 @@ export class TransportShipExecution implements Execution {
this.attacker = mg.player(this.attackerID);
// Notify the target player about the incoming naval invasion
if (this.targetID && this.targetID !== mg.terraNullius().id()) {
mg.displayMessage(
`Naval invasion incoming from ${this.attacker.displayName()}`,
MessageType.WARN,
this.targetID,
);
}
if (
this.attacker.units(UnitType.TransportShip).length >=
mg.config().boatMaxNumber()
@@ -91,13 +81,16 @@ export class TransportShipExecution implements Execution {
return;
}
if (this.targetID == null || this.targetID == this.mg.terraNullius().id()) {
if (
this.targetID === null ||
this.targetID === this.mg.terraNullius().id()
) {
this.target = mg.terraNullius();
} else {
this.target = mg.player(this.targetID);
}
if (this.troops == null) {
if (this.troops === null) {
this.troops = this.mg
.config()
.boatAttackAmount(this.attacker, this.target);
@@ -106,7 +99,7 @@ export class TransportShipExecution implements Execution {
this.troops = Math.min(this.troops, this.attacker.troops());
this.dst = targetTransportTile(this.mg, this.ref);
if (this.dst == null) {
if (this.dst === null) {
consolex.warn(
`${this.attacker} cannot send ship to ${this.target}, cannot find attack tile`,
);
@@ -118,19 +111,19 @@ export class TransportShipExecution implements Execution {
UnitType.TransportShip,
this.dst,
);
if (closestTileSrc == false) {
if (closestTileSrc === false) {
consolex.warn(`can't build transport ship`);
this.active = false;
return;
}
if (this.src == null) {
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.owner(this.src) !== this.attacker ||
!this.mg.isShore(this.src)
) {
console.warn(
@@ -140,14 +133,26 @@ export class TransportShipExecution implements Execution {
}
}
this.boat = this.attacker.buildUnit(
UnitType.TransportShip,
this.troops,
this.src,
);
this.boat = this.attacker.buildUnit(UnitType.TransportShip, this.src, {
troops: this.troops,
});
// Notify the target player about the incoming naval invasion
if (this.targetID && this.targetID !== mg.terraNullius().id()) {
mg.displayIncomingUnit(
this.boat.id(),
`Naval invasion incoming from ${this.attacker.displayName()}`,
MessageType.WARN,
this.targetID,
);
}
}
tick(ticks: number) {
if (this.dst === null) {
this.active = false;
return;
}
if (!this.active) {
return;
}
@@ -155,10 +160,6 @@ export class TransportShipExecution implements Execution {
this.active = false;
return;
}
if (this.embarkDelay > 0) {
this.embarkDelay--;
return;
}
if (ticks - this.lastMove < this.ticksPerMove) {
return;
}
@@ -167,7 +168,7 @@ export class TransportShipExecution implements Execution {
const result = this.pathFinder.nextTile(this.boat.tile(), this.dst);
switch (result.type) {
case PathFindResultType.Completed:
if (this.mg.owner(this.dst) == this.attacker) {
if (this.mg.owner(this.dst) === this.attacker) {
this.attacker.addTroops(this.troops);
this.boat.delete(false);
this.active = false;
+2 -2
View File
@@ -10,11 +10,11 @@ export function closestTwoTiles(
gm: GameMap,
x: Iterable<TileRef>,
y: Iterable<TileRef>,
): { x: TileRef; y: TileRef } {
): { x: TileRef; y: TileRef } | null {
const xSorted = Array.from(x).sort((a, b) => gm.x(a) - gm.x(b));
const ySorted = Array.from(y).sort((a, b) => gm.x(a) - gm.x(b));
if (xSorted.length == 0 || ySorted.length == 0) {
if (xSorted.length === 0 || ySorted.length === 0) {
return null;
}

Some files were not shown because too many files have changed in this diff Show More