format codebase with prettier

This commit is contained in:
Evan
2025-02-01 12:05:11 -08:00
parent cd121a5cd4
commit 4ee37323f9
98 changed files with 12190 additions and 10233 deletions
+261 -196
View File
@@ -1,240 +1,305 @@
import { Executor } from "../core/execution/ExecutionManager";
import { Cell, Game, PlayerID, GameMapType, Difficulty, GameType } from "../core/game/Game";
import {
Cell,
Game,
PlayerID,
GameMapType,
Difficulty,
GameType,
} from "../core/game/Game";
import { EventBus } from "../core/EventBus";
import { createRenderer, GameRenderer } from "./graphics/GameRenderer";
import { InputHandler, MouseUpEvent, ZoomEvent, DragEvent, MouseDownEvent } from "./InputHandler"
import { ClientID, ClientIntentMessageSchema, ClientJoinMessageSchema, ClientMessageSchema, GameConfig, GameID, Intent, ServerMessage, ServerMessageSchema, ServerSyncMessage, Turn } from "../core/Schemas";
import { loadTerrainFromFile, loadTerrainMap } from "../core/game/TerrainMapLoader";
import { SendAttackIntentEvent, SendSpawnIntentEvent, Transport } from "./Transport";
import {
InputHandler,
MouseUpEvent,
ZoomEvent,
DragEvent,
MouseDownEvent,
} from "./InputHandler";
import {
ClientID,
ClientIntentMessageSchema,
ClientJoinMessageSchema,
ClientMessageSchema,
GameConfig,
GameID,
Intent,
ServerMessage,
ServerMessageSchema,
ServerSyncMessage,
Turn,
} from "../core/Schemas";
import {
loadTerrainFromFile,
loadTerrainMap,
} from "../core/game/TerrainMapLoader";
import {
SendAttackIntentEvent,
SendSpawnIntentEvent,
Transport,
} from "./Transport";
import { createCanvas } from "./Utils";
import { MessageType } from '../core/game/Game';
import { MessageType } from "../core/game/Game";
import { DisplayMessageUpdate, ErrorUpdate } from "../core/game/GameUpdates";
import { WorkerClient } from "../core/worker/WorkerClient";
import { consolex, initRemoteSender } from "../core/Consolex";
import { getConfig, getServerConfig } from "../core/configuration/Config";
import { GameView, PlayerView } from "../core/game/GameView";
import { GameUpdateViewData } from '../core/game/GameUpdates';
import { GameUpdateViewData } from "../core/game/GameUpdates";
export interface LobbyConfig {
playerName: () => string
clientID: ClientID,
playerID: PlayerID,
persistentID: string,
gameType: GameType
gameID: GameID,
map: GameMapType | null
difficulty: Difficulty | null
playerName: () => string;
clientID: ClientID;
playerID: PlayerID;
persistentID: string;
gameType: GameType;
gameID: GameID;
map: GameMapType | null;
difficulty: Difficulty | null;
}
export function joinLobby(lobbyConfig: LobbyConfig, onjoin: () => void): () => void {
const eventBus = new EventBus()
initRemoteSender(eventBus)
export function joinLobby(
lobbyConfig: LobbyConfig,
onjoin: () => void,
): () => void {
const eventBus = new EventBus();
initRemoteSender(eventBus);
consolex.log(`joinging lobby: gameID: ${lobbyConfig.gameID}, clientID: ${lobbyConfig.clientID}, persistentID: ${lobbyConfig.persistentID}`)
consolex.log(
`joinging lobby: gameID: ${lobbyConfig.gameID}, clientID: ${lobbyConfig.clientID}, persistentID: ${lobbyConfig.persistentID}`,
);
const serverConfig = getServerConfig()
const serverConfig = getServerConfig();
let gameConfig: GameConfig = null
if (lobbyConfig.gameType == GameType.Singleplayer) {
gameConfig = {
gameType: GameType.Singleplayer,
gameMap: lobbyConfig.map,
difficulty: lobbyConfig.difficulty,
}
}
const transport = new Transport(
lobbyConfig,
gameConfig,
eventBus,
serverConfig,
)
const onconnect = () => {
consolex.log(`Joined game lobby ${lobbyConfig.gameID}`);
transport.joinGame(0)
let gameConfig: GameConfig = null;
if (lobbyConfig.gameType == GameType.Singleplayer) {
gameConfig = {
gameType: GameType.Singleplayer,
gameMap: lobbyConfig.map,
difficulty: lobbyConfig.difficulty,
};
const onmessage = (message: ServerMessage) => {
if (message.type == "start") {
consolex.log('lobby: game started')
onjoin()
createClientGame(lobbyConfig, message.config, eventBus, transport).then(r => r.start())
};
}
transport.connect(onconnect, onmessage)
return () => {
consolex.log('leaving game')
transport.leaveGame()
}
const transport = new Transport(
lobbyConfig,
gameConfig,
eventBus,
serverConfig,
);
const onconnect = () => {
consolex.log(`Joined game lobby ${lobbyConfig.gameID}`);
transport.joinGame(0);
};
const onmessage = (message: ServerMessage) => {
if (message.type == "start") {
consolex.log("lobby: game started");
onjoin();
createClientGame(lobbyConfig, message.config, eventBus, transport).then(
(r) => r.start(),
);
}
};
transport.connect(onconnect, onmessage);
return () => {
consolex.log("leaving game");
transport.leaveGame();
};
}
export async function createClientGame(
lobbyConfig: LobbyConfig,
gameConfig: GameConfig,
eventBus: EventBus,
transport: Transport,
): Promise<ClientGameRunner> {
const config = getConfig(gameConfig);
export async function createClientGame(lobbyConfig: LobbyConfig, gameConfig: GameConfig, eventBus: EventBus, transport: Transport): Promise<ClientGameRunner> {
const config = getConfig(gameConfig)
const gameMap = await loadTerrainMap(gameConfig.gameMap);
const worker = new WorkerClient(lobbyConfig.gameID, gameConfig);
await worker.initialize();
const gameView = new GameView(
worker,
config,
gameMap.gameMap,
lobbyConfig.clientID,
);
const gameMap = await loadTerrainMap(gameConfig.gameMap);
const worker = new WorkerClient(lobbyConfig.gameID, gameConfig)
await worker.initialize()
const gameView = new GameView(worker, config, gameMap.gameMap, lobbyConfig.clientID)
consolex.log("going to init path finder");
consolex.log("inited path finder");
const canvas = createCanvas();
let gameRenderer = createRenderer(
canvas,
gameView,
eventBus,
lobbyConfig.clientID,
);
consolex.log(
`creating private game got difficulty: ${gameConfig.difficulty}`,
);
consolex.log('going to init path finder')
consolex.log('inited path finder')
const canvas = createCanvas()
let gameRenderer = createRenderer(canvas, gameView, eventBus, lobbyConfig.clientID)
consolex.log(`creating private game got difficulty: ${gameConfig.difficulty}`)
return new ClientGameRunner(
lobbyConfig.clientID,
eventBus,
gameRenderer,
new InputHandler(canvas, eventBus),
transport,
worker,
gameView
)
return new ClientGameRunner(
lobbyConfig.clientID,
eventBus,
gameRenderer,
new InputHandler(canvas, eventBus),
transport,
worker,
gameView,
);
}
export class ClientGameRunner {
private myPlayer: PlayerView
private isActive = false
private myPlayer: PlayerView;
private isActive = false;
private turnsSeen = 0
private hasJoined = false
private turnsSeen = 0;
private hasJoined = false;
constructor(
private clientID: ClientID,
private eventBus: EventBus,
private renderer: GameRenderer,
private input: InputHandler,
private transport: Transport,
private worker: WorkerClient,
private gameView: GameView
) { }
constructor(
private clientID: ClientID,
private eventBus: EventBus,
private renderer: GameRenderer,
private input: InputHandler,
private transport: Transport,
private worker: WorkerClient,
private gameView: GameView,
) {}
public start() {
consolex.log('starting client game')
this.isActive = true
this.eventBus.on(MouseUpEvent, (e) => this.inputEvent(e))
public start() {
consolex.log("starting client game");
this.isActive = true;
this.eventBus.on(MouseUpEvent, (e) => this.inputEvent(e));
this.renderer.initialize()
this.input.initialize()
this.worker.start((gu: GameUpdateViewData | ErrorUpdate) => {
if ('errMsg' in gu) {
showErrorModal(gu.errMsg, gu.stack, this.clientID)
return
}
this.gameView.update(gu)
this.renderer.tick()
})
const worker = this.worker
const keepWorkerAlive = () => {
worker.sendHeartbeat
requestAnimationFrame(keepWorkerAlive)
this.renderer.initialize();
this.input.initialize();
this.worker.start((gu: GameUpdateViewData | ErrorUpdate) => {
if ("errMsg" in gu) {
showErrorModal(gu.errMsg, gu.stack, this.clientID);
return;
}
this.gameView.update(gu);
this.renderer.tick();
});
const worker = this.worker;
const keepWorkerAlive = () => {
worker.sendHeartbeat;
requestAnimationFrame(keepWorkerAlive);
};
requestAnimationFrame(keepWorkerAlive);
const onconnect = () => {
consolex.log("Connected to game server!");
this.transport.joinGame(this.turnsSeen);
};
const onmessage = (message: ServerMessage) => {
if (message.type == "start") {
this.hasJoined = true;
consolex.log("starting game!");
for (const turn of message.turns) {
if (turn.turnNumber < this.turnsSeen) {
continue;
}
this.worker.sendTurn(turn);
this.turnsSeen++;
}
requestAnimationFrame(keepWorkerAlive)
}
if (message.type == "turn") {
if (!this.hasJoined) {
this.transport.joinGame(0);
return;
}
if (this.turnsSeen != message.turn.turnNumber) {
consolex.error(
`got wrong turn have turns ${this.turnsSeen}, received turn ${message.turn.turnNumber}`,
);
} else {
this.worker.sendTurn(message.turn);
this.turnsSeen++;
}
}
};
this.transport.connect(onconnect, onmessage);
}
const onconnect = () => {
consolex.log('Connected to game server!');
this.transport.joinGame(this.turnsSeen)
};
const onmessage = (message: ServerMessage) => {
if (message.type == "start") {
this.hasJoined = true
consolex.log("starting game!")
for (const turn of message.turns) {
if (turn.turnNumber < this.turnsSeen) {
continue
}
this.worker.sendTurn(turn)
this.turnsSeen++
}
}
if (message.type == "turn") {
if (!this.hasJoined) {
this.transport.joinGame(0)
return
}
if (this.turnsSeen != message.turn.turnNumber) {
consolex.error(`got wrong turn have turns ${this.turnsSeen}, received turn ${message.turn.turnNumber}`)
} else {
this.worker.sendTurn(message.turn)
this.turnsSeen++
}
}
};
this.transport.connect(onconnect, onmessage)
public stop() {
this.worker.cleanup();
this.isActive = false;
this.transport.leaveGame();
}
private inputEvent(event: MouseUpEvent) {
if (!this.isActive) {
return;
}
public stop() {
this.worker.cleanup()
this.isActive = false
this.transport.leaveGame()
const cell = this.renderer.transformHandler.screenToWorldCoordinates(
event.x,
event.y,
);
if (!this.gameView.isValidCoord(cell.x, cell.y)) {
return;
}
private inputEvent(event: MouseUpEvent) {
if (!this.isActive) {
return
}
const cell = this.renderer.transformHandler.screenToWorldCoordinates(event.x, event.y)
if (!this.gameView.isValidCoord(cell.x, cell.y)) {
return
}
consolex.log(`clicked cell ${cell}`)
const tile = this.gameView.ref(cell.x, cell.y)
if (this.gameView.isLand(tile) && !this.gameView.hasOwner(tile) && this.gameView.inSpawnPhase()) {
this.eventBus.emit(new SendSpawnIntentEvent(cell))
return
}
if (this.gameView.inSpawnPhase()) {
return
}
if (this.myPlayer == null) {
this.myPlayer = this.gameView.playerByClientID(this.clientID)
if (this.myPlayer == null) {
return
}
}
this.myPlayer.actions(tile).then(actions => {
console.log(`got actions: ${JSON.stringify(actions)}`)
if (actions.canAttack) {
this.eventBus.emit(
new SendAttackIntentEvent(
this.gameView.owner(tile).id(),
this.myPlayer.troops() * this.renderer.uiState.attackRatio
)
)
}
})
consolex.log(`clicked cell ${cell}`);
const tile = this.gameView.ref(cell.x, cell.y);
if (
this.gameView.isLand(tile) &&
!this.gameView.hasOwner(tile) &&
this.gameView.inSpawnPhase()
) {
this.eventBus.emit(new SendSpawnIntentEvent(cell));
return;
}
if (this.gameView.inSpawnPhase()) {
return;
}
if (this.myPlayer == null) {
this.myPlayer = this.gameView.playerByClientID(this.clientID);
if (this.myPlayer == null) {
return;
}
}
this.myPlayer.actions(tile).then((actions) => {
console.log(`got actions: ${JSON.stringify(actions)}`);
if (actions.canAttack) {
this.eventBus.emit(
new SendAttackIntentEvent(
this.gameView.owner(tile).id(),
this.myPlayer.troops() * this.renderer.uiState.attackRatio,
),
);
}
});
}
}
function showErrorModal(errMsg: string, stack: string, clientID: ClientID) {
const errorText = `Error: ${errMsg}\nStack: ${stack}`;
consolex.error(errorText);
const errorText = `Error: ${errMsg}\nStack: ${stack}`;
consolex.error(errorText);
const modal = document.createElement('div');
const content = `Game crashed! client id: ${clientID}\nPlease paste the following in your bug report in Discord:\n${errorText}`;
const modal = document.createElement("div");
const content = `Game crashed! client id: ${clientID}\nPlease paste the following in your bug report in Discord:\n${errorText}`;
// Create elements
const pre = document.createElement('pre');
pre.textContent = content;
// Create elements
const pre = document.createElement("pre");
pre.textContent = content;
const button = document.createElement('button');
button.textContent = 'Copy to clipboard';
button.style.cssText = 'padding: 8px 16px; margin-top: 10px; background: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer;';
button.addEventListener('click', () => {
navigator.clipboard.writeText(content)
.then(() => button.textContent = 'Copied!')
.catch(() => button.textContent = 'Failed to copy');
});
const button = document.createElement("button");
button.textContent = "Copy to clipboard";
button.style.cssText =
"padding: 8px 16px; margin-top: 10px; background: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer;";
button.addEventListener("click", () => {
navigator.clipboard
.writeText(content)
.then(() => (button.textContent = "Copied!"))
.catch(() => (button.textContent = "Failed to copy"));
});
// Add to modal
modal.style.cssText = 'position:fixed; padding:20px; background:white; border:1px solid black; top:50%; left:50%; transform:translate(-50%,-50%); z-index:9999;';
modal.appendChild(pre);
modal.appendChild(button);
// Add to modal
modal.style.cssText =
"position:fixed; padding:20px; background:white; border:1px solid black; top:50%; left:50%; transform:translate(-50%,-50%); z-index:9999;";
modal.appendChild(pre);
modal.appendChild(button);
document.body.appendChild(modal);
}
document.body.appendChild(modal);
}
+118 -78
View File
@@ -1,19 +1,19 @@
import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { Difficulty, GameMapType, GameType } from '../core/game/Game';
import { Lobby } from '../core/Schemas';
import { consolex } from '../core/Consolex';
import { LitElement, html, css } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { Difficulty, GameMapType, GameType } from "../core/game/Game";
import { Lobby } from "../core/Schemas";
import { consolex } from "../core/Consolex";
@customElement('host-lobby-modal')
@customElement("host-lobby-modal")
export class HostLobbyModal extends LitElement {
@state() private isModalOpen = false;
@state() private selectedMap: GameMapType = GameMapType.World;
@state() private selectedDiffculty: Difficulty = Difficulty.Medium;
@state() private lobbyId = '';
@state() private lobbyId = "";
@state() private copySuccess = false;
@state() private players: string[] = []
@state() private players: string[] = [];
private playersInterval = null
private playersInterval = null;
static styles = css`
.modal-overlay {
@@ -101,113 +101,154 @@ export class HostLobbyModal extends LitElement {
render() {
return html`
<div class="modal-overlay" style="display: ${this.isModalOpen ? 'block' : 'none'}">
<div
class="modal-overlay"
style="display: ${this.isModalOpen ? "block" : "none"}"
>
<div class="modal-content">
<span class="close" @click=${this.close}>&times;</span>
<h2>Private Lobby</h2>
<div class="lobby-id-container">
<h3>Lobby ID: ${this.lobbyId}</h3>
<svg @click=${this.copyToClipboard} class="clipboard-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path>
<svg
@click=${this.copyToClipboard}
class="clipboard-icon"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"
></path>
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect>
</svg>
</div>
${this.copySuccess ? html`<p class="copy-success">Copied to clipboard!</p>` : ''}
${this.copySuccess
? html`<p class="copy-success">Copied to clipboard!</p>`
: ""}
<div>
<label for="map-select">Map: </label>
<select id="map-select" @change=${this.handleMapChange}>
${Object.entries(GameMapType)
.filter(([key]) => isNaN(Number(key)))
.map(([key, value]) => html`
<option value=${value} ?selected=${this.selectedMap === value}>
${key}
</option>
`)}
.filter(([key]) => isNaN(Number(key)))
.map(
([key, value]) => html`
<option
value=${value}
?selected=${this.selectedMap === value}
>
${key}
</option>
`,
)}
</select>
</div>
<div>
<label for="map-select">Difficulty: </label>
<select id="map-select" @change=${this.handleDifficultyChange}>
${Object.entries(Difficulty)
.filter(([key]) => isNaN(Number(key)))
.map(([key, value]) => html`
<option value=${value} ?selected=${this.selectedDiffculty === value}>
${key}
</option>
`)}
.filter(([key]) => isNaN(Number(key)))
.map(
([key, value]) => html`
<option
value=${value}
?selected=${this.selectedDiffculty === value}
>
${key}
</option>
`,
)}
</select>
</div>
<button @click=${this.startGame}>Start Game</button>
<div>
<p>Players: ${this.players.join(", ")}<p>
</div>
<div>
<p>Players: ${this.players.join(", ")}</p>
<p></p>
</div>
</div>
</div>
`;
}
public open() {
createLobby().then((lobby) => {
this.lobbyId = lobby.id
// join lobby
}).then(() => {
this.dispatchEvent(new CustomEvent('join-lobby', {
detail: {
gameType: GameType.Private,
lobby: {
id: this.lobbyId,
},
map: this.selectedMap,
difficulty: this.selectedDiffculty,
},
bubbles: true,
composed: true
}));
})
createLobby()
.then((lobby) => {
this.lobbyId = lobby.id;
// join lobby
})
.then(() => {
this.dispatchEvent(
new CustomEvent("join-lobby", {
detail: {
gameType: GameType.Private,
lobby: {
id: this.lobbyId,
},
map: this.selectedMap,
difficulty: this.selectedDiffculty,
},
bubbles: true,
composed: true,
}),
);
});
this.isModalOpen = true;
this.playersInterval = setInterval(() => this.pollPlayers(), 1000)
this.playersInterval = setInterval(() => this.pollPlayers(), 1000);
}
public close() {
this.isModalOpen = false;
this.copySuccess = false;
if (this.playersInterval) {
clearInterval(this.playersInterval)
this.playersInterval = null
clearInterval(this.playersInterval);
this.playersInterval = null;
}
}
private async handleMapChange(e: Event) {
this.selectedMap = String((e.target as HTMLSelectElement).value) as GameMapType;
consolex.log(`updating map to ${this.selectedMap}`)
this.putGameConfig()
this.selectedMap = String(
(e.target as HTMLSelectElement).value,
) as GameMapType;
consolex.log(`updating map to ${this.selectedMap}`);
this.putGameConfig();
}
private async handleDifficultyChange(e: Event) {
this.selectedDiffculty = String((e.target as HTMLSelectElement).value) as Difficulty;
consolex.log(`updating difficulty to ${this.selectedDiffculty}`)
this.putGameConfig()
this.selectedDiffculty = String(
(e.target as HTMLSelectElement).value,
) as Difficulty;
consolex.log(`updating difficulty to ${this.selectedDiffculty}`);
this.putGameConfig();
}
private async putGameConfig() {
const response = await fetch(`/private_lobby/${this.lobbyId}`, {
method: 'PUT',
method: "PUT",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify({ gameMap: this.selectedMap, difficulty: this.selectedDiffculty })
body: JSON.stringify({
gameMap: this.selectedMap,
difficulty: this.selectedDiffculty,
}),
});
}
private async startGame() {
consolex.log(`Starting private game with map: ${GameMapType[this.selectedMap]}`);
consolex.log(
`Starting private game with map: ${GameMapType[this.selectedMap]}`,
);
this.close();
const response = await fetch(`/start_private_lobby/${this.lobbyId}`, {
method: 'POST',
method: "POST",
headers: {
'Content-Type': 'application/json',
}
"Content-Type": "application/json",
},
});
}
@@ -219,31 +260,31 @@ export class HostLobbyModal extends LitElement {
this.copySuccess = false;
}, 2000);
} catch (err) {
consolex.error('Failed to copy text: ', err);
consolex.error("Failed to copy text: ", err);
}
}
private async pollPlayers() {
fetch(`/lobby/${this.lobbyId}`, {
method: 'GET',
method: "GET",
headers: {
'Content-Type': 'application/json',
}
}).then(response => response.json())
.then(data => {
console.log(`got response: ${data}`)
this.players = data.players.map(p => p.username)
})
"Content-Type": "application/json",
},
})
.then((response) => response.json())
.then((data) => {
console.log(`got response: ${data}`);
this.players = data.players.map((p) => p.username);
});
}
}
async function createLobby(): Promise<Lobby> {
try {
const response = await fetch('/private_lobby', {
method: 'POST',
const response = await fetch("/private_lobby", {
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
// body: JSON.stringify(data), // Include this if you need to send data
});
@@ -253,7 +294,7 @@ async function createLobby(): Promise<Lobby> {
}
const data = await response.json();
consolex.log('Success:', data);
consolex.log("Success:", data);
// Assuming the server returns an object with an 'id' property
const lobby: Lobby = {
@@ -263,8 +304,7 @@ async function createLobby(): Promise<Lobby> {
return lobby;
} catch (error) {
consolex.error('Error creating lobby:', error);
consolex.error("Error creating lobby:", error);
throw error; // Re-throw the error so the caller can handle it
}
}
+30 -9
View File
@@ -2,31 +2,46 @@ import { EventBus, GameEvent } from "../core/EventBus";
import { Game } from "../core/game/Game";
export class MouseUpEvent implements GameEvent {
constructor(public readonly x: number, public readonly y: number) {}
constructor(
public readonly x: number,
public readonly y: number,
) {}
}
export class MouseDownEvent implements GameEvent {
constructor(public readonly x: number, public readonly y: number) {}
constructor(
public readonly x: number,
public readonly y: number,
) {}
}
export class MouseMoveEvent implements GameEvent {
constructor(public readonly x: number, public readonly y: number) {}
constructor(
public readonly x: number,
public readonly y: number,
) {}
}
export class ContextMenuEvent implements GameEvent {
constructor(public readonly x: number, public readonly y: number) {}
constructor(
public readonly x: number,
public readonly y: number,
) {}
}
export class ZoomEvent implements GameEvent {
constructor(
public readonly x: number,
public readonly y: number,
public readonly delta: number
public readonly delta: number,
) {}
}
export class DragEvent implements GameEvent {
constructor(public readonly deltaX: number, public readonly deltaY: number) {}
constructor(
public readonly deltaX: number,
public readonly deltaY: number,
) {}
}
export class AlternateViewEvent implements GameEvent {
@@ -36,7 +51,10 @@ export class AlternateViewEvent implements GameEvent {
export class RefreshGraphicsEvent implements GameEvent {}
export class ShowBuildMenuEvent implements GameEvent {
constructor(public readonly x: number, public readonly y: number) {}
constructor(
public readonly x: number,
public readonly y: number,
) {}
}
export class InputHandler {
@@ -54,7 +72,10 @@ export class InputHandler {
private alternateView = false;
constructor(private canvas: HTMLCanvasElement, private eventBus: EventBus) {}
constructor(
private canvas: HTMLCanvasElement,
private eventBus: EventBus,
) {}
initialize() {
this.canvas.addEventListener("pointerdown", (e) => this.onPointerDown(e));
@@ -175,7 +196,7 @@ export class InputHandler {
// Threshold to avoid tiny zoom adjustments
const zoomCenter = this.getPinchCenter();
this.eventBus.emit(
new ZoomEvent(zoomCenter.x, zoomCenter.y, -pinchDelta * 2)
new ZoomEvent(zoomCenter.x, zoomCenter.y, -pinchDelta * 2),
);
this.lastPinchDistance = currentPinchDistance;
}
+87 -77
View File
@@ -1,14 +1,14 @@
import { LitElement, html, css } from 'lit';
import { customElement, property, state, query } from 'lit/decorators.js';
import { GameMapType, GameType } from '../core/game/Game';
import { consolex } from '../core/Consolex';
import { LitElement, html, css } from "lit";
import { customElement, property, state, query } from "lit/decorators.js";
import { GameMapType, GameType } from "../core/game/Game";
import { consolex } from "../core/Consolex";
@customElement('join-private-lobby-modal')
@customElement("join-private-lobby-modal")
export class JoinPrivateLobbyModal extends LitElement {
@state() private isModalOpen = false;
@state() private message: string = '';
@query('#lobbyIdInput') private lobbyIdInput!: HTMLInputElement;
@state() private hasJoined = false
@state() private message: string = "";
@query("#lobbyIdInput") private lobbyIdInput!: HTMLInputElement;
@state() private hasJoined = false;
static styles = css`
.modal-overlay {
@@ -78,51 +78,58 @@ export class JoinPrivateLobbyModal extends LitElement {
margin-top: 10px;
}
.message-area {
margin-top: 10px;
padding: 10px;
border-radius: 4px;
font-size: 14px;
transition: opacity 0.3s ease;
opacity: 0;
height: 0;
overflow: hidden;
}
.message-area {
margin-top: 10px;
padding: 10px;
border-radius: 4px;
font-size: 14px;
transition: opacity 0.3s ease;
opacity: 0;
height: 0;
overflow: hidden;
}
.message-area.show {
opacity: 1;
height: auto;
margin-bottom: 10px;
}
.message-area.show {
opacity: 1;
height: auto;
margin-bottom: 10px;
}
.message-area.error {
background-color: #ffebee;
color: #c62828;
}
.message-area.error {
background-color: #ffebee;
color: #c62828;
}
.message-area.success {
background-color: #e8f5e9;
color: #2e7d32;
}
.message-area.success {
background-color: #e8f5e9;
color: #2e7d32;
}
`;
render() {
return html`
<div class="modal-overlay" style="display: ${this.isModalOpen ? 'block' : 'none'}">
<div class="modal-content">
<span class="close" @click=${this.closeAndLeave}>&times;</span>
<h2>Join Private Lobby</h2>
<div class="lobby-id-container">
<input type="text" id="lobbyIdInput" placeholder="Enter Lobby ID">
<button @click=${this.pasteFromClipboard}>Paste</button>
<div
class="modal-overlay"
style="display: ${this.isModalOpen ? "block" : "none"}"
>
<div class="modal-content">
<span class="close" @click=${this.closeAndLeave}>&times;</span>
<h2>Join Private Lobby</h2>
<div class="lobby-id-container">
<input type="text" id="lobbyIdInput" placeholder="Enter Lobby ID" />
<button @click=${this.pasteFromClipboard}>Paste</button>
</div>
<div class="message-area ${this.message ? "show" : ""}">
${this.message}
</div>
${!this.hasJoined
? html`<button class="join-button" @click=${this.joinLobby}>
Join Lobby
</button>`
: ""}
</div>
<div class="message-area ${this.message ? 'show' : ''}">
${this.message}
</div>
${!this.hasJoined ? html`<button class="join-button" @click=${this.joinLobby}>Join Lobby</button>` : ''}
</div>
</div>
`;
`;
}
public open() {
@@ -131,18 +138,20 @@ export class JoinPrivateLobbyModal extends LitElement {
public close() {
this.isModalOpen = false;
this.lobbyIdInput.value = null
this.lobbyIdInput.value = null;
}
public closeAndLeave() {
this.close()
this.hasJoined = false
this.message = ""
this.dispatchEvent(new CustomEvent('leave-lobby', {
detail: { lobby: this.lobbyIdInput.value },
bubbles: true,
composed: true
}));
this.close();
this.hasJoined = false;
this.message = "";
this.dispatchEvent(
new CustomEvent("leave-lobby", {
detail: { lobby: this.lobbyIdInput.value },
bubbles: true,
composed: true,
}),
);
}
private async pasteFromClipboard() {
@@ -150,43 +159,44 @@ export class JoinPrivateLobbyModal extends LitElement {
const clipText = await navigator.clipboard.readText();
this.lobbyIdInput.value = clipText;
} catch (err) {
consolex.error('Failed to read clipboard contents: ', err);
consolex.error("Failed to read clipboard contents: ", err);
}
}
private joinLobby() {
const lobbyId = this.lobbyIdInput.value;
consolex.log(`Joining lobby with ID: ${lobbyId}`);
this.message = 'Checking lobby...'; // Set initial message
this.message = "Checking lobby..."; // Set initial message
fetch(`/lobby/${lobbyId}/exists`, {
method: 'GET',
method: "GET",
headers: {
'Content-Type': 'application/json',
}
"Content-Type": "application/json",
},
})
.then(response => response.json())
.then(data => {
.then((response) => response.json())
.then((data) => {
if (data.exists) {
this.message = 'Joined successfully! Waiting for game to start...';
this.hasJoined = true
this.dispatchEvent(new CustomEvent('join-lobby', {
detail: {
lobby: { id: lobbyId },
gameType: GameType.Private,
map: GameMapType.World,
},
bubbles: true,
composed: true
}));
this.message = "Joined successfully! Waiting for game to start...";
this.hasJoined = true;
this.dispatchEvent(
new CustomEvent("join-lobby", {
detail: {
lobby: { id: lobbyId },
gameType: GameType.Private,
map: GameMapType.World,
},
bubbles: true,
composed: true,
}),
);
} else {
this.message = 'Lobby not found. Please check the ID and try again.';
this.message = "Lobby not found. Please check the ID and try again.";
}
})
.catch(error => {
consolex.error('Error checking lobby existence:', error);
this.message = 'An error occurred. Please try again.';
.catch((error) => {
consolex.error("Error checking lobby existence:", error);
this.message = "An error occurred. Please try again.";
});
}
}
}
+109 -90
View File
@@ -1,111 +1,130 @@
import { Config, GameEnv, ServerConfig } from "../core/configuration/Config";
import { consolex } from "../core/Consolex";
import { GameEvent } from "../core/EventBus";
import { ClientID, ClientMessage, ClientMessageSchema, GameConfig, GameID, GameRecordSchema, Intent, PlayerRecord, ServerMessage, ServerStartGameMessageSchema, ServerTurnMessageSchema, Turn } from "../core/Schemas";
import {
ClientID,
ClientMessage,
ClientMessageSchema,
GameConfig,
GameID,
GameRecordSchema,
Intent,
PlayerRecord,
ServerMessage,
ServerStartGameMessageSchema,
ServerTurnMessageSchema,
Turn,
} from "../core/Schemas";
import { CreateGameRecord, generateID } from "../core/Util";
import { LobbyConfig } from "./ClientGameRunner";
import { getPersistentIDFromCookie } from "./Main";
export class LocalServer {
private turns: Turn[] = []
private intents: Intent[] = []
private startedAt: number
private turns: Turn[] = [];
private intents: Intent[] = [];
private startedAt: number;
private endTurnIntervalID
private endTurnIntervalID;
private paused = false
private paused = false;
private winner: ClientID | null = null
private winner: ClientID | null = null;
constructor(
private serverConfig: ServerConfig,
private gameConfig: GameConfig,
private lobbyConfig: LobbyConfig,
private clientConnect: () => void,
private clientMessage: (message: ServerMessage) => void,
) {}
constructor(
private serverConfig: ServerConfig,
private gameConfig: GameConfig,
private lobbyConfig: LobbyConfig,
private clientConnect: () => void,
private clientMessage: (message: ServerMessage) => void
) {
}
start() {
this.startedAt = Date.now();
this.endTurnIntervalID = setInterval(
() => this.endTurn(),
this.serverConfig.turnIntervalMs(),
);
this.clientConnect();
this.clientMessage(
ServerStartGameMessageSchema.parse({
type: "start",
config: this.gameConfig,
turns: [],
}),
);
}
start() {
this.startedAt = Date.now()
this.endTurnIntervalID = setInterval(() => this.endTurn(), this.serverConfig.turnIntervalMs());
this.clientConnect()
this.clientMessage(ServerStartGameMessageSchema.parse({
type: "start",
config: this.gameConfig,
turns: [],
}))
}
pause() {
this.paused = true;
}
pause() {
this.paused = true
}
resume() {
this.paused = false;
}
resume() {
this.paused = false
}
onMessage(message: string) {
const clientMsg: ClientMessage = ClientMessageSchema.parse(JSON.parse(message))
if (clientMsg.type == "intent") {
if (this.paused) {
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)
}
return
}
this.intents.push(clientMsg.intent)
}
if (clientMsg.type == "winner") {
this.winner = clientMsg.winner
onMessage(message: string) {
const clientMsg: ClientMessage = ClientMessageSchema.parse(
JSON.parse(message),
);
if (clientMsg.type == "intent") {
if (this.paused) {
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);
}
return;
}
this.intents.push(clientMsg.intent);
}
if (clientMsg.type == "winner") {
this.winner = clientMsg.winner;
}
}
private endTurn() {
if (this.paused) {
return
}
const pastTurn: Turn = {
turnNumber: this.turns.length,
gameID: this.lobbyConfig.gameID,
intents: this.intents
}
this.turns.push(pastTurn)
this.intents = []
this.clientMessage({
type: "turn",
turn: pastTurn
})
private endTurn() {
if (this.paused) {
return;
}
const pastTurn: Turn = {
turnNumber: this.turns.length,
gameID: this.lobbyConfig.gameID,
intents: this.intents,
};
this.turns.push(pastTurn);
this.intents = [];
this.clientMessage({
type: "turn",
turn: pastTurn,
});
}
public endGame() {
consolex.log('local server ending game')
clearInterval(this.endTurnIntervalID)
const players: PlayerRecord[] = [{
ip: null,
persistentID: getPersistentIDFromCookie(),
username: this.lobbyConfig.playerName(),
clientID: this.lobbyConfig.clientID
}]
const record = CreateGameRecord(
this.lobbyConfig.gameID,
this.gameConfig,
players,
this.turns,
this.startedAt,
Date.now(),
this.winner
)
// Clear turns because beacon only supports up to 64kb
record.turns = []
// For unload events, sendBeacon is the only reliable method
const blob = new Blob([JSON.stringify(GameRecordSchema.parse(record))], {
type: 'application/json'
});
navigator.sendBeacon('/archive_singleplayer_game', blob);
}
}
public endGame() {
consolex.log("local server ending game");
clearInterval(this.endTurnIntervalID);
const players: PlayerRecord[] = [
{
ip: null,
persistentID: getPersistentIDFromCookie(),
username: this.lobbyConfig.playerName(),
clientID: this.lobbyConfig.clientID,
},
];
const record = CreateGameRecord(
this.lobbyConfig.gameID,
this.gameConfig,
players,
this.turns,
this.startedAt,
Date.now(),
this.winner,
);
// Clear turns because beacon only supports up to 64kb
record.turns = [];
// For unload events, sendBeacon is the only reliable method
const blob = new Blob([JSON.stringify(GameRecordSchema.parse(record))], {
type: "application/json",
});
navigator.sendBeacon("/archive_singleplayer_game", blob);
}
}
+118 -105
View File
@@ -1,10 +1,10 @@
import { ClientGameRunner, joinLobby } from "./ClientGameRunner";
import backgroundImage from '../../resources/images/EuropeBackground.png';
import favicon from '../../resources/images/Favicon.svg';
import backgroundImage from "../../resources/images/EuropeBackground.png";
import favicon from "../../resources/images/Favicon.svg";
import './PublicLobby';
import './UsernameInput';
import './styles.css';
import "./PublicLobby";
import "./UsernameInput";
import "./styles.css";
import { UsernameInput } from "./UsernameInput";
import { SinglePlayerModal } from "./SinglePlayerModal";
import { HostLobbyModal as HostPrivateLobbyModal } from "./HostLobbyModal";
@@ -14,130 +14,143 @@ import { generateCryptoRandomUUID } from "./Utils";
import { consolex } from "../core/Consolex";
class Client {
private gameStop: () => void
private gameStop: () => void;
private usernameInput: UsernameInput | null = null;
private usernameInput: UsernameInput | null = null;
private joinModal: JoinPrivateLobbyModal
constructor() {
private joinModal: JoinPrivateLobbyModal;
constructor() {}
initialize(): void {
this.usernameInput = document.querySelector(
"username-input",
) as UsernameInput;
if (!this.usernameInput) {
consolex.warn("Username input element not found");
}
window.addEventListener("beforeunload", (event) => {
consolex.log("Browser is closing");
if (this.gameStop != null) {
this.gameStop();
}
});
initialize(): void {
this.usernameInput = document.querySelector('username-input') as UsernameInput;
if (!this.usernameInput) {
consolex.warn('Username input element not found');
setFavicon();
document.addEventListener("join-lobby", this.handleJoinLobby.bind(this));
document.addEventListener("leave-lobby", this.handleLeaveLobby.bind(this));
document.addEventListener(
"single-player",
this.handleSinglePlayer.bind(this),
);
const spModal = document.querySelector(
"single-player-modal",
) as SinglePlayerModal;
spModal instanceof SinglePlayerModal;
document.getElementById("single-player").addEventListener("click", () => {
if (this.usernameInput.isValid()) {
spModal.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();
}
window.addEventListener('beforeunload', (event) => {
consolex.log('Browser is closing');
if (this.gameStop != null) {
this.gameStop()
}
});
});
setFavicon()
document.addEventListener('join-lobby', this.handleJoinLobby.bind(this));
document.addEventListener('leave-lobby', this.handleLeaveLobby.bind(this));
document.addEventListener('single-player', this.handleSinglePlayer.bind(this));
const spModal = document.querySelector('single-player-modal') as SinglePlayerModal;
spModal instanceof SinglePlayerModal
document.getElementById('single-player').addEventListener('click', () => {
if (this.usernameInput.isValid()) {
spModal.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.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();
}
})
}
private async handleJoinLobby(event: CustomEvent) {
const lobby = event.detail.lobby
consolex.log(`joining lobby ${lobby.id}`)
if (this.gameStop != null) {
consolex.log('joining lobby, stopping existing game')
this.gameStop()
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();
}
this.gameStop = joinLobby(
{
gameType: event.detail.gameType,
playerName: (): string => this.usernameInput.getCurrentUsername(),
gameID: lobby.id,
persistentID: getPersistentIDFromCookie(),
playerID: generateID(),
clientID: generateID(),
map: event.detail.map,
difficulty: event.detail.difficulty,
},
() => this.joinModal.close()
);
}
});
}
private async handleLeaveLobby(event: CustomEvent) {
if (this.gameStop == null) {
return
}
consolex.log('leaving lobby, cancelling game')
this.gameStop()
this.gameStop = null
private async handleJoinLobby(event: CustomEvent) {
const lobby = event.detail.lobby;
consolex.log(`joining lobby ${lobby.id}`);
if (this.gameStop != null) {
consolex.log("joining lobby, stopping existing game");
this.gameStop();
}
this.gameStop = joinLobby(
{
gameType: event.detail.gameType,
playerName: (): string => this.usernameInput.getCurrentUsername(),
gameID: lobby.id,
persistentID: getPersistentIDFromCookie(),
playerID: generateID(),
clientID: generateID(),
map: event.detail.map,
difficulty: event.detail.difficulty,
},
() => this.joinModal.close(),
);
}
private async handleSinglePlayer(event: CustomEvent) {
alert('coming soon')
private async handleLeaveLobby(event: CustomEvent) {
if (this.gameStop == null) {
return;
}
consolex.log("leaving lobby, cancelling game");
this.gameStop();
this.gameStop = null;
}
private async handleSinglePlayer(event: CustomEvent) {
alert("coming soon");
}
}
// Initialize the client when the DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
new Client().initialize();
document.addEventListener("DOMContentLoaded", () => {
new Client().initialize();
});
document.body.style.backgroundImage = `url(${backgroundImage})`;
function setFavicon(): void {
const link = document.createElement('link');
link.type = 'image/x-icon';
link.rel = 'shortcut icon';
link.href = favicon;
document.head.appendChild(link);
const link = document.createElement("link");
link.type = "image/x-icon";
link.rel = "shortcut icon";
link.href = favicon;
document.head.appendChild(link);
}
// WARNING: DO NOT EXPOSE THIS ID
export function getPersistentIDFromCookie(): string {
const COOKIE_NAME = 'player_persistent_id'
const COOKIE_NAME = "player_persistent_id";
// Try to get existing cookie
const cookies = document.cookie.split(';')
for (let cookie of cookies) {
const [cookieName, cookieValue] = cookie.split('=').map(c => c.trim())
if (cookieName === COOKIE_NAME) {
return cookieValue
}
// Try to get existing cookie
const cookies = document.cookie.split(";");
for (let cookie of cookies) {
const [cookieName, cookieValue] = cookie.split("=").map((c) => c.trim());
if (cookieName === COOKIE_NAME) {
return cookieValue;
}
}
// If no cookie exists, create new ID and set cookie
const newID = generateCryptoRandomUUID()
document.cookie = [
`${COOKIE_NAME}=${newID}`,
`max-age=${5 * 365 * 24 * 60 * 60}`, // 5 years
'path=/',
'SameSite=Strict',
'Secure'
].join(';')
// If no cookie exists, create new ID and set cookie
const newID = generateCryptoRandomUUID();
document.cookie = [
`${COOKIE_NAME}=${newID}`,
`max-age=${5 * 365 * 24 * 60 * 60}`, // 5 years
"path=/",
"SameSite=Strict",
"Secure",
].join(";");
return newID
}
return newID;
}
+98 -89
View File
@@ -1,100 +1,109 @@
import { LitElement, html } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { Lobby } from "../core/Schemas";
import { Difficulty, GameMapType, GameType } from '../core/game/Game';
import { consolex } from '../core/Consolex';
import { Difficulty, GameMapType, GameType } from "../core/game/Game";
import { consolex } from "../core/Consolex";
@customElement('public-lobby')
@customElement("public-lobby")
export class PublicLobby extends LitElement {
@state() private lobbies: Lobby[] = [];
@state() private isLobbyHighlighted: boolean = false;
private lobbiesInterval: number | null = null;
private currLobby: Lobby = null;
@state() private lobbies: Lobby[] = [];
@state() private isLobbyHighlighted: boolean = false;
private lobbiesInterval: number | null = null;
private currLobby: Lobby = null;
createRenderRoot() {
return this;
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
this.fetchAndUpdateLobbies();
this.lobbiesInterval = window.setInterval(
() => this.fetchAndUpdateLobbies(),
1000,
);
}
disconnectedCallback() {
super.disconnectedCallback();
if (this.lobbiesInterval !== null) {
clearInterval(this.lobbiesInterval);
this.lobbiesInterval = null;
}
}
connectedCallback() {
super.connectedCallback();
this.fetchAndUpdateLobbies();
this.lobbiesInterval = window.setInterval(() => this.fetchAndUpdateLobbies(), 1000);
private async fetchAndUpdateLobbies(): Promise<void> {
try {
const lobbies = await this.fetchLobbies();
this.lobbies = lobbies;
} catch (error) {
consolex.error("Error fetching lobbies:", error);
}
}
disconnectedCallback() {
super.disconnectedCallback();
if (this.lobbiesInterval !== null) {
clearInterval(this.lobbiesInterval);
this.lobbiesInterval = null;
}
async fetchLobbies(): Promise<Lobby[]> {
try {
const response = await fetch("/lobbies");
if (!response.ok)
throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
return data.lobbies;
} catch (error) {
consolex.error("Error fetching lobbies:", error);
throw error;
}
}
private async fetchAndUpdateLobbies(): Promise<void> {
try {
const lobbies = await this.fetchLobbies();
this.lobbies = lobbies;
} catch (error) {
consolex.error('Error fetching lobbies:', error);
}
render() {
if (this.lobbies.length === 0) return html``;
const lobby = this.lobbies[0];
const timeRemaining = Math.max(0, Math.floor(lobby.msUntilStart / 1000));
return html`
<button
@click=${() => this.lobbyClicked(lobby)}
class="w-full mx-auto p-4 md:p-6 ${this.isLobbyHighlighted
? "bg-gradient-to-r from-green-600 to-green-500"
: "bg-gradient-to-r from-blue-600 to-blue-500"} text-white font-medium rounded-xl transition-opacity duration-200 hover:opacity-90"
>
<div class="text-lg md:text-2xl font-semibold mb-2">Next Game</div>
<div
class="flex flex-col gap-1 md:gap-2 text-blue-100 text-s md:text-lg"
>
<div>Starts in: ${timeRemaining}s</div>
<div>Players: ${lobby.numClients}</div>
<div>ID: ${lobby.id}</div>
</div>
</button>
`;
}
private lobbyClicked(lobby: Lobby) {
this.isLobbyHighlighted = !this.isLobbyHighlighted;
if (this.currLobby == null) {
this.currLobby = lobby;
this.dispatchEvent(
new CustomEvent("join-lobby", {
detail: {
lobby,
gameType: GameType.Public,
map: GameMapType.World,
difficulty: Difficulty.Medium,
},
bubbles: true,
composed: true,
}),
);
} else {
this.dispatchEvent(
new CustomEvent("leave-lobby", {
detail: { lobby: this.currLobby },
bubbles: true,
composed: true,
}),
);
this.currLobby = null;
}
async fetchLobbies(): Promise<Lobby[]> {
try {
const response = await fetch('/lobbies');
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
return data.lobbies;
} catch (error) {
consolex.error('Error fetching lobbies:', error);
throw error;
}
}
render() {
if (this.lobbies.length === 0) return html``;
const lobby = this.lobbies[0];
const timeRemaining = Math.max(0, Math.floor(lobby.msUntilStart / 1000));
return html`
<button
@click=${() => this.lobbyClicked(lobby)}
class="w-full mx-auto p-4 md:p-6 ${this.isLobbyHighlighted
? 'bg-gradient-to-r from-green-600 to-green-500'
: 'bg-gradient-to-r from-blue-600 to-blue-500'
} text-white font-medium rounded-xl transition-opacity duration-200 hover:opacity-90"
>
<div class="text-lg md:text-2xl font-semibold mb-2">Next Game</div>
<div class="flex flex-col gap-1 md:gap-2 text-blue-100 text-s md:text-lg">
<div>Starts in: ${timeRemaining}s</div>
<div>Players: ${lobby.numClients}</div>
<div>ID: ${lobby.id}</div>
</div>
</button>
`;
}
private lobbyClicked(lobby: Lobby) {
this.isLobbyHighlighted = !this.isLobbyHighlighted;
if (this.currLobby == null) {
this.currLobby = lobby;
this.dispatchEvent(new CustomEvent('join-lobby', {
detail: {
lobby,
gameType: GameType.Public,
map: GameMapType.World,
difficulty: Difficulty.Medium,
},
bubbles: true,
composed: true
}));
} else {
this.dispatchEvent(new CustomEvent('leave-lobby', {
detail: { lobby: this.currLobby },
bubbles: true,
composed: true
}));
this.currLobby = null;
}
}
}
}
}
+55 -34
View File
@@ -1,10 +1,10 @@
import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { Difficulty, GameMapType, GameType } from '../core/game/Game';
import { generateID as generateID } from '../core/Util';
import { consolex } from '../core/Consolex';
import { LitElement, html, css } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { Difficulty, GameMapType, GameType } from "../core/game/Game";
import { generateID as generateID } from "../core/Util";
import { consolex } from "../core/Consolex";
@customElement('single-player-modal')
@customElement("single-player-modal")
export class SinglePlayerModal extends LitElement {
@state() private isModalOpen = false;
@state() private selectedMap: GameMapType = GameMapType.World;
@@ -74,7 +74,10 @@ export class SinglePlayerModal extends LitElement {
render() {
return html`
<div class="modal-overlay" style="display: ${this.isModalOpen ? 'block' : 'none'}">
<div
class="modal-overlay"
style="display: ${this.isModalOpen ? "block" : "none"}"
>
<div class="modal-content">
<span class="close" @click=${this.close}>&times;</span>
<h2>Start Single Player Game</h2>
@@ -82,24 +85,34 @@ export class SinglePlayerModal extends LitElement {
<label for="map-select">Map: </label>
<select id="map-select" @change=${this.handleMapChange}>
${Object.entries(GameMapType)
.filter(([key]) => isNaN(Number(key)))
.map(([key, value]) => html`
<option value=${value} ?selected=${this.selectedMap === value}>
${value}
</option>
`)}
.filter(([key]) => isNaN(Number(key)))
.map(
([key, value]) => html`
<option
value=${value}
?selected=${this.selectedMap === value}
>
${value}
</option>
`,
)}
</select>
</div>
<div>
<label for="map-select">Difficulty: </label>
<select id="map-select" @change=${this.handleDifficultyChange}>
${Object.entries(Difficulty)
.filter(([key]) => isNaN(Number(key)))
.map(([key, value]) => html`
<option value=${value} ?selected=${this.selectedDifficulty === value}>
${value}
</option>
`)}
.filter(([key]) => isNaN(Number(key)))
.map(
([key, value]) => html`
<option
value=${value}
?selected=${this.selectedDifficulty === value}
>
${value}
</option>
`,
)}
</select>
</div>
@@ -118,25 +131,33 @@ export class SinglePlayerModal extends LitElement {
}
private handleMapChange(e: Event) {
this.selectedMap = String((e.target as HTMLSelectElement).value) as GameMapType;
this.selectedMap = String(
(e.target as HTMLSelectElement).value,
) as GameMapType;
}
private handleDifficultyChange(e: Event) {
this.selectedDifficulty = String((e.target as HTMLSelectElement).value) as Difficulty;
this.selectedDifficulty = String(
(e.target as HTMLSelectElement).value,
) as Difficulty;
}
private startGame() {
consolex.log(`Starting single player game with map: ${GameMapType[this.selectedMap]}`);
this.dispatchEvent(new CustomEvent('join-lobby', {
detail: {
gameType: GameType.Singleplayer,
lobby: {
id: generateID(),
consolex.log(
`Starting single player game with map: ${GameMapType[this.selectedMap]}`,
);
this.dispatchEvent(
new CustomEvent("join-lobby", {
detail: {
gameType: GameType.Singleplayer,
lobby: {
id: generateID(),
},
map: this.selectedMap,
difficulty: this.selectedDifficulty,
},
map: this.selectedMap,
difficulty: this.selectedDifficulty
},
bubbles: true,
composed: true
}));
bubbles: true,
composed: true,
}),
);
this.close();
}
}
}
+438 -381
View File
@@ -1,448 +1,505 @@
import { Config, ServerConfig } from "../core/configuration/Config"
import { SendLogEvent } from "../core/Consolex"
import { EventBus, GameEvent } from "../core/EventBus"
import { AllianceRequest, AllPlayers, Cell, GameType, Player, PlayerID, PlayerType, UnitType } from "../core/game/Game"
import { ClientID, ClientIntentMessageSchema, ClientJoinMessageSchema, GameID, Intent, ServerMessage, ServerMessageSchema, ClientPingMessageSchema, GameConfig, ClientLogMessageSchema, ClientSendWinnerSchema } from "../core/Schemas"
import { LobbyConfig } from "./ClientGameRunner"
import { LocalServer } from "./LocalServer"
import { Config, ServerConfig } from "../core/configuration/Config";
import { SendLogEvent } from "../core/Consolex";
import { EventBus, GameEvent } from "../core/EventBus";
import {
AllianceRequest,
AllPlayers,
Cell,
GameType,
Player,
PlayerID,
PlayerType,
UnitType,
} from "../core/game/Game";
import {
ClientID,
ClientIntentMessageSchema,
ClientJoinMessageSchema,
GameID,
Intent,
ServerMessage,
ServerMessageSchema,
ClientPingMessageSchema,
GameConfig,
ClientLogMessageSchema,
ClientSendWinnerSchema,
} from "../core/Schemas";
import { LobbyConfig } from "./ClientGameRunner";
import { LocalServer } from "./LocalServer";
import { UsernameInput } from "./UsernameInput";
import { HostLobbyModal as HostPrivateLobbyModal } from "./HostLobbyModal";
import { JoinPrivateLobbyModal } from "./JoinPrivateLobbyModal";
import { SinglePlayerModal } from "./SinglePlayerModal";
import { PlayerView } from "../core/game/GameView"
import { PlayerView } from "../core/game/GameView";
export class PauseGameEvent implements GameEvent {
constructor(public readonly paused: boolean) { }
constructor(public readonly paused: boolean) {}
}
export class SendAllianceRequestIntentEvent implements GameEvent {
constructor(
public readonly requestor: PlayerView,
public readonly recipient: PlayerView
) { }
constructor(
public readonly requestor: PlayerView,
public readonly recipient: PlayerView,
) {}
}
export class SendBreakAllianceIntentEvent implements GameEvent {
constructor(
public readonly requestor: PlayerView,
public readonly recipient: PlayerView
) { }
constructor(
public readonly requestor: PlayerView,
public readonly recipient: PlayerView,
) {}
}
export class SendAllianceReplyIntentEvent implements GameEvent {
constructor(
// The original alliance requestor
public readonly requestor: PlayerView,
public readonly recipient: PlayerView,
public readonly accepted: boolean
) { }
constructor(
// The original alliance requestor
public readonly requestor: PlayerView,
public readonly recipient: PlayerView,
public readonly accepted: boolean,
) {}
}
export class SendSpawnIntentEvent implements GameEvent {
constructor(
public readonly cell: Cell,
) { }
constructor(public readonly cell: Cell) {}
}
export class SendAttackIntentEvent implements GameEvent {
constructor(
public readonly targetID: PlayerID,
public readonly troops: number,
) { }
constructor(
public readonly targetID: PlayerID,
public readonly troops: number,
) {}
}
export class SendBoatAttackIntentEvent implements GameEvent {
constructor(
public readonly targetID: PlayerID,
public readonly cell: Cell,
public readonly troops: number
) { }
constructor(
public readonly targetID: PlayerID,
public readonly cell: Cell,
public readonly troops: number,
) {}
}
export class BuildUnitIntentEvent implements GameEvent {
constructor(
public readonly unit: UnitType,
public readonly cell: Cell,
) { }
constructor(
public readonly unit: UnitType,
public readonly cell: Cell,
) {}
}
export class SendTargetPlayerIntentEvent implements GameEvent {
constructor(
public readonly targetID: PlayerID,
) { }
constructor(public readonly targetID: PlayerID) {}
}
export class SendEmojiIntentEvent implements GameEvent {
constructor(
public readonly recipient: PlayerView | typeof AllPlayers,
public readonly emoji: string
) { }
constructor(
public readonly recipient: PlayerView | typeof AllPlayers,
public readonly emoji: string,
) {}
}
export class SendDonateIntentEvent implements GameEvent {
constructor(
public readonly sender: PlayerView,
public readonly recipient: PlayerView,
public readonly troops: number | null,
) { }
constructor(
public readonly sender: PlayerView,
public readonly recipient: PlayerView,
public readonly troops: number | null,
) {}
}
export class SendSetTargetTroopRatioEvent implements GameEvent {
constructor(
public readonly ratio: number,
) { }
constructor(public readonly ratio: number) {}
}
export class SendWinnerEvent implements GameEvent {
constructor(
public readonly winner: ClientID
) { }
constructor(public readonly winner: ClientID) {}
}
export class Transport {
private socket: WebSocket;
private socket: WebSocket
private localServer: LocalServer;
private localServer: LocalServer
private buffer: string[] = [];
private buffer: string[] = []
private onconnect: () => void;
private onmessage: (msg: ServerMessage) => void;
private pingInterval: number | null = null;
private isLocal: boolean;
private onconnect: () => void
private onmessage: (msg: ServerMessage) => void
constructor(
private lobbyConfig: LobbyConfig,
// gameConfig only set on private games
private gameConfig: GameConfig | null,
private eventBus: EventBus,
private serverConfig: ServerConfig,
) {
this.isLocal = lobbyConfig.gameType == GameType.Singleplayer;
this.eventBus.on(SendAllianceRequestIntentEvent, (e) =>
this.onSendAllianceRequest(e),
);
this.eventBus.on(SendAllianceReplyIntentEvent, (e) =>
this.onAllianceRequestReplyUIEvent(e),
);
this.eventBus.on(SendBreakAllianceIntentEvent, (e) =>
this.onBreakAllianceRequestUIEvent(e),
);
this.eventBus.on(SendSpawnIntentEvent, (e) =>
this.onSendSpawnIntentEvent(e),
);
this.eventBus.on(SendAttackIntentEvent, (e) => this.onSendAttackIntent(e));
this.eventBus.on(SendBoatAttackIntentEvent, (e) =>
this.onSendBoatAttackIntent(e),
);
this.eventBus.on(SendTargetPlayerIntentEvent, (e) =>
this.onSendTargetPlayerIntent(e),
);
this.eventBus.on(SendEmojiIntentEvent, (e) => this.onSendEmojiIntent(e));
this.eventBus.on(SendDonateIntentEvent, (e) => this.onSendDonateIntent(e));
this.eventBus.on(SendSetTargetTroopRatioEvent, (e) =>
this.onSendSetTargetTroopRatioEvent(e),
);
this.eventBus.on(BuildUnitIntentEvent, (e) => this.onBuildUnitIntent(e));
private pingInterval: number | null = null
private isLocal: boolean
this.eventBus.on(SendLogEvent, (e) => this.onSendLogEvent(e));
this.eventBus.on(PauseGameEvent, (e) => this.onPauseGameEvent(e));
this.eventBus.on(SendWinnerEvent, (e) => this.onSendWinnerEvent(e));
}
constructor(
private lobbyConfig: LobbyConfig,
// gameConfig only set on private games
private gameConfig: GameConfig | null,
private eventBus: EventBus,
private serverConfig: ServerConfig,
) {
this.isLocal = lobbyConfig.gameType == GameType.Singleplayer
this.eventBus.on(SendAllianceRequestIntentEvent, (e) => this.onSendAllianceRequest(e))
this.eventBus.on(SendAllianceReplyIntentEvent, (e) => this.onAllianceRequestReplyUIEvent(e))
this.eventBus.on(SendBreakAllianceIntentEvent, (e) => this.onBreakAllianceRequestUIEvent(e))
this.eventBus.on(SendSpawnIntentEvent, (e) => this.onSendSpawnIntentEvent(e))
this.eventBus.on(SendAttackIntentEvent, (e) => this.onSendAttackIntent(e))
this.eventBus.on(SendBoatAttackIntentEvent, (e) => this.onSendBoatAttackIntent(e))
this.eventBus.on(SendTargetPlayerIntentEvent, (e) => this.onSendTargetPlayerIntent(e))
this.eventBus.on(SendEmojiIntentEvent, (e) => this.onSendEmojiIntent(e))
this.eventBus.on(SendDonateIntentEvent, (e) => this.onSendDonateIntent(e))
this.eventBus.on(SendSetTargetTroopRatioEvent, (e) => this.onSendSetTargetTroopRatioEvent(e))
this.eventBus.on(BuildUnitIntentEvent, (e) => this.onBuildUnitIntent(e))
this.eventBus.on(SendLogEvent, (e) => this.onSendLogEvent(e))
this.eventBus.on(PauseGameEvent, (e) => this.onPauseGameEvent(e))
this.eventBus.on(SendWinnerEvent, (e) => this.onSendWinnerEvent(e))
}
private startPing() {
if (this.isLocal || this.pingInterval) return;
if (this.pingInterval == null) {
this.pingInterval = window.setInterval(() => {
if (this.socket != null && this.socket.readyState === WebSocket.OPEN) {
this.sendMsg(JSON.stringify(ClientPingMessageSchema.parse({
type: 'ping',
clientID: this.lobbyConfig.clientID,
gameID: this.lobbyConfig.gameID,
})))
}
}, 5 * 1000);
}
}
private stopPing() {
if (this.pingInterval) {
window.clearInterval(this.pingInterval);
this.pingInterval = null;
}
}
public connect(onconnect: () => void, onmessage: (message: ServerMessage) => void) {
if (this.isLocal) {
this.connectLocal(onconnect, onmessage)
} else {
this.connectRemote(onconnect, onmessage)
}
}
private connectLocal(onconnect: () => void, onmessage: (message: ServerMessage) => void) {
this.localServer = new LocalServer(this.serverConfig, this.gameConfig, this.lobbyConfig, onconnect, onmessage)
this.localServer.start()
}
private connectRemote(onconnect: () => void, onmessage: (message: ServerMessage) => void) {
this.startPing()
this.maybeKillSocket()
const wsHost = process.env.WEBSOCKET_URL || window.location.host;
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
this.socket = new WebSocket(`${wsProtocol}//${wsHost}`)
this.onconnect = onconnect
this.onmessage = onmessage
this.socket.onopen = () => {
console.log('Connected to game server!');
while (this.buffer.length > 0) {
console.log('sending dropped message')
this.sendMsg(this.buffer.pop())
}
onconnect()
};
this.socket.onmessage = (event: MessageEvent) => {
try {
const serverMsg = ServerMessageSchema.parse(JSON.parse(event.data));
this.onmessage(serverMsg);
} catch (error) {
console.error('Failed to process server message:', error);
}
};
this.socket.onerror = (err) => {
console.error('Socket encountered error: ', err, 'Closing socket');
this.socket.close();
};
this.socket.onclose = (event: CloseEvent) => {
console.log(`WebSocket closed. Code: ${event.code}, Reason: ${event.reason}`);
if (event.code != 1000) {
console.log(`reconnecting`)
this.connect(onconnect, onmessage)
}
};
}
private onSendLogEvent(event: SendLogEvent) {
this.sendMsg(
private startPing() {
if (this.isLocal || this.pingInterval) return;
if (this.pingInterval == null) {
this.pingInterval = window.setInterval(() => {
if (this.socket != null && this.socket.readyState === WebSocket.OPEN) {
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,
})
)
)
}
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()
})
)
)
}
leaveGame() {
if (this.isLocal) {
this.localServer.endGame()
return
}
this.stopPing()
if (this.socket.readyState === WebSocket.OPEN) {
console.log('on stop: leaving game')
this.socket.close()
} else {
console.log('WebSocket is not open. Current state:', this.socket.readyState);
console.error('attempting reconnect')
}
this.socket.onclose = (event: CloseEvent) => { }
}
private onSendAllianceRequest(event: SendAllianceRequestIntentEvent) {
this.sendIntent({
type: "allianceRequest",
clientID: this.lobbyConfig.clientID,
requestor: event.requestor.id(),
recipient: event.recipient.id(),
})
}
private onAllianceRequestReplyUIEvent(event: SendAllianceReplyIntentEvent) {
this.sendIntent({
type: "allianceRequestReply",
clientID: this.lobbyConfig.clientID,
requestor: event.requestor.id(),
recipient: event.recipient.id(),
accept: event.accepted,
})
}
private onBreakAllianceRequestUIEvent(event: SendBreakAllianceIntentEvent) {
this.sendIntent({
type: "breakAlliance",
clientID: this.lobbyConfig.clientID,
requestor: event.requestor.id(),
recipient: event.recipient.id(),
})
}
private onSendSpawnIntentEvent(event: SendSpawnIntentEvent) {
this.sendIntent({
type: "spawn",
clientID: this.lobbyConfig.clientID,
playerID: this.lobbyConfig.playerID,
name: this.lobbyConfig.playerName(),
playerType: PlayerType.Human,
x: event.cell.x,
y: event.cell.y
})
}
private onSendAttackIntent(event: SendAttackIntentEvent) {
this.sendIntent({
type: "attack",
clientID: this.lobbyConfig.clientID,
attackerID: this.lobbyConfig.playerID,
targetID: event.targetID,
troops: event.troops,
})
}
private onSendBoatAttackIntent(event: SendBoatAttackIntentEvent) {
this.sendIntent({
type: "boat",
clientID: this.lobbyConfig.clientID,
attackerID: this.lobbyConfig.playerID,
targetID: event.targetID,
troops: event.troops,
x: event.cell.x,
y: event.cell.y,
})
}
private onSendTargetPlayerIntent(event: SendTargetPlayerIntentEvent) {
this.sendIntent({
type: "targetPlayer",
clientID: this.lobbyConfig.clientID,
requestor: this.lobbyConfig.playerID,
target: event.targetID,
})
}
private onSendEmojiIntent(event: SendEmojiIntentEvent) {
this.sendIntent({
type: "emoji",
clientID: this.lobbyConfig.clientID,
sender: this.lobbyConfig.playerID,
recipient: event.recipient == AllPlayers ? AllPlayers : event.recipient.id(),
emoji: event.emoji
})
}
private onSendDonateIntent(event: SendDonateIntentEvent) {
this.sendIntent({
type: "donate",
clientID: this.lobbyConfig.clientID,
sender: event.sender.id(),
recipient: event.recipient.id(),
troops: event.troops,
})
}
private onSendSetTargetTroopRatioEvent(event: SendSetTargetTroopRatioEvent) {
this.sendIntent({
type: "troop_ratio",
clientID: this.lobbyConfig.clientID,
player: this.lobbyConfig.playerID,
ratio: event.ratio,
})
}
private onBuildUnitIntent(event: BuildUnitIntentEvent) {
this.sendIntent({
type: "build_unit",
clientID: this.lobbyConfig.clientID,
player: this.lobbyConfig.playerID,
unit: event.unit,
x: event.cell.x,
y: event.cell.y,
})
}
private onPauseGameEvent(event: PauseGameEvent) {
if (!this.isLocal) {
console.log(`cannot pause multiplayer games`)
return
}
if (event.paused) {
this.localServer.pause()
} else {
this.localServer.resume()
}
}
private onSendWinnerEvent(event: SendWinnerEvent) {
if (this.isLocal || this.socket.readyState === WebSocket.OPEN) {
const msg = ClientSendWinnerSchema.parse({
type: "winner",
ClientPingMessageSchema.parse({
type: "ping",
clientID: this.lobbyConfig.clientID,
gameID: this.lobbyConfig.gameID,
winner: event.winner,
})
this.sendMsg(JSON.stringify(msg))
} else {
console.log('WebSocket is not open. Current state:', this.socket.readyState);
console.log('attempting reconnect')
}),
),
);
}
}, 5 * 1000);
}
}
private sendIntent(intent: Intent) {
if (this.isLocal || this.socket.readyState === WebSocket.OPEN) {
const msg = ClientIntentMessageSchema.parse({
type: "intent",
clientID: this.lobbyConfig.clientID,
gameID: this.lobbyConfig.gameID,
intent: intent
})
this.sendMsg(JSON.stringify(msg))
} else {
console.log('WebSocket is not open. Current state:', this.socket.readyState);
console.log('attempting reconnect')
}
private stopPing() {
if (this.pingInterval) {
window.clearInterval(this.pingInterval);
this.pingInterval = null;
}
}
private sendMsg(msg: string) {
if (this.isLocal) {
this.localServer.onMessage(msg)
} else {
if (this.socket.readyState == WebSocket.CLOSED || this.socket.readyState == WebSocket.CLOSED) {
console.warn('socket not ready, closing and trying later')
this.socket.close()
this.socket = null
this.connectRemote(this.onconnect, this.onmessage)
this.buffer.push(msg)
} else {
this.socket.send(msg)
}
}
public connect(
onconnect: () => void,
onmessage: (message: ServerMessage) => void,
) {
if (this.isLocal) {
this.connectLocal(onconnect, onmessage);
} else {
this.connectRemote(onconnect, onmessage);
}
}
private maybeKillSocket(): void {
if (this.socket == null) {
return
}
// Remove all event listeners
this.socket.onmessage = null;
this.socket.onopen = null;
this.socket.onclose = null;
this.socket.onerror = null;
private connectLocal(
onconnect: () => void,
onmessage: (message: ServerMessage) => void,
) {
this.localServer = new LocalServer(
this.serverConfig,
this.gameConfig,
this.lobbyConfig,
onconnect,
onmessage,
);
this.localServer.start();
}
// Close the connection if it's still open
if (this.socket.readyState === WebSocket.OPEN) {
this.socket.close();
}
this.socket = null
private connectRemote(
onconnect: () => void,
onmessage: (message: ServerMessage) => void,
) {
this.startPing();
this.maybeKillSocket();
const wsHost = process.env.WEBSOCKET_URL || window.location.host;
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
this.socket = new WebSocket(`${wsProtocol}//${wsHost}`);
this.onconnect = onconnect;
this.onmessage = onmessage;
this.socket.onopen = () => {
console.log("Connected to game server!");
while (this.buffer.length > 0) {
console.log("sending dropped message");
this.sendMsg(this.buffer.pop());
}
onconnect();
};
this.socket.onmessage = (event: MessageEvent) => {
try {
const serverMsg = ServerMessageSchema.parse(JSON.parse(event.data));
this.onmessage(serverMsg);
} catch (error) {
console.error("Failed to process server message:", error);
}
};
this.socket.onerror = (err) => {
console.error("Socket encountered error: ", err, "Closing socket");
this.socket.close();
};
this.socket.onclose = (event: CloseEvent) => {
console.log(
`WebSocket closed. Code: ${event.code}, Reason: ${event.reason}`,
);
if (event.code != 1000) {
console.log(`reconnecting`);
this.connect(onconnect, onmessage);
}
};
}
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,
}),
),
);
}
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(),
}),
),
);
}
leaveGame() {
if (this.isLocal) {
this.localServer.endGame();
return;
}
this.stopPing();
if (this.socket.readyState === WebSocket.OPEN) {
console.log("on stop: leaving game");
this.socket.close();
} else {
console.log(
"WebSocket is not open. Current state:",
this.socket.readyState,
);
console.error("attempting reconnect");
}
this.socket.onclose = (event: CloseEvent) => {};
}
private onSendAllianceRequest(event: SendAllianceRequestIntentEvent) {
this.sendIntent({
type: "allianceRequest",
clientID: this.lobbyConfig.clientID,
requestor: event.requestor.id(),
recipient: event.recipient.id(),
});
}
private onAllianceRequestReplyUIEvent(event: SendAllianceReplyIntentEvent) {
this.sendIntent({
type: "allianceRequestReply",
clientID: this.lobbyConfig.clientID,
requestor: event.requestor.id(),
recipient: event.recipient.id(),
accept: event.accepted,
});
}
private onBreakAllianceRequestUIEvent(event: SendBreakAllianceIntentEvent) {
this.sendIntent({
type: "breakAlliance",
clientID: this.lobbyConfig.clientID,
requestor: event.requestor.id(),
recipient: event.recipient.id(),
});
}
private onSendSpawnIntentEvent(event: SendSpawnIntentEvent) {
this.sendIntent({
type: "spawn",
clientID: this.lobbyConfig.clientID,
playerID: this.lobbyConfig.playerID,
name: this.lobbyConfig.playerName(),
playerType: PlayerType.Human,
x: event.cell.x,
y: event.cell.y,
});
}
private onSendAttackIntent(event: SendAttackIntentEvent) {
this.sendIntent({
type: "attack",
clientID: this.lobbyConfig.clientID,
attackerID: this.lobbyConfig.playerID,
targetID: event.targetID,
troops: event.troops,
});
}
private onSendBoatAttackIntent(event: SendBoatAttackIntentEvent) {
this.sendIntent({
type: "boat",
clientID: this.lobbyConfig.clientID,
attackerID: this.lobbyConfig.playerID,
targetID: event.targetID,
troops: event.troops,
x: event.cell.x,
y: event.cell.y,
});
}
private onSendTargetPlayerIntent(event: SendTargetPlayerIntentEvent) {
this.sendIntent({
type: "targetPlayer",
clientID: this.lobbyConfig.clientID,
requestor: this.lobbyConfig.playerID,
target: event.targetID,
});
}
private onSendEmojiIntent(event: SendEmojiIntentEvent) {
this.sendIntent({
type: "emoji",
clientID: this.lobbyConfig.clientID,
sender: this.lobbyConfig.playerID,
recipient:
event.recipient == AllPlayers ? AllPlayers : event.recipient.id(),
emoji: event.emoji,
});
}
private onSendDonateIntent(event: SendDonateIntentEvent) {
this.sendIntent({
type: "donate",
clientID: this.lobbyConfig.clientID,
sender: event.sender.id(),
recipient: event.recipient.id(),
troops: event.troops,
});
}
private onSendSetTargetTroopRatioEvent(event: SendSetTargetTroopRatioEvent) {
this.sendIntent({
type: "troop_ratio",
clientID: this.lobbyConfig.clientID,
player: this.lobbyConfig.playerID,
ratio: event.ratio,
});
}
private onBuildUnitIntent(event: BuildUnitIntentEvent) {
this.sendIntent({
type: "build_unit",
clientID: this.lobbyConfig.clientID,
player: this.lobbyConfig.playerID,
unit: event.unit,
x: event.cell.x,
y: event.cell.y,
});
}
private onPauseGameEvent(event: PauseGameEvent) {
if (!this.isLocal) {
console.log(`cannot pause multiplayer games`);
return;
}
if (event.paused) {
this.localServer.pause();
} else {
this.localServer.resume();
}
}
private onSendWinnerEvent(event: SendWinnerEvent) {
if (this.isLocal || this.socket.readyState === WebSocket.OPEN) {
const msg = ClientSendWinnerSchema.parse({
type: "winner",
clientID: this.lobbyConfig.clientID,
gameID: this.lobbyConfig.gameID,
winner: event.winner,
});
this.sendMsg(JSON.stringify(msg));
} else {
console.log(
"WebSocket is not open. Current state:",
this.socket.readyState,
);
console.log("attempting reconnect");
}
}
private sendIntent(intent: Intent) {
if (this.isLocal || this.socket.readyState === WebSocket.OPEN) {
const msg = ClientIntentMessageSchema.parse({
type: "intent",
clientID: this.lobbyConfig.clientID,
gameID: this.lobbyConfig.gameID,
intent: intent,
});
this.sendMsg(JSON.stringify(msg));
} else {
console.log(
"WebSocket is not open. Current state:",
this.socket.readyState,
);
console.log("attempting reconnect");
}
}
private sendMsg(msg: string) {
if (this.isLocal) {
this.localServer.onMessage(msg);
} else {
if (
this.socket.readyState == WebSocket.CLOSED ||
this.socket.readyState == WebSocket.CLOSED
) {
console.warn("socket not ready, closing and trying later");
this.socket.close();
this.socket = null;
this.connectRemote(this.onconnect, this.onmessage);
this.buffer.push(msg);
} else {
this.socket.send(msg);
}
}
}
private maybeKillSocket(): void {
if (this.socket == null) {
return;
}
// Remove all event listeners
this.socket.onmessage = null;
this.socket.onopen = null;
this.socket.onclose = null;
this.socket.onerror = null;
// Close the connection if it's still open
if (this.socket.readyState === WebSocket.OPEN) {
this.socket.close();
}
this.socket = null;
}
}
+95 -86
View File
@@ -1,100 +1,109 @@
import { LitElement, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { v4 as uuidv4 } from 'uuid';
import { MAX_USERNAME_LENGTH, validateUsername } from '../core/validations/username';
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { v4 as uuidv4 } from "uuid";
import {
MAX_USERNAME_LENGTH,
validateUsername,
} from "../core/validations/username";
const usernameKey: string = 'username';
const usernameKey: string = "username";
@customElement('username-input')
@customElement("username-input")
export class UsernameInput extends LitElement {
@state() private username: string = '';
@property({ type: String }) validationError: string = '';
private _isValid: boolean = true;
@state() private username: string = "";
@property({ type: String }) validationError: string = "";
private _isValid: boolean = true;
// Remove static styles since we're using Tailwind
// Remove static styles since we're using Tailwind
createRenderRoot() {
// Disable shadow DOM to allow Tailwind classes to work
return this;
createRenderRoot() {
// Disable shadow DOM to allow Tailwind classes to work
return this;
}
public getCurrentUsername(): string {
return this.username;
}
connectedCallback() {
super.connectedCallback();
this.username = this.getStoredUsername();
this.dispatchUsernameEvent();
}
render() {
return html`
<input
type="text"
.value=${this.username}
@input=${this.handleChange}
@change=${this.handleChange}
placeholder="Enter your username"
maxlength="${MAX_USERNAME_LENGTH}"
class="w-full px-4 py-2 bg-white border border-gray-300 rounded-xl shadow-sm text-2xl text-gray-900 text-center focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
${this.validationError
? html`<div
class="mt-2 px-3 py-1 text-lg text-red-600 bg-white border border-red-600 rounded"
>
${this.validationError}
</div>`
: null}
`;
}
private handleChange(e: Event) {
const input = e.target as HTMLInputElement;
this.username = input.value.trim();
const result = validateUsername(this.username);
this._isValid = result.isValid;
if (result.isValid) {
this.storeUsername(this.username);
this.validationError = "";
} else {
this.validationError = result.error;
}
}
public getCurrentUsername(): string {
return this.username;
private getStoredUsername(): string {
const storedUsername = localStorage.getItem(usernameKey);
if (storedUsername) {
return storedUsername;
}
return this.generateNewUsername();
}
connectedCallback() {
super.connectedCallback();
this.username = this.getStoredUsername();
this.dispatchUsernameEvent();
private storeUsername(username: string) {
if (username) {
localStorage.setItem(usernameKey, username);
}
}
render() {
return html`
<input
type="text"
.value=${this.username}
@input=${this.handleChange}
@change=${this.handleChange}
placeholder="Enter your username"
maxlength="${MAX_USERNAME_LENGTH}"
class="w-full px-4 py-2 bg-white border border-gray-300 rounded-xl shadow-sm text-2xl text-gray-900 text-center focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
${this.validationError
? html`<div class="mt-2 px-3 py-1 text-lg text-red-600 bg-white border border-red-600 rounded">${this.validationError}</div>`
: null}
`;
}
private dispatchUsernameEvent() {
this.dispatchEvent(
new CustomEvent("username-change", {
detail: { username: this.username },
bubbles: true,
composed: true,
}),
);
}
private handleChange(e: Event) {
const input = e.target as HTMLInputElement;
this.username = input.value.trim();
const result = validateUsername(this.username)
this._isValid = result.isValid
if (result.isValid) {
this.storeUsername(this.username);
this.validationError = ''
} else {
this.validationError = result.error
}
}
private generateNewUsername(): string {
const newUsername = "Anon" + this.uuidToThreeDigits();
this.storeUsername(newUsername);
return newUsername;
}
private getStoredUsername(): string {
const storedUsername = localStorage.getItem(usernameKey);
if (storedUsername) {
return storedUsername;
}
return this.generateNewUsername();
}
private uuidToThreeDigits(): string {
const uuid = uuidv4();
const cleanUuid = uuid.replace(/-/g, "").toLowerCase();
const decimal = BigInt(`0x${cleanUuid}`);
const threeDigits = decimal % 1000n;
return threeDigits.toString().padStart(3, "0");
}
private storeUsername(username: string) {
if (username) {
localStorage.setItem(usernameKey, username);
}
}
private dispatchUsernameEvent() {
this.dispatchEvent(new CustomEvent('username-change', {
detail: { username: this.username },
bubbles: true,
composed: true
}));
}
private generateNewUsername(): string {
const newUsername = "Anon" + this.uuidToThreeDigits();
this.storeUsername(newUsername);
return newUsername;
}
private uuidToThreeDigits(): string {
const uuid = uuidv4();
const cleanUuid = uuid.replace(/-/g, '').toLowerCase();
const decimal = BigInt(`0x${cleanUuid}`);
const threeDigits = decimal % 1000n;
return threeDigits.toString().padStart(3, '0');
}
public isValid(): boolean {
return this._isValid;
}
}
public isValid(): boolean {
return this._isValid;
}
}
+51 -49
View File
@@ -1,66 +1,68 @@
export function renderTroops(troops: number): string {
return renderNumber(troops / 10)
return renderNumber(troops / 10);
}
export function renderNumber(num: number) {
let numStr = ''
if (num >= 10_000_000) {
numStr = (num / 1000000).toFixed(1) + "M"
} else if (num >= 1_000_000) {
numStr = (num / 1000000).toFixed(2) + "M"
} else if (num >= 100000) {
numStr = Math.floor(num / 1000) + "K"
} else if (num >= 10000) {
numStr = (num / 1000).toFixed(1) + "K"
} else if (num >= 1000) {
numStr = (num / 1000).toFixed(2) + "K"
} else {
numStr = Math.floor(num).toString()
}
return numStr
let numStr = "";
if (num >= 10_000_000) {
numStr = (num / 1000000).toFixed(1) + "M";
} else if (num >= 1_000_000) {
numStr = (num / 1000000).toFixed(2) + "M";
} else if (num >= 100000) {
numStr = Math.floor(num / 1000) + "K";
} else if (num >= 10000) {
numStr = (num / 1000).toFixed(1) + "K";
} else if (num >= 1000) {
numStr = (num / 1000).toFixed(2) + "K";
} else {
numStr = Math.floor(num).toString();
}
return numStr;
}
export function createCanvas(): HTMLCanvasElement {
const canvas = document.createElement('canvas');
const canvas = document.createElement("canvas");
// Set canvas style to fill the screen
canvas.style.position = 'fixed';
canvas.style.left = '0';
canvas.style.top = '0';
canvas.style.width = '100%';
canvas.style.height = '100%';
canvas.style.touchAction = 'none';
// Set canvas style to fill the screen
canvas.style.position = "fixed";
canvas.style.left = "0";
canvas.style.top = "0";
canvas.style.width = "100%";
canvas.style.height = "100%";
canvas.style.touchAction = "none";
return canvas
return canvas;
}
/**
* A polyfill for crypto.randomUUID that provides fallback implementations
* for older browsers, particularly Safari versions < 15.4
*/
export function generateCryptoRandomUUID(): string {
// Type guard to check if randomUUID is available
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID();
}
// Type guard to check if randomUUID is available
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
// Fallback using crypto.getRandomValues
if (typeof crypto !== 'undefined' && 'getRandomValues' in crypto) {
return ([1e7] as any + -1e3 + -4e3 + -8e3 + -1e11).replace(
/[018]/g,
(c: number): string =>
(c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4)).toString(16)
);
}
// Last resort fallback using Math.random
// Note: This is less cryptographically secure but ensures functionality
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(
/[xy]/g,
(c: string): string => {
const r: number = Math.random() * 16 | 0;
const v: number = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
}
// Fallback using crypto.getRandomValues
if (typeof crypto !== "undefined" && "getRandomValues" in crypto) {
return (([1e7] as any) + -1e3 + -4e3 + -8e3 + -1e11).replace(
/[018]/g,
(c: number): string =>
(
c ^
(crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))
).toString(16),
);
}
}
// Last resort fallback using Math.random
// Note: This is less cryptographically secure but ensures functionality
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
/[xy]/g,
(c: string): string => {
const r: number = (Math.random() * 16) | 0;
const v: number = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
},
);
}
+8 -8
View File
@@ -28,7 +28,7 @@ export function createRenderer(
canvas: HTMLCanvasElement,
game: GameView,
eventBus: EventBus,
clientID: ClientID
clientID: ClientID,
): GameRenderer {
const transformHandler = new TransformHandler(game, eventBus, canvas);
@@ -64,7 +64,7 @@ export function createRenderer(
controlPanel.game = game;
const eventsDisplay = document.querySelector(
"events-display"
"events-display",
) as EventsDisplay;
if (!(eventsDisplay instanceof EventsDisplay)) {
consolex.error("events display not found");
@@ -74,7 +74,7 @@ export function createRenderer(
eventsDisplay.clientID = clientID;
const playerInfo = document.querySelector(
"player-info-overlay"
"player-info-overlay",
) as PlayerInfoOverlay;
if (!(playerInfo instanceof PlayerInfoOverlay)) {
consolex.error("player info overlay not found");
@@ -119,7 +119,7 @@ export function createRenderer(
emojiTable as EmojiTable,
buildMenu,
uiState,
playerInfo
playerInfo,
),
new SpawnTimer(game, transformHandler),
leaderboard,
@@ -136,7 +136,7 @@ export function createRenderer(
canvas,
transformHandler,
uiState,
layers
layers,
);
}
@@ -149,7 +149,7 @@ export class GameRenderer {
private canvas: HTMLCanvasElement,
public transformHandler: TransformHandler,
public uiState: UIState,
private layers: Layer[]
private layers: Layer[],
) {
this.context = canvas.getContext("2d");
}
@@ -172,7 +172,7 @@ export class GameRenderer {
this.transformHandler = new TransformHandler(
this.game,
this.eventBus,
this.canvas
this.canvas,
);
requestAnimationFrame(() => this.renderGame());
@@ -218,7 +218,7 @@ export class GameRenderer {
const duration = performance.now() - start;
if (duration > 50) {
console.warn(
`tick ${this.game.ticks()} took ${duration}ms to render frame`
`tick ${this.game.ticks()} took ${duration}ms to render frame`,
);
}
}
+135 -123
View File
@@ -1,158 +1,170 @@
import { Game, Player, Cell } from '../../core/game/Game';
import { NameViewData } from '../../core/game/Game';
import { calculateBoundingBox, within } from '../../core/Util';
import { Game, Player, Cell } from "../../core/game/Game";
import { NameViewData } from "../../core/game/Game";
import { calculateBoundingBox, within } from "../../core/Util";
export interface Point {
x: number;
y: number;
x: number;
y: number;
}
export interface Rectangle {
x: number;
y: number;
width: number;
height: number;
x: number;
y: number;
width: number;
height: number;
}
export function placeName(game: Game, player: Player): NameViewData {
const boundingBox =
player.largestClusterBoundingBox ??
calculateBoundingBox(game, player.borderTiles());
const boundingBox = player.largestClusterBoundingBox ?? calculateBoundingBox(game, player.borderTiles());
let scalingFactor = 1;
const width = boundingBox.max.x - boundingBox.min.x;
const height = boundingBox.max.y - boundingBox.min.y;
const size = Math.min(width, height);
if (size < 25) {
scalingFactor = 1;
} else if (size < 50) {
scalingFactor = 2;
} else if (size < 100) {
scalingFactor = 4;
} else if (size < 250) {
scalingFactor = 8;
} else if (size < 500) {
scalingFactor = 16;
} else {
scalingFactor = 32;
}
let scalingFactor = 1
const width = boundingBox.max.x - boundingBox.min.x
const height = boundingBox.max.y - boundingBox.min.y
const size = Math.min(width, height)
if (size < 25) {
scalingFactor = 1
} else if (size < 50) {
scalingFactor = 2
} else if (size < 100) {
scalingFactor = 4
} else if (size < 250) {
scalingFactor = 8
} else if (size < 500) {
scalingFactor = 16
} else {
scalingFactor = 32
}
const grid = createGrid(game, player, boundingBox, scalingFactor);
const largestRectangle = findLargestInscribedRectangle(grid);
largestRectangle.x = largestRectangle.x * scalingFactor;
largestRectangle.y = largestRectangle.y * scalingFactor;
largestRectangle.width = largestRectangle.width * scalingFactor;
largestRectangle.height = largestRectangle.height * scalingFactor;
const grid = createGrid(game, player, boundingBox, scalingFactor);
const largestRectangle = findLargestInscribedRectangle(grid);
largestRectangle.x = largestRectangle.x * scalingFactor
largestRectangle.y = largestRectangle.y * scalingFactor
largestRectangle.width = largestRectangle.width * scalingFactor
largestRectangle.height = largestRectangle.height * scalingFactor
let center = new Cell(
Math.floor(
largestRectangle.x + largestRectangle.width / 2 + boundingBox.min.x,
),
Math.floor(
largestRectangle.y + largestRectangle.height / 2 + boundingBox.min.y,
),
);
let center = new Cell(
Math.floor(largestRectangle.x + largestRectangle.width / 2 + boundingBox.min.x),
Math.floor(largestRectangle.y + largestRectangle.height / 2 + boundingBox.min.y),
)
const fontSize = calculateFontSize(largestRectangle, player.name());
center = new Cell(center.x, center.y - fontSize / 3);
const fontSize = calculateFontSize(largestRectangle, player.name());
center = new Cell(center.x, center.y - fontSize / 3)
return {
x: Math.ceil(center.x),
y: Math.ceil(center.y),
size: fontSize,
}
return {
x: Math.ceil(center.x),
y: Math.ceil(center.y),
size: fontSize,
};
}
export function createGrid(game: Game, player: Player, boundingBox: { min: Point; max: Point }, scalingFactor: number): boolean[][] {
const scaledBoundingBox: { min: Point; max: Point } = {
min: {
x: Math.floor(boundingBox.min.x / scalingFactor),
y: Math.floor(boundingBox.min.y / scalingFactor)
},
max: {
x: Math.floor(boundingBox.max.x / scalingFactor),
y: Math.floor(boundingBox.max.y / scalingFactor)
}
export function createGrid(
game: Game,
player: Player,
boundingBox: { min: Point; max: Point },
scalingFactor: number,
): boolean[][] {
const scaledBoundingBox: { min: Point; max: Point } = {
min: {
x: Math.floor(boundingBox.min.x / scalingFactor),
y: Math.floor(boundingBox.min.y / scalingFactor),
},
max: {
x: Math.floor(boundingBox.max.x / scalingFactor),
y: Math.floor(boundingBox.max.y / scalingFactor),
},
};
const width = scaledBoundingBox.max.x - scaledBoundingBox.min.x + 1;
const height = scaledBoundingBox.max.y - scaledBoundingBox.min.y + 1;
const grid: boolean[][] = Array(width)
.fill(null)
.map(() => Array(height).fill(false));
for (let x = scaledBoundingBox.min.x; x <= scaledBoundingBox.max.x; x++) {
for (let y = scaledBoundingBox.min.y; y <= scaledBoundingBox.max.y; y++) {
const cell = new Cell(x * scalingFactor, y * scalingFactor);
if (game.isOnMap(cell)) {
const tile = game.ref(cell.x, cell.y);
grid[x - scaledBoundingBox.min.x][y - scaledBoundingBox.min.y] =
game.isLake(tile) || game.owner(tile) === player; // TODO: okay if lake
}
}
}
const width = scaledBoundingBox.max.x - scaledBoundingBox.min.x + 1;
const height = scaledBoundingBox.max.y - scaledBoundingBox.min.y + 1;
const grid: boolean[][] = Array(width).fill(null).map(() => Array(height).fill(false));
for (let x = scaledBoundingBox.min.x; x <= scaledBoundingBox.max.x; x++) {
for (let y = scaledBoundingBox.min.y; y <= scaledBoundingBox.max.y; y++) {
const cell = new Cell(x * scalingFactor, y * scalingFactor);
if (game.isOnMap(cell)) {
const tile = game.ref(cell.x, cell.y);
grid[x - scaledBoundingBox.min.x][y - scaledBoundingBox.min.y] = game.isLake(tile) || game.owner(tile) === player; // TODO: okay if lake
}
}
}
return grid;
return grid;
}
export function findLargestInscribedRectangle(grid: boolean[][]): Rectangle {
const rows = grid[0].length;
const cols = grid.length;
const heights: number[] = new Array(cols).fill(0);
let largestRect: Rectangle = { x: 0, y: 0, width: 0, height: 0 };
const rows = grid[0].length;
const cols = grid.length;
const heights: number[] = new Array(cols).fill(0);
let largestRect: Rectangle = { x: 0, y: 0, width: 0, height: 0 };
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
if (grid[col][row]) {
heights[col]++;
} else {
heights[col] = 0;
}
}
const rectForRow = largestRectangleInHistogram(heights);
if (rectForRow.width * rectForRow.height > largestRect.width * largestRect.height) {
largestRect = {
x: rectForRow.x,
y: row - rectForRow.height + 1,
width: rectForRow.width,
height: rectForRow.height
};
}
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
if (grid[col][row]) {
heights[col]++;
} else {
heights[col] = 0;
}
}
return largestRect;
const rectForRow = largestRectangleInHistogram(heights);
if (
rectForRow.width * rectForRow.height >
largestRect.width * largestRect.height
) {
largestRect = {
x: rectForRow.x,
y: row - rectForRow.height + 1,
width: rectForRow.width,
height: rectForRow.height,
};
}
}
return largestRect;
}
export function largestRectangleInHistogram(widths: number[]): Rectangle {
const stack: number[] = [];
let maxArea = 0;
let largestRect: Rectangle = { x: 0, y: 0, width: 0, height: 0 };
const stack: number[] = [];
let maxArea = 0;
let largestRect: Rectangle = { x: 0, y: 0, width: 0, height: 0 };
for (let i = 0; i <= widths.length; i++) {
const h = i === widths.length ? 0 : widths[i];
for (let i = 0; i <= widths.length; i++) {
const h = i === widths.length ? 0 : widths[i];
while (stack.length > 0 && h < widths[stack[stack.length - 1]]) {
const height = widths[stack.pop()!];
const width = stack.length === 0 ? i : i - stack[stack.length - 1] - 1;
while (stack.length > 0 && h < widths[stack[stack.length - 1]]) {
const height = widths[stack.pop()!];
const width = stack.length === 0 ? i : i - stack[stack.length - 1] - 1;
if (height * width > maxArea) {
maxArea = height * width;
largestRect = {
x: stack.length === 0 ? 0 : stack[stack.length - 1] + 1,
y: 0,
width: width,
height: height
};
}
}
stack.push(i);
if (height * width > maxArea) {
maxArea = height * width;
largestRect = {
x: stack.length === 0 ? 0 : stack[stack.length - 1] + 1,
y: 0,
width: width,
height: height,
};
}
}
return largestRect;
stack.push(i);
}
return largestRect;
}
export function calculateFontSize(rectangle: Rectangle, name: string): number {
// This is a simplified calculation. You might want to adjust it based on your specific font and rendering system.
const widthConstrained = rectangle.width / name.length * 2;
const heightConstrained = rectangle.height / 3;
return Math.min(widthConstrained, heightConstrained);
// This is a simplified calculation. You might want to adjust it based on your specific font and rendering system.
const widthConstrained = (rectangle.width / name.length) * 2;
const heightConstrained = rectangle.height / 3;
return Math.min(widthConstrained, heightConstrained);
}
+210 -183
View File
@@ -1,198 +1,225 @@
import { colord } from "colord";
import { EventBus } from "../../core/EventBus"
import { EventBus } from "../../core/EventBus";
import { Cell, Game, Player } from "../../core/game/Game";
import { calculateBoundingBox, calculateBoundingBoxCenter } from "../../core/Util";
import {
calculateBoundingBox,
calculateBoundingBoxCenter,
} from "../../core/Util";
import { ZoomEvent, DragEvent } from "../InputHandler";
import { GoToPlayerEvent } from "./layers/Leaderboard";
import { placeName } from "./NameBoxCalculator";
import { GameView } from "../../core/game/GameView";
export class TransformHandler {
public scale: number = 1.8
private offsetX: number = -350
private offsetY: number = -200
public scale: number = 1.8;
private offsetX: number = -350;
private offsetY: number = -200;
private target: Cell
private intervalID = null
private changed = false
private target: Cell;
private intervalID = null;
private changed = false;
constructor(private game: GameView, private eventBus: EventBus, private canvas: HTMLCanvasElement) {
this.eventBus.on(ZoomEvent, (e) => this.onZoom(e))
this.eventBus.on(DragEvent, (e) => this.onMove(e))
this.eventBus.on(GoToPlayerEvent, (e) => this.onGoToPlayer(e))
constructor(
private game: GameView,
private eventBus: EventBus,
private canvas: HTMLCanvasElement,
) {
this.eventBus.on(ZoomEvent, (e) => this.onZoom(e));
this.eventBus.on(DragEvent, (e) => this.onMove(e));
this.eventBus.on(GoToPlayerEvent, (e) => this.onGoToPlayer(e));
}
boundingRect(): DOMRect {
return this.canvas.getBoundingClientRect();
}
width(): number {
return this.boundingRect().width;
}
hasChanged(): boolean {
return this.changed;
}
handleTransform(context: CanvasRenderingContext2D) {
// Disable image smoothing for pixelated effect
context.imageSmoothingEnabled = false;
// Apply zoom and pan
context.setTransform(
this.scale,
0,
0,
this.scale,
this.game.width() / 2 - this.offsetX * this.scale,
this.game.height() / 2 - this.offsetY * this.scale,
);
this.changed = false;
}
worldToScreenCoordinates(cell: Cell): { x: number; y: number } {
// Step 1: Convert from Cell coordinates to game coordinates
// (reverse of Math.floor operation - we'll use the exact values)
const gameX = cell.x;
const gameY = cell.y;
// Step 2: Reverse the game center offset calculation
// Original: gameX = centerX + this.game.width() / 2
// Therefore: centerX = gameX - this.game.width() / 2
const centerX = gameX - this.game.width() / 2;
const centerY = gameY - this.game.height() / 2;
// Step 3: Reverse the world point calculation
// Original: centerX = (canvasX - this.game.width() / 2) / this.scale + this.offsetX
// Therefore: canvasX = (centerX - this.offsetX) * this.scale + this.game.width() / 2
const canvasX =
(centerX - this.offsetX) * this.scale + this.game.width() / 2;
const canvasY =
(centerY - this.offsetY) * this.scale + this.game.height() / 2;
// Step 4: Convert canvas coordinates back to screen coordinates
const canvasRect = this.boundingRect();
const screenX = canvasX + canvasRect.left;
const screenY = canvasY + canvasRect.top;
return { x: screenX, y: screenY };
}
screenToWorldCoordinates(screenX: number, screenY: number): Cell {
const canvasRect = this.boundingRect();
const canvasX = screenX - canvasRect.left;
const canvasY = screenY - canvasRect.top;
// Calculate the world point we want to zoom towards
const centerX =
(canvasX - this.game.width() / 2) / this.scale + this.offsetX;
const centerY =
(canvasY - this.game.height() / 2) / this.scale + this.offsetY;
const gameX = centerX + this.game.width() / 2;
const gameY = centerY + this.game.height() / 2;
return new Cell(Math.floor(gameX), Math.floor(gameY));
}
screenBoundingRect(): [Cell, Cell] {
const LeftX = -this.game.width() / 2 / this.scale + this.offsetX;
const TopY = -this.game.height() / 2 / this.scale + this.offsetY;
const gameLeftX = LeftX + this.game.width() / 2;
const gameTopY = TopY + this.game.height() / 2;
const rightX =
(screen.width - this.game.width() / 2) / this.scale + this.offsetX;
const rightY =
(screen.height - this.game.height() / 2) / this.scale + this.offsetY;
const gameRightX = rightX + this.game.width() / 2;
const gameBottomY = rightY + this.game.height() / 2;
return [
new Cell(Math.floor(gameLeftX), Math.floor(gameTopY)),
new Cell(Math.floor(gameRightX), Math.floor(gameBottomY)),
];
}
isOnScreen(cell: Cell): boolean {
const [topLeft, bottomRight] = this.screenBoundingRect();
return (
cell.x > topLeft.x &&
cell.x < bottomRight.x &&
cell.y > topLeft.y &&
cell.y < bottomRight.y
);
}
screenCenter(): { screenX: number; screenY: number } {
const [upperLeft, bottomRight] = this.screenBoundingRect();
return {
screenX: upperLeft.x + Math.floor((bottomRight.x - upperLeft.x) / 2),
screenY: upperLeft.y + Math.floor((bottomRight.y - upperLeft.y) / 2),
};
}
onGoToPlayer(event: GoToPlayerEvent) {
this.clearTarget();
this.target = new Cell(
event.player.nameLocation().x,
event.player.nameLocation().y,
);
this.intervalID = setInterval(() => this.goTo(), 1);
}
private goTo() {
const { screenX, screenY } = this.screenCenter();
const screenMapCenter = new Cell(screenX, screenY);
if (
this.game.manhattanDist(
this.game.ref(screenX, screenY),
this.game.ref(this.target.x, this.target.y),
) < 2
) {
this.clearTarget();
return;
}
boundingRect(): DOMRect {
return this.canvas.getBoundingClientRect()
const dX = Math.abs(screenMapCenter.x - this.target.x);
if (dX > 2) {
const offsetDx = Math.max(1, Math.floor(dX / 25));
if (screenMapCenter.x > this.target.x) {
this.offsetX -= offsetDx;
} else {
this.offsetX += offsetDx;
}
}
width(): number {
return this.boundingRect().width
const dY = Math.abs(screenMapCenter.y - this.target.y);
if (dY > 2) {
const offsetDy = Math.max(1, Math.floor(dY / 25));
if (screenMapCenter.y > this.target.y) {
this.offsetY -= offsetDy;
} else {
this.offsetY += offsetDy;
}
}
hasChanged(): boolean {
return this.changed
this.changed = true;
}
onZoom(event: ZoomEvent) {
this.clearTarget();
const oldScale = this.scale;
const zoomFactor = 1 + event.delta / 600;
this.scale /= zoomFactor;
// Clamp the scale to prevent extreme zooming
this.scale = Math.max(0.5, Math.min(20, this.scale));
const canvasRect = this.boundingRect();
const canvasX = event.x - canvasRect.left;
const canvasY = event.y - canvasRect.top;
// Calculate the world point we want to zoom towards
const zoomPointX =
(canvasX - this.game.width() / 2) / oldScale + this.offsetX;
const zoomPointY =
(canvasY - this.game.height() / 2) / oldScale + this.offsetY;
// Adjust the offset
this.offsetX = zoomPointX - (canvasX - this.game.width() / 2) / this.scale;
this.offsetY = zoomPointY - (canvasY - this.game.height() / 2) / this.scale;
this.changed = true;
}
onMove(event: DragEvent) {
this.clearTarget();
this.offsetX -= event.deltaX / this.scale;
this.offsetY -= event.deltaY / this.scale;
this.changed = true;
}
private clearTarget() {
if (this.intervalID != null) {
clearInterval(this.intervalID);
this.intervalID = null;
}
handleTransform(context: CanvasRenderingContext2D) {
// Disable image smoothing for pixelated effect
context.imageSmoothingEnabled = false;
// Apply zoom and pan
context.setTransform(
this.scale,
0,
0,
this.scale,
this.game.width() / 2 - this.offsetX * this.scale,
this.game.height() / 2 - this.offsetY * this.scale
);
this.changed = false
}
worldToScreenCoordinates(cell: Cell): { x: number, y: number } {
// Step 1: Convert from Cell coordinates to game coordinates
// (reverse of Math.floor operation - we'll use the exact values)
const gameX = cell.x;
const gameY = cell.y;
// Step 2: Reverse the game center offset calculation
// Original: gameX = centerX + this.game.width() / 2
// Therefore: centerX = gameX - this.game.width() / 2
const centerX = gameX - this.game.width() / 2;
const centerY = gameY - this.game.height() / 2;
// Step 3: Reverse the world point calculation
// Original: centerX = (canvasX - this.game.width() / 2) / this.scale + this.offsetX
// Therefore: canvasX = (centerX - this.offsetX) * this.scale + this.game.width() / 2
const canvasX = (centerX - this.offsetX) * this.scale + this.game.width() / 2;
const canvasY = (centerY - this.offsetY) * this.scale + this.game.height() / 2;
// Step 4: Convert canvas coordinates back to screen coordinates
const canvasRect = this.boundingRect();
const screenX = canvasX + canvasRect.left;
const screenY = canvasY + canvasRect.top;
return { x: screenX, y: screenY }
}
screenToWorldCoordinates(screenX: number, screenY: number): Cell {
const canvasRect = this.boundingRect();
const canvasX = screenX - canvasRect.left;
const canvasY = screenY - canvasRect.top;
// Calculate the world point we want to zoom towards
const centerX = (canvasX - this.game.width() / 2) / this.scale + this.offsetX;
const centerY = (canvasY - this.game.height() / 2) / this.scale + this.offsetY;
const gameX = centerX + this.game.width() / 2
const gameY = centerY + this.game.height() / 2
return new Cell(Math.floor(gameX), Math.floor(gameY));
}
screenBoundingRect(): [Cell, Cell] {
const LeftX = (- this.game.width() / 2) / this.scale + this.offsetX;
const TopY = (- this.game.height() / 2) / this.scale + this.offsetY;
const gameLeftX = LeftX + this.game.width() / 2
const gameTopY = TopY + this.game.height() / 2
const rightX = (screen.width - this.game.width() / 2) / this.scale + this.offsetX;
const rightY = (screen.height - this.game.height() / 2) / this.scale + this.offsetY;
const gameRightX = rightX + this.game.width() / 2
const gameBottomY = rightY + this.game.height() / 2
return [new Cell(Math.floor(gameLeftX), Math.floor(gameTopY)), new Cell(Math.floor(gameRightX), Math.floor(gameBottomY))]
}
isOnScreen(cell: Cell): boolean {
const [topLeft, bottomRight] = this.screenBoundingRect()
return cell.x > topLeft.x && cell.x < bottomRight.x && cell.y > topLeft.y && cell.y < bottomRight.y
}
screenCenter(): { screenX: number, screenY: number } {
const [upperLeft, bottomRight] = this.screenBoundingRect()
return {
screenX: upperLeft.x + Math.floor((bottomRight.x - upperLeft.x) / 2),
screenY: upperLeft.y + Math.floor((bottomRight.y - upperLeft.y) / 2)
}
}
onGoToPlayer(event: GoToPlayerEvent) {
this.clearTarget();
this.target = new Cell(event.player.nameLocation().x, event.player.nameLocation().y)
this.intervalID = setInterval(() => this.goTo(), 1)
}
private goTo() {
const { screenX, screenY } = this.screenCenter()
const screenMapCenter = new Cell(screenX, screenY)
if (this.game.manhattanDist(this.game.ref(screenX, screenY), this.game.ref(this.target.x, this.target.y)) < 2) {
this.clearTarget()
return
}
const dX = Math.abs(screenMapCenter.x - this.target.x)
if (dX > 2) {
const offsetDx = Math.max(1, Math.floor(dX / 25))
if (screenMapCenter.x > this.target.x) {
this.offsetX -= offsetDx
} else {
this.offsetX += offsetDx
}
}
const dY = Math.abs(screenMapCenter.y - this.target.y)
if (dY > 2) {
const offsetDy = Math.max(1, Math.floor(dY / 25))
if (screenMapCenter.y > this.target.y) {
this.offsetY -= offsetDy
} else {
this.offsetY += offsetDy
}
}
this.changed = true
}
onZoom(event: ZoomEvent) {
this.clearTarget()
const oldScale = this.scale;
const zoomFactor = 1 + event.delta / 600;
this.scale /= zoomFactor;
// Clamp the scale to prevent extreme zooming
this.scale = Math.max(0.5, Math.min(20, this.scale));
const canvasRect = this.boundingRect()
const canvasX = event.x - canvasRect.left;
const canvasY = event.y - canvasRect.top;
// Calculate the world point we want to zoom towards
const zoomPointX = (canvasX - this.game.width() / 2) / oldScale + this.offsetX;
const zoomPointY = (canvasY - this.game.height() / 2) / oldScale + this.offsetY;
// Adjust the offset
this.offsetX = zoomPointX - (canvasX - this.game.width() / 2) / this.scale;
this.offsetY = zoomPointY - (canvasY - this.game.height() / 2) / this.scale;
this.changed = true
}
onMove(event: DragEvent) {
this.clearTarget()
this.offsetX -= event.deltaX / this.scale;
this.offsetY -= event.deltaY / this.scale;
this.changed = true
}
private clearTarget() {
if (this.intervalID != null) {
clearInterval(this.intervalID)
this.intervalID = null
}
this.target = null
}
}
this.target = null;
}
}
+2 -2
View File
@@ -1,3 +1,3 @@
export interface UIState {
attackRatio: number
}
attackRatio: number;
}
+12 -12
View File
@@ -125,10 +125,10 @@ export class EventsDisplay extends LitElement implements Layer {
}
const requestor = this.game.playerBySmallID(
update.requestorID
update.requestorID,
) as PlayerView;
const recipient = this.game.playerBySmallID(
update.recipientID
update.recipientID,
) as PlayerView;
this.addEvent({
@@ -139,7 +139,7 @@ export class EventsDisplay extends LitElement implements Layer {
className: "btn",
action: () =>
this.eventBus.emit(
new SendAllianceReplyIntentEvent(requestor, recipient, true)
new SendAllianceReplyIntentEvent(requestor, recipient, true),
),
},
{
@@ -147,7 +147,7 @@ export class EventsDisplay extends LitElement implements Layer {
className: "btn-info",
action: () =>
this.eventBus.emit(
new SendAllianceReplyIntentEvent(requestor, recipient, false)
new SendAllianceReplyIntentEvent(requestor, recipient, false),
),
},
],
@@ -156,7 +156,7 @@ export class EventsDisplay extends LitElement implements Layer {
createdAt: this.game.ticks(),
onDelete: () =>
this.eventBus.emit(
new SendAllianceReplyIntentEvent(requestor, recipient, false)
new SendAllianceReplyIntentEvent(requestor, recipient, false),
),
});
}
@@ -168,7 +168,7 @@ export class EventsDisplay extends LitElement implements Layer {
}
const recipient = this.game.playerBySmallID(
update.request.recipientID
update.request.recipientID,
) as PlayerView;
this.addEvent({
@@ -213,8 +213,8 @@ export class EventsDisplay extends LitElement implements Layer {
update.player1ID === myPlayer.smallID()
? update.player2ID
: update.player2ID === myPlayer.smallID()
? update.player1ID
: null;
? update.player1ID
: null;
const other = this.game.playerBySmallID(otherID) as PlayerView;
if (!other || !myPlayer.isAlive() || !other.isAlive()) return;
@@ -250,7 +250,7 @@ export class EventsDisplay extends LitElement implements Layer {
? AllPlayers
: this.game.playerBySmallID(update.emoji.recipientID);
const sender = this.game.playerBySmallID(
update.emoji.senderID
update.emoji.senderID,
) as PlayerView;
if (recipient == myPlayer) {
@@ -306,7 +306,7 @@ export class EventsDisplay extends LitElement implements Layer {
(event, index) => html`
<tr
class="border-b border-opacity-0 ${this.getMessageTypeClasses(
event.type
event.type,
)}"
>
<td class="lg:p-3 p-1 text-left">
@@ -331,14 +331,14 @@ export class EventsDisplay extends LitElement implements Layer {
>
${btn.text}
</button>
`
`,
)}
</div>
`
: ""}
</td>
</tr>
`
`,
)}
</tbody>
</table>
+7 -7
View File
@@ -1,9 +1,9 @@
import { Game } from "../../../core/game/Game"
import { Game } from "../../../core/game/Game";
export interface Layer {
init?()
tick?()
renderLayer?(context: CanvasRenderingContext2D)
shouldTransform?(): boolean
redraw?(): void
}
init?();
tick?();
renderLayer?(context: CanvasRenderingContext2D);
shouldTransform?(): boolean;
redraw?(): void;
}
+130 -127
View File
@@ -1,167 +1,169 @@
import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { Layer } from './Layer';
import { ClientID } from '../../../core/Schemas';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { EventBus, GameEvent } from '../../../core/EventBus';
import { renderNumber } from '../../Utils';
import { GameView, PlayerView } from '../../../core/game/GameView';
import { LitElement, html, css } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { Layer } from "./Layer";
import { ClientID } from "../../../core/Schemas";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { EventBus, GameEvent } from "../../../core/EventBus";
import { renderNumber } from "../../Utils";
import { GameView, PlayerView } from "../../../core/game/GameView";
interface Entry {
name: string
position: number
score: string
gold: string
isMyPlayer: boolean
player: PlayerView
name: string;
position: number;
score: string;
gold: string;
isMyPlayer: boolean;
player: PlayerView;
}
export class GoToPlayerEvent implements GameEvent {
constructor(public player: PlayerView) { }
constructor(public player: PlayerView) {}
}
@customElement('leader-board')
@customElement("leader-board")
export class Leaderboard extends LitElement implements Layer {
public game: GameView;
public clientID: ClientID;
public eventBus: EventBus;
public game: GameView
public clientID: ClientID
public eventBus: EventBus
init() {
}
init() {}
tick() {
if (this._hidden && !this.game.inSpawnPhase()) {
this.showLeaderboard()
this.updateLeaderboard()
this.showLeaderboard();
this.updateLeaderboard();
}
if (this._hidden) {
return
return;
}
if (this.game.ticks() % 10 == 0) {
this.updateLeaderboard()
this.updateLeaderboard();
}
}
private updateLeaderboard() {
if (this.clientID == null) {
return
return;
}
const myPlayer = this.game.playerViews().find(p => p.clientID() == this.clientID)
const myPlayer = this.game
.playerViews()
.find((p) => p.clientID() == this.clientID);
const sorted = this.game.playerViews()
.sort((a, b) => b.numTilesOwned() - a.numTilesOwned())
const sorted = this.game
.playerViews()
.sort((a, b) => b.numTilesOwned() - a.numTilesOwned());
const numTilesWithoutFallout = this.game.numLandTiles() - this.game.numTilesWithFallout()
const numTilesWithoutFallout =
this.game.numLandTiles() - this.game.numTilesWithFallout();
this.players = sorted
.slice(0, 5)
.map((player, index) => ({
name: player.displayName(),
position: index + 1,
score: formatPercentage(player.numTilesOwned() / numTilesWithoutFallout),
gold: renderNumber(player.gold()),
isMyPlayer: player == myPlayer,
player: player
}));
this.players = sorted.slice(0, 5).map((player, index) => ({
name: player.displayName(),
position: index + 1,
score: formatPercentage(player.numTilesOwned() / numTilesWithoutFallout),
gold: renderNumber(player.gold()),
isMyPlayer: player == myPlayer,
player: player,
}));
if (myPlayer != null && this.players.find(p => p.isMyPlayer) == null) {
let place = 0
if (myPlayer != null && this.players.find((p) => p.isMyPlayer) == null) {
let place = 0;
for (const p of sorted) {
place++
place++;
if (p == myPlayer) {
break
break;
}
}
this.players.pop()
this.players.pop();
this.players.push({
name: myPlayer.displayName(),
position: place,
score: formatPercentage(myPlayer.numTilesOwned() / this.game.numLandTiles()),
score: formatPercentage(
myPlayer.numTilesOwned() / this.game.numLandTiles(),
),
gold: renderNumber(myPlayer.gold()),
isMyPlayer: true,
player: myPlayer
})
player: myPlayer,
});
}
this.requestUpdate()
this.requestUpdate();
}
private handleRowClick(player: PlayerView) {
this.eventBus.emit(new GoToPlayerEvent(player))
this.eventBus.emit(new GoToPlayerEvent(player));
}
renderLayer(context: CanvasRenderingContext2D) {
}
renderLayer(context: CanvasRenderingContext2D) {}
shouldTransform(): boolean {
return false
return false;
}
static styles = css`
:host {
display: block;
}
img.emoji {
height: 1em;
width: auto;
}
.leaderboard {
position: fixed;
top: 10px;
left: 10px;
z-index: 9999;
background-color: rgba(30, 30, 30, 0.7);
padding: 10px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
border-radius: 10px;
max-width: 300px;
max-height: 80vh;
overflow-y: auto;
width: 300px;
backdrop-filter: blur(5px);
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 5px;
text-align: center;
border-bottom: 1px solid rgba(51, 51, 51, 0.2);
color: white;
}
th {
background-color: rgba(44, 44, 44, 0.5);
color: white;
}
.myPlayer {
font-weight: bold;
font-size: 1.2em;
}
.otherPlayer {
font-size: 1.0em;
}
tr:nth-child(even) {
background-color: rgba(44, 44, 44, 0.5);
}
tbody tr {
cursor: pointer;
transition: background-color 0.2s;
}
tbody tr:hover {
background-color: rgba(78, 78, 78, 0.8);
}
.hidden {
display: none !important;
}
@media (max-width: 1000px) {
:host {
display: block;
}
img.emoji {
height: 1em;
width: auto;
}
.leaderboard {
position: fixed;
top: 10px;
left: 10px;
z-index: 9999;
background-color: rgba(30, 30, 30, 0.7);
padding: 10px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
border-radius: 10px;
max-width: 300px;
max-height: 80vh;
overflow-y: auto;
width: 300px;
backdrop-filter: blur(5px);
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 5px;
text-align: center;
border-bottom: 1px solid rgba(51, 51, 51, 0.2);
color: white;
}
th {
background-color: rgba(44, 44, 44, 0.5);
color: white;
}
.myPlayer {
font-weight: bold;
font-size: 1.2em;
}
.otherPlayer {
font-size: 1em;
}
tr:nth-child(even) {
background-color: rgba(44, 44, 44, 0.5);
}
tbody tr {
cursor: pointer;
transition: background-color 0.2s;
}
tbody tr:hover {
background-color: rgba(78, 78, 78, 0.8);
}
.hidden {
display: none !important;
}
}
`;
@media (max-width: 1000px) {
.leaderboard {
display: none !important;
}
}
`;
players: Entry[] = [];
@@ -178,7 +180,7 @@ export class Leaderboard extends LitElement implements Layer {
// </div>
// `;
return html`
<div class="leaderboard ${this._hidden ? 'hidden' : ''}">
<div class="leaderboard ${this._hidden ? "hidden" : ""}">
<table>
<thead>
<tr>
@@ -189,10 +191,10 @@ export class Leaderboard extends LitElement implements Layer {
</tr>
</thead>
<tbody>
${this.players
.map((player) => html`
<tr
class="${player.isMyPlayer ? 'myPlayer' : 'otherPlayer'}"
${this.players.map(
(player) => html`
<tr
class="${player.isMyPlayer ? "myPlayer" : "otherPlayer"}"
@click=${() => this.handleRowClick(player.player)}
>
<td>${player.position}</td>
@@ -200,7 +202,8 @@ export class Leaderboard extends LitElement implements Layer {
<td>${player.score}</td>
<td>${player.gold}</td>
</tr>
`)}
`,
)}
</tbody>
</table>
</div>
@@ -223,15 +226,15 @@ export class Leaderboard extends LitElement implements Layer {
}
function formatPercentage(value: number): string {
const perc = value * 100
const perc = value * 100;
if (perc > 99.5) {
return "100%"
return "100%";
}
if (perc < .01) {
return "0%"
if (perc < 0.01) {
return "0%";
}
if (perc < .1) {
return (perc).toPrecision(1) + '%'
if (perc < 0.1) {
return perc.toPrecision(1) + "%";
}
return perc.toPrecision(2) + '%';
}
return perc.toPrecision(2) + "%";
}
+24 -14
View File
@@ -25,7 +25,7 @@ class RenderInfo {
public lastRenderCalc: number,
public location: Cell,
public fontSize: number,
public element: HTMLElement
public element: HTMLElement,
) {}
}
@@ -49,7 +49,7 @@ export class NameLayer implements Layer {
private game: GameView,
private theme: Theme,
private transformHandler: TransformHandler,
private clientID: ClientID
private clientID: ClientID,
) {
this.traitorIconImage = new Image();
this.traitorIconImage.src = traitorIcon;
@@ -100,7 +100,13 @@ export class NameLayer implements Layer {
if (!this.seenPlayers.has(player)) {
this.seenPlayers.add(player);
this.renders.push(
new RenderInfo(player, 0, null, 0, this.createPlayerElement(player))
new RenderInfo(
player,
0,
null,
0,
this.createPlayerElement(player),
),
);
}
}
@@ -109,11 +115,11 @@ export class NameLayer implements Layer {
public renderLayer(mainContex: CanvasRenderingContext2D) {
const screenPosOld = this.transformHandler.worldToScreenCoordinates(
new Cell(0, 0)
new Cell(0, 0),
);
const screenPos = new Cell(
screenPosOld.x - window.innerWidth / 2,
screenPosOld.y - window.innerHeight / 2
screenPosOld.y - window.innerHeight / 2,
);
this.container.style.transform = `translate(${screenPos.x}px, ${screenPos.y}px) scale(${this.transformHandler.scale})`;
@@ -130,7 +136,7 @@ export class NameLayer implements Layer {
0,
0,
mainContex.canvas.width,
mainContex.canvas.height
mainContex.canvas.height,
);
}
@@ -185,7 +191,7 @@ export class NameLayer implements Layer {
const oldLocation = render.location;
render.location = new Cell(
render.player.nameLocation().x,
render.player.nameLocation().y
render.player.nameLocation().y,
);
// Calculate base size and scale
@@ -224,7 +230,7 @@ export class NameLayer implements Layer {
if (render.player === this.firstPlace) {
if (!existingCrown) {
iconsDiv.appendChild(
this.createIconElement(this.crownIconImage.src, iconSize, "crown")
this.createIconElement(this.crownIconImage.src, iconSize, "crown"),
);
}
} else if (existingCrown) {
@@ -236,7 +242,11 @@ export class NameLayer implements Layer {
if (render.player.isTraitor()) {
if (!existingTraitor) {
iconsDiv.appendChild(
this.createIconElement(this.traitorIconImage.src, iconSize, "traitor")
this.createIconElement(
this.traitorIconImage.src,
iconSize,
"traitor",
),
);
}
} else if (existingTraitor) {
@@ -251,8 +261,8 @@ export class NameLayer implements Layer {
this.createIconElement(
this.allianceIconImage.src,
iconSize,
"alliance"
)
"alliance",
),
);
}
} else if (existingAlliance) {
@@ -267,7 +277,7 @@ export class NameLayer implements Layer {
) {
if (!existingTarget) {
iconsDiv.appendChild(
this.createIconElement(this.targetIconImage.src, iconSize, "target")
this.createIconElement(this.targetIconImage.src, iconSize, "target"),
);
}
} else if (existingTarget) {
@@ -281,7 +291,7 @@ export class NameLayer implements Layer {
.filter(
(emoji) =>
emoji.recipientID == AllPlayers ||
emoji.recipientID == myPlayer?.smallID()
emoji.recipientID == myPlayer?.smallID(),
);
if (emojis.length > 0) {
@@ -314,7 +324,7 @@ export class NameLayer implements Layer {
private createIconElement(
src: string,
size: number,
id: string
id: string,
): HTMLImageElement {
const icon = document.createElement("img");
icon.src = src;
@@ -22,7 +22,7 @@ import { renderNumber, renderTroops } from "../../Utils";
function euclideanDistWorld(
coord: { x: number; y: number },
tileRef: TileRef,
game: GameView
game: GameView,
): number {
const x = game.x(tileRef);
const y = game.y(tileRef);
@@ -71,7 +71,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
init() {
this.eventBus.on(MouseMoveEvent, (e: MouseMoveEvent) =>
this.onMouseEvent(e)
this.onMouseEvent(e),
);
this._isActive = true;
}
+24 -24
View File
@@ -1,34 +1,34 @@
import { GameView } from '../../../core/game/GameView';
import { TransformHandler } from '../TransformHandler';
import { Layer } from './Layer';
import { GameView } from "../../../core/game/GameView";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
export class SpawnTimer implements Layer {
constructor(
private game: GameView,
private transformHandler: TransformHandler,
) {}
constructor(private game: GameView, private transformHandler: TransformHandler) { }
init() {}
tick() {}
shouldTransform(): boolean {
return false;
}
init() {
}
tick() {
}
shouldTransform(): boolean {
return false
renderLayer(context: CanvasRenderingContext2D) {
if (!this.game.inSpawnPhase()) {
return;
}
renderLayer(context: CanvasRenderingContext2D) {
if (!this.game.inSpawnPhase()) {
return
}
const barHeight = 15;
const barBackgroundWidth = this.transformHandler.width();
const barHeight = 15;
const barBackgroundWidth = this.transformHandler.width();
const ratio = this.game.ticks() / this.game.config().numSpawnPhaseTurns();
const ratio = this.game.ticks() / this.game.config().numSpawnPhaseTurns()
// Draw bar background
context.fillStyle = "rgba(0, 0, 0, 0.5)";
context.fillRect(0, 0, barBackgroundWidth, barHeight);
// Draw bar background
context.fillStyle = 'rgba(0, 0, 0, 0.5)';
context.fillRect(0, 0, barBackgroundWidth, barHeight);
context.fillStyle = 'rgba(0, 128, 255, 0.7)';
context.fillRect(0, 0, barBackgroundWidth * ratio, barHeight);
}
context.fillStyle = "rgba(0, 128, 255, 0.7)";
context.fillRect(0, 0, barBackgroundWidth * ratio, barHeight);
}
}
+184 -160
View File
@@ -3,192 +3,216 @@ import { Theme } from "../../../core/configuration/Config";
import { Layer } from "./Layer";
import { EventBus } from "../../../core/EventBus";
import anchorIcon from '../../../../resources/images/AnchorIcon.png';
import missileSiloIcon from '../../../../resources/images/MissileSiloUnit.png';
import shieldIcon from '../../../../resources/images/ShieldIcon.png';
import cityIcon from '../../../../resources/images/CityIcon.png';
import anchorIcon from "../../../../resources/images/AnchorIcon.png";
import missileSiloIcon from "../../../../resources/images/MissileSiloUnit.png";
import shieldIcon from "../../../../resources/images/ShieldIcon.png";
import cityIcon from "../../../../resources/images/CityIcon.png";
import { GameView, UnitView } from "../../../core/game/GameView";
import { Cell, Unit, UnitType } from "../../../core/game/Game";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { euclDistFN } from "../../../core/game/GameMap";
interface UnitRenderConfig {
icon: string;
borderRadius: number;
territoryRadius: number;
icon: string;
borderRadius: number;
territoryRadius: number;
}
export class StructureLayer implements Layer {
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
private unitIcons: Map<string, ImageData> = new Map();
private theme: Theme = null;
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
private unitIcons: Map<string, ImageData> = new Map();
private theme: Theme = null;
// Configuration for supported unit types only
private readonly unitConfigs: Partial<Record<UnitType, UnitRenderConfig>> = {
[UnitType.Port]: {
icon: anchorIcon,
borderRadius: 8,
territoryRadius: 6,
},
[UnitType.MissileSilo]: {
icon: missileSiloIcon,
borderRadius: 8,
territoryRadius: 6,
},
[UnitType.DefensePost]: {
icon: shieldIcon,
borderRadius: 8,
territoryRadius: 6,
},
[UnitType.City]: {
icon: cityIcon,
borderRadius: 8,
territoryRadius: 6,
},
};
// Configuration for supported unit types only
private readonly unitConfigs: Partial<Record<UnitType, UnitRenderConfig>> = {
[UnitType.Port]: {
icon: anchorIcon,
borderRadius: 8,
territoryRadius: 6
},
[UnitType.MissileSilo]: {
icon: missileSiloIcon,
borderRadius: 8,
territoryRadius: 6
},
[UnitType.DefensePost]: {
icon: shieldIcon,
borderRadius: 8,
territoryRadius: 6
},
[UnitType.City]: {
icon: cityIcon,
borderRadius: 8,
territoryRadius: 6
}
};
constructor(
private game: GameView,
private eventBus: EventBus,
) {
this.theme = game.config().theme();
this.loadIconData();
}
constructor(private game: GameView, private eventBus: EventBus) {
this.theme = game.config().theme();
this.loadIconData();
}
private loadIconData() {
Object.entries(this.unitConfigs).forEach(([unitType, config]) => {
const image = new Image();
image.src = config.icon;
image.onload = () => {
// Create temporary canvas for icon processing
const tempCanvas = document.createElement("canvas");
const tempContext = tempCanvas.getContext("2d");
tempCanvas.width = image.width;
tempCanvas.height = image.height;
private loadIconData() {
Object.entries(this.unitConfigs).forEach(([unitType, config]) => {
const image = new Image();
image.src = config.icon;
image.onload = () => {
// Create temporary canvas for icon processing
const tempCanvas = document.createElement('canvas');
const tempContext = tempCanvas.getContext('2d');
tempCanvas.width = image.width;
tempCanvas.height = image.height;
// Draw the unit icon
tempContext.drawImage(image, 0, 0);
const iconData = tempContext.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
this.unitIcons.set(unitType, iconData)
console.log(`icond data width height: ${iconData.width}, ${iconData.height}`)
};
});
}
shouldTransform(): boolean {
return true;
}
tick() {
this.game.updatesSinceLastTick()[GameUpdateType.Unit]
.forEach(u => this.handleUnitRendering(this.game.unit(u.id)));
}
init() {
this.redraw();
}
redraw() {
console.log('structure layer redrawing');
this.canvas = document.createElement('canvas');
this.context = this.canvas.getContext("2d", { alpha: true });
this.canvas.width = this.game.width();
this.canvas.height = this.game.height();
this.game.units().forEach(u => this.handleUnitRendering(u));
}
renderLayer(context: CanvasRenderingContext2D) {
context.drawImage(
this.canvas,
-this.game.width() / 2,
-this.game.height() / 2,
this.game.width(),
this.game.height()
// Draw the unit icon
tempContext.drawImage(image, 0, 0);
const iconData = tempContext.getImageData(
0,
0,
tempCanvas.width,
tempCanvas.height,
);
this.unitIcons.set(unitType, iconData);
console.log(
`icond data width height: ${iconData.width}, ${iconData.height}`,
);
};
});
}
shouldTransform(): boolean {
return true;
}
tick() {
this.game
.updatesSinceLastTick()
[
GameUpdateType.Unit
].forEach((u) => this.handleUnitRendering(this.game.unit(u.id)));
}
init() {
this.redraw();
}
redraw() {
console.log("structure layer redrawing");
this.canvas = document.createElement("canvas");
this.context = this.canvas.getContext("2d", { alpha: true });
this.canvas.width = this.game.width();
this.canvas.height = this.game.height();
this.game.units().forEach((u) => this.handleUnitRendering(u));
}
renderLayer(context: CanvasRenderingContext2D) {
context.drawImage(
this.canvas,
-this.game.width() / 2,
-this.game.height() / 2,
this.game.width(),
this.game.height(),
);
}
private isUnitTypeSupported(unitType: UnitType): boolean {
return unitType in this.unitConfigs;
}
private handleUnitRendering(unit: UnitView) {
const unitType = unit.type();
if (!this.isUnitTypeSupported(unitType)) return;
const config = this.unitConfigs[unitType];
const icon = this.unitIcons.get(unitType);
if (!config || !icon) return;
// Clear previous rendering
for (const tile of this.game.bfs(
unit.tile(),
euclDistFN(unit.tile(), config.borderRadius),
)) {
this.clearCell(new Cell(this.game.x(tile), this.game.y(tile)));
}
private isUnitTypeSupported(unitType: UnitType): boolean {
return unitType in this.unitConfigs;
if (!unit.isActive()) {
return;
}
private handleUnitRendering(unit: UnitView) {
const unitType = unit.type();
if (!this.isUnitTypeSupported(unitType)) return;
// Draw border and territory
for (const tile of this.game.bfs(
unit.tile(),
euclDistFN(unit.tile(), config.borderRadius),
)) {
this.paintCell(
new Cell(this.game.x(tile), this.game.y(tile)),
this.theme.borderColor(unit.owner().info()),
255,
);
}
for (const tile of this.game.bfs(
unit.tile(),
euclDistFN(unit.tile(), config.territoryRadius),
)) {
this.paintCell(
new Cell(this.game.x(tile), this.game.y(tile)),
this.theme.territoryColor(unit.owner().info()),
130,
);
}
const config = this.unitConfigs[unitType];
const icon = this.unitIcons.get(unitType);
const startX = this.game.x(unit.tile()) - Math.floor(icon.width / 2);
const startY = this.game.y(unit.tile()) - Math.floor(icon.height / 2);
// Draw the icon
this.renderIcon(icon, startX, startY, icon.width, icon.height, unit);
}
if (!config || !icon) return;
private renderIcon(
iconData: ImageData,
startX: number,
startY: number,
width: number,
height: number,
unit: UnitView,
) {
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const iconIndex = (y * width + x) * 4;
const alpha = iconData.data[iconIndex + 3];
// Clear previous rendering
for (const tile of this.game.bfs(unit.tile(), euclDistFN(unit.tile(), config.borderRadius))) {
this.clearCell(new Cell(this.game.x(tile), this.game.y(tile)));
}
if (alpha > 0) {
const targetX = startX + x;
const targetY = startY + y;
if (!unit.isActive()) {
return
}
// Draw border and territory
for (const tile of this.game.bfs(unit.tile(), euclDistFN(unit.tile(), config.borderRadius))) {
if (
targetX >= 0 &&
targetX < this.game.width() &&
targetY >= 0 &&
targetY < this.game.height()
) {
this.paintCell(
new Cell(this.game.x(tile), this.game.y(tile)),
this.theme.borderColor(unit.owner().info()),
255
new Cell(targetX, targetY),
this.theme.borderColor(unit.owner().info()),
alpha,
);
}
}
for (const tile of this.game.bfs(unit.tile(), euclDistFN(unit.tile(), config.territoryRadius))) {
this.paintCell(
new Cell(this.game.x(tile), this.game.y(tile)),
this.theme.territoryColor(unit.owner().info()),
130
);
}
const startX = this.game.x(unit.tile()) - Math.floor(icon.width / 2);
const startY = this.game.y(unit.tile()) - Math.floor(icon.height / 2);
// Draw the icon
this.renderIcon(icon, startX, startY, icon.width, icon.height, unit);
}
}
}
private renderIcon(
iconData: ImageData,
startX: number,
startY: number,
width: number,
height: number,
unit: UnitView
) {
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const iconIndex = (y * width + x) * 4;
const alpha = iconData.data[iconIndex + 3];
paintCell(cell: Cell, color: Colord, alpha: number) {
this.clearCell(cell);
this.context.fillStyle = color.alpha(alpha / 255).toRgbString();
this.context.fillRect(cell.x, cell.y, 1, 1);
}
if (alpha > 0) {
const targetX = startX + x;
const targetY = startY + y;
if (targetX >= 0 && targetX < this.game.width() &&
targetY >= 0 && targetY < this.game.height()) {
this.paintCell(
new Cell(targetX, targetY),
this.theme.borderColor(unit.owner().info()),
alpha
);
}
}
}
}
}
paintCell(cell: Cell, color: Colord, alpha: number) {
this.clearCell(cell);
this.context.fillStyle = color.alpha(alpha / 255).toRgbString();
this.context.fillRect(cell.x, cell.y, 1, 1);
}
clearCell(cell: Cell) {
this.context.clearRect(cell.x, cell.y, 1, 1);
}
}
clearCell(cell: Cell) {
this.context.clearRect(cell.x, cell.y, 1, 1);
}
}
+49 -46
View File
@@ -2,55 +2,58 @@ import { Layer } from "./Layer";
import { GameView } from "../../../core/game/GameView";
export class TerrainLayer implements Layer {
private canvas: HTMLCanvasElement
private context: CanvasRenderingContext2D
private imageData: ImageData
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
private imageData: ImageData;
constructor(private game: GameView) {}
shouldTransform(): boolean {
return true;
}
tick() {}
constructor(private game: GameView) { }
shouldTransform(): boolean {
return true
}
tick() {
}
init() {
console.log("redrew terrain layer");
this.redraw();
}
init() {
console.log('redrew terrain layer')
this.redraw()
}
redraw(): void {
this.canvas = document.createElement("canvas");
this.context = this.canvas.getContext("2d");
redraw(): void {
this.canvas = document.createElement('canvas');
this.context = this.canvas.getContext("2d")
this.imageData = this.context.getImageData(
0,
0,
this.game.width(),
this.game.height(),
);
this.initImageData();
this.canvas.width = this.game.width();
this.canvas.height = this.game.height();
this.context.putImageData(this.imageData, 0, 0);
}
this.imageData = this.context.getImageData(0, 0, this.game.width(), this.game.height())
this.initImageData()
this.canvas.width = this.game.width();
this.canvas.height = this.game.height();
this.context.putImageData(this.imageData, 0, 0);
}
initImageData() {
const theme = this.game.config().theme();
this.game.forEachTile((tile) => {
let terrainColor = theme.terrainColor(this.game, tile);
// TODO: isn'te tileref and index the same?
const index = this.game.y(tile) * this.game.width() + this.game.x(tile);
const offset = index * 4;
this.imageData.data[offset] = terrainColor.rgba.r;
this.imageData.data[offset + 1] = terrainColor.rgba.g;
this.imageData.data[offset + 2] = terrainColor.rgba.b;
this.imageData.data[offset + 3] = (terrainColor.rgba.a * 255) | 0;
});
}
initImageData() {
const theme = this.game.config().theme()
this.game.forEachTile((tile) => {
let terrainColor = theme.terrainColor(this.game, tile)
// TODO: isn'te tileref and index the same?
const index = (this.game.y(tile) * this.game.width()) + this.game.x(tile)
const offset = index * 4
this.imageData.data[offset] = terrainColor.rgba.r;
this.imageData.data[offset + 1] = terrainColor.rgba.g;
this.imageData.data[offset + 2] = terrainColor.rgba.b;
this.imageData.data[offset + 3] = terrainColor.rgba.a * 255 | 0
})
}
renderLayer(context: CanvasRenderingContext2D) {
context.drawImage(
this.canvas,
-this.game.width() / 2,
-this.game.height() / 2,
this.game.width(),
this.game.height()
)
}
}
renderLayer(context: CanvasRenderingContext2D) {
context.drawImage(
this.canvas,
-this.game.width() / 2,
-this.game.height() / 2,
this.game.width(),
this.game.height(),
);
}
}
+232 -192
View File
@@ -1,222 +1,262 @@
import { PriorityQueue } from "@datastructures-js/priority-queue";
import { Cell, Game, Player, PlayerType, Unit, UnitType } from "../../../core/game/Game";
import {
Cell,
Game,
Player,
PlayerType,
Unit,
UnitType,
} from "../../../core/game/Game";
import { UnitUpdate } from "../../../core/game/GameUpdates";
import { PseudoRandom } from "../../../core/PseudoRandom";
import { colord, Colord } from "colord";
import { Theme } from "../../../core/configuration/Config";
import { Layer } from "./Layer";
import { EventBus } from "../../../core/EventBus";
import { AlternateViewEvent, DragEvent, MouseDownEvent } from "../../InputHandler";
import {
AlternateViewEvent,
DragEvent,
MouseDownEvent,
} from "../../InputHandler";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { euclDistFN, TileRef } from "../../../core/game/GameMap";
export class TerritoryLayer implements Layer {
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
private imageData: ImageData;
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
private imageData: ImageData;
private tileToRenderQueue: PriorityQueue<{ tile: TileRef, lastUpdate: number }> = new PriorityQueue((a, b) => { return a.lastUpdate - b.lastUpdate });
private random = new PseudoRandom(123);
private theme: Theme = null;
private tileToRenderQueue: PriorityQueue<{
tile: TileRef;
lastUpdate: number;
}> = new PriorityQueue((a, b) => {
return a.lastUpdate - b.lastUpdate;
});
private random = new PseudoRandom(123);
private theme: Theme = null;
// Used for spawn highlighting
private highlightCanvas: HTMLCanvasElement;
private highlightContext: CanvasRenderingContext2D;
// Used for spawn highlighting
private highlightCanvas: HTMLCanvasElement;
private highlightContext: CanvasRenderingContext2D;
private alternativeView = false;
private lastDragTime = 0;
private nodrawDragDuration = 200;
private alternativeView = false;
private lastDragTime = 0;
private nodrawDragDuration = 200;
private refreshRate = 50;
private lastRefresh = 0;
private refreshRate = 50;
private lastRefresh = 0;
constructor(private game: GameView, private eventBus: EventBus) {
this.theme = game.config().theme();
constructor(
private game: GameView,
private eventBus: EventBus,
) {
this.theme = game.config().theme();
}
shouldTransform(): boolean {
return true;
}
tick() {
this.game.recentlyUpdatedTiles().forEach((t) => this.enqueueTile(t));
if (!this.game.inSpawnPhase()) {
return;
}
if (this.game.ticks() % 5 == 0) {
return;
}
shouldTransform(): boolean {
return true;
}
this.highlightContext.clearRect(
0,
0,
this.game.width(),
this.game.height(),
);
const humans = this.game
.playerViews()
.filter((p) => p.type() == PlayerType.Human);
tick() {
this.game.recentlyUpdatedTiles()
.forEach(t => this.enqueueTile(t));
if (!this.game.inSpawnPhase()) {
return;
}
if (this.game.ticks() % 5 == 0) {
return;
}
this.highlightContext.clearRect(0, 0, this.game.width(), this.game.height());
const humans = this.game.playerViews()
.filter(p => p.type() == PlayerType.Human);
for (const human of humans) {
const center = human.nameLocation();
if (!center) {
continue;
}
const centerTile = this.game.ref(center.x, center.y)
if (!centerTile) {
continue;
}
for (const tile of this.game.bfs(centerTile, euclDistFN(centerTile, 9))) {
if (!this.game.hasOwner(tile)) {
this.paintHighlightCell(
new Cell(this.game.x(tile), this.game.y(tile)),
this.theme.spawnHighlightColor(),
255
);
}
}
}
}
init() {
this.eventBus.on(AlternateViewEvent, e => { this.alternativeView = e.alternateView; });
this.eventBus.on(DragEvent, e => { this.lastDragTime = Date.now(); });
this.redraw();
}
redraw() {
console.log('redrew territory layer');
this.canvas = document.createElement('canvas');
this.context = this.canvas.getContext("2d");
this.imageData = this.context.getImageData(0, 0, this.game.width(), this.game.height());
this.initImageData();
this.canvas.width = this.game.width();
this.canvas.height = this.game.height();
this.context.putImageData(this.imageData, 0, 0);
// Add a second canvas for highlights
this.highlightCanvas = document.createElement('canvas');
this.highlightContext = this.highlightCanvas.getContext("2d", { alpha: true });
this.highlightCanvas.width = this.game.width();
this.highlightCanvas.height = this.game.height();
this.game.forEachTile(t => {
this.paintTerritory(t);
});
}
initImageData() {
this.game.forEachTile((tile) => {
const cell = new Cell(this.game.x(tile), this.game.y(tile));
const index = (cell.y * this.game.width()) + cell.x;
const offset = index * 4;
this.imageData.data[offset + 3] = 0;
});
}
renderLayer(context: CanvasRenderingContext2D) {
if (Date.now() > this.lastDragTime + this.nodrawDragDuration && Date.now() > this.lastRefresh + this.refreshRate) {
this.lastRefresh = Date.now();
this.renderTerritory();
this.context.putImageData(this.imageData, 0, 0);
}
if (this.alternativeView) {
return;
}
context.drawImage(
this.canvas,
-this.game.width() / 2,
-this.game.height() / 2,
this.game.width(),
this.game.height()
);
if (this.game.inSpawnPhase()) {
context.drawImage(
this.highlightCanvas,
-this.game.width() / 2,
-this.game.height() / 2,
this.game.width(),
this.game.height()
);
}
}
renderTerritory() {
let numToRender = Math.floor(this.tileToRenderQueue.size() / 5);
if (numToRender == 0 || this.game.inSpawnPhase()) {
numToRender = this.tileToRenderQueue.size();
}
while (numToRender > 0) {
numToRender--;
const tile = this.tileToRenderQueue.pop().tile;
this.paintTerritory(tile);
for (const neighbor of this.game.neighbors(tile)) {
this.paintTerritory(neighbor, true);
}
}
}
paintTerritory(tile: TileRef, isBorder: boolean = false) {
if (isBorder && !this.game.hasOwner(tile)) {
return;
}
for (const human of humans) {
const center = human.nameLocation();
if (!center) {
continue;
}
const centerTile = this.game.ref(center.x, center.y);
if (!centerTile) {
continue;
}
for (const tile of this.game.bfs(centerTile, euclDistFN(centerTile, 9))) {
if (!this.game.hasOwner(tile)) {
if (this.game.hasFallout(tile)) {
this.paintCell(
this.game.x(tile),
this.game.y(tile),
this.theme.falloutColor(),
150
);
return;
}
this.clearCell(new Cell(this.game.x(tile), this.game.y(tile)));
return;
}
const owner = this.game.owner(tile) as Player;
if (this.game.isBorder(tile)) {
this.paintCell(
this.game.x(tile), this.game.y(tile),
this.theme.borderColor(owner.info()),
255
);
} else {
this.paintCell(
this.game.x(tile), this.game.y(tile),
this.theme.territoryColor(owner.info()),
150
);
this.paintHighlightCell(
new Cell(this.game.x(tile), this.game.y(tile)),
this.theme.spawnHighlightColor(),
255,
);
}
}
}
}
init() {
this.eventBus.on(AlternateViewEvent, (e) => {
this.alternativeView = e.alternateView;
});
this.eventBus.on(DragEvent, (e) => {
this.lastDragTime = Date.now();
});
this.redraw();
}
redraw() {
console.log("redrew territory layer");
this.canvas = document.createElement("canvas");
this.context = this.canvas.getContext("2d");
this.imageData = this.context.getImageData(
0,
0,
this.game.width(),
this.game.height(),
);
this.initImageData();
this.canvas.width = this.game.width();
this.canvas.height = this.game.height();
this.context.putImageData(this.imageData, 0, 0);
// Add a second canvas for highlights
this.highlightCanvas = document.createElement("canvas");
this.highlightContext = this.highlightCanvas.getContext("2d", {
alpha: true,
});
this.highlightCanvas.width = this.game.width();
this.highlightCanvas.height = this.game.height();
this.game.forEachTile((t) => {
this.paintTerritory(t);
});
}
initImageData() {
this.game.forEachTile((tile) => {
const cell = new Cell(this.game.x(tile), this.game.y(tile));
const index = cell.y * this.game.width() + cell.x;
const offset = index * 4;
this.imageData.data[offset + 3] = 0;
});
}
renderLayer(context: CanvasRenderingContext2D) {
if (
Date.now() > this.lastDragTime + this.nodrawDragDuration &&
Date.now() > this.lastRefresh + this.refreshRate
) {
this.lastRefresh = Date.now();
this.renderTerritory();
this.context.putImageData(this.imageData, 0, 0);
}
if (this.alternativeView) {
return;
}
paintCell(x: number, y: number, color: Colord, alpha: number) {
const index = (y * this.game.width()) + x;
const offset = index * 4;
this.imageData.data[offset] = color.rgba.r;
this.imageData.data[offset + 1] = color.rgba.g;
this.imageData.data[offset + 2] = color.rgba.b;
this.imageData.data[offset + 3] = alpha;
context.drawImage(
this.canvas,
-this.game.width() / 2,
-this.game.height() / 2,
this.game.width(),
this.game.height(),
);
if (this.game.inSpawnPhase()) {
context.drawImage(
this.highlightCanvas,
-this.game.width() / 2,
-this.game.height() / 2,
this.game.width(),
this.game.height(),
);
}
}
renderTerritory() {
let numToRender = Math.floor(this.tileToRenderQueue.size() / 5);
if (numToRender == 0 || this.game.inSpawnPhase()) {
numToRender = this.tileToRenderQueue.size();
}
clearCell(cell: Cell) {
const index = (cell.y * this.game.width()) + cell.x;
const offset = index * 4;
this.imageData.data[offset + 3] = 0; // Set alpha to 0 (fully transparent)
while (numToRender > 0) {
numToRender--;
const tile = this.tileToRenderQueue.pop().tile;
this.paintTerritory(tile);
for (const neighbor of this.game.neighbors(tile)) {
this.paintTerritory(neighbor, true);
}
}
}
enqueueTile(tile: TileRef) {
this.tileToRenderQueue.push({
tile: tile,
lastUpdate: this.game.ticks() + this.random.nextFloat(0, .5)
});
paintTerritory(tile: TileRef, isBorder: boolean = false) {
if (isBorder && !this.game.hasOwner(tile)) {
return;
}
if (!this.game.hasOwner(tile)) {
if (this.game.hasFallout(tile)) {
this.paintCell(
this.game.x(tile),
this.game.y(tile),
this.theme.falloutColor(),
150,
);
return;
}
this.clearCell(new Cell(this.game.x(tile), this.game.y(tile)));
return;
}
const owner = this.game.owner(tile) as Player;
if (this.game.isBorder(tile)) {
this.paintCell(
this.game.x(tile),
this.game.y(tile),
this.theme.borderColor(owner.info()),
255,
);
} else {
this.paintCell(
this.game.x(tile),
this.game.y(tile),
this.theme.territoryColor(owner.info()),
150,
);
}
}
paintHighlightCell(cell: Cell, color: Colord, alpha: number) {
this.clearCell(cell);
this.highlightContext.fillStyle = color.alpha(alpha / 255).toRgbString();
this.highlightContext.fillRect(cell.x, cell.y, 1, 1);
}
paintCell(x: number, y: number, color: Colord, alpha: number) {
const index = y * this.game.width() + x;
const offset = index * 4;
this.imageData.data[offset] = color.rgba.r;
this.imageData.data[offset + 1] = color.rgba.g;
this.imageData.data[offset + 2] = color.rgba.b;
this.imageData.data[offset + 3] = alpha;
}
clearHighlightCell(cell: Cell) {
this.highlightContext.clearRect(cell.x, cell.y, 1, 1);
}
}
clearCell(cell: Cell) {
const index = cell.y * this.game.width() + cell.x;
const offset = index * 4;
this.imageData.data[offset + 3] = 0; // Set alpha to 0 (fully transparent)
}
enqueueTile(tile: TileRef) {
this.tileToRenderQueue.push({
tile: tile,
lastUpdate: this.game.ticks() + this.random.nextFloat(0, 0.5),
});
}
paintHighlightCell(cell: Cell, color: Colord, alpha: number) {
this.clearCell(cell);
this.highlightContext.fillStyle = color.alpha(alpha / 255).toRgbString();
this.highlightContext.fillRect(cell.x, cell.y, 1, 1);
}
clearHighlightCell(cell: Cell) {
this.highlightContext.clearRect(cell.x, cell.y, 1, 1);
}
}
+356 -310
View File
@@ -7,358 +7,404 @@ import { EventBus } from "../../../core/EventBus";
import { AlternateViewEvent } from "../../InputHandler";
import { ClientID } from "../../../core/Schemas";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import { euclDistFN, manhattanDistFN, TileRef } from "../../../core/game/GameMap";
import {
euclDistFN,
manhattanDistFN,
TileRef,
} from "../../../core/game/GameMap";
enum Relationship {
Self,
Ally,
Enemy
Self,
Ally,
Enemy,
}
export class UnitLayer implements Layer {
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
private boatToTrail = new Map<UnitView, Set<TileRef>>();
private boatToTrail = new Map<UnitView, Set<TileRef>>();
private theme: Theme = null;
private theme: Theme = null;
private alternateView = false;
private alternateView = false;
private myPlayer: PlayerView | null = null;
private myPlayer: PlayerView | null = null;
private oldShellTile = new Map<UnitView, TileRef>();
private oldShellTile = new Map<UnitView, TileRef>();
constructor(private game: GameView, private eventBus: EventBus, private clientID: ClientID) {
this.theme = game.config().theme();
constructor(
private game: GameView,
private eventBus: EventBus,
private clientID: ClientID,
) {
this.theme = game.config().theme();
}
shouldTransform(): boolean {
return true;
}
tick() {
if (this.myPlayer == null) {
this.myPlayer = this.game.playerByClientID(this.clientID);
}
for (const unit of this.game.units()) {
if (unit.wasUpdated()) this.onUnitEvent(unit);
}
}
init() {
this.eventBus.on(AlternateViewEvent, (e) => this.onAlternativeViewEvent(e));
this.redraw();
}
renderLayer(context: CanvasRenderingContext2D) {
context.drawImage(
this.canvas,
-this.game.width() / 2,
-this.game.height() / 2,
this.game.width(),
this.game.height(),
);
}
onAlternativeViewEvent(event: AlternateViewEvent) {
this.alternateView = event.alternateView;
this.redraw();
}
redraw() {
this.canvas = document.createElement("canvas");
this.context = this.canvas.getContext("2d");
this.canvas.width = this.game.width();
this.canvas.height = this.game.height();
for (const unit of this.game.units()) {
// this.onUnitEvent(new UnitEvent(unit, unit.tile()))
}
}
private relationship(unit: UnitView): Relationship {
if (this.myPlayer == null) {
return Relationship.Enemy;
}
if (this.myPlayer == unit.owner()) {
return Relationship.Self;
}
if (this.myPlayer.isAlliedWith(unit.owner())) {
return Relationship.Ally;
}
return Relationship.Enemy;
}
onUnitEvent(unit: UnitView) {
switch (unit.type()) {
case UnitType.TransportShip:
this.handleBoatEvent(unit);
break;
case UnitType.Destroyer:
this.handleDestroyerEvent(unit);
break;
case UnitType.Battleship:
this.handleBattleshipEvent(unit);
break;
case UnitType.Shell:
this.handleShellEvent(unit);
break;
case UnitType.TradeShip:
this.handleTradeShipEvent(unit);
break;
case UnitType.AtomBomb:
case UnitType.HydrogenBomb:
this.handleNuke(unit);
break;
}
}
private handleDestroyerEvent(unit: UnitView) {
const rel = this.relationship(unit);
// Clear previous area
for (const t of this.game.bfs(
unit.lastTile(),
euclDistFN(unit.lastTile(), 4),
)) {
this.clearCell(this.game.x(t), this.game.y(t));
}
shouldTransform(): boolean {
return true;
if (!unit.isActive()) {
return;
}
tick() {
if (this.myPlayer == null) {
this.myPlayer = this.game.playerByClientID(this.clientID);
}
for (const unit of this.game.units()) {
if (unit.wasUpdated())
this.onUnitEvent(unit);
}
// Paint border
for (const t of this.game.bfs(unit.tile(), euclDistFN(unit.tile(), 4))) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.borderColor(unit.owner().info()),
255,
);
}
init() {
this.eventBus.on(AlternateViewEvent, e => this.onAlternativeViewEvent(e));
this.redraw();
// Paint territory
for (const t of this.game.bfs(
unit.tile(),
manhattanDistFN(unit.tile(), 3),
)) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.territoryColor(unit.owner().info()),
255,
);
}
}
private handleBattleshipEvent(unit: UnitView) {
const rel = this.relationship(unit);
// Clear previous area
for (const t of this.game.bfs(
unit.lastTile(),
euclDistFN(unit.lastTile(), 6),
)) {
this.clearCell(this.game.x(t), this.game.y(t));
}
renderLayer(context: CanvasRenderingContext2D) {
context.drawImage(
this.canvas,
-this.game.width() / 2,
-this.game.height() / 2,
this.game.width(),
this.game.height()
);
if (!unit.isActive()) {
return;
}
onAlternativeViewEvent(event: AlternateViewEvent) {
this.alternateView = event.alternateView;
this.redraw();
// Paint outer territory
for (const t of this.game.bfs(unit.tile(), euclDistFN(unit.tile(), 5))) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.territoryColor(unit.owner().info()),
255,
);
}
redraw() {
this.canvas = document.createElement('canvas');
this.context = this.canvas.getContext("2d");
this.canvas.width = this.game.width();
this.canvas.height = this.game.height();
for (const unit of this.game.units()) {
// this.onUnitEvent(new UnitEvent(unit, unit.tile()))
}
// Paint border
for (const t of this.game.bfs(
unit.tile(),
manhattanDistFN(unit.tile(), 4),
)) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.borderColor(unit.owner().info()),
255,
);
}
private relationship(unit: UnitView): Relationship {
if (this.myPlayer == null) {
return Relationship.Enemy;
}
if (this.myPlayer == unit.owner()) {
return Relationship.Self;
}
if (this.myPlayer.isAlliedWith(unit.owner())) {
return Relationship.Ally;
}
return Relationship.Enemy;
// Paint inner territory
for (const t of this.game.bfs(unit.tile(), euclDistFN(unit.tile(), 1))) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.territoryColor(unit.owner().info()),
255,
);
}
}
private handleShellEvent(unit: UnitView) {
const rel = this.relationship(unit);
// 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);
this.clearCell(this.game.x(oldTile), this.game.y(oldTile));
}
onUnitEvent(unit: UnitView) {
switch (unit.type()) {
case UnitType.TransportShip:
this.handleBoatEvent(unit);
break;
case UnitType.Destroyer:
this.handleDestroyerEvent(unit);
break;
case UnitType.Battleship:
this.handleBattleshipEvent(unit);
break;
case UnitType.Shell:
this.handleShellEvent(unit);
break;
case UnitType.TradeShip:
this.handleTradeShipEvent(unit);
break;
case UnitType.AtomBomb:
case UnitType.HydrogenBomb:
this.handleNuke(unit);
break;
}
this.oldShellTile.set(unit, unit.lastTile());
if (!unit.isActive()) {
return;
}
private handleDestroyerEvent(unit: UnitView) {
const rel = this.relationship(unit);
// Paint current and previous positions
this.paintCell(
this.game.x(unit.tile()),
this.game.y(unit.tile()),
rel,
this.theme.borderColor(unit.owner().info()),
255,
);
this.paintCell(
this.game.x(unit.lastTile()),
this.game.y(unit.lastTile()),
rel,
this.theme.borderColor(unit.owner().info()),
255,
);
}
// Clear previous area
for (const t of this.game.bfs(unit.lastTile(), euclDistFN(unit.lastTile(), 4))) {
this.clearCell(this.game.x(t), this.game.y(t));
}
private handleNuke(unit: UnitView) {
const rel = this.relationship(unit);
if (!unit.isActive()) {
return;
}
// Paint border
for (const t of this.game.bfs(unit.tile(), euclDistFN(unit.tile(), 4))) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.borderColor(unit.owner().info()),
255
);
}
// Paint territory
for (const t of this.game.bfs(unit.tile(), manhattanDistFN(unit.tile(), 3))) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.territoryColor(unit.owner().info()),
255
);
}
// Clear previous area
for (const t of this.game.bfs(
unit.lastTile(),
euclDistFN(unit.lastTile(), 2),
)) {
this.clearCell(this.game.x(t), this.game.y(t));
}
private handleBattleshipEvent(unit: UnitView) {
const rel = this.relationship(unit);
// Clear previous area
for (const t of this.game.bfs(unit.lastTile(), euclDistFN(unit.lastTile(), 6))) {
this.clearCell(this.game.x(t), this.game.y(t));
}
if (!unit.isActive()) {
return;
}
// Paint outer territory
for (const t of this.game.bfs(unit.tile(), euclDistFN(unit.tile(), 5))) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.territoryColor(unit.owner().info()),
255
);
}
// Paint border
for (const t of this.game.bfs(unit.tile(), manhattanDistFN(unit.tile(), 4))) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.borderColor(unit.owner().info()),
255
);
}
// Paint inner territory
for (const t of this.game.bfs(unit.tile(), euclDistFN(unit.tile(), 1))) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.territoryColor(unit.owner().info()),
255
);
}
}
private handleShellEvent(unit: UnitView) {
const rel = this.relationship(unit);
// 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);
this.clearCell(this.game.x(oldTile), this.game.y(oldTile));
}
this.oldShellTile.set(unit, unit.lastTile());
if (!unit.isActive()) {
return;
}
// Paint current and previous positions
if (unit.isActive()) {
// Paint area
for (const t of this.game.bfs(unit.tile(), euclDistFN(unit.tile(), 2))) {
this.paintCell(
this.game.x(unit.tile()),
this.game.y(unit.tile()),
rel,
this.theme.borderColor(unit.owner().info()),
255
this.game.x(t),
this.game.y(t),
rel,
this.theme.borderColor(unit.owner().info()),
255,
);
}
}
}
private handleTradeShipEvent(unit: UnitView) {
const rel = this.relationship(unit);
// Clear previous area
for (const t of this.game.bfs(
unit.lastTile(),
euclDistFN(unit.lastTile(), 3),
)) {
this.clearCell(this.game.x(t), this.game.y(t));
}
if (unit.isActive()) {
// Paint territory
for (const t of this.game.bfs(
unit.tile(),
manhattanDistFN(unit.tile(), 2),
)) {
this.paintCell(
this.game.x(unit.lastTile()),
this.game.y(unit.lastTile()),
rel,
this.theme.borderColor(unit.owner().info()),
255
this.game.x(t),
this.game.y(t),
rel,
this.theme.territoryColor(unit.owner().info()),
255,
);
}
// Paint border
for (const t of this.game.bfs(
unit.tile(),
manhattanDistFN(unit.tile(), 1),
)) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.borderColor(unit.owner().info()),
255,
);
}
}
}
private handleBoatEvent(unit: UnitView) {
const rel = this.relationship(unit);
if (!this.boatToTrail.has(unit)) {
this.boatToTrail.set(unit, new Set<TileRef>());
}
const trail = this.boatToTrail.get(unit);
trail.add(unit.lastTile());
// Clear previous area
for (const t of this.game.bfs(
unit.lastTile(),
manhattanDistFN(unit.lastTile(), 3),
)) {
this.clearCell(this.game.x(t), this.game.y(t));
}
private handleNuke(unit: UnitView) {
const rel = this.relationship(unit);
if (unit.isActive()) {
// Paint trail
for (const t of trail) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.territoryColor(unit.owner().info()),
150,
);
}
// Clear previous area
for (const t of this.game.bfs(unit.lastTile(), euclDistFN(unit.lastTile(), 2))) {
this.clearCell(this.game.x(t), this.game.y(t));
}
// Paint border
for (const t of this.game.bfs(
unit.tile(),
manhattanDistFN(unit.tile(), 2),
)) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.borderColor(unit.owner().info()),
255,
);
}
if (unit.isActive()) {
// Paint area
for (const t of this.game.bfs(unit.tile(), euclDistFN(unit.tile(), 2))) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.borderColor(unit.owner().info()),
255
);
}
}
// Paint territory
for (const t of this.game.bfs(
unit.tile(),
manhattanDistFN(unit.tile(), 1),
)) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.territoryColor(unit.owner().info()),
255,
);
}
} else {
for (const t of trail) {
this.clearCell(this.game.x(t), this.game.y(t));
}
this.boatToTrail.delete(unit);
}
}
private handleTradeShipEvent(unit: UnitView) {
const rel = this.relationship(unit);
// Clear previous area
for (const t of this.game.bfs(unit.lastTile(), euclDistFN(unit.lastTile(), 3))) {
this.clearCell(this.game.x(t), this.game.y(t));
}
if (unit.isActive()) {
// Paint territory
for (const t of this.game.bfs(unit.tile(), manhattanDistFN(unit.tile(), 2))) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.territoryColor(unit.owner().info()),
255
);
}
// Paint border
for (const t of this.game.bfs(unit.tile(), manhattanDistFN(unit.tile(), 1))) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.borderColor(unit.owner().info()),
255
);
}
}
paintCell(
x: number,
y: number,
relationship: Relationship,
color: Colord,
alpha: number,
) {
this.clearCell(x, y);
if (this.alternateView) {
switch (relationship) {
case Relationship.Self:
this.context.fillStyle = this.theme.selfColor().toRgbString();
break;
case Relationship.Ally:
this.context.fillStyle = this.theme.allyColor().toRgbString();
break;
case Relationship.Enemy:
this.context.fillStyle = this.theme.enemyColor().toRgbString();
break;
}
} else {
this.context.fillStyle = color.alpha(alpha / 255).toRgbString();
}
this.context.fillRect(x, y, 1, 1);
}
private handleBoatEvent(unit: UnitView) {
const rel = this.relationship(unit);
if (!this.boatToTrail.has(unit)) {
this.boatToTrail.set(unit, new Set<TileRef>());
}
const trail = this.boatToTrail.get(unit);
trail.add(unit.lastTile());
// Clear previous area
for (const t of this.game.bfs(unit.lastTile(), manhattanDistFN(unit.lastTile(), 3))) {
this.clearCell(this.game.x(t), this.game.y(t));
}
if (unit.isActive()) {
// Paint trail
for (const t of trail) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.territoryColor(unit.owner().info()),
150
);
}
// Paint border
for (const t of this.game.bfs(unit.tile(), manhattanDistFN(unit.tile(), 2))) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.borderColor(unit.owner().info()),
255
);
}
// Paint territory
for (const t of this.game.bfs(unit.tile(), manhattanDistFN(unit.tile(), 1))) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.territoryColor(unit.owner().info()),
255
);
}
} else {
for (const t of trail) {
this.clearCell(this.game.x(t), this.game.y(t));
}
this.boatToTrail.delete(unit);
}
}
paintCell(x: number, y: number, relationship: Relationship, color: Colord, alpha: number) {
this.clearCell(x, y);
if (this.alternateView) {
switch (relationship) {
case Relationship.Self:
this.context.fillStyle = this.theme.selfColor().toRgbString();
break;
case Relationship.Ally:
this.context.fillStyle = this.theme.allyColor().toRgbString();
break;
case Relationship.Enemy:
this.context.fillStyle = this.theme.enemyColor().toRgbString();
break;
}
} else {
this.context.fillStyle = color.alpha(alpha / 255).toRgbString();
}
this.context.fillRect(x, y, 1, 1);
}
clearCell(x: number, y: number) {
this.context.clearRect(x, y, 1, 1);
}
}
clearCell(x: number, y: number) {
this.context.clearRect(x, y, 1, 1);
}
}
+289 -289
View File
@@ -1,328 +1,328 @@
import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { Player } from '../../../core/game/Game';
import { ClientID } from '../../../core/Schemas';
import { GameView, PlayerView } from '../../../core/game/GameView';
import { Layer } from './Layer';
import { GameUpdateType } from '../../../core/game/GameUpdates';
import { PseudoRandom } from '../../../core/PseudoRandom';
import { simpleHash } from '../../../core/Util';
import { EventBus } from '../../../core/EventBus';
import { SendWinnerEvent } from '../../Transport';
import { LitElement, html, css } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { Player } from "../../../core/game/Game";
import { ClientID } from "../../../core/Schemas";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { Layer } from "./Layer";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { PseudoRandom } from "../../../core/PseudoRandom";
import { simpleHash } from "../../../core/Util";
import { EventBus } from "../../../core/EventBus";
import { SendWinnerEvent } from "../../Transport";
const lowRadiationVictoryQuotes = [
"Victory is mine. The world endures - under new management.",
"They thought they could stop me. Now they serve me.",
"The old order has fallen. My reign begins.",
"Not every victory requires destruction.",
"From this day forward, all will know who rules.",
"The war is over. Long live the victor.",
"A new empire rises - with me at its helm.",
"The throne is claimed. The crown is mine.",
"Today marks the beginning of my dynasty.",
"They feared my wrath. Now they'll know my rule.",
"Victory was inevitable. Surrender was optional.",
"A new era dawns - under my command.",
"The pieces are in place. The victory is complete.",
"Let history remember who conquered all.",
"Their resistance only delayed the inevitable.",
"Power shifts. Empires fall. I remain.",
"The world has a new master now.",
"Their armies fell. Their nations surrendered. I prevailed.",
"All paths led to this victory.",
"The world bows to its new ruler.",
"From the ashes of their defeat, my victory rises.",
"Destiny called. I answered. The world followed.",
"They called me tyrant. Now they call me emperor.",
"The old powers have fallen. Mine endures.",
"Every empire needs a beginning. This is mine.",
"Their defiance crumbled before my ambition.",
"No more rebels. No more resistance. Only order.",
"The final piece falls into place.",
"Let them write of this day in their histories.",
"My vision becomes reality.",
"Their surrender was wise. My victory was certain.",
"The wheels of fate turn in my favor.",
"A new chapter begins - written by the victor.",
"They fought the inevitable. The inevitable won.",
"All that was theirs is now mine.",
"The gods themselves bow before my triumph.",
"Their kingdoms shatter. My empire rises.",
"Victory tastes sweeter than wine.",
"The crown suits me well, don't you think?",
"Behold the dawn of my eternal reign."
"Victory is mine. The world endures - under new management.",
"They thought they could stop me. Now they serve me.",
"The old order has fallen. My reign begins.",
"Not every victory requires destruction.",
"From this day forward, all will know who rules.",
"The war is over. Long live the victor.",
"A new empire rises - with me at its helm.",
"The throne is claimed. The crown is mine.",
"Today marks the beginning of my dynasty.",
"They feared my wrath. Now they'll know my rule.",
"Victory was inevitable. Surrender was optional.",
"A new era dawns - under my command.",
"The pieces are in place. The victory is complete.",
"Let history remember who conquered all.",
"Their resistance only delayed the inevitable.",
"Power shifts. Empires fall. I remain.",
"The world has a new master now.",
"Their armies fell. Their nations surrendered. I prevailed.",
"All paths led to this victory.",
"The world bows to its new ruler.",
"From the ashes of their defeat, my victory rises.",
"Destiny called. I answered. The world followed.",
"They called me tyrant. Now they call me emperor.",
"The old powers have fallen. Mine endures.",
"Every empire needs a beginning. This is mine.",
"Their defiance crumbled before my ambition.",
"No more rebels. No more resistance. Only order.",
"The final piece falls into place.",
"Let them write of this day in their histories.",
"My vision becomes reality.",
"Their surrender was wise. My victory was certain.",
"The wheels of fate turn in my favor.",
"A new chapter begins - written by the victor.",
"They fought the inevitable. The inevitable won.",
"All that was theirs is now mine.",
"The gods themselves bow before my triumph.",
"Their kingdoms shatter. My empire rises.",
"Victory tastes sweeter than wine.",
"The crown suits me well, don't you think?",
"Behold the dawn of my eternal reign.",
];
const highRadiationVictoryQuotes = [
"Let the world burn. I just want to rule the ashes.",
"The old world died screaming. My new one rises from its bones.",
"They could have surrendered. Now they glow.",
"Everything burns. The throne of ashes awaits.",
"A wasteland needs a king. I have answered the call.",
"Their cities turned to glass. My victory endures.",
"Who needs a pristine world when you can rule its ruins?",
"They feared the fire. I embraced it.",
"The radiation clears the way for my reign.",
"A dead world makes for quiet subjects.",
"From atomic fire, my kingdom rises.",
"Nothing left but ashes and victory.",
"They wanted war. I gave them annihilation.",
"The world burns green. My empire glows eternal.",
"All thrones are built on ashes. Mine just glows.",
"Look upon my works and despair - if you can still see.",
"The mushroom clouds herald my coronation.",
"A crown of thorns for a radioactive realm.",
"They chose extinction. I chose supremacy.",
"The wasteland's throne is mine to claim.",
"The Geiger counter clicks. The masses bow.",
"Atoms split. Nations fall. I reign supreme.",
"My kingdom radiates with possibility.",
"The isotopes of victory decay slowly.",
"Critical mass achieved. Dominion secured.",
"Chain reaction complete: world falls, empire rises.",
"In nuclear fire, I forge my legacy."
"Let the world burn. I just want to rule the ashes.",
"The old world died screaming. My new one rises from its bones.",
"They could have surrendered. Now they glow.",
"Everything burns. The throne of ashes awaits.",
"A wasteland needs a king. I have answered the call.",
"Their cities turned to glass. My victory endures.",
"Who needs a pristine world when you can rule its ruins?",
"They feared the fire. I embraced it.",
"The radiation clears the way for my reign.",
"A dead world makes for quiet subjects.",
"From atomic fire, my kingdom rises.",
"Nothing left but ashes and victory.",
"They wanted war. I gave them annihilation.",
"The world burns green. My empire glows eternal.",
"All thrones are built on ashes. Mine just glows.",
"Look upon my works and despair - if you can still see.",
"The mushroom clouds herald my coronation.",
"A crown of thorns for a radioactive realm.",
"They chose extinction. I chose supremacy.",
"The wasteland's throne is mine to claim.",
"The Geiger counter clicks. The masses bow.",
"Atoms split. Nations fall. I reign supreme.",
"My kingdom radiates with possibility.",
"The isotopes of victory decay slowly.",
"Critical mass achieved. Dominion secured.",
"Chain reaction complete: world falls, empire rises.",
"In nuclear fire, I forge my legacy.",
];
export const defeatQuotes = [
// Last words and final thoughts
"The flame of our nation flickers out...",
"History will remember we fought to the last.",
"Our glory fades into darkness.",
"The end comes for all nations. Today, it comes for us.",
"We fought. We failed. We fade.",
"Our time in the sun is done.",
"The pages of history close on our chapter.",
"So falls the dream of empire.",
"We built in stone, but even stone crumbles.",
"The stars themselves will remember our defiance.",
// Last words and final thoughts
"The flame of our nation flickers out...",
"History will remember we fought to the last.",
"Our glory fades into darkness.",
"The end comes for all nations. Today, it comes for us.",
"We fought. We failed. We fade.",
"Our time in the sun is done.",
"The pages of history close on our chapter.",
"So falls the dream of empire.",
"We built in stone, but even stone crumbles.",
"The stars themselves will remember our defiance.",
// Bitter defeats
"Treachery and fate conspired against us.",
"Our enemies dance on the graves of heroes.",
"The vultures circle what remains.",
"Let them celebrate. Dead men need no vengeance.",
"The light dies. The darkness wins.",
"Our walls fall. Our spirit breaks.",
"Victory goes to the ruthless.",
"Time claims another empire.",
"The crown shatters. The throne burns.",
"Our banners fall. Our story ends.",
// Bitter defeats
"Treachery and fate conspired against us.",
"Our enemies dance on the graves of heroes.",
"The vultures circle what remains.",
"Let them celebrate. Dead men need no vengeance.",
"The light dies. The darkness wins.",
"Our walls fall. Our spirit breaks.",
"Victory goes to the ruthless.",
"Time claims another empire.",
"The crown shatters. The throne burns.",
"Our banners fall. Our story ends.",
// Philosophical acceptance
"All great nations must face their sunset.",
"Time is the ultimate conqueror.",
"Today we join the ghosts of fallen empires.",
"What rises must also fall.",
"Our legacy scatters like dust in the wind.",
"The wheel turns. We descend.",
"From glory to ashes, as all things must.",
"The tides of fate show no mercy.",
"Let history judge if we were worthy.",
"We join the eternal silence.",
// Philosophical acceptance
"All great nations must face their sunset.",
"Time is the ultimate conqueror.",
"Today we join the ghosts of fallen empires.",
"What rises must also fall.",
"Our legacy scatters like dust in the wind.",
"The wheel turns. We descend.",
"From glory to ashes, as all things must.",
"The tides of fate show no mercy.",
"Let history judge if we were worthy.",
"We join the eternal silence.",
// Defiant last stands
"Our spirit remains unbroken.",
"They may take our lands, but not our pride.",
"Remember us as we were, not as we fell.",
"We chose death before dishonor.",
"Our courage lives beyond our defeat.",
"Let them write of how we stood fast.",
"We fall, but we fall fighting.",
"Honor guides us to our end.",
"Death before surrender.",
"The echoes of our defiance will ring eternal.",
// Defiant last stands
"Our spirit remains unbroken.",
"They may take our lands, but not our pride.",
"Remember us as we were, not as we fell.",
"We chose death before dishonor.",
"Our courage lives beyond our defeat.",
"Let them write of how we stood fast.",
"We fall, but we fall fighting.",
"Honor guides us to our end.",
"Death before surrender.",
"The echoes of our defiance will ring eternal.",
// Prophetic/Cursed
"Our shadow will haunt their victory.",
"They'll learn the price of empire.",
"Time will prove our cause was just.",
"The seeds of their downfall are sown in our ashes.",
"Our fall heralds their doom.",
"Victory today. Nemesis tomorrow.",
"The wheel turns for all.",
"They'll remember us in their nightmares.",
"Our curse follows them to their graves.",
"What rises in our place will shake the world."
// Prophetic/Cursed
"Our shadow will haunt their victory.",
"They'll learn the price of empire.",
"Time will prove our cause was just.",
"The seeds of their downfall are sown in our ashes.",
"Our fall heralds their doom.",
"Victory today. Nemesis tomorrow.",
"The wheel turns for all.",
"They'll remember us in their nightmares.",
"Our curse follows them to their graves.",
"What rises in our place will shake the world.",
];
@customElement('win-modal')
@customElement("win-modal")
export class WinModal extends LitElement implements Layer {
public game: GameView
public eventBus: EventBus
public game: GameView;
public eventBus: EventBus;
private rand: PseudoRandom;
private rand: PseudoRandom;
private hasShownDeathModal = false;
private hasShownDeathModal = false
@state()
isVisible = false;
@state()
isVisible = false
private _title: string;
private message: string;
private _title: string
private message: string
static styles = css`
:host {
display: block;
}
static styles = css`
:host {
display: block;
}
.modal {
display: none;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: rgba(30, 30, 30, 0.7);
padding: 25px;
border-radius: 10px;
z-index: 9999;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
backdrop-filter: blur(5px);
color: white;
width: 300px;
transition:
opacity 0.3s ease-in-out,
visibility 0.3s ease-in-out;
}
.modal {
display: none;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: rgba(30, 30, 30, 0.7);
padding: 25px;
border-radius: 10px;
z-index: 9999;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
backdrop-filter: blur(5px);
color: white;
width: 300px;
transition: opacity 0.3s ease-in-out, visibility 0.3s ease-in-out;
}
.modal.visible {
display: block;
animation: fadeIn 0.3s ease-out;
}
.modal.visible {
display: block;
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translate(-50%, -48%);
}
to {
opacity: 1;
transform: translate(-50%, -50%);
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translate(-50%, -48%);
}
to {
opacity: 1;
transform: translate(-50%, -50%);
}
}
h2 {
margin: 0 0 15px 0;
font-size: 24px;
text-align: center;
color: white;
}
h2 {
margin: 0 0 15px 0;
font-size: 24px;
text-align: center;
color: white;
}
p {
margin: 0 0 20px 0;
text-align: center;
background-color: rgba(0, 0, 0, 0.3);
padding: 10px;
border-radius: 5px;
}
p {
margin: 0 0 20px 0;
text-align: center;
background-color: rgba(0, 0, 0, 0.3);
padding: 10px;
border-radius: 5px;
}
.button-container {
display: flex;
justify-content: space-between;
gap: 10px;
}
.button-container {
display: flex;
justify-content: space-between;
gap: 10px;
}
button {
flex: 1;
padding: 12px;
font-size: 16px;
cursor: pointer;
background: rgba(0, 150, 255, 0.6);
color: white;
border: none;
border-radius: 5px;
transition:
background-color 0.2s ease,
transform 0.1s ease;
}
button {
flex: 1;
padding: 12px;
font-size: 16px;
cursor: pointer;
background: rgba(0, 150, 255, 0.6);
color: white;
border: none;
border-radius: 5px;
transition: background-color 0.2s ease, transform 0.1s ease;
}
button:hover {
background: rgba(0, 150, 255, 0.8);
transform: translateY(-1px);
}
button:hover {
background: rgba(0, 150, 255, 0.8);
transform: translateY(-1px);
}
button:active {
transform: translateY(1px);
}
button:active {
transform: translateY(1px);
}
@media (max-width: 768px) {
.modal {
width: 90%;
max-width: 300px;
padding: 20px;
}
@media (max-width: 768px) {
.modal {
width: 90%;
max-width: 300px;
padding: 20px;
}
h2 {
font-size: 20px;
}
h2 {
font-size: 20px;
}
button {
padding: 10px;
font-size: 14px;
}
}
`;
button {
padding: 10px;
font-size: 14px;
}
}
render() {
return html`
<div class="modal ${this.isVisible ? "visible" : ""}">
<h2>${this._title}</h2>
<p>${this.message}</p>
<div class="button-container">
<button @click=${this._handleExit}>Exit Game</button>
<button @click=${this.hide}>Keep Playing</button>
</div>
</div>
`;
}
render() {
return html`
<div class="modal ${this.isVisible ? 'visible' : ''}">
<h2>${this._title}</h2>
<p>${this.message}</p>
<div class="button-container">
<button @click=${this._handleExit}>Exit Game</button>
<button @click=${this.hide}>Keep Playing</button>
</div>
</div>
`;
show() {
this.isVisible = true;
this.requestUpdate();
}
hide() {
this.isVisible = false;
this.requestUpdate();
}
private _handleExit() {
this.hide();
window.location.reload();
}
init() {
this.rand = new PseudoRandom(simpleHash(this.game.myClientID()));
}
tick() {
const myPlayer = this.game.myPlayer();
if (!this.hasShownDeathModal && myPlayer && !myPlayer.isAlive()) {
this.hasShownDeathModal = true;
this._title = "You died";
this.message = this.rand.randElement(defeatQuotes);
this.show();
}
show() {
this.isVisible = true;
this.requestUpdate();
}
hide() {
this.isVisible = false;
this.requestUpdate();
}
private _handleExit() {
this.hide();
window.location.reload();
}
init() {
this.rand = new PseudoRandom(simpleHash(this.game.myClientID()))
}
tick() {
const myPlayer = this.game.myPlayer()
if (!this.hasShownDeathModal && myPlayer && !myPlayer.isAlive()) {
this.hasShownDeathModal = true
this._title = 'You died'
this.message = this.rand.randElement(defeatQuotes)
this.show()
this.game.updatesSinceLastTick()[GameUpdateType.WinUpdate].forEach((wu) => {
const winner = this.game.playerBySmallID(wu.winnerID) as PlayerView;
this.eventBus.emit(new SendWinnerEvent(winner.clientID()));
if (winner == this.game.myPlayer()) {
this._title = "You Won!";
if (this.game.numTilesWithFallout() / this.game.numLandTiles() > 0.6) {
this.message = this.rand.randElement(highRadiationVictoryQuotes);
} else {
this.message = this.rand.randElement(lowRadiationVictoryQuotes);
}
this.game.updatesSinceLastTick()[GameUpdateType.WinUpdate]
.forEach(wu => {
const winner = this.game.playerBySmallID(wu.winnerID) as PlayerView
this.eventBus.emit(new SendWinnerEvent(winner.clientID()))
if (winner == this.game.myPlayer()) {
this._title = 'You Won!'
if (this.game.numTilesWithFallout() / this.game.numLandTiles() > .6) {
this.message = this.rand.randElement(highRadiationVictoryQuotes)
} else {
this.message = this.rand.randElement(lowRadiationVictoryQuotes)
}
} else {
this._title = `${winner.name()} has won!`
this.message = this.rand.randElement(defeatQuotes)
}
this.show()
})
}
} else {
this._title = `${winner.name()} has won!`;
this.message = this.rand.randElement(defeatQuotes);
}
this.show();
});
}
renderLayer(context: CanvasRenderingContext2D) {
}
renderLayer(context: CanvasRenderingContext2D) {}
shouldTransform(): boolean {
return false
}
}
shouldTransform(): boolean {
return false;
}
}
+261 -230
View File
@@ -1,246 +1,277 @@
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { EventBus } from '../../../../core/EventBus';
import { Cell, Game, Player, PlayerActions, UnitType } from '../../../../core/game/Game';
import { BuildUnitIntentEvent } from '../../../Transport';
import atomBombIcon from '../../../../../resources/images/NukeIconWhite.svg';
import hydrogenBombIcon from '../../../../../resources/images/MushroomCloudIconWhite.svg';
import destroyerIcon from '../../../../../resources/images/DestroyerIconWhite.svg';
import battleshipIcon from '../../../../../resources/images/BattleshipIconWhite.svg';
import missileSiloIcon from '../../../../../resources/images/MissileSiloIconWhite.svg';
import goldCoinIcon from '../../../../../resources/images/GoldCoinIcon.svg';
import portIcon from '../../../../../resources/images/PortIcon.svg';
import shieldIcon from '../../../../../resources/images/ShieldIconWhite.svg';
import cityIcon from '../../../../../resources/images/CityIconWhite.svg';
import { renderNumber } from '../../../Utils';
import { ContextMenuEvent } from '../../../InputHandler';
import { GameView, PlayerView } from '../../../../core/game/GameView';
import { LitElement, html, css } from "lit";
import { customElement, state } from "lit/decorators.js";
import { EventBus } from "../../../../core/EventBus";
import {
Cell,
Game,
Player,
PlayerActions,
UnitType,
} from "../../../../core/game/Game";
import { BuildUnitIntentEvent } from "../../../Transport";
import atomBombIcon from "../../../../../resources/images/NukeIconWhite.svg";
import hydrogenBombIcon from "../../../../../resources/images/MushroomCloudIconWhite.svg";
import destroyerIcon from "../../../../../resources/images/DestroyerIconWhite.svg";
import battleshipIcon from "../../../../../resources/images/BattleshipIconWhite.svg";
import missileSiloIcon from "../../../../../resources/images/MissileSiloIconWhite.svg";
import goldCoinIcon from "../../../../../resources/images/GoldCoinIcon.svg";
import portIcon from "../../../../../resources/images/PortIcon.svg";
import shieldIcon from "../../../../../resources/images/ShieldIconWhite.svg";
import cityIcon from "../../../../../resources/images/CityIconWhite.svg";
import { renderNumber } from "../../../Utils";
import { ContextMenuEvent } from "../../../InputHandler";
import { GameView, PlayerView } from "../../../../core/game/GameView";
interface BuildItemDisplay {
unitType: UnitType
icon: string;
unitType: UnitType;
icon: string;
}
const buildTable: BuildItemDisplay[][] = [
[
{ unitType: UnitType.AtomBomb, icon: atomBombIcon },
{ unitType: UnitType.HydrogenBomb, icon: hydrogenBombIcon },
{ unitType: UnitType.Destroyer, icon: destroyerIcon },
{ unitType: UnitType.Battleship, icon: battleshipIcon },
{ unitType: UnitType.Port, icon: portIcon },
{ unitType: UnitType.MissileSilo, icon: missileSiloIcon },
// { unitType: UnitType.DefensePost, icon: shieldIcon },
{ unitType: UnitType.City, icon: cityIcon }
]
[
{ unitType: UnitType.AtomBomb, icon: atomBombIcon },
{ unitType: UnitType.HydrogenBomb, icon: hydrogenBombIcon },
{ unitType: UnitType.Destroyer, icon: destroyerIcon },
{ unitType: UnitType.Battleship, icon: battleshipIcon },
{ unitType: UnitType.Port, icon: portIcon },
{ unitType: UnitType.MissileSilo, icon: missileSiloIcon },
// { unitType: UnitType.DefensePost, icon: shieldIcon },
{ unitType: UnitType.City, icon: cityIcon },
],
];
@customElement('build-menu')
@customElement("build-menu")
export class BuildMenu extends LitElement {
public game: GameView;
public eventBus: EventBus;
private myPlayer: PlayerView;
private clickedCell: Cell;
private playerActions: PlayerActions | null
public game: GameView;
public eventBus: EventBus;
private myPlayer: PlayerView;
private clickedCell: Cell;
private playerActions: PlayerActions | null;
static styles = css`
:host {
display: block;
}
.build-menu {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 9999;
background-color: #1E1E1E;
padding: 15px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
border-radius: 10px;
display: flex;
flex-direction: column;
align-items: center;
max-width: 95vw;
max-height: 95vh;
overflow-y: auto;
}
.build-row {
display: flex;
justify-content: center;
flex-wrap: wrap;
width: 100%;
}
.build-button {
width: 120px;
height: 120px;
border: 2px solid #444;
background-color: #2C2C2C;
color: white;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
margin: 8px;
padding: 10px;
}
.build-button:not(:disabled):hover {
background-color: #3A3A3A;
transform: scale(1.05);
border-color: #666;
}
.build-button:not(:disabled):active {
background-color: #4A4A4A;
transform: scale(0.95);
}
.build-button:disabled {
background-color: #1A1A1A;
border-color: #333;
cursor: not-allowed;
opacity: 0.7;
}
.build-button:disabled img {
opacity: 0.5;
}
.build-button:disabled .build-cost {
color: #FF4444;
}
.build-icon {
font-size: 40px;
margin-bottom: 5px;
}
.build-name {
font-size: 14px;
font-weight: bold;
margin-bottom: 5px;
text-align: center;
}
.build-cost {
font-size: 14px;
}
.hidden {
display: none !important;
}
@media (max-width: 768px) {
.build-menu {
padding: 10px;
max-height: 80vh;
}
.build-button {
width: 90px;
height: 90px;
margin: 4px;
padding: 6px;
}
.build-icon {
font-size: 28px;
}
.build-name {
font-size: 12px;
margin-bottom: 3px;
}
.build-cost {
font-size: 11px;
}
.build-button img {
width: 32px;
height: 32px;
}
}
@media (max-width: 480px) {
.build-menu {
padding: 8px;
max-height: 70vh;
}
.build-button {
width: 70px;
height: 70px;
margin: 3px;
padding: 4px;
border-width: 1px;
}
.build-icon {
font-size: 24px;
}
.build-name {
font-size: 10px;
margin-bottom: 2px;
}
.build-cost {
font-size: 9px;
}
.build-button img {
width: 24px;
height: 24px;
}
.build-cost img {
width: 10px;
height: 10px;
}
}
`;
@state()
private _hidden = true;
private canBuild(item: BuildItemDisplay): boolean {
if (this.myPlayer == null || this.playerActions == null) {
return false
}
return this.playerActions.buildableUnits.some(u => u == item.unitType)
static styles = css`
:host {
display: block;
}
.build-menu {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 9999;
background-color: #1e1e1e;
padding: 15px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
border-radius: 10px;
display: flex;
flex-direction: column;
align-items: center;
max-width: 95vw;
max-height: 95vh;
overflow-y: auto;
}
.build-row {
display: flex;
justify-content: center;
flex-wrap: wrap;
width: 100%;
}
.build-button {
width: 120px;
height: 120px;
border: 2px solid #444;
background-color: #2c2c2c;
color: white;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
margin: 8px;
padding: 10px;
}
.build-button:not(:disabled):hover {
background-color: #3a3a3a;
transform: scale(1.05);
border-color: #666;
}
.build-button:not(:disabled):active {
background-color: #4a4a4a;
transform: scale(0.95);
}
.build-button:disabled {
background-color: #1a1a1a;
border-color: #333;
cursor: not-allowed;
opacity: 0.7;
}
.build-button:disabled img {
opacity: 0.5;
}
.build-button:disabled .build-cost {
color: #ff4444;
}
.build-icon {
font-size: 40px;
margin-bottom: 5px;
}
.build-name {
font-size: 14px;
font-weight: bold;
margin-bottom: 5px;
text-align: center;
}
.build-cost {
font-size: 14px;
}
.hidden {
display: none !important;
}
public onBuildSelected = (item: BuildItemDisplay) => {
this.eventBus.emit(new BuildUnitIntentEvent(item.unitType, this.clickedCell))
this.hideMenu()
};
@media (max-width: 768px) {
.build-menu {
padding: 10px;
max-height: 80vh;
}
.build-button {
width: 90px;
height: 90px;
margin: 4px;
padding: 6px;
}
.build-icon {
font-size: 28px;
}
.build-name {
font-size: 12px;
margin-bottom: 3px;
}
.build-cost {
font-size: 11px;
}
.build-button img {
width: 32px;
height: 32px;
}
}
render() {
return html`
<div class="build-menu ${this._hidden ? 'hidden' : ''}">
${buildTable.map(row => html`
<div class="build-row">
${row.map(item => html`
<button
class="build-button"
@click=${() => this.onBuildSelected(item)}
?disabled=${!this.canBuild(item)}
title=${!this.canBuild(item) ? 'Not enough money' : ''}
>
<img src=${item.icon} alt="${item.unitType}" width="40" height="40">
<span class="build-name">${item.unitType}</span>
<span class="build-cost">
${renderNumber(this.game && this.myPlayer ? this.game.unitInfo(item.unitType).cost(this.myPlayer) : 0)}
<img src=${goldCoinIcon} alt="gold" width="12" height="12" style="vertical-align: middle;">
</span>
</button>
`)}
</div>
`)}
@media (max-width: 480px) {
.build-menu {
padding: 8px;
max-height: 70vh;
}
.build-button {
width: 70px;
height: 70px;
margin: 3px;
padding: 4px;
border-width: 1px;
}
.build-icon {
font-size: 24px;
}
.build-name {
font-size: 10px;
margin-bottom: 2px;
}
.build-cost {
font-size: 9px;
}
.build-button img {
width: 24px;
height: 24px;
}
.build-cost img {
width: 10px;
height: 10px;
}
}
`;
@state()
private _hidden = true;
private canBuild(item: BuildItemDisplay): boolean {
if (this.myPlayer == null || this.playerActions == null) {
return false;
}
return this.playerActions.buildableUnits.some((u) => u == item.unitType);
}
public onBuildSelected = (item: BuildItemDisplay) => {
this.eventBus.emit(
new BuildUnitIntentEvent(item.unitType, this.clickedCell),
);
this.hideMenu();
};
render() {
return html`
<div class="build-menu ${this._hidden ? "hidden" : ""}">
${buildTable.map(
(row) => html`
<div class="build-row">
${row.map(
(item) => html`
<button
class="build-button"
@click=${() => this.onBuildSelected(item)}
?disabled=${!this.canBuild(item)}
title=${!this.canBuild(item) ? "Not enough money" : ""}
>
<img
src=${item.icon}
alt="${item.unitType}"
width="40"
height="40"
/>
<span class="build-name">${item.unitType}</span>
<span class="build-cost">
${renderNumber(
this.game && this.myPlayer
? this.game
.unitInfo(item.unitType)
.cost(this.myPlayer)
: 0,
)}
<img
src=${goldCoinIcon}
alt="gold"
width="12"
height="12"
style="vertical-align: middle;"
/>
</span>
</button>
`,
)}
</div>
`;
}
`,
)}
</div>
`;
}
hideMenu() {
this._hidden = true;
hideMenu() {
this._hidden = true;
this.requestUpdate();
}
showMenu(player: PlayerView, clickedCell: Cell) {
player
.actions(this.game.ref(clickedCell.x, clickedCell.y))
.then((actions) => {
console.log(`got actions: ${JSON.stringify(actions)}`);
this.playerActions = actions;
this.myPlayer = player;
this.clickedCell = clickedCell;
this._hidden = false;
this.requestUpdate();
}
});
}
showMenu(player: PlayerView, clickedCell: Cell) {
player.actions(this.game.ref(clickedCell.x, clickedCell.y)).then(actions => {
console.log(`got actions: ${JSON.stringify(actions)}`)
this.playerActions = actions
this.myPlayer = player;
this.clickedCell = clickedCell;
this._hidden = false;
this.requestUpdate();
})
}
get isVisible() {
return !this._hidden;
}
}
get isVisible() {
return !this._hidden;
}
}
+121 -116
View File
@@ -1,129 +1,134 @@
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { LitElement, html, css } from "lit";
import { customElement, state } from "lit/decorators.js";
const emojiTable: string[][] = [
["😀", "😱", "🤡", "😡", "🥺"],
["😈", "👏", "🥉", "🥈", "🥇"],
["🤙", "🥰", "😇", "😊", "🔥"],
["💪", "🏳️", "💀", "😭", "🤦‍♂️"],
["😎", "👎", "👍", "🥱", "💔"],
["❤️", "💰", "🤝", "🖕", "💥"],
["🆘", "🕊️", "➡️", "⬅️", "↙️"],
["↖️", "↗️", "⬆️", "↘️", "⬇️"]
["😀", "😱", "🤡", "😡", "🥺"],
["😈", "👏", "🥉", "🥈", "🥇"],
["🤙", "🥰", "😇", "😊", "🔥"],
["💪", "🏳️", "💀", "😭", "🤦‍♂️"],
["😎", "👎", "👍", "🥱", "💔"],
["❤️", "💰", "🤝", "🖕", "💥"],
["🆘", "🕊️", "➡️", "⬅️", "↙️"],
["↖️", "↗️", "⬆️", "↘️", "⬇️"],
];
@customElement('emoji-table')
@customElement("emoji-table")
export class EmojiTable extends LitElement {
static styles = css`
:host {
display: block;
}
.emoji-table {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 9999;
background-color: #1E1E1E;
padding: 15px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
border-radius: 10px;
display: flex;
flex-direction: column;
align-items: center;
max-width: 95vw;
max-height: 95vh;
overflow-y: auto;
}
.emoji-row {
display: flex;
justify-content: center;
width: 100%;
}
.emoji-button {
font-size: 60px;
width: 80px;
height: 80px;
border: 1px solid #333;
background-color: #2C2C2C;
color: white;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
justify-content: center;
align-items: center;
margin: 8px;
}
.emoji-button:hover {
background-color: #3A3A3A;
transform: scale(1.1);
}
.emoji-button:active {
background-color: #4A4A4A;
transform: scale(0.95);
}
.hidden {
display: none !important;
}
@media (max-width: 600px) {
.emoji-button {
font-size: 32px;
/* Slightly smaller font size for mobile */
width: 60px;
/* Smaller width for mobile */
height: 60px;
/* Smaller height for mobile */
margin: 5px;
/* Smaller margin for mobile */
}
}
static styles = css`
:host {
display: block;
}
.emoji-table {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 9999;
background-color: #1e1e1e;
padding: 15px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
border-radius: 10px;
display: flex;
flex-direction: column;
align-items: center;
max-width: 95vw;
max-height: 95vh;
overflow-y: auto;
}
.emoji-row {
display: flex;
justify-content: center;
width: 100%;
}
.emoji-button {
font-size: 60px;
width: 80px;
height: 80px;
border: 1px solid #333;
background-color: #2c2c2c;
color: white;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
justify-content: center;
align-items: center;
margin: 8px;
}
.emoji-button:hover {
background-color: #3a3a3a;
transform: scale(1.1);
}
.emoji-button:active {
background-color: #4a4a4a;
transform: scale(0.95);
}
.hidden {
display: none !important;
}
@media (max-width: 400px) {
.emoji-button {
font-size: 28px;
width: 50px;
height: 50px;
margin: 3px;
}
}
`;
@media (max-width: 600px) {
.emoji-button {
font-size: 32px;
/* Slightly smaller font size for mobile */
width: 60px;
/* Smaller width for mobile */
height: 60px;
/* Smaller height for mobile */
margin: 5px;
/* Smaller margin for mobile */
}
}
@state()
private _hidden = true;
@media (max-width: 400px) {
.emoji-button {
font-size: 28px;
width: 50px;
height: 50px;
margin: 3px;
}
}
`;
public onEmojiClicked: (emoji: string) => void = () => { }
@state()
private _hidden = true;
render() {
return html`
<div class="emoji-table ${this._hidden ? 'hidden' : ''}">
${emojiTable.map(row => html`
<div class="emoji-row">
${row.map(emoji => html`
<button class="emoji-button" @click=${() => this.onEmojiClicked(emoji)}>
${emoji}
</button>
`)}
</div>
`)}
public onEmojiClicked: (emoji: string) => void = () => {};
render() {
return html`
<div class="emoji-table ${this._hidden ? "hidden" : ""}">
${emojiTable.map(
(row) => html`
<div class="emoji-row">
${row.map(
(emoji) => html`
<button
class="emoji-button"
@click=${() => this.onEmojiClicked(emoji)}
>
${emoji}
</button>
`,
)}
</div>
`;
}
`,
)}
</div>
`;
}
hideTable() {
this._hidden = true;
this.requestUpdate();
}
hideTable() {
this._hidden = true;
this.requestUpdate();
showTable() {
this._hidden = false;
this.requestUpdate();
}
}
showTable() {
this._hidden = false;
this.requestUpdate();
}
get isVisible() {
return !this._hidden;
}
}
get isVisible() {
return !this._hidden;
}
}
+11 -11
View File
@@ -97,7 +97,7 @@ export class RadialMenu implements Layer {
private emojiTable: EmojiTable,
private buildMenu: BuildMenu,
private uiState: UIState,
private playerInfoOverlay: PlayerInfoOverlay
private playerInfoOverlay: PlayerInfoOverlay,
) {}
init() {
@@ -106,7 +106,7 @@ export class RadialMenu implements Layer {
this.eventBus.on(ShowBuildMenuEvent, (e) => {
const clickedCell = this.transformHandler.screenToWorldCoordinates(
e.x,
e.y
e.y,
);
if (clickedCell == null) {
return;
@@ -139,7 +139,7 @@ export class RadialMenu implements Layer {
.append("g")
.attr(
"transform",
`translate(${this.menuSize / 2},${this.menuSize / 2})`
`translate(${this.menuSize / 2},${this.menuSize / 2})`,
);
const pie = d3
@@ -162,7 +162,7 @@ export class RadialMenu implements Layer {
.append("path")
.attr("d", arc)
.attr("fill", (d) =>
d.data.disabled ? this.disabledColor : d.data.color
d.data.disabled ? this.disabledColor : d.data.color,
)
.attr("stroke", "#ffffff")
.attr("stroke-width", "2")
@@ -287,7 +287,7 @@ export class RadialMenu implements Layer {
this.clickedCell = this.transformHandler.screenToWorldCoordinates(
event.x,
event.y
event.y,
);
if (!this.g.isValidCoord(this.clickedCell.x, this.clickedCell.y)) {
return;
@@ -316,7 +316,7 @@ export class RadialMenu implements Layer {
private handlePlayerActions(
myPlayer: PlayerView,
actions: PlayerActions,
tile: TileRef
tile: TileRef,
) {
this.activateMenuElement(Slot.Build, "#ebe250", buildIcon, () => {
this.buildMenu.showMenu(myPlayer, this.clickedCell);
@@ -348,8 +348,8 @@ export class RadialMenu implements Layer {
new SendBoatAttackIntentEvent(
this.g.owner(tile).id(),
this.clickedCell,
this.uiState.attackRatio * myPlayer.troops()
)
this.uiState.attackRatio * myPlayer.troops(),
),
);
});
}
@@ -426,8 +426,8 @@ export class RadialMenu implements Layer {
this.eventBus.emit(
new SendAttackIntentEvent(
this.g.owner(clicked).id(),
this.uiState.attackRatio * myPlayer.troops()
)
this.uiState.attackRatio * myPlayer.troops(),
),
);
}
}
@@ -438,7 +438,7 @@ export class RadialMenu implements Layer {
slot: Slot,
color: string,
icon: string,
action: () => void
action: () => void,
) {
const menuItem = this.menuItems.get(slot);
menuItem.action = action;
+1 -1
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8" />
+27 -27
View File
@@ -1,39 +1,39 @@
import { EventBus, GameEvent } from "./EventBus"
import { LogSeverity } from "./Schemas"
import { EventBus, GameEvent } from "./EventBus";
import { LogSeverity } from "./Schemas";
export const consolex = {
log: console.log,
warn: console.warn,
error: console.error
}
log: console.log,
warn: console.warn,
error: console.error,
};
let inited = false
let inited = false;
// Only call this in client/browser!
export function initRemoteSender(eventBus: EventBus) {
if (inited) {
return
}
inited = true
if (inited) {
return;
}
inited = true;
consolex.log = (...args: any[]): void => {
console.log(...args);
// eventBus.emit(new SendLogEvent(LogSeverity.Info, args.join(' ')))
}
consolex.log = (...args: any[]): void => {
console.log(...args);
// eventBus.emit(new SendLogEvent(LogSeverity.Info, args.join(' ')))
};
consolex.warn = (...args: any[]): void => {
console.warn(...args);
// eventBus.emit(new SendLogEvent(LogSeverity.Warn, args.join(' ')))
}
consolex.warn = (...args: any[]): void => {
console.warn(...args);
// eventBus.emit(new SendLogEvent(LogSeverity.Warn, args.join(' ')))
};
consolex.error = (...args: any[]): void => {
console.error(...args);
// eventBus.emit(new SendLogEvent(LogSeverity.Error, args.join(' ')))
}
consolex.error = (...args: any[]): void => {
console.error(...args);
// eventBus.emit(new SendLogEvent(LogSeverity.Error, args.join(' ')))
};
}
export class SendLogEvent implements GameEvent {
constructor(
public readonly severity: LogSeverity,
public readonly log: string
) { }
constructor(
public readonly severity: LogSeverity,
public readonly log: string,
) {}
}
+10 -6
View File
@@ -1,11 +1,12 @@
export interface GameEvent { }
export interface GameEvent {}
export interface EventConstructor<T extends GameEvent = GameEvent> {
new(...args: any[]): T;
new (...args: any[]): T;
}
export class EventBus {
private listeners: Map<EventConstructor, Array<(event: GameEvent) => void>> = new Map();
private listeners: Map<EventConstructor, Array<(event: GameEvent) => void>> =
new Map();
emit<T extends GameEvent>(event: T): void {
const eventConstructor = event.constructor as EventConstructor<T>;
@@ -19,7 +20,7 @@ export class EventBus {
on<T extends GameEvent>(
eventType: EventConstructor<T>,
callback: (event: T) => void
callback: (event: T) => void,
): void {
if (!this.listeners.has(eventType)) {
this.listeners.set(eventType, []);
@@ -28,7 +29,10 @@ export class EventBus {
callbacks.push(callback as (event: GameEvent) => void);
}
off<T extends GameEvent>(eventType: EventConstructor<T>, callback: (event: T) => void): void {
off<T extends GameEvent>(
eventType: EventConstructor<T>,
callback: (event: T) => void,
): void {
const callbacks = this.listeners.get(eventType);
if (callbacks) {
const index = callbacks.indexOf(callback as (event: GameEvent) => void);
@@ -37,4 +41,4 @@ export class EventBus {
}
}
}
}
}
+144 -108
View File
@@ -4,137 +4,173 @@ import { getConfig } from "./configuration/Config";
import { EventBus } from "./EventBus";
import { Executor } from "./execution/ExecutionManager";
import { WinCheckExecution } from "./execution/WinCheckExecution";
import { AllPlayers, Cell, Game, GameUpdates, MessageType, Player, PlayerActions, PlayerID, PlayerProfile, PlayerType, UnitType } from "./game/Game";
import {
AllPlayers,
Cell,
Game,
GameUpdates,
MessageType,
Player,
PlayerActions,
PlayerID,
PlayerProfile,
PlayerType,
UnitType,
} from "./game/Game";
import { DisplayMessageUpdate, ErrorUpdate } from "./game/GameUpdates";
import { NameViewData } from './game/Game';
import { NameViewData } from "./game/Game";
import { GameUpdateType } from "./game/GameUpdates";
import { createGame } from "./game/GameImpl";
import { loadTerrainMap as loadGameMap } from "./game/TerrainMapLoader";
import { GameConfig, Turn } from "./Schemas";
import { GameUpdateViewData } from './game/GameUpdates';
import { GameUpdateViewData } from "./game/GameUpdates";
import { andFN, manhattanDistFN, TileRef } from "./game/GameMap";
import { targetTransportTile } from "./Util";
export async function createGameRunner(gameID: string, gameConfig: GameConfig, callBack: (gu: GameUpdateViewData) => void): Promise<GameRunner> {
const config = getConfig(gameConfig)
const gameMap = await loadGameMap(gameConfig.gameMap);
const game = createGame(gameMap.gameMap, gameMap.miniGameMap, gameMap.nationMap, config)
const gr = new GameRunner(game as Game, new Executor(game, gameID), callBack)
gr.init()
return gr
export async function createGameRunner(
gameID: string,
gameConfig: GameConfig,
callBack: (gu: GameUpdateViewData) => void,
): Promise<GameRunner> {
const config = getConfig(gameConfig);
const gameMap = await loadGameMap(gameConfig.gameMap);
const game = createGame(
gameMap.gameMap,
gameMap.miniGameMap,
gameMap.nationMap,
config,
);
const gr = new GameRunner(game as Game, new Executor(game, gameID), callBack);
gr.init();
return gr;
}
export class GameRunner {
private tickInterval = null
private turns: Turn[] = []
private currTurn = 0
private isExecuting = false
private tickInterval = null;
private turns: Turn[] = [];
private currTurn = 0;
private isExecuting = false;
private playerViewData: Record<PlayerID, NameViewData> = {}
private playerViewData: Record<PlayerID, NameViewData> = {};
constructor(
public game: Game,
private execManager: Executor,
private callBack: (gu: GameUpdateViewData | ErrorUpdate) => void
) {
constructor(
public game: Game,
private execManager: Executor,
private callBack: (gu: GameUpdateViewData | ErrorUpdate) => void,
) {}
init() {
this.game.addExecution(
...this.execManager.spawnBots(this.game.config().numBots()),
);
if (this.game.config().spawnNPCs()) {
this.game.addExecution(...this.execManager.fakeHumanExecutions());
}
this.game.addExecution(new WinCheckExecution());
this.tickInterval = setInterval(() => this.executeNextTick(), 10);
}
init() {
this.game.addExecution(...this.execManager.spawnBots(this.game.config().numBots()))
if (this.game.config().spawnNPCs()) {
this.game.addExecution(...this.execManager.fakeHumanExecutions())
}
this.game.addExecution(new WinCheckExecution())
this.tickInterval = setInterval(() => this.executeNextTick(), 10)
public addTurn(turn: Turn): void {
this.turns.push(turn);
}
public executeNextTick() {
if (this.isExecuting) {
return;
}
public addTurn(turn: Turn): void {
this.turns.push(turn)
if (this.currTurn >= this.turns.length) {
return;
}
this.isExecuting = true;
public executeNextTick() {
if (this.isExecuting) {
return
}
if (this.currTurn >= this.turns.length) {
return
}
this.isExecuting = true
this.game.addExecution(
...this.execManager.createExecs(this.turns[this.currTurn]),
);
this.currTurn++;
let updates: GameUpdates;
this.game.addExecution(...this.execManager.createExecs(this.turns[this.currTurn]))
this.currTurn++
let updates: GameUpdates;
try {
updates = this.game.executeNextTick();
} catch (error: unknown) {
if (error instanceof Error) {
console.error('Game tick error:', error.message);
this.callBack({
errMsg: error.message,
stack: error.stack
} as ErrorUpdate)
clearInterval(this.tickInterval)
return
}
}
if (this.game.inSpawnPhase() && this.game.ticks() % 2 == 0) {
this.game.players()
.filter(p => 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) {
this.game.players().forEach(p => {
this.playerViewData[p.id()] = placeName(this.game, p)
})
}
// Many tiles are updated to pack it into an array
const packedTileUpdates = updates[GameUpdateType.Tile].map(u => u.update)
updates[GameUpdateType.Tile] = []
try {
updates = this.game.executeNextTick();
} catch (error: unknown) {
if (error instanceof Error) {
console.error("Game tick error:", error.message);
this.callBack({
tick: this.game.ticks(),
packedTileUpdates: new BigUint64Array(packedTileUpdates),
updates: updates,
playerNameViewData: this.playerViewData
})
this.isExecuting = false
errMsg: error.message,
stack: error.stack,
} as ErrorUpdate);
clearInterval(this.tickInterval);
return;
}
}
public playerActions(playerID: PlayerID, x: number, y: number): PlayerActions {
const player = this.game.player(playerID)
const tile = this.game.ref(x, y)
const actions = {
canBoat: player.canBoat(tile),
canAttack: player.canAttack(tile),
buildableUnits: Object.values(UnitType).filter(ut => player.canBuild(ut, tile) != false),
canSendEmojiAllPlayers: player.canSendEmoji(AllPlayers)
} as PlayerActions
if (this.game.hasOwner(tile)) {
const other = this.game.owner(tile) as Player
actions.interaction = {
sharedBorder: player.sharesBorderWith(other),
canSendEmoji: player.canSendEmoji(other),
canTarget: player.canTarget(other),
canSendAllianceRequest: !player.recentOrPendingAllianceRequestWith(other),
canBreakAlliance: player.isAlliedWith(other),
canDonate: player.canDonate(other)
}
}
return actions
if (this.game.inSpawnPhase() && this.game.ticks() % 2 == 0) {
this.game
.players()
.filter(
(p) =>
p.type() == PlayerType.Human || p.type() == PlayerType.FakeHuman,
)
.forEach(
(p) => (this.playerViewData[p.id()] = placeName(this.game, p)),
);
}
public playerProfile(playerID: number): PlayerProfile {
const player = this.game.playerBySmallID(playerID)
if (!player.isPlayer()) {
throw new Error(`player with id ${playerID} not found`)
}
return player.playerProfile()
if (this.game.ticks() < 3 || this.game.ticks() % 30 == 0) {
this.game.players().forEach((p) => {
this.playerViewData[p.id()] = placeName(this.game, p);
});
}
// Many tiles are updated to pack it into an array
const packedTileUpdates = updates[GameUpdateType.Tile].map((u) => u.update);
updates[GameUpdateType.Tile] = [];
this.callBack({
tick: this.game.ticks(),
packedTileUpdates: new BigUint64Array(packedTileUpdates),
updates: updates,
playerNameViewData: this.playerViewData,
});
this.isExecuting = false;
}
public playerActions(
playerID: PlayerID,
x: number,
y: number,
): PlayerActions {
const player = this.game.player(playerID);
const tile = this.game.ref(x, y);
const actions = {
canBoat: player.canBoat(tile),
canAttack: player.canAttack(tile),
buildableUnits: Object.values(UnitType).filter(
(ut) => player.canBuild(ut, tile) != false,
),
canSendEmojiAllPlayers: player.canSendEmoji(AllPlayers),
} as PlayerActions;
if (this.game.hasOwner(tile)) {
const other = this.game.owner(tile) as Player;
actions.interaction = {
sharedBorder: player.sharesBorderWith(other),
canSendEmoji: player.canSendEmoji(other),
canTarget: player.canTarget(other),
canSendAllianceRequest:
!player.recentOrPendingAllianceRequestWith(other),
canBreakAlliance: player.isAlliedWith(other),
canDonate: player.canDonate(other),
};
}
return actions;
}
public playerProfile(playerID: number): PlayerProfile {
const player = this.game.playerBySmallID(playerID);
if (!player.isPlayer()) {
throw new Error(`player with id ${playerID} not found`);
}
return player.playerProfile();
}
}
+46 -47
View File
@@ -1,51 +1,50 @@
export class PseudoRandom {
private m: number = 0x80000000; // 2**31
private a: number = 1103515245;
private c: number = 12345;
private state: number;
private m: number = 0x80000000; // 2**31
private a: number = 1103515245;
private c: number = 12345;
private state: number;
constructor(seed: number) {
this.state = seed % this.m;
constructor(seed: number) {
this.state = seed % this.m;
}
/**
* Generates the next pseudorandom number.
* @returns A number between 0 (inclusive) and 1 (exclusive).
*/
next(): number {
this.state = (this.a * this.state + this.c) % this.m;
return this.state / this.m;
}
/**
* Generates a random integer between min (inclusive) and max (exclusive).
*/
nextInt(min: number, max: number): number {
return Math.floor(this.next() * (max - min) + min);
}
/**
* Generates a random float between min (inclusive) and max (exclusive).
*/
nextFloat(min: number, max: number): number {
return this.next() * (max - min) + min;
}
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
}
randElement<T>(arr: T[]): T {
if (arr.length == 0) {
throw new Error("array must not be empty");
}
return arr[this.nextInt(0, arr.length)];
}
/**
* Generates the next pseudorandom number.
* @returns A number between 0 (inclusive) and 1 (exclusive).
*/
next(): number {
this.state = (this.a * this.state + this.c) % this.m;
return this.state / this.m;
}
/**
* Generates a random integer between min (inclusive) and max (exclusive).
*/
nextInt(min: number, max: number): number {
return Math.floor(this.next() * (max - min) + min);
}
/**
* Generates a random float between min (inclusive) and max (exclusive).
*/
nextFloat(min: number, max: number): number {
return this.next() * (max - min) + min;
}
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
}
randElement<T>(arr: T[]): T {
if (arr.length == 0) {
throw new Error('array must not be empty')
}
return arr[this.nextInt(0, arr.length)];
}
chance(odds: number): boolean {
return this.nextInt(0, odds) == 0
}
}
chance(odds: number): boolean {
return this.nextInt(0, odds) == 0;
}
}
+229 -197
View File
@@ -1,288 +1,320 @@
import { z } from 'zod';
import { AllPlayers, Difficulty, GameMapType, GameType, PlayerType, UnitType } from './game/Game';
import { z } from "zod";
import {
AllPlayers,
Difficulty,
GameMapType,
GameType,
PlayerType,
UnitType,
} from "./game/Game";
export type GameID = string
export type ClientID = string
export type GameID = string;
export type ClientID = string;
export type Intent = SpawnIntent
| AttackIntent
| BoatAttackIntent
| AllianceRequestIntent
| AllianceRequestReplyIntent
| BreakAllianceIntent
| TargetPlayerIntent
| EmojiIntent
| DonateIntent
| TargetTroopRatioIntent
| BuildUnitIntent
export type Intent =
| SpawnIntent
| AttackIntent
| BoatAttackIntent
| AllianceRequestIntent
| AllianceRequestReplyIntent
| BreakAllianceIntent
| TargetPlayerIntent
| EmojiIntent
| DonateIntent
| TargetTroopRatioIntent
| BuildUnitIntent;
export type AttackIntent = z.infer<typeof AttackIntentSchema>
export type SpawnIntent = z.infer<typeof SpawnIntentSchema>
export type BoatAttackIntent = z.infer<typeof BoatAttackIntentSchema>
export type AllianceRequestIntent = z.infer<typeof AllianceRequestIntentSchema>
export type AllianceRequestReplyIntent = z.infer<typeof AllianceRequestReplyIntentSchema>
export type BreakAllianceIntent = z.infer<typeof BreakAllianceIntentSchema>
export type TargetPlayerIntent = z.infer<typeof TargetPlayerIntentSchema>
export type EmojiIntent = z.infer<typeof EmojiIntentSchema>
export type DonateIntent = z.infer<typeof DonateIntentSchema>
export type TargetTroopRatioIntent = z.infer<typeof TargetTroopRatioIntentSchema>
export type BuildUnitIntent = z.infer<typeof BuildUnitIntentSchema>
export type AttackIntent = z.infer<typeof AttackIntentSchema>;
export type SpawnIntent = z.infer<typeof SpawnIntentSchema>;
export type BoatAttackIntent = z.infer<typeof BoatAttackIntentSchema>;
export type AllianceRequestIntent = z.infer<typeof AllianceRequestIntentSchema>;
export type AllianceRequestReplyIntent = z.infer<
typeof AllianceRequestReplyIntentSchema
>;
export type BreakAllianceIntent = z.infer<typeof BreakAllianceIntentSchema>;
export type TargetPlayerIntent = z.infer<typeof TargetPlayerIntentSchema>;
export type EmojiIntent = z.infer<typeof EmojiIntentSchema>;
export type DonateIntent = z.infer<typeof DonateIntentSchema>;
export type TargetTroopRatioIntent = z.infer<
typeof TargetTroopRatioIntentSchema
>;
export type BuildUnitIntent = z.infer<typeof BuildUnitIntentSchema>;
export type Turn = z.infer<typeof TurnSchema>
export type GameConfig = z.infer<typeof GameConfigSchema>
export type Turn = z.infer<typeof TurnSchema>;
export type GameConfig = z.infer<typeof GameConfigSchema>;
export type ClientMessage = ClientSendWinnerMessage | ClientPingMessage | ClientIntentMessage | ClientJoinMessage | ClientLogMessage
export type ServerMessage = ServerSyncMessage | ServerStartGameMessage | ServerPingMessage
export type ClientMessage =
| ClientSendWinnerMessage
| ClientPingMessage
| ClientIntentMessage
| ClientJoinMessage
| ClientLogMessage;
export type ServerMessage =
| ServerSyncMessage
| ServerStartGameMessage
| ServerPingMessage;
export type ServerSyncMessage = z.infer<typeof ServerTurnMessageSchema>
export type ServerStartGameMessage = z.infer<typeof ServerStartGameMessageSchema>
export type ServerPingMessage = z.infer<typeof ServerPingMessageSchema>
export type ServerSyncMessage = z.infer<typeof ServerTurnMessageSchema>;
export type ServerStartGameMessage = z.infer<
typeof ServerStartGameMessageSchema
>;
export type ServerPingMessage = z.infer<typeof ServerPingMessageSchema>;
export type ClientSendWinnerMessage = z.infer<typeof ClientSendWinnerSchema>
export type ClientPingMessage = z.infer<typeof ClientPingMessageSchema>
export type ClientIntentMessage = z.infer<typeof ClientIntentMessageSchema>
export type ClientJoinMessage = z.infer<typeof ClientJoinMessageSchema>
export type ClientLogMessage = z.infer<typeof ClientLogMessageSchema>
export type ClientSendWinnerMessage = z.infer<typeof ClientSendWinnerSchema>;
export type ClientPingMessage = z.infer<typeof ClientPingMessageSchema>;
export type ClientIntentMessage = z.infer<typeof ClientIntentMessageSchema>;
export type ClientJoinMessage = z.infer<typeof ClientJoinMessageSchema>;
export type ClientLogMessage = z.infer<typeof ClientLogMessageSchema>;
export type PlayerRecord = z.infer<typeof PlayerRecordSchema>
export type GameRecord = z.infer<typeof GameRecordSchema>
export type PlayerRecord = z.infer<typeof PlayerRecordSchema>;
export type GameRecord = z.infer<typeof GameRecordSchema>;
const PlayerTypeSchema = z.nativeEnum(PlayerType);
export enum LogSeverity {
Debug = 'DEBUG',
Info = 'INFO',
Warn = 'WARN',
Error = 'ERROR',
Fatal = 'FATAL'
Debug = "DEBUG",
Info = "INFO",
Warn = "WARN",
Error = "ERROR",
Fatal = "FATAL",
}
// TODO: create Cell schema
export interface Lobby {
id: string;
msUntilStart?: number;
numClients?: number;
id: string;
msUntilStart?: number;
numClients?: number;
}
const GameConfigSchema = z.object({
gameMap: z.nativeEnum(GameMapType),
difficulty: z.nativeEnum(Difficulty),
gameType: z.nativeEnum(GameType)
})
gameMap: z.nativeEnum(GameMapType),
difficulty: z.nativeEnum(Difficulty),
gameType: z.nativeEnum(GameType),
});
const SafeString = z.string()
// Remove common dangerous characters and patterns
.regex(/^[a-zA-Z0-9\s.,!?@#$%&*()-_+=[\]{}|;:"'\/]+$/)
// Reasonable max length to prevent DOS
.max(1000)
const SafeString = z
.string()
// Remove common dangerous characters and patterns
.regex(/^[a-zA-Z0-9\s.,!?@#$%&*()-_+=[\]{}|;:"'\/]+$/)
// Reasonable max length to prevent DOS
.max(1000);
const EmojiSchema = z.string().refine(
(val) => {
return /\p{Emoji}/u.test(val);
},
{
message: "Must contain at least one emoji character"
}
(val) => {
return /\p{Emoji}/u.test(val);
},
{
message: "Must contain at least one emoji character",
},
);
const ID = z.string()
.regex(/^[a-zA-Z0-9]+$/)
.length(8);
const ID = z
.string()
.regex(/^[a-zA-Z0-9]+$/)
.length(8);
// Zod schemas
const BaseIntentSchema = z.object({
type: z.enum(['attack', 'spawn', 'boat', 'name', 'targetPlayer', 'emoji', 'troop_ratio', 'build_unit']),
clientID: ID,
type: z.enum([
"attack",
"spawn",
"boat",
"name",
"targetPlayer",
"emoji",
"troop_ratio",
"build_unit",
]),
clientID: ID,
});
export const AttackIntentSchema = BaseIntentSchema.extend({
type: z.literal('attack'),
attackerID: ID,
targetID: ID.nullable(),
troops: z.number().nullable(),
type: z.literal("attack"),
attackerID: ID,
targetID: ID.nullable(),
troops: z.number().nullable(),
});
export const SpawnIntentSchema = BaseIntentSchema.extend({
type: z.literal('spawn'),
playerID: ID,
name: SafeString,
playerType: PlayerTypeSchema,
x: z.number(),
y: z.number(),
})
type: z.literal("spawn"),
playerID: ID,
name: SafeString,
playerType: PlayerTypeSchema,
x: z.number(),
y: z.number(),
});
export const BoatAttackIntentSchema = BaseIntentSchema.extend({
type: z.literal('boat'),
attackerID: ID,
targetID: ID.nullable(),
troops: z.number().nullable(),
x: z.number(),
y: z.number(),
})
type: z.literal("boat"),
attackerID: ID,
targetID: ID.nullable(),
troops: z.number().nullable(),
x: z.number(),
y: z.number(),
});
export const AllianceRequestIntentSchema = BaseIntentSchema.extend({
type: z.literal('allianceRequest'),
requestor: ID,
recipient: ID,
})
type: z.literal("allianceRequest"),
requestor: ID,
recipient: ID,
});
export const AllianceRequestReplyIntentSchema = BaseIntentSchema.extend({
type: z.literal('allianceRequestReply'),
requestor: ID, // The one who made the original alliance request
recipient: ID,
accept: z.boolean(),
})
type: z.literal("allianceRequestReply"),
requestor: ID, // The one who made the original alliance request
recipient: ID,
accept: z.boolean(),
});
export const BreakAllianceIntentSchema = BaseIntentSchema.extend({
type: z.literal('breakAlliance'),
requestor: ID, // The one who made the original alliance request
recipient: ID,
})
type: z.literal("breakAlliance"),
requestor: ID, // The one who made the original alliance request
recipient: ID,
});
export const TargetPlayerIntentSchema = BaseIntentSchema.extend({
type: z.literal('targetPlayer'),
requestor: ID,
target: ID,
})
type: z.literal("targetPlayer"),
requestor: ID,
target: ID,
});
export const EmojiIntentSchema = BaseIntentSchema.extend({
type: z.literal('emoji'),
sender: ID,
recipient: z.union([ID, z.literal(AllPlayers)]),
emoji: EmojiSchema,
})
type: z.literal("emoji"),
sender: ID,
recipient: z.union([ID, z.literal(AllPlayers)]),
emoji: EmojiSchema,
});
export const DonateIntentSchema = BaseIntentSchema.extend({
type: z.literal('donate'),
sender: ID,
recipient: ID,
troops: z.number().nullable(),
})
type: z.literal("donate"),
sender: ID,
recipient: ID,
troops: z.number().nullable(),
});
export const TargetTroopRatioIntentSchema = BaseIntentSchema.extend({
type: z.literal('troop_ratio'),
player: ID,
ratio: z.number().min(0).max(1),
})
type: z.literal("troop_ratio"),
player: ID,
ratio: z.number().min(0).max(1),
});
export const BuildUnitIntentSchema = BaseIntentSchema.extend({
type: z.literal('build_unit'),
player: ID,
unit: z.nativeEnum(UnitType),
x: z.number(),
y: z.number(),
})
type: z.literal("build_unit"),
player: ID,
unit: z.nativeEnum(UnitType),
x: z.number(),
y: z.number(),
});
const IntentSchema = z.union([
AttackIntentSchema,
SpawnIntentSchema,
BoatAttackIntentSchema,
AllianceRequestIntentSchema,
AllianceRequestReplyIntentSchema,
BreakAllianceIntentSchema,
TargetPlayerIntentSchema,
EmojiIntentSchema,
DonateIntentSchema,
TargetTroopRatioIntentSchema,
BuildUnitIntentSchema,
AttackIntentSchema,
SpawnIntentSchema,
BoatAttackIntentSchema,
AllianceRequestIntentSchema,
AllianceRequestReplyIntentSchema,
BreakAllianceIntentSchema,
TargetPlayerIntentSchema,
EmojiIntentSchema,
DonateIntentSchema,
TargetTroopRatioIntentSchema,
BuildUnitIntentSchema,
]);
export const TurnSchema = z.object({
turnNumber: z.number(),
gameID: ID,
intents: z.array(IntentSchema)
})
turnNumber: z.number(),
gameID: ID,
intents: z.array(IntentSchema),
});
// Server
const ServerBaseMessageSchema = z.object({
type: SafeString
})
type: SafeString,
});
export const ServerTurnMessageSchema = ServerBaseMessageSchema.extend({
type: z.literal('turn'),
turn: TurnSchema,
})
type: z.literal("turn"),
turn: TurnSchema,
});
export const ServerPingMessageSchema = ServerBaseMessageSchema.extend({
type: z.literal('ping')
})
type: z.literal("ping"),
});
export const ServerStartGameMessageSchema = ServerBaseMessageSchema.extend({
type: z.literal('start'),
// Turns the client missed if they are late to the game.
turns: z.array(TurnSchema),
config: GameConfigSchema
})
type: z.literal("start"),
// Turns the client missed if they are late to the game.
turns: z.array(TurnSchema),
config: GameConfigSchema,
});
export const ServerMessageSchema = z.union([
ServerTurnMessageSchema,
ServerStartGameMessageSchema,
ServerPingMessageSchema,
ServerTurnMessageSchema,
ServerStartGameMessageSchema,
ServerPingMessageSchema,
]);
// Client
const ClientBaseMessageSchema = z.object({
type: z.enum(['winner', 'join', 'intent', 'ping', 'log']),
clientID: ID,
gameID: ID,
})
type: z.enum(["winner", "join", "intent", "ping", "log"]),
clientID: ID,
gameID: ID,
});
export const ClientSendWinnerSchema = ClientBaseMessageSchema.extend({
type: z.literal('winner'),
winner: ID.nullable(),
})
type: z.literal("winner"),
winner: ID.nullable(),
});
export const ClientLogMessageSchema = ClientBaseMessageSchema.extend({
type: z.literal('log'),
severity: z.nativeEnum(LogSeverity),
log: ID,
persistentID: SafeString,
})
type: z.literal("log"),
severity: z.nativeEnum(LogSeverity),
log: ID,
persistentID: SafeString,
});
export const ClientPingMessageSchema = ClientBaseMessageSchema.extend({
type: z.literal('ping'),
})
type: z.literal("ping"),
});
export const ClientIntentMessageSchema = ClientBaseMessageSchema.extend({
type: z.literal('intent'),
intent: IntentSchema
})
type: z.literal("intent"),
intent: IntentSchema,
});
// WARNING: never send this message to clients.
export const ClientJoinMessageSchema = ClientBaseMessageSchema.extend({
type: z.literal('join'),
persistentID: SafeString, // WARNING: persistent id is private.
lastTurn: z.number(), // The last turn the client saw.
username: SafeString,
})
type: z.literal("join"),
persistentID: SafeString, // WARNING: persistent id is private.
lastTurn: z.number(), // The last turn the client saw.
username: SafeString,
});
export const ClientMessageSchema = z.union([
ClientSendWinnerSchema,
ClientPingMessageSchema,
ClientIntentMessageSchema,
ClientJoinMessageSchema,
ClientLogMessageSchema,
ClientSendWinnerSchema,
ClientPingMessageSchema,
ClientIntentMessageSchema,
ClientJoinMessageSchema,
ClientLogMessageSchema,
]);
export const PlayerRecordSchema = z.object({
clientID: ID,
username: SafeString,
ip: SafeString.nullable(), // WARNING: PII
persistentID: SafeString, // WARNING: PII
})
clientID: ID,
username: SafeString,
ip: SafeString.nullable(), // WARNING: PII
persistentID: SafeString, // WARNING: PII
});
export const GameRecordSchema = z.object({
id: ID,
gameConfig: GameConfigSchema,
players: z.array(PlayerRecordSchema),
startTimestampMS: z.number(),
endTimestampMS: z.number(),
durationSeconds: z.number(),
date: SafeString,
num_turns: z.number(),
turns: z.array(TurnSchema),
winner: ID.nullable()
})
id: ID,
gameConfig: GameConfigSchema,
players: z.array(PlayerRecordSchema),
startTimestampMS: z.number(),
endTimestampMS: z.number(),
durationSeconds: z.number(),
date: SafeString,
num_turns: z.number(),
turns: z.array(TurnSchema),
winner: ID.nullable(),
});
+232 -173
View File
@@ -1,174 +1,229 @@
import { v4 as uuidv4 } from 'uuid';
import twemoji from 'twemoji';
import DOMPurify from 'dompurify';
import { v4 as uuidv4 } from "uuid";
import twemoji from "twemoji";
import DOMPurify from "dompurify";
import { Cell, Game, Player, Unit } from "./game/Game";
import { ClientID, GameConfig, GameID, GameRecord, PlayerRecord, Turn } from './Schemas';
import { customAlphabet, nanoid } from 'nanoid';
import { andFN, GameMap, manhattanDistFN, TileRef } from './game/GameMap';
import {
ClientID,
GameConfig,
GameID,
GameRecord,
PlayerRecord,
Turn,
} from "./Schemas";
import { customAlphabet, nanoid } from "nanoid";
import { andFN, GameMap, manhattanDistFN, TileRef } from "./game/GameMap";
export function manhattanDistWrapped(
c1: Cell,
c2: Cell,
width: number,
): number {
// Calculate x distance
let dx = Math.abs(c1.x - c2.x);
// Check if wrapping around the x-axis is shorter
dx = Math.min(dx, width - dx);
// Calculate y distance (no wrapping for y-axis)
let dy = Math.abs(c1.y - c2.y);
export function manhattanDistWrapped(c1: Cell, c2: Cell, width: number): number {
// Calculate x distance
let dx = Math.abs(c1.x - c2.x);
// Check if wrapping around the x-axis is shorter
dx = Math.min(dx, width - dx);
// Calculate y distance (no wrapping for y-axis)
let dy = Math.abs(c1.y - c2.y);
// Return the sum of x and y distances
return dx + dy;
// Return the sum of x and y distances
return dx + dy;
}
export function within(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
return Math.min(Math.max(value, min), max);
}
export function distSort(gm: GameMap, target: TileRef): (a: TileRef, b: TileRef) => number {
return (a: TileRef, b: TileRef) => {
return gm.manhattanDist(a, target) - gm.manhattanDist(b, target);
}
export function distSort(
gm: GameMap,
target: TileRef,
): (a: TileRef, b: TileRef) => number {
return (a: TileRef, b: TileRef) => {
return gm.manhattanDist(a, target) - gm.manhattanDist(b, target);
};
}
export function distSortUnit(gm: GameMap, target: Unit | TileRef): (a: Unit, b: Unit) => number {
const targetRef = (typeof target === 'number') ? target : target.tile()
export function distSortUnit(
gm: GameMap,
target: Unit | TileRef,
): (a: Unit, b: Unit) => number {
const targetRef = typeof target === "number" ? target : target.tile();
return (a: Unit, b: Unit) => {
return gm.manhattanDist(a.tile(), targetRef) - gm.manhattanDist(b.tile(), targetRef);
}
return (a: Unit, b: Unit) => {
return (
gm.manhattanDist(a.tile(), targetRef) -
gm.manhattanDist(b.tile(), targetRef)
);
};
}
// TODO: refactor to new file
export function sourceDstOceanShore(gm: Game, src: Player, tile: TileRef): [TileRef | null, TileRef | null] {
const dst = gm.owner(tile)
let srcTile = closestOceanShoreFromPlayer(gm, src, tile)
let dstTile: TileRef | null = null
if (dst.isPlayer()) {
dstTile = closestOceanShoreFromPlayer(gm, dst as Player, tile)
} else {
dstTile = closestOceanShoreTN(gm, tile, 300)
}
return [srcTile, dstTile]
export function sourceDstOceanShore(
gm: Game,
src: Player,
tile: TileRef,
): [TileRef | null, TileRef | null] {
const dst = gm.owner(tile);
let srcTile = closestOceanShoreFromPlayer(gm, src, tile);
let dstTile: TileRef | null = null;
if (dst.isPlayer()) {
dstTile = closestOceanShoreFromPlayer(gm, dst as Player, tile);
} else {
dstTile = closestOceanShoreTN(gm, tile, 300);
}
return [srcTile, dstTile];
}
export function targetTransportTile(gm: Game, tile: TileRef): TileRef | null {
const dst = gm.playerBySmallID(gm.ownerID(tile))
let dstTile: TileRef | null = null
if (dst.isPlayer()) {
dstTile = closestOceanShoreFromPlayer(gm, dst as Player, tile)
} else {
dstTile = closestOceanShoreTN(gm, tile, 300)
}
return dstTile
const dst = gm.playerBySmallID(gm.ownerID(tile));
let dstTile: TileRef | null = null;
if (dst.isPlayer()) {
dstTile = closestOceanShoreFromPlayer(gm, dst as Player, tile);
} else {
dstTile = closestOceanShoreTN(gm, tile, 300);
}
return dstTile;
}
export function closestOceanShoreFromPlayer(gm: GameMap, player: Player, target: TileRef): TileRef | null {
const shoreTiles = Array.from(player.borderTiles()).filter(t => gm.isOceanShore(t))
if (shoreTiles.length == 0) {
return null
}
export function closestOceanShoreFromPlayer(
gm: GameMap,
player: Player,
target: TileRef,
): TileRef | null {
const shoreTiles = Array.from(player.borderTiles()).filter((t) =>
gm.isOceanShore(t),
);
if (shoreTiles.length == 0) {
return null;
}
return shoreTiles.reduce((closest, current) => {
const closestDistance = manhattanDistWrapped(gm.cell(target), gm.cell(closest), gm.width());
const currentDistance = manhattanDistWrapped(gm.cell(target), gm.cell(current), gm.width());
return currentDistance < closestDistance ? current : closest;
});
return shoreTiles.reduce((closest, current) => {
const closestDistance = manhattanDistWrapped(
gm.cell(target),
gm.cell(closest),
gm.width(),
);
const currentDistance = manhattanDistWrapped(
gm.cell(target),
gm.cell(current),
gm.width(),
);
return currentDistance < closestDistance ? current : closest;
});
}
function closestOceanShoreTN(gm: GameMap, tile: TileRef, searchDist: number): TileRef {
const tn = Array.from(gm.bfs(tile, andFN((_, t) => !gm.hasOwner(t), manhattanDistFN(tile, searchDist))))
.filter(t => gm.isOceanShore(t))
.sort((a, b) => gm.manhattanDist(tile, a) - gm.manhattanDist(tile, b))
if (tn.length == 0) {
return null
}
return tn[0]
function closestOceanShoreTN(
gm: GameMap,
tile: TileRef,
searchDist: number,
): TileRef {
const tn = Array.from(
gm.bfs(
tile,
andFN((_, t) => !gm.hasOwner(t), manhattanDistFN(tile, searchDist)),
),
)
.filter((t) => gm.isOceanShore(t))
.sort((a, b) => gm.manhattanDist(tile, a) - gm.manhattanDist(tile, b));
if (tn.length == 0) {
return null;
}
return tn[0];
}
export function simpleHash(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer
}
return Math.abs(hash);
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32-bit integer
}
return Math.abs(hash);
}
export function calculateBoundingBox(gm: GameMap, borderTiles: ReadonlySet<TileRef>): { min: Cell; max: Cell } {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
export function calculateBoundingBox(
gm: GameMap,
borderTiles: ReadonlySet<TileRef>,
): { min: Cell; max: Cell } {
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
borderTiles.forEach((tile: TileRef) => {
const cell = gm.cell(tile);
minX = Math.min(minX, cell.x);
minY = Math.min(minY, cell.y);
maxX = Math.max(maxX, cell.x);
maxY = Math.max(maxY, cell.y);
});
borderTiles.forEach((tile: TileRef) => {
const cell = gm.cell(tile);
minX = Math.min(minX, cell.x);
minY = Math.min(minY, cell.y);
maxX = Math.max(maxX, cell.x);
maxY = Math.max(maxY, cell.y);
});
return { min: new Cell(minX, minY), max: new Cell(maxX, maxY) }
return { min: new Cell(minX, minY), max: new Cell(maxX, maxY) };
}
export function calculateBoundingBoxCenter(gm: GameMap, borderTiles: ReadonlySet<TileRef>): Cell {
const { min, max } = calculateBoundingBox(gm, borderTiles)
return new Cell(
min.x + Math.floor((max.x - min.x) / 2),
min.y + Math.floor((max.y - min.y) / 2)
)
export function calculateBoundingBoxCenter(
gm: GameMap,
borderTiles: ReadonlySet<TileRef>,
): Cell {
const { min, max } = calculateBoundingBox(gm, borderTiles);
return new Cell(
min.x + Math.floor((max.x - min.x) / 2),
min.y + Math.floor((max.y - min.y) / 2),
);
}
export function inscribed(outer: { min: Cell; max: Cell }, inner: { min: Cell; max: Cell }): boolean {
return (
outer.min.x <= inner.min.x &&
outer.min.y <= inner.min.y &&
outer.max.x >= inner.max.x &&
outer.max.y >= inner.max.y
);
export function inscribed(
outer: { min: Cell; max: Cell },
inner: { min: Cell; max: Cell },
): boolean {
return (
outer.min.x <= inner.min.x &&
outer.min.y <= inner.min.y &&
outer.max.x >= inner.max.x &&
outer.max.y >= inner.max.y
);
}
export function getMode(list: Set<number>): number {
// Count occurrences
const counts = new Map<number, number>()
for (const item of list) {
counts.set(item, (counts.get(item) || 0) + 1);
// Count occurrences
const counts = new Map<number, number>();
for (const item of list) {
counts.set(item, (counts.get(item) || 0) + 1);
}
// Find the item with the highest count
let mode = 0;
let maxCount = 0;
for (const [item, count] of counts) {
if (count > maxCount) {
maxCount = count;
mode = item;
}
}
// Find the item with the highest count
let mode = 0;
let maxCount = 0;
for (const [item, count] of counts) {
if (count > maxCount) {
maxCount = count
mode = item;
}
}
return mode;
return mode;
}
export function sanitize(name: string): string {
return Array.from(name).join('').replace(/[^\p{L}\p{N}\s\p{Emoji}\p{Emoji_Component}]/gu, '');
return Array.from(name)
.join("")
.replace(/[^\p{L}\p{N}\s\p{Emoji}\p{Emoji_Component}]/gu, "");
}
export function processName(name: string): string {
// First sanitize the raw input - strip everything except text and emojis
const sanitizedName = sanitize(name);
// First sanitize the raw input - strip everything except text and emojis
const sanitizedName = sanitize(name);
// Process emojis with twemoji
const withEmojis = twemoji.parse(sanitizedName, {
base: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/',
folder: 'svg',
ext: '.svg'
});
// Process emojis with twemoji
const withEmojis = twemoji.parse(sanitizedName, {
base: "https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/",
folder: "svg",
ext: ".svg",
});
// Add CSS styles inline to the wrapper span
const styledHTML = `
// Add CSS styles inline to the wrapper span
const styledHTML = `
<span class="player-name" style="
display: inline-flex;
align-items: center;
@@ -180,71 +235,75 @@ export function processName(name: string): string {
</span>
`;
// Add CSS for the emoji images
const withEmojiStyles = styledHTML.replace(
/<img/g,
'<img style="height: 1.2em; width: 1.2em; vertical-align: -0.2em; margin: 0 0.05em 0 0.1em;"'
);
// Add CSS for the emoji images
const withEmojiStyles = styledHTML.replace(
/<img/g,
'<img style="height: 1.2em; width: 1.2em; vertical-align: -0.2em; margin: 0 0.05em 0 0.1em;"',
);
// Sanitize the final HTML, allowing styles and specific attributes
return onlyImages(withEmojiStyles)
// Sanitize the final HTML, allowing styles and specific attributes
return onlyImages(withEmojiStyles);
}
export function onlyImages(html: string) {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['span', 'img'],
ALLOWED_ATTR: ['src', 'alt', 'class', 'style'],
ALLOWED_URI_REGEXP: /^https:\/\/cdn\.jsdelivr\.net\/gh\/twitter\/twemoji/,
ADD_ATTR: ['style']
});
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ["span", "img"],
ALLOWED_ATTR: ["src", "alt", "class", "style"],
ALLOWED_URI_REGEXP: /^https:\/\/cdn\.jsdelivr\.net\/gh\/twitter\/twemoji/,
ADD_ATTR: ["style"],
});
}
export function CreateGameRecord(
id: GameID,
gameConfig: GameConfig,
// username does not need to be set.
players: PlayerRecord[],
turns: Turn[],
start: number,
end: number,
winner: ClientID | null
id: GameID,
gameConfig: GameConfig,
// username does not need to be set.
players: PlayerRecord[],
turns: Turn[],
start: number,
end: number,
winner: ClientID | null,
): GameRecord {
const record: GameRecord = {
id: id,
gameConfig: gameConfig,
startTimestampMS: start,
endTimestampMS: end,
date: new Date().toISOString().split('T')[0],
turns: []
}
const record: GameRecord = {
id: id,
gameConfig: gameConfig,
startTimestampMS: start,
endTimestampMS: end,
date: new Date().toISOString().split("T")[0],
turns: [],
};
for (const turn of turns) {
if (turn.intents.length != 0) {
record.turns.push(turn)
for (const intent of turn.intents) {
if (intent.type == "spawn") {
for (const playerRecord of players) {
if (playerRecord.clientID == intent.clientID) {
playerRecord.username = intent.name
}
}
}
for (const turn of turns) {
if (turn.intents.length != 0) {
record.turns.push(turn);
for (const intent of turn.intents) {
if (intent.type == "spawn") {
for (const playerRecord of players) {
if (playerRecord.clientID == intent.clientID) {
playerRecord.username = intent.name;
}
}
}
}
}
record.players = players
record.durationSeconds = Math.floor((record.endTimestampMS - record.startTimestampMS) / 1000)
record.num_turns = turns.length
record.winner = winner
return record;
}
record.players = players;
record.durationSeconds = Math.floor(
(record.endTimestampMS - record.startTimestampMS) / 1000,
);
record.num_turns = turns.length;
record.winner = winner;
return record;
}
export function assertNever(x: never): never {
throw new Error('Unexpected value: ' + x);
throw new Error("Unexpected value: " + x);
}
export function generateID(): GameID {
const nanoid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', 8)
return nanoid()
const nanoid = customAlphabet(
"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
8,
);
return nanoid();
}
+104 -83
View File
@@ -1,4 +1,15 @@
import { Difficulty, GameType, Gold, Player, PlayerID, PlayerInfo, TerraNullius, Tick, UnitInfo, UnitType } from "../game/Game";
import {
Difficulty,
GameType,
Gold,
Player,
PlayerID,
PlayerInfo,
TerraNullius,
Tick,
UnitInfo,
UnitType,
} from "../game/Game";
import { Colord, colord } from "colord";
import { preprodConfig } from "./PreprodConfig";
import { prodConfig } from "./ProdConfig";
@@ -10,101 +21,111 @@ import { GameMap, TileRef } from "../game/GameMap";
import { PlayerView } from "../game/GameView";
export enum GameEnv {
Dev,
Prod
Dev,
Prod,
}
export function getConfig(gameConfig: GameConfig): Config {
const sc = getServerConfig()
switch (process.env.GAME_ENV) {
case 'dev':
return new DevConfig(sc, gameConfig)
case 'preprod':
case 'prod':
consolex.log('using prod config')
return new DefaultConfig(sc, gameConfig)
default:
throw Error(`unsupported server configuration: ${process.env.GAME_ENV}`)
}
const sc = getServerConfig();
switch (process.env.GAME_ENV) {
case "dev":
return new DevConfig(sc, gameConfig);
case "preprod":
case "prod":
consolex.log("using prod config");
return new DefaultConfig(sc, gameConfig);
default:
throw Error(`unsupported server configuration: ${process.env.GAME_ENV}`);
}
}
export function getServerConfig(): ServerConfig {
switch (process.env.GAME_ENV) {
case 'dev':
consolex.log('using dev config')
return new DevServerConfig()
case 'preprod':
consolex.log('using preprod config')
return preprodConfig
case 'prod':
default:
consolex.log('using prod config')
return prodConfig
// default:
// throw Error(`unsupported server configuration: ${process.env.GAME_ENV}`)
}
switch (process.env.GAME_ENV) {
case "dev":
consolex.log("using dev config");
return new DevServerConfig();
case "preprod":
consolex.log("using preprod config");
return preprodConfig;
case "prod":
default:
consolex.log("using prod config");
return prodConfig;
// default:
// throw Error(`unsupported server configuration: ${process.env.GAME_ENV}`)
}
}
export interface ServerConfig {
turnIntervalMs(): number
gameCreationRate(): number
lobbyLifetime(): number
turnIntervalMs(): number;
gameCreationRate(): number;
lobbyLifetime(): number;
}
export interface Config {
serverConfig(): ServerConfig
gameConfig(): GameConfig
theme(): Theme;
percentageTilesOwnedToWin(): number
numBots(): number
spawnNPCs(): boolean
numSpawnPhaseTurns(): number
serverConfig(): ServerConfig;
gameConfig(): GameConfig;
theme(): Theme;
percentageTilesOwnedToWin(): number;
numBots(): number;
spawnNPCs(): boolean;
numSpawnPhaseTurns(): number;
startManpower(playerInfo: PlayerInfo): number
populationIncreaseRate(player: Player | PlayerView): number
goldAdditionRate(player: Player | PlayerView): number
troopAdjustmentRate(player: Player): number
attackTilesPerTick(attckTroops: number, attacker: Player, defender: Player | TerraNullius, numAdjacentTilesWithEnemy: number): number
attackLogic(gm: GameMap, attackTroops: number, attacker: Player, defender: Player | TerraNullius, tileToConquer: TileRef): {
attackerTroopLoss: number,
defenderTroopLoss: number,
tilesPerTickUsed: number
}
attackAmount(attacker: Player, defender: Player | TerraNullius): number
maxPopulation(player: Player | PlayerView): number
cityPopulationIncrease(): number
boatAttackAmount(attacker: Player, defender: Player | TerraNullius): number
boatMaxDistance(): number
boatMaxNumber(): number
allianceDuration(): Tick
allianceRequestCooldown(): Tick
targetDuration(): Tick
targetCooldown(): Tick
emojiMessageCooldown(): Tick
emojiMessageDuration(): Tick
donateCooldown(): Tick
defaultDonationAmount(sender: Player): number
unitInfo(type: UnitType): UnitInfo
tradeShipGold(dist: number): Gold
tradeShipSpawnRate(): number
defensePostRange(): number
defensePostDefenseBonus(): number
falloutDefenseModifier(): number
difficultyModifier(difficulty: Difficulty): number
startManpower(playerInfo: PlayerInfo): number;
populationIncreaseRate(player: Player | PlayerView): number;
goldAdditionRate(player: Player | PlayerView): number;
troopAdjustmentRate(player: Player): number;
attackTilesPerTick(
attckTroops: number,
attacker: Player,
defender: Player | TerraNullius,
numAdjacentTilesWithEnemy: number,
): number;
attackLogic(
gm: GameMap,
attackTroops: number,
attacker: Player,
defender: Player | TerraNullius,
tileToConquer: TileRef,
): {
attackerTroopLoss: number;
defenderTroopLoss: number;
tilesPerTickUsed: number;
};
attackAmount(attacker: Player, defender: Player | TerraNullius): number;
maxPopulation(player: Player | PlayerView): number;
cityPopulationIncrease(): number;
boatAttackAmount(attacker: Player, defender: Player | TerraNullius): number;
boatMaxDistance(): number;
boatMaxNumber(): number;
allianceDuration(): Tick;
allianceRequestCooldown(): Tick;
targetDuration(): Tick;
targetCooldown(): Tick;
emojiMessageCooldown(): Tick;
emojiMessageDuration(): Tick;
donateCooldown(): Tick;
defaultDonationAmount(sender: Player): number;
unitInfo(type: UnitType): UnitInfo;
tradeShipGold(dist: number): Gold;
tradeShipSpawnRate(): number;
defensePostRange(): number;
defensePostDefenseBonus(): number;
falloutDefenseModifier(): number;
difficultyModifier(difficulty: Difficulty): number;
}
export interface Theme {
playerInfoColor(id: PlayerID): Colord;
territoryColor(playerInfo: PlayerInfo): Colord;
borderColor(playerInfo: PlayerInfo): Colord;
defendedBorderColor(playerInfo: PlayerInfo): Colord;
terrainColor(gm: GameMap, tile: TileRef): Colord;
backgroundColor(): Colord;
falloutColor(): Colord
font(): string;
// unit color for alternate view
selfColor(): Colord
allyColor(): Colord
enemyColor(): Colord
spawnHighlightColor(): Colord
playerInfoColor(id: PlayerID): Colord;
territoryColor(playerInfo: PlayerInfo): Colord;
borderColor(playerInfo: PlayerInfo): Colord;
defendedBorderColor(playerInfo: PlayerInfo): Colord;
terrainColor(gm: GameMap, tile: TileRef): Colord;
backgroundColor(): Colord;
falloutColor(): Colord;
font(): string;
// unit color for alternate view
selfColor(): Colord;
allyColor(): Colord;
enemyColor(): Colord;
spawnHighlightColor(): Colord;
}
+370 -320
View File
@@ -1,4 +1,16 @@
import { Difficulty, GameType, Gold, Player, PlayerInfo, PlayerType, TerrainType, TerraNullius, Tick, UnitInfo, UnitType } from "../game/Game";
import {
Difficulty,
GameType,
Gold,
Player,
PlayerInfo,
PlayerType,
TerrainType,
TerraNullius,
Tick,
UnitInfo,
UnitType,
} from "../game/Game";
import { GameMap, TileRef } from "../game/GameMap";
import { PlayerView } from "../game/GameView";
import { GameConfig } from "../Schemas";
@@ -7,338 +19,376 @@ import { Config, ServerConfig, Theme } from "./Config";
import { pastelTheme } from "./PastelTheme";
export abstract class DefaultServerConfig implements ServerConfig {
turnIntervalMs(): number {
return 100
}
gameCreationRate(): number {
return 1 * 60 * 1000
}
lobbyLifetime(): number {
return 2 * 60 * 1000
}
turnIntervalMs(): number {
return 100;
}
gameCreationRate(): number {
return 1 * 60 * 1000;
}
lobbyLifetime(): number {
return 2 * 60 * 1000;
}
}
export class DefaultConfig implements Config {
constructor(
private _serverConfig: ServerConfig,
private _gameConfig: GameConfig,
) {}
constructor(private _serverConfig: ServerConfig, private _gameConfig: GameConfig) {
gameConfig(): GameConfig {
return this._gameConfig;
}
serverConfig(): ServerConfig {
return this._serverConfig;
}
difficultyModifier(difficulty: Difficulty): number {
switch (difficulty) {
case Difficulty.Easy:
return 1;
case Difficulty.Medium:
return 3;
case Difficulty.Hard:
return 9;
case Difficulty.Impossible:
return 18;
}
}
cityPopulationIncrease(): number {
return 250_000;
}
falloutDefenseModifier(): number {
return 5;
}
defensePostRange(): number {
return 40;
}
defensePostDefenseBonus(): number {
return 5;
}
spawnNPCs(): boolean {
return true;
}
tradeShipGold(dist: number): Gold {
return 10000 + 100 * Math.pow(dist, 1.1);
}
tradeShipSpawnRate(): number {
return 500;
}
unitInfo(type: UnitType): UnitInfo {
switch (type) {
case UnitType.TransportShip:
return {
cost: () => 0,
territoryBound: false,
};
case UnitType.Destroyer:
return {
cost: (p: Player) =>
(p.units(UnitType.Destroyer).length + 1) * 250_000,
territoryBound: false,
maxHealth: 1000,
};
case UnitType.Battleship:
return {
cost: (p: Player) =>
(p.units(UnitType.Battleship).length + 1) * 500_000,
territoryBound: false,
maxHealth: 5000,
};
case UnitType.Shell:
return {
cost: () => 0,
territoryBound: false,
damage: 250,
};
case UnitType.Port:
return {
cost: (p: Player) =>
Math.min(
1_000_000,
Math.pow(2, p.units(UnitType.Port).length) * 250_000,
),
territoryBound: true,
};
case UnitType.AtomBomb:
return {
cost: () => 750_000,
territoryBound: false,
};
case UnitType.HydrogenBomb:
return {
cost: () => 5_000_000,
territoryBound: false,
};
case UnitType.TradeShip:
return {
cost: () => 0,
territoryBound: false,
};
case UnitType.MissileSilo:
return {
cost: () => 1_000_000,
territoryBound: true,
};
case UnitType.DefensePost:
return {
cost: (p: Player) =>
Math.min(
250_000,
(p.units(UnitType.DefensePost).length + 1) * 50_000,
),
territoryBound: true,
};
case UnitType.City:
return {
cost: (p: Player) =>
Math.min(
1_000_000,
Math.pow(2, p.units(UnitType.City).length) * 125_000,
),
territoryBound: true,
};
default:
assertNever(type);
}
}
defaultDonationAmount(sender: Player): number {
return Math.floor(sender.troops() / 3);
}
donateCooldown(): Tick {
return 10 * 10;
}
emojiMessageDuration(): Tick {
return 5 * 10;
}
emojiMessageCooldown(): Tick {
return 15 * 10;
}
targetDuration(): Tick {
return 10 * 10;
}
targetCooldown(): Tick {
return 15 * 10;
}
allianceRequestCooldown(): Tick {
return 30 * 10;
}
allianceDuration(): Tick {
return 600 * 10;
}
percentageTilesOwnedToWin(): number {
return 80;
}
boatMaxNumber(): number {
return 3;
}
boatMaxDistance(): number {
return 500;
}
numSpawnPhaseTurns(): number {
return this._gameConfig.gameType == GameType.Singleplayer ? 100 : 300;
}
numBots(): number {
return 400;
}
theme(): Theme {
return pastelTheme;
}
attackLogic(
gm: GameMap,
attackTroops: number,
attacker: Player,
defender: Player | TerraNullius,
tileToConquer: TileRef,
): {
attackerTroopLoss: number;
defenderTroopLoss: number;
tilesPerTickUsed: number;
} {
let mag = 0;
let speed = 0;
const type = gm.terrainType(tileToConquer);
switch (type) {
case TerrainType.Plains:
mag = 80;
speed = 15;
break;
case TerrainType.Highland:
mag = 100;
speed = 20;
break;
case TerrainType.Mountain:
mag = 120;
speed = 25;
break;
default:
throw new Error(`terrain type ${type} not supported`);
}
// TODO
// mag *= tileToConquer.defenseBonus(attacker)
// speed *= tileToConquer.defenseBonus(attacker)
if (gm.hasFallout(tileToConquer)) {
mag *= this.falloutDefenseModifier();
speed *= this.falloutDefenseModifier();
}
gameConfig(): GameConfig {
return this._gameConfig
if (attacker.isPlayer() && defender.isPlayer()) {
if (
attacker.type() == PlayerType.Human &&
defender.type() == PlayerType.Bot
) {
mag *= 0.8;
}
if (
attacker.type() == PlayerType.FakeHuman &&
defender.type() == PlayerType.Bot
) {
mag *= 0.8;
}
}
serverConfig(): ServerConfig {
return this._serverConfig
if (defender.isPlayer()) {
return {
attackerTroopLoss:
within(defender.troops() / (2.5 * attackTroops), 0.1, 10) * mag,
defenderTroopLoss: defender.troops() / defender.numTilesOwned(),
tilesPerTickUsed:
within(defender.troops() / (5 * attackTroops), 0.2, 1.5) * speed,
};
} else {
return {
attackerTroopLoss:
attacker.type() == PlayerType.Bot ? mag / 10 : mag / 5,
defenderTroopLoss: 0,
tilesPerTickUsed: within(
(2000 * Math.max(10, speed)) / attackTroops,
5,
100,
),
};
}
}
attackTilesPerTick(
attackTroops: number,
attacker: Player,
defender: Player | TerraNullius,
numAdjacentTilesWithEnemy: number,
): number {
if (defender.isPlayer()) {
return (
within(((5 * attackTroops) / defender.troops()) * 2, 0.01, 0.5) *
numAdjacentTilesWithEnemy *
3
);
} else {
return numAdjacentTilesWithEnemy * 2;
}
}
boatAttackAmount(attacker: Player, defender: Player | TerraNullius): number {
return Math.floor(attacker.troops() / 5);
}
attackAmount(attacker: Player, defender: Player | TerraNullius) {
if (attacker.type() == PlayerType.Bot) {
return attacker.troops() / 20;
} else {
return attacker.troops() / 5;
}
}
startManpower(playerInfo: PlayerInfo): number {
if (playerInfo.playerType == PlayerType.Bot) {
return 10000;
}
if (playerInfo.playerType == PlayerType.FakeHuman) {
// start troops * strength * difficulty
switch (this._gameConfig.difficulty) {
case Difficulty.Easy:
case Difficulty.Medium:
return 1000;
case Difficulty.Hard:
case Difficulty.Impossible:
return 2000;
}
}
return 25000;
}
maxPopulation(player: Player | PlayerView): number {
let maxPop = Math.pow(player.numTilesOwned(), 0.6) * 1000 + 50000;
if (player.type() == PlayerType.Bot) {
return maxPop;
}
return (
maxPop * 2 +
player.units(UnitType.City).length * this.cityPopulationIncrease()
);
}
populationIncreaseRate(player: Player): number {
let max = this.maxPopulation(player);
// const thing = Math.sqrt(player.population() + player.population() * player.workers())
let toAdd = 10 + Math.pow(player.population(), 0.73) / 4;
const ratio = 1 - player.population() / max;
toAdd *= ratio;
if (player.type() == PlayerType.FakeHuman) {
toAdd *= 1.0;
}
if (player.type() == PlayerType.Bot) {
toAdd *= 0.7;
}
let difficultyMultiplier = 1;
switch (this._gameConfig.difficulty) {
case Difficulty.Easy:
difficultyMultiplier = 1;
break;
case Difficulty.Medium:
difficultyMultiplier = 1.2;
break;
case Difficulty.Hard:
difficultyMultiplier = 1.5;
break;
case Difficulty.Impossible:
difficultyMultiplier = 1.7;
break;
}
if (player.type() == PlayerType.FakeHuman) {
toAdd *= difficultyMultiplier;
}
difficultyModifier(difficulty: Difficulty): number {
switch (difficulty) {
case Difficulty.Easy:
return 1
case Difficulty.Medium:
return 3
case Difficulty.Hard:
return 9
case Difficulty.Impossible:
return 18
}
}
return Math.min(player.population() + toAdd, max) - player.population();
}
goldAdditionRate(player: Player): number {
return Math.sqrt(player.workers() * player.numTilesOwned()) / 200;
}
cityPopulationIncrease(): number {
return 250_000
troopAdjustmentRate(player: Player): number {
const maxDiff = this.maxPopulation(player) / 1000;
const target = player.population() * player.targetTroopRatio();
const diff = target - player.troops();
if (Math.abs(diff) < maxDiff) {
return diff;
}
falloutDefenseModifier(): number {
return 5
}
defensePostRange(): number {
return 40
}
defensePostDefenseBonus(): number {
return 5
}
spawnNPCs(): boolean {
return true
}
tradeShipGold(dist: number): Gold {
return 10000 + 100 * Math.pow(dist, 1.1)
}
tradeShipSpawnRate(): number {
return 500
}
unitInfo(type: UnitType): UnitInfo {
switch (type) {
case UnitType.TransportShip:
return {
cost: () => 0,
territoryBound: false,
}
case UnitType.Destroyer:
return {
cost: (p: Player) => (p.units(UnitType.Destroyer).length + 1) * 250_000,
territoryBound: false,
maxHealth: 1000,
}
case UnitType.Battleship:
return {
cost: (p: Player) => (p.units(UnitType.Battleship).length + 1) * 500_000,
territoryBound: false,
maxHealth: 5000
}
case UnitType.Shell:
return {
cost: () => 0,
territoryBound: false,
damage: 250
}
case UnitType.Port:
return {
cost: (p: Player) =>
Math.min(
1_000_000,
Math.pow(2, p.units(UnitType.Port).length) * 250_000
),
territoryBound: true
}
case UnitType.AtomBomb:
return {
cost: () => 750_000,
territoryBound: false
}
case UnitType.HydrogenBomb:
return {
cost: () => 5_000_000,
territoryBound: false
}
case UnitType.TradeShip:
return {
cost: () => 0,
territoryBound: false
}
case UnitType.MissileSilo:
return {
cost: () => 1_000_000,
territoryBound: true
}
case UnitType.DefensePost:
return {
cost: (p: Player) =>
Math.min(
250_000,
(p.units(UnitType.DefensePost).length + 1) * 50_000
),
territoryBound: true
}
case UnitType.City:
return {
cost: (p: Player) => Math.min(
1_000_000,
Math.pow(2, p.units(UnitType.City).length) * 125_000,
),
territoryBound: true
}
default:
assertNever(type)
}
}
defaultDonationAmount(sender: Player): number {
return Math.floor(sender.troops() / 3)
}
donateCooldown(): Tick {
return 10 * 10
}
emojiMessageDuration(): Tick {
return 5 * 10
}
emojiMessageCooldown(): Tick {
return 15 * 10
}
targetDuration(): Tick {
return 10 * 10
}
targetCooldown(): Tick {
return 15 * 10
}
allianceRequestCooldown(): Tick {
return 30 * 10
}
allianceDuration(): Tick {
return 600 * 10
}
percentageTilesOwnedToWin(): number {
return 80
}
boatMaxNumber(): number {
return 3
}
boatMaxDistance(): number {
return 500
}
numSpawnPhaseTurns(): number {
return this._gameConfig.gameType == GameType.Singleplayer ? 100 : 300
}
numBots(): number {
return 400
}
theme(): Theme { return pastelTheme; }
attackLogic(gm: GameMap, attackTroops: number, attacker: Player, defender: Player | TerraNullius, tileToConquer: TileRef): { attackerTroopLoss: number; defenderTroopLoss: number; tilesPerTickUsed: number } {
let mag = 0
let speed = 0
const type = gm.terrainType(tileToConquer)
switch (type) {
case TerrainType.Plains:
mag = 80
speed = 15
break
case TerrainType.Highland:
mag = 100
speed = 20
break
case TerrainType.Mountain:
mag = 120
speed = 25
break
default:
throw new Error(`terrain type ${type} not supported`)
}
// TODO
// mag *= tileToConquer.defenseBonus(attacker)
// speed *= tileToConquer.defenseBonus(attacker)
if (gm.hasFallout(tileToConquer)) {
mag *= this.falloutDefenseModifier()
speed *= this.falloutDefenseModifier()
}
if (attacker.isPlayer() && defender.isPlayer()) {
if (attacker.type() == PlayerType.Human && defender.type() == PlayerType.Bot) {
mag *= .8
}
if (attacker.type() == PlayerType.FakeHuman && defender.type() == PlayerType.Bot) {
mag *= .8
}
}
if (defender.isPlayer()) {
return {
attackerTroopLoss: within(defender.troops() / (2.5 * attackTroops), .1, 10) * mag,
defenderTroopLoss: defender.troops() / defender.numTilesOwned(),
tilesPerTickUsed: within(defender.troops() / (5 * attackTroops), .2, 1.5) * speed
}
} else {
return {
attackerTroopLoss: attacker.type() == PlayerType.Bot ? mag / 10 : mag / 5,
defenderTroopLoss: 0,
tilesPerTickUsed: within(2000 * Math.max(10, speed) / attackTroops, 5, 100)
}
}
}
attackTilesPerTick(attackTroops: number, attacker: Player, defender: Player | TerraNullius, numAdjacentTilesWithEnemy: number): number {
if (defender.isPlayer()) {
return within((5 * attackTroops) / defender.troops() * 2, .01, .5) * numAdjacentTilesWithEnemy * 3
} else {
return numAdjacentTilesWithEnemy * 2
}
}
boatAttackAmount(attacker: Player, defender: Player | TerraNullius): number {
return Math.floor(attacker.troops() / 5)
}
attackAmount(attacker: Player, defender: Player | TerraNullius) {
if (attacker.type() == PlayerType.Bot) {
return attacker.troops() / 20
} else {
return attacker.troops() / 5
}
}
startManpower(playerInfo: PlayerInfo): number {
if (playerInfo.playerType == PlayerType.Bot) {
return 10000
}
if (playerInfo.playerType == PlayerType.FakeHuman) {
// start troops * strength * difficulty
switch (this._gameConfig.difficulty) {
case Difficulty.Easy:
case Difficulty.Medium:
return 1000
case Difficulty.Hard:
case Difficulty.Impossible:
return 2000
}
}
return 25000
}
maxPopulation(player: Player | PlayerView): number {
let maxPop = Math.pow(player.numTilesOwned(), .6) * 1000 + 50000
if (player.type() == PlayerType.Bot) {
return maxPop
}
return maxPop * 2 + player.units(UnitType.City).length * this.cityPopulationIncrease()
}
populationIncreaseRate(player: Player): number {
let max = this.maxPopulation(player)
// const thing = Math.sqrt(player.population() + player.population() * player.workers())
let toAdd = 10 + Math.pow(player.population(), .73) / 4
const ratio = 1 - (player.population() / max)
toAdd *= ratio
if (player.type() == PlayerType.FakeHuman) {
toAdd *= 1.0
}
if (player.type() == PlayerType.Bot) {
toAdd *= .7
}
let difficultyMultiplier = 1
switch (this._gameConfig.difficulty) {
case Difficulty.Easy:
difficultyMultiplier = 1
break
case Difficulty.Medium:
difficultyMultiplier = 1.2
break
case Difficulty.Hard:
difficultyMultiplier = 1.5
break
case Difficulty.Impossible:
difficultyMultiplier = 1.7
break
}
if (player.type() == PlayerType.FakeHuman) {
toAdd *= difficultyMultiplier
}
return Math.min(player.population() + toAdd, max) - player.population()
}
goldAdditionRate(player: Player): number {
return Math.sqrt(player.workers() * player.numTilesOwned()) / 200
}
troopAdjustmentRate(player: Player): number {
const maxDiff = this.maxPopulation(player) / 1000
const target = player.population() * player.targetTroopRatio()
const diff = target - player.troops()
if (Math.abs(diff) < maxDiff) {
return diff
}
const adjustment = maxDiff * Math.sign(diff)
// Can ramp down troops much faster
if (adjustment < 0) {
return adjustment * 5
}
return adjustment
const adjustment = maxDiff * Math.sign(diff);
// Can ramp down troops much faster
if (adjustment < 0) {
return adjustment * 5;
}
return adjustment;
}
}
+202 -202
View File
@@ -6,219 +6,219 @@ import { PseudoRandom } from "../PseudoRandom";
import { simpleHash } from "../Util";
import { GameMap, TileRef } from "../game/GameMap";
export const pastelTheme = new class implements Theme {
export const pastelTheme = new (class implements Theme {
private rand = new PseudoRandom(123);
private rand = new PseudoRandom(123)
private background = colord({ r: 60, g: 60, b: 60 });
private land = colord({ r: 194, g: 193, b: 148 });
private shore = colord({ r: 204, g: 203, b: 158 });
private falloutColors = [
colord({ r: 120, g: 255, b: 71 }), // Original color
colord({ r: 130, g: 255, b: 85 }), // Slightly lighter
colord({ r: 110, g: 245, b: 65 }), // Slightly darker
colord({ r: 125, g: 255, b: 75 }), // Warmer tint
colord({ r: 115, g: 250, b: 68 }), // Cooler tint
];
private water = colord({ r: 75, g: 142, b: 190 });
private shorelineWater = colord({ r: 100, g: 143, b: 255 });
private background = colord({ r: 60, g: 60, b: 60 });
private land = colord({ r: 194, g: 193, b: 148 });
private shore = colord({ r: 204, g: 203, b: 158 });
private falloutColors = [
colord({ r: 120, g: 255, b: 71 }), // Original color
colord({ r: 130, g: 255, b: 85 }), // Slightly lighter
colord({ r: 110, g: 245, b: 65 }), // Slightly darker
colord({ r: 125, g: 255, b: 75 }), // Warmer tint
colord({ r: 115, g: 250, b: 68 }) // Cooler tint
private territoryColors: Colord[] = [
colord({ r: 230, g: 100, b: 100 }), // Bright Red
colord({ r: 100, g: 180, b: 230 }), // Sky Blue
colord({ r: 230, g: 180, b: 80 }), // Golden Yellow
colord({ r: 180, g: 100, b: 230 }), // Purple
colord({ r: 80, g: 200, b: 120 }), // Emerald Green
colord({ r: 230, g: 130, b: 180 }), // Pink
colord({ r: 100, g: 160, b: 80 }), // Olive Green
colord({ r: 230, g: 150, b: 100 }), // Peach
colord({ r: 80, g: 130, b: 190 }), // Navy Blue
colord({ r: 210, g: 210, b: 100 }), // Lime Yellow
colord({ r: 190, g: 100, b: 130 }), // Maroon
colord({ r: 100, g: 210, b: 210 }), // Turquoise
colord({ r: 210, g: 140, b: 80 }), // Light Orange
colord({ r: 150, g: 110, b: 190 }), // Lavender
colord({ r: 180, g: 210, b: 120 }), // Light Green
colord({ r: 210, g: 100, b: 160 }), // Hot Pink
colord({ r: 100, g: 140, b: 110 }), // Sea Green
colord({ r: 230, g: 180, b: 180 }), // Light Pink
colord({ r: 120, g: 120, b: 190 }), // Periwinkle
colord({ r: 190, g: 170, b: 100 }), // Sand
colord({ r: 100, g: 180, b: 160 }), // Aquamarine
colord({ r: 210, g: 160, b: 200 }), // Orchid
colord({ r: 170, g: 190, b: 100 }), // Yellow Green
colord({ r: 100, g: 130, b: 150 }), // Steel Blue
colord({ r: 230, g: 140, b: 140 }), // Salmon
colord({ r: 140, g: 180, b: 220 }), // Light Blue
colord({ r: 200, g: 160, b: 110 }), // Tan
colord({ r: 180, g: 130, b: 180 }), // Plum
colord({ r: 130, g: 200, b: 130 }), // Light Sea Green
colord({ r: 220, g: 120, b: 120 }), // Coral
colord({ r: 120, g: 160, b: 200 }), // Cornflower Blue
colord({ r: 200, g: 200, b: 140 }), // Khaki
colord({ r: 160, g: 120, b: 160 }), // Purple Gray
colord({ r: 140, g: 180, b: 140 }), // Dark Sea Green
colord({ r: 200, g: 130, b: 110 }), // Dark Salmon
colord({ r: 130, g: 170, b: 190 }), // Cadet Blue
colord({ r: 190, g: 180, b: 160 }), // Tan Gray
colord({ r: 170, g: 140, b: 190 }), // Medium Purple
colord({ r: 160, g: 190, b: 160 }), // Pale Green
colord({ r: 190, g: 150, b: 130 }), // Rosy Brown
colord({ r: 140, g: 150, b: 180 }), // Light Slate Gray
colord({ r: 180, g: 170, b: 140 }), // Dark Khaki
colord({ r: 150, g: 130, b: 150 }), // Thistle
colord({ r: 170, g: 190, b: 180 }), // Pale Blue Green
colord({ r: 190, g: 140, b: 150 }), // Puce
colord({ r: 130, g: 180, b: 170 }), // Medium Aquamarine
colord({ r: 180, g: 160, b: 180 }), // Mauve
colord({ r: 160, g: 180, b: 140 }), // Dark Olive Green
colord({ r: 170, g: 150, b: 170 }), // Dusty Rose
colord({ r: 100, g: 180, b: 230 }), // Sky Blue
colord({ r: 230, g: 180, b: 80 }), // Golden Yellow
colord({ r: 180, g: 100, b: 230 }), // Purple
colord({ r: 80, g: 200, b: 120 }), // Emerald Green
colord({ r: 230, g: 130, b: 180 }), // Pink
colord({ r: 100, g: 160, b: 80 }), // Olive Green
colord({ r: 230, g: 150, b: 100 }), // Peach
colord({ r: 80, g: 130, b: 190 }), // Navy Blue
colord({ r: 210, g: 210, b: 100 }), // Lime Yellow
colord({ r: 190, g: 100, b: 130 }), // Maroon
colord({ r: 100, g: 210, b: 210 }), // Turquoise
colord({ r: 210, g: 140, b: 80 }), // Light Orange
colord({ r: 150, g: 110, b: 190 }), // Lavender
colord({ r: 180, g: 210, b: 120 }), // Light Green
colord({ r: 210, g: 100, b: 160 }), // Hot Pink
colord({ r: 100, g: 140, b: 110 }), // Sea Green
colord({ r: 230, g: 180, b: 180 }), // Light Pink
colord({ r: 120, g: 120, b: 190 }), // Periwinkle
colord({ r: 190, g: 170, b: 100 }), // Sand
colord({ r: 100, g: 180, b: 160 }), // Aquamarine
colord({ r: 210, g: 160, b: 200 }), // Orchid
colord({ r: 170, g: 190, b: 100 }), // Yellow Green
colord({ r: 100, g: 130, b: 150 }), // Steel Blue
colord({ r: 230, g: 140, b: 140 }), // Salmon
colord({ r: 140, g: 180, b: 220 }), // Light Blue
colord({ r: 200, g: 160, b: 110 }), // Tan
colord({ r: 180, g: 130, b: 180 }), // Plum
colord({ r: 130, g: 200, b: 130 }), // Light Sea Green
colord({ r: 220, g: 120, b: 120 }), // Coral
colord({ r: 120, g: 160, b: 200 }), // Cornflower Blue
colord({ r: 200, g: 200, b: 140 }), // Khaki
colord({ r: 160, g: 120, b: 160 }), // Purple Gray
colord({ r: 140, g: 180, b: 140 }), // Dark Sea Green
colord({ r: 200, g: 130, b: 110 }), // Dark Salmon
colord({ r: 130, g: 170, b: 190 }), // Cadet Blue
colord({ r: 190, g: 180, b: 160 }), // Tan Gray
colord({ r: 170, g: 140, b: 190 }), // Medium Purple
colord({ r: 160, g: 190, b: 160 }), // Pale Green
colord({ r: 190, g: 150, b: 130 }), // Rosy Brown
colord({ r: 140, g: 150, b: 180 }), // Light Slate Gray
colord({ r: 180, g: 170, b: 140 }), // Dark Khaki
colord({ r: 150, g: 130, b: 150 }), // Thistle
colord({ r: 170, g: 190, b: 180 }), // Pale Blue Green
colord({ r: 190, g: 140, b: 150 }), // Puce
colord({ r: 130, g: 180, b: 170 }), // Medium Aquamarine
colord({ r: 180, g: 160, b: 180 }), // Mauve
colord({ r: 160, g: 180, b: 140 }), // Dark Olive Green
colord({ r: 170, g: 150, b: 170 }), // Dusty Rose
];
private _selfColor = colord({ r: 0, g: 255, b: 0 });
private _allyColor = colord({ r: 255, g: 255, b: 0 });
private _enemyColor = colord({ r: 255, g: 0, b: 0 });
private _spawnHighlightColor = colord({ r: 255, g: 213, b: 79 });
playerInfoColor(id: PlayerID): Colord {
return colord({ r: 50, g: 50, b: 50 });
}
territoryColor(playerInfo: PlayerInfo): Colord {
return this.territoryColors[
simpleHash(playerInfo.name) % this.territoryColors.length
];
private water = colord({ r: 75, g: 142, b: 190 });
private shorelineWater = colord({ r: 100, g: 143, b: 255 });
}
private territoryColors: Colord[] = [
colord({ r: 230, g: 100, b: 100 }), // Bright Red
colord({ r: 100, g: 180, b: 230 }), // Sky Blue
colord({ r: 230, g: 180, b: 80 }), // Golden Yellow
colord({ r: 180, g: 100, b: 230 }), // Purple
colord({ r: 80, g: 200, b: 120 }), // Emerald Green
colord({ r: 230, g: 130, b: 180 }), // Pink
colord({ r: 100, g: 160, b: 80 }), // Olive Green
colord({ r: 230, g: 150, b: 100 }), // Peach
colord({ r: 80, g: 130, b: 190 }), // Navy Blue
colord({ r: 210, g: 210, b: 100 }), // Lime Yellow
colord({ r: 190, g: 100, b: 130 }), // Maroon
colord({ r: 100, g: 210, b: 210 }), // Turquoise
colord({ r: 210, g: 140, b: 80 }), // Light Orange
colord({ r: 150, g: 110, b: 190 }), // Lavender
colord({ r: 180, g: 210, b: 120 }), // Light Green
colord({ r: 210, g: 100, b: 160 }), // Hot Pink
colord({ r: 100, g: 140, b: 110 }), // Sea Green
colord({ r: 230, g: 180, b: 180 }), // Light Pink
colord({ r: 120, g: 120, b: 190 }), // Periwinkle
colord({ r: 190, g: 170, b: 100 }), // Sand
colord({ r: 100, g: 180, b: 160 }), // Aquamarine
colord({ r: 210, g: 160, b: 200 }), // Orchid
colord({ r: 170, g: 190, b: 100 }), // Yellow Green
colord({ r: 100, g: 130, b: 150 }), // Steel Blue
colord({ r: 230, g: 140, b: 140 }), // Salmon
colord({ r: 140, g: 180, b: 220 }), // Light Blue
colord({ r: 200, g: 160, b: 110 }), // Tan
colord({ r: 180, g: 130, b: 180 }), // Plum
colord({ r: 130, g: 200, b: 130 }), // Light Sea Green
colord({ r: 220, g: 120, b: 120 }), // Coral
colord({ r: 120, g: 160, b: 200 }), // Cornflower Blue
colord({ r: 200, g: 200, b: 140 }), // Khaki
colord({ r: 160, g: 120, b: 160 }), // Purple Gray
colord({ r: 140, g: 180, b: 140 }), // Dark Sea Green
colord({ r: 200, g: 130, b: 110 }), // Dark Salmon
colord({ r: 130, g: 170, b: 190 }), // Cadet Blue
colord({ r: 190, g: 180, b: 160 }), // Tan Gray
colord({ r: 170, g: 140, b: 190 }), // Medium Purple
colord({ r: 160, g: 190, b: 160 }), // Pale Green
colord({ r: 190, g: 150, b: 130 }), // Rosy Brown
colord({ r: 140, g: 150, b: 180 }), // Light Slate Gray
colord({ r: 180, g: 170, b: 140 }), // Dark Khaki
colord({ r: 150, g: 130, b: 150 }), // Thistle
colord({ r: 170, g: 190, b: 180 }), // Pale Blue Green
colord({ r: 190, g: 140, b: 150 }), // Puce
colord({ r: 130, g: 180, b: 170 }), // Medium Aquamarine
colord({ r: 180, g: 160, b: 180 }), // Mauve
colord({ r: 160, g: 180, b: 140 }), // Dark Olive Green
colord({ r: 170, g: 150, b: 170 }), // Dusty Rose
colord({ r: 100, g: 180, b: 230 }), // Sky Blue
colord({ r: 230, g: 180, b: 80 }), // Golden Yellow
colord({ r: 180, g: 100, b: 230 }), // Purple
colord({ r: 80, g: 200, b: 120 }), // Emerald Green
colord({ r: 230, g: 130, b: 180 }), // Pink
colord({ r: 100, g: 160, b: 80 }), // Olive Green
colord({ r: 230, g: 150, b: 100 }), // Peach
colord({ r: 80, g: 130, b: 190 }), // Navy Blue
colord({ r: 210, g: 210, b: 100 }), // Lime Yellow
colord({ r: 190, g: 100, b: 130 }), // Maroon
colord({ r: 100, g: 210, b: 210 }), // Turquoise
colord({ r: 210, g: 140, b: 80 }), // Light Orange
colord({ r: 150, g: 110, b: 190 }), // Lavender
colord({ r: 180, g: 210, b: 120 }), // Light Green
colord({ r: 210, g: 100, b: 160 }), // Hot Pink
colord({ r: 100, g: 140, b: 110 }), // Sea Green
colord({ r: 230, g: 180, b: 180 }), // Light Pink
colord({ r: 120, g: 120, b: 190 }), // Periwinkle
colord({ r: 190, g: 170, b: 100 }), // Sand
colord({ r: 100, g: 180, b: 160 }), // Aquamarine
colord({ r: 210, g: 160, b: 200 }), // Orchid
colord({ r: 170, g: 190, b: 100 }), // Yellow Green
colord({ r: 100, g: 130, b: 150 }), // Steel Blue
colord({ r: 230, g: 140, b: 140 }), // Salmon
colord({ r: 140, g: 180, b: 220 }), // Light Blue
colord({ r: 200, g: 160, b: 110 }), // Tan
colord({ r: 180, g: 130, b: 180 }), // Plum
colord({ r: 130, g: 200, b: 130 }), // Light Sea Green
colord({ r: 220, g: 120, b: 120 }), // Coral
colord({ r: 120, g: 160, b: 200 }), // Cornflower Blue
colord({ r: 200, g: 200, b: 140 }), // Khaki
colord({ r: 160, g: 120, b: 160 }), // Purple Gray
colord({ r: 140, g: 180, b: 140 }), // Dark Sea Green
colord({ r: 200, g: 130, b: 110 }), // Dark Salmon
colord({ r: 130, g: 170, b: 190 }), // Cadet Blue
colord({ r: 190, g: 180, b: 160 }), // Tan Gray
colord({ r: 170, g: 140, b: 190 }), // Medium Purple
colord({ r: 160, g: 190, b: 160 }), // Pale Green
colord({ r: 190, g: 150, b: 130 }), // Rosy Brown
colord({ r: 140, g: 150, b: 180 }), // Light Slate Gray
colord({ r: 180, g: 170, b: 140 }), // Dark Khaki
colord({ r: 150, g: 130, b: 150 }), // Thistle
colord({ r: 170, g: 190, b: 180 }), // Pale Blue Green
colord({ r: 190, g: 140, b: 150 }), // Puce
colord({ r: 130, g: 180, b: 170 }), // Medium Aquamarine
colord({ r: 180, g: 160, b: 180 }), // Mauve
colord({ r: 160, g: 180, b: 140 }), // Dark Olive Green
colord({ r: 170, g: 150, b: 170 }) // Dusty Rose
];
borderColor(playerInfo: PlayerInfo): Colord {
const tc = this.territoryColor(playerInfo).rgba;
return colord({
r: Math.max(tc.r - 40, 0),
g: Math.max(tc.g - 40, 0),
b: Math.max(tc.b - 40, 0),
});
}
defendedBorderColor(playerInfo: PlayerInfo): Colord {
const bc = this.borderColor(playerInfo).rgba;
return colord({
r: Math.max(bc.r - 40, 0),
g: Math.max(bc.g - 40, 0),
b: Math.max(bc.b - 40, 0),
});
}
private _selfColor = colord({ r: 0, g: 255, b: 0 })
private _allyColor = colord({ r: 255, g: 255, b: 0 })
private _enemyColor = colord({ r: 255, g: 0, b: 0 })
private _spawnHighlightColor = colord({ r: 255, g: 213, b: 79 })
playerInfoColor(id: PlayerID): Colord {
return colord({ r: 50, g: 50, b: 50 })
terrainColor(gm: GameMap, tile: TileRef): Colord {
let mag = gm.magnitude(tile);
if (gm.isShore(tile)) {
return this.shore;
}
territoryColor(playerInfo: PlayerInfo): Colord {
return this.territoryColors[simpleHash(playerInfo.name) % this.territoryColors.length]
}
borderColor(playerInfo: PlayerInfo): Colord {
const tc = this.territoryColor(playerInfo).rgba;
return colord({
r: Math.max(tc.r - 40, 0),
g: Math.max(tc.g - 40, 0),
b: Math.max(tc.b - 40, 0)
})
}
defendedBorderColor(playerInfo: PlayerInfo): Colord {
const bc = this.borderColor(playerInfo).rgba;
return colord({
r: Math.max(bc.r - 40, 0),
g: Math.max(bc.g - 40, 0),
b: Math.max(bc.b - 40, 0)
})
}
terrainColor(gm: GameMap, tile: TileRef): Colord {
let mag = gm.magnitude(tile)
if (gm.isShore(tile)) {
return this.shore
switch (gm.terrainType(tile)) {
case TerrainType.Ocean:
case TerrainType.Lake:
const w = this.water.rgba;
if (gm.isShoreline(tile) && gm.isWater(tile)) {
return this.shorelineWater;
}
switch (gm.terrainType(tile)) {
case TerrainType.Ocean:
case TerrainType.Lake:
const w = this.water.rgba
if (gm.isShoreline(tile) && gm.isWater(tile)) {
return this.shorelineWater
}
if (gm.magnitude(tile) < 7) {
return colord({
r: Math.max(w.r - 7 + mag, 0),
g: Math.max(w.g - 7 + mag, 0),
b: Math.max(w.b - 7 + mag, 0)
})
}
return this.water
case TerrainType.Plains:
return colord({
r: 190,
g: 220 - 2 * mag,
b: 138
})
case TerrainType.Highland:
return colord({
r: 200 + 2 * mag,
g: 183 + 2 * mag,
b: 138 + 2 * mag
})
case TerrainType.Mountain:
return colord({
r: 230 + mag / 2,
g: 230 + mag / 2,
b: 230 + mag / 2
})
if (gm.magnitude(tile) < 7) {
return colord({
r: Math.max(w.r - 7 + mag, 0),
g: Math.max(w.g - 7 + mag, 0),
b: Math.max(w.b - 7 + mag, 0),
});
}
return this.water;
case TerrainType.Plains:
return colord({
r: 190,
g: 220 - 2 * mag,
b: 138,
});
case TerrainType.Highland:
return colord({
r: 200 + 2 * mag,
g: 183 + 2 * mag,
b: 138 + 2 * mag,
});
case TerrainType.Mountain:
return colord({
r: 230 + mag / 2,
g: 230 + mag / 2,
b: 230 + mag / 2,
});
}
}
backgroundColor(): Colord {
return this.background;
}
backgroundColor(): Colord {
return this.background;
}
falloutColor(): Colord {
return this.rand.randElement(this.falloutColors)
}
falloutColor(): Colord {
return this.rand.randElement(this.falloutColors);
}
font(): string {
return "Overpass, sans-serif";
}
font(): string {
return "Overpass, sans-serif";
}
selfColor(): Colord {
return this._selfColor
}
allyColor(): Colord {
return this._allyColor
}
enemyColor(): Colord {
return this._enemyColor
}
selfColor(): Colord {
return this._selfColor;
}
allyColor(): Colord {
return this._allyColor;
}
enemyColor(): Colord {
return this._enemyColor;
}
spawnHighlightColor(): Colord {
return this._spawnHighlightColor
}
}
spawnHighlightColor(): Colord {
return this._spawnHighlightColor;
}
})();
+1 -3
View File
@@ -1,5 +1,3 @@
import { DefaultConfig, DefaultServerConfig } from "./DefaultConfig";
export const preprodConfig = new class extends DefaultServerConfig {
}
export const preprodConfig = new (class extends DefaultServerConfig {})();
+1 -3
View File
@@ -1,5 +1,3 @@
import { DefaultConfig, DefaultServerConfig } from "./DefaultConfig";
export const prodConfig = new class extends DefaultServerConfig {
}
export const prodConfig = new (class extends DefaultServerConfig {})();
+261 -206
View File
@@ -1,243 +1,298 @@
import { PriorityQueue } from "@datastructures-js/priority-queue";
import { Cell, Execution, Game, Player, PlayerID, PlayerType, TerrainType, TerraNullius } from "../game/Game";
import {
Cell,
Execution,
Game,
Player,
PlayerID,
PlayerType,
TerrainType,
TerraNullius,
} from "../game/Game";
import { PseudoRandom } from "../PseudoRandom";
import { MessageType } from '../game/Game';
import { MessageType } from "../game/Game";
import { renderNumber } from "../../client/Utils";
import { TileRef } from "../game/GameMap";
export class AttackExecution implements Execution {
private breakAlliance = false
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
private breakAlliance = false;
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.priority - b.priority
return a.tick - b.tick;
}
return a.priority - b.priority;
});
private random = new PseudoRandom(123)
private random = new PseudoRandom(123);
private _owner: Player
private target: Player | TerraNullius
private _owner: Player;
private target: Player | TerraNullius;
private mg: Game
private mg: Game;
private border = new Set<TileRef>()
private border = new Set<TileRef>();
constructor(
private troops: number | null,
private _ownerID: PlayerID,
private _targetID: PlayerID | null,
private sourceTile: TileRef | null,
private removeTroops: boolean = true,
) { }
constructor(
private troops: number | null,
private _ownerID: PlayerID,
private _targetID: PlayerID | null,
private sourceTile: TileRef | null,
private removeTroops: boolean = true,
) {}
public targetID(): PlayerID {
return this._targetID
public targetID(): PlayerID {
return this._targetID;
}
activeDuringSpawnPhase(): boolean {
return false;
}
init(mg: Game, ticks: number) {
if (!this.active) {
return;
}
this.mg = mg;
this._owner = mg.player(this._ownerID);
this.target =
this._targetID == this.mg.terraNullius().id()
? mg.terraNullius()
: mg.player(this._targetID);
if (this._owner == this.target) {
throw new Error(`Player ${this._owner} cannot attack itself`);
}
activeDuringSpawnPhase(): boolean {
return false
if (this.troops == null) {
this.troops = this.mg.config().attackAmount(this._owner, this.target);
}
this.troops = Math.min(this._owner.troops(), this.troops);
if (this.removeTroops) {
this._owner.removeTroops(this.troops);
}
init(mg: Game, ticks: number) {
if (!this.active) {
return
for (const exec of mg.executions()) {
if (exec.isActive() && exec instanceof AttackExecution && exec != this) {
const otherAttack = exec as AttackExecution;
// Target has opposing attack, cancel them out
if (
this.target.isPlayer() &&
otherAttack._targetID == this._ownerID &&
this._targetID == otherAttack._ownerID
) {
if (otherAttack.troops > this.troops) {
otherAttack.troops -= this.troops;
// otherAttack.calculateToConquer()
this.active = false;
return;
} else {
this.troops -= otherAttack.troops;
otherAttack.active = false;
}
}
this.mg = mg
this._owner = mg.player(this._ownerID)
this.target = this._targetID == this.mg.terraNullius().id() ? mg.terraNullius() : mg.player(this._targetID)
if (this._owner == this.target) {
throw new Error(`Player ${this._owner} cannot attack itself`)
// Existing attack on same target, add troops
if (
otherAttack._owner == this._owner &&
otherAttack._targetID == this._targetID &&
this.sourceTile == otherAttack.sourceTile
) {
otherAttack.troops += this.troops;
otherAttack.refreshToConquer();
this.active = false;
return;
}
if (this.troops == null) {
this.troops = this.mg.config().attackAmount(this._owner, this.target)
}
this.troops = Math.min(this._owner.troops(), this.troops)
if (this.removeTroops) {
this._owner.removeTroops(this.troops)
}
for (const exec of mg.executions()) {
if (exec.isActive() && exec instanceof AttackExecution && exec != this) {
const otherAttack = exec as AttackExecution
// Target has opposing attack, cancel them out
if (this.target.isPlayer() && otherAttack._targetID == this._ownerID && this._targetID == otherAttack._ownerID) {
if (otherAttack.troops > this.troops) {
otherAttack.troops -= this.troops
// otherAttack.calculateToConquer()
this.active = false
return
} else {
this.troops -= otherAttack.troops
otherAttack.active = false
}
}
// Existing attack on same target, add troops
if (otherAttack._owner == this._owner && otherAttack._targetID == this._targetID && this.sourceTile == otherAttack.sourceTile) {
otherAttack.troops += this.troops
otherAttack.refreshToConquer()
this.active = false
return
}
}
}
if (this._owner.type() != PlayerType.Bot && this.target.isPlayer() && this.target.type() == PlayerType.Human) {
mg.displayMessage(`You are being attacked by ${this._owner.displayName()}`, MessageType.ERROR, this._targetID)
}
if (this.sourceTile != null) {
this.addNeighbors(this.sourceTile)
} else {
this.refreshToConquer()
}
if (this.target.isPlayer()) {
if (this._owner.isAlliedWith(this.target)) {
// No updates should happen in init.
this.breakAlliance = true
}
this.target.updateRelation(this._owner, -80)
}
}
}
if (
this._owner.type() != PlayerType.Bot &&
this.target.isPlayer() &&
this.target.type() == PlayerType.Human
) {
mg.displayMessage(
`You are being attacked by ${this._owner.displayName()}`,
MessageType.ERROR,
this._targetID,
);
}
if (this.sourceTile != null) {
this.addNeighbors(this.sourceTile);
} else {
this.refreshToConquer();
}
private refreshToConquer() {
this.toConquer.clear()
this.border.clear()
for (const tile of this._owner.borderTiles()) {
this.addNeighbors(tile)
}
if (this.target.isPlayer()) {
if (this._owner.isAlliedWith(this.target)) {
// No updates should happen in init.
this.breakAlliance = true;
}
this.target.updateRelation(this._owner, -80);
}
}
private refreshToConquer() {
this.toConquer.clear();
this.border.clear();
for (const tile of this._owner.borderTiles()) {
this.addNeighbors(tile);
}
}
tick(ticks: number) {
if (!this.active) {
return;
}
const alliance = this._owner.allianceWith(this.target as Player);
if (this.breakAlliance && alliance != null) {
this.breakAlliance = false;
this._owner.breakAlliance(alliance);
}
if (this.target.isPlayer() && this._owner.isAlliedWith(this.target)) {
// In this case a new alliance was created AFTER the attack started.
this._owner.addTroops(this.troops);
this.active = false;
return;
}
tick(ticks: number) {
if (!this.active) {
return
}
const alliance = this._owner.allianceWith(this.target as Player)
if (this.breakAlliance && alliance != null) {
this.breakAlliance = false
this._owner.breakAlliance(alliance)
}
if (this.target.isPlayer() && this._owner.isAlliedWith(this.target)) {
// In this case a new alliance was created AFTER the attack started.
this._owner.addTroops(this.troops)
this.active = false
return
}
let numTilesPerTick = this.mg
.config()
.attackTilesPerTick(
this.troops,
this._owner,
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}`)
let numTilesPerTick = this.mg.config().attackTilesPerTick(this.troops, this._owner, 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.troops < 1) {
this.active = false;
return;
}
if (this.toConquer.size() == 0) {
this.refreshToConquer();
this.active = false;
this._owner.addTroops(this.troops);
return;
}
while (numTilesPerTick > 0) {
if (this.troops < 1) {
this.active = false
return
}
const tileToConquer = this.toConquer.dequeue().tile;
this.border.delete(tileToConquer);
if (this.toConquer.size() == 0) {
this.refreshToConquer()
this.active = false
this._owner.addTroops(this.troops)
return
}
const tileToConquer = this.toConquer.dequeue().tile
this.border.delete(tileToConquer)
const onBorder = this.mg.neighbors(tileToConquer).filter(t => this.mg.owner(t) == this._owner).length > 0
if (this.mg.owner(tileToConquer) != this.target || !onBorder) {
continue
}
this.addNeighbors(tileToConquer)
const { attackerTroopLoss, defenderTroopLoss, tilesPerTickUsed } = this.mg.config().attackLogic(this.mg, this.troops, this._owner, this.target, tileToConquer)
numTilesPerTick -= tilesPerTickUsed
this.troops -= attackerTroopLoss
if (this.target.isPlayer()) {
this.target.removeTroops(defenderTroopLoss)
}
this._owner.conquer(tileToConquer)
this.handleDeadDefender()
}
const onBorder =
this.mg
.neighbors(tileToConquer)
.filter((t) => this.mg.owner(t) == this._owner).length > 0;
if (this.mg.owner(tileToConquer) != this.target || !onBorder) {
continue;
}
this.addNeighbors(tileToConquer);
const { attackerTroopLoss, defenderTroopLoss, tilesPerTickUsed } = this.mg
.config()
.attackLogic(
this.mg,
this.troops,
this._owner,
this.target,
tileToConquer,
);
numTilesPerTick -= tilesPerTickUsed;
this.troops -= attackerTroopLoss;
if (this.target.isPlayer()) {
this.target.removeTroops(defenderTroopLoss);
}
this._owner.conquer(tileToConquer);
this.handleDeadDefender();
}
}
private addNeighbors(tile: TileRef) {
for (const neighbor of this.mg.neighbors(tile)) {
if (this.mg.isWater(neighbor) || this.mg.owner(neighbor) != this.target) {
continue
private addNeighbors(tile: TileRef) {
for (const neighbor of this.mg.neighbors(tile)) {
if (this.mg.isWater(neighbor) || this.mg.owner(neighbor) != this.target) {
continue;
}
this.border.add(neighbor);
let numOwnedByMe = this.mg
.neighbors(neighbor)
.filter((t) => this.mg.owner(t) == this._owner).length;
let dist = 0;
if (numOwnedByMe > 2) {
numOwnedByMe = 10;
}
let mag = 0;
switch (this.mg.terrainType(tile)) {
case TerrainType.Plains:
mag = 1;
break;
case TerrainType.Highland:
mag = 1.5;
break;
case TerrainType.Mountain:
mag = 2;
break;
}
this.toConquer.enqueue(
new TileContainer(
neighbor,
dist / 100 + this.random.nextInt(0, 2) - numOwnedByMe + mag,
this.mg.ticks(),
),
);
}
}
private handleDeadDefender() {
if (this.target.isPlayer() && this.target.numTilesOwned() < 100) {
const gold = this.target.gold();
this.mg.displayMessage(
`Conquered ${this.target.displayName()} received ${renderNumber(gold)} gold`,
MessageType.SUCCESS,
this._owner.id(),
);
this.target.removeGold(gold);
this._owner.addGold(gold);
for (let i = 0; i < 10; i++) {
for (const tile of this.target.tiles()) {
const borders = this.mg
.neighbors(tile)
.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) {
this.mg.player(no.id()).conquer(tile);
break;
}
}
this.border.add(neighbor)
let numOwnedByMe = this.mg.neighbors(neighbor)
.filter(t => this.mg.owner(t) == this._owner)
.length
let dist = 0
if (numOwnedByMe > 2) {
numOwnedByMe = 10
}
let mag = 0
switch (this.mg.terrainType(tile)) {
case TerrainType.Plains:
mag = 1
break
case TerrainType.Highland:
mag = 1.5
break
case TerrainType.Mountain:
mag = 2
break
}
this.toConquer.enqueue(new TileContainer(
neighbor,
dist / 100 + this.random.nextInt(0, 2) - numOwnedByMe + mag,
this.mg.ticks()
))
}
}
}
}
}
private handleDeadDefender() {
if (this.target.isPlayer() && this.target.numTilesOwned() < 100) {
const gold = this.target.gold()
this.mg.displayMessage(`Conquered ${this.target.displayName()} received ${renderNumber(gold)} gold`, MessageType.SUCCESS, this._owner.id())
this.target.removeGold(gold)
this._owner.addGold(gold)
for (let i = 0; i < 10; i++) {
for (const tile of this.target.tiles()) {
const borders = this.mg.neighbors(tile).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) {
this.mg.player(no.id()).conquer(tile)
break
}
}
}
}
}
}
}
owner(): Player {
return this._owner
}
isActive(): boolean {
return this.active
}
owner(): Player {
return this._owner;
}
isActive(): boolean {
return this.active;
}
}
class TileContainer {
constructor(public readonly tile: TileRef, public readonly priority: number, public readonly tick: number) { }
}
constructor(
public readonly tile: TileRef,
public readonly priority: number,
public readonly tick: number,
) {}
}
+145 -112
View File
@@ -1,4 +1,13 @@
import { Cell, Execution, Game, Player, Unit, PlayerID, TerrainType, UnitType } from "../game/Game";
import {
Cell,
Execution,
Game,
Player,
Unit,
PlayerID,
TerrainType,
UnitType,
} from "../game/Game";
import { PathFinder } from "../pathfinding/PathFinding";
import { PathFindResultType } from "../pathfinding/AStar";
import { PseudoRandom } from "../PseudoRandom";
@@ -8,133 +17,157 @@ import { consolex } from "../Consolex";
import { TileRef } from "../game/GameMap";
export class BattleshipExecution implements Execution {
private random: PseudoRandom
private random: PseudoRandom;
private _owner: Player
private active = true
private battleship: Unit = null
private mg: Game = null
private _owner: Player;
private active = true;
private battleship: Unit = null;
private mg: Game = null;
private pathfinder: PathFinder
private pathfinder: PathFinder;
private patrolTile: TileRef;
private patrolTile: TileRef;
// TODO: put in config
private searchRange = 100
private attackRate = 5
private lastAttack = 0
// TODO: put in config
private searchRange = 100;
private attackRate = 5;
private lastAttack = 0;
private alreadyTargeted = new Set<Unit>()
private alreadyTargeted = new Set<Unit>();
constructor(
private playerID: PlayerID,
private patrolCenterTile: TileRef,
) { }
constructor(
private playerID: PlayerID,
private patrolCenterTile: TileRef,
) {}
init(mg: Game, ticks: number): void {
this.pathfinder = PathFinder.Mini(mg, 5000, false);
this._owner = mg.player(this.playerID);
this.mg = mg;
this.patrolTile = this.patrolCenterTile;
this.random = new PseudoRandom(mg.ticks());
}
init(mg: Game, ticks: number): void {
this.pathfinder = PathFinder.Mini(mg, 5000, false)
this._owner = mg.player(this.playerID)
this.mg = mg
this.patrolTile = this.patrolCenterTile
this.random = new PseudoRandom(mg.ticks())
tick(ticks: number): void {
this.alreadyTargeted.forEach((u) => {
if (!u.isActive()) {
this.alreadyTargeted.delete(u);
}
});
if (this.battleship == null) {
const spawn = this._owner.canBuild(UnitType.Battleship, this.patrolTile);
if (spawn == false) {
this.active = false;
return;
}
this.battleship = this._owner.buildUnit(UnitType.Battleship, 0, spawn);
return;
}
if (!this.battleship.isActive()) {
this.active = false;
return;
}
tick(ticks: number): void {
this.alreadyTargeted.forEach(u => {
if (!u.isActive()) {
this.alreadyTargeted.delete(u)
}
})
if (this.battleship == null) {
const spawn = this._owner.canBuild(UnitType.Battleship, this.patrolTile)
if (spawn == false) {
this.active = false
return
}
this.battleship = this._owner.buildUnit(UnitType.Battleship, 0, spawn)
return
}
if (!this.battleship.isActive()) {
this.active = false
return
}
if (this.mg.ticks() % 2 == 0) {
const result = this.pathfinder.nextTile(this.battleship.tile(), this.patrolTile)
switch (result.type) {
case PathFindResultType.Completed:
this.patrolTile = this.randomTile()
break
case PathFindResultType.NextTile:
this.battleship.move(result.tile)
break
case PathFindResultType.Pending:
return
case PathFindResultType.PathNotFound:
consolex.log(`path not found to patrol tile`)
this.patrolTile = this.randomTile()
break
}
}
if (this.mg.ticks() - this.lastAttack < this.attackRate) {
return
}
let ships = this.mg.units(UnitType.TransportShip, UnitType.Destroyer, UnitType.TradeShip, UnitType.Battleship)
.filter(u => this.mg.manhattanDist(u.tile(), this.battleship.tile()) < 100)
.filter(u => u.owner() != this.battleship.owner())
.filter(u => u != this.battleship)
.filter(u => !u.owner().isAlliedWith(this.battleship.owner()))
.filter(u => !this.alreadyTargeted.has(u))
.sort(distSortUnit(this.mg, this.battleship));
const friendlyDestroyerNearby = this.battleship.owner().units(UnitType.Destroyer)
.filter(d => this.mg.manhattanDist(d.tile(), this.battleship.tile()) < 120)
.length > 0
if (friendlyDestroyerNearby) {
// Don't attack trade ships to allow friendly destroyer to capture them
ships = ships.filter(s => s.type() != UnitType.TradeShip)
}
if (ships.length > 0) {
const toAttack = ships[0]
if (!toAttack.hasHealth()) {
// Don't send multiple shells to target if it can be one-shotted.
this.alreadyTargeted.add(toAttack)
}
this.lastAttack = this.mg.ticks()
this.mg.addExecution(new ShellExecution(this.battleship.tile(), this.battleship.owner(), this.battleship, toAttack))
}
if (this.mg.ticks() % 2 == 0) {
const result = this.pathfinder.nextTile(
this.battleship.tile(),
this.patrolTile,
);
switch (result.type) {
case PathFindResultType.Completed:
this.patrolTile = this.randomTile();
break;
case PathFindResultType.NextTile:
this.battleship.move(result.tile);
break;
case PathFindResultType.Pending:
return;
case PathFindResultType.PathNotFound:
consolex.log(`path not found to patrol tile`);
this.patrolTile = this.randomTile();
break;
}
}
owner(): Player {
return this._owner
if (this.mg.ticks() - this.lastAttack < this.attackRate) {
return;
}
isActive(): boolean {
return this.active
let ships = this.mg
.units(
UnitType.TransportShip,
UnitType.Destroyer,
UnitType.TradeShip,
UnitType.Battleship,
)
.filter(
(u) => this.mg.manhattanDist(u.tile(), this.battleship.tile()) < 100,
)
.filter((u) => u.owner() != this.battleship.owner())
.filter((u) => u != this.battleship)
.filter((u) => !u.owner().isAlliedWith(this.battleship.owner()))
.filter((u) => !this.alreadyTargeted.has(u))
.sort(distSortUnit(this.mg, this.battleship));
const friendlyDestroyerNearby =
this.battleship
.owner()
.units(UnitType.Destroyer)
.filter(
(d) => this.mg.manhattanDist(d.tile(), this.battleship.tile()) < 120,
).length > 0;
if (friendlyDestroyerNearby) {
// Don't attack trade ships to allow friendly destroyer to capture them
ships = ships.filter((s) => s.type() != UnitType.TradeShip);
}
activeDuringSpawnPhase(): boolean {
return false
if (ships.length > 0) {
const toAttack = ships[0];
if (!toAttack.hasHealth()) {
// Don't send multiple shells to target if it can be one-shotted.
this.alreadyTargeted.add(toAttack);
}
this.lastAttack = this.mg.ticks();
this.mg.addExecution(
new ShellExecution(
this.battleship.tile(),
this.battleship.owner(),
this.battleship,
toAttack,
),
);
}
}
randomTile(): TileRef {
while (true) {
const x = this.mg.x(this.patrolCenterTile) + this.random.nextInt(-this.searchRange / 2, this.searchRange / 2)
const y = this.mg.y(this.patrolCenterTile) + this.random.nextInt(-this.searchRange / 2, this.searchRange / 2)
if (!this.mg.isValidCoord(x, y)) {
continue
}
const tile = this.mg.ref(x, y)
if (!this.mg.isOcean(tile)) {
continue
}
return tile
}
owner(): Player {
return this._owner;
}
isActive(): boolean {
return this.active;
}
activeDuringSpawnPhase(): boolean {
return false;
}
randomTile(): TileRef {
while (true) {
const x =
this.mg.x(this.patrolCenterTile) +
this.random.nextInt(-this.searchRange / 2, this.searchRange / 2);
const y =
this.mg.y(this.patrolCenterTile) +
this.random.nextInt(-this.searchRange / 2, this.searchRange / 2);
if (!this.mg.isValidCoord(x, y)) {
continue;
}
const tile = this.mg.ref(x, y);
if (!this.mg.isOcean(tile)) {
continue;
}
return tile;
}
}
}
}
+100 -91
View File
@@ -1,109 +1,118 @@
import { Cell, Execution, Game, Player, PlayerType, TerraNullius } from "../game/Game"
import { PseudoRandom } from "../PseudoRandom"
import {
Cell,
Execution,
Game,
Player,
PlayerType,
TerraNullius,
} from "../game/Game";
import { PseudoRandom } from "../PseudoRandom";
import { simpleHash } from "../Util";
import { AttackExecution } from "./AttackExecution";
export class BotExecution implements Execution {
private active = true;
private random: PseudoRandom;
private attackRate: number;
private mg: Game;
private neighborsTerraNullius = true;
private active = true
private random: PseudoRandom;
private attackRate: number
private mg: Game
private neighborsTerraNullius = true
constructor(private bot: Player) {
this.random = new PseudoRandom(simpleHash(bot.id()));
this.attackRate = this.random.nextInt(10, 50);
}
activeDuringSpawnPhase(): boolean {
return false;
}
init(mg: Game, ticks: number) {
this.mg = mg;
// this.neighborsTerra = this.bot.neighbors().filter(n => n == this.gs.terraNullius()).length > 0
}
constructor(private bot: Player) {
this.random = new PseudoRandom(simpleHash(bot.id()))
this.attackRate = this.random.nextInt(10, 50)
}
activeDuringSpawnPhase(): boolean {
return false
tick(ticks: number) {
if (!this.bot.isAlive()) {
this.active = false;
return;
}
init(mg: Game, ticks: number) {
this.mg = mg
// this.neighborsTerra = this.bot.neighbors().filter(n => n == this.gs.terraNullius()).length > 0
if (ticks % this.attackRate != 0) {
return;
}
tick(ticks: number) {
if (!this.bot.isAlive()) {
this.active = false
return
this.bot.incomingAllianceRequests().forEach((ar) => {
if (ar.requestor().isTraitor()) {
ar.reject();
} else {
ar.accept();
}
});
const traitors = this.bot
.neighbors()
.filter((n) => n.isPlayer() && n.isTraitor()) as Player[];
if (traitors.length > 0) {
const toAttack = this.random.randElement(traitors);
const odds = this.bot.isAlliedWith(toAttack) ? 6 : 3;
if (this.random.chance(odds)) {
this.sendAttack(toAttack);
return;
}
}
if (this.neighborsTerraNullius) {
for (const b of this.bot.borderTiles()) {
for (const n of this.mg.neighbors(b)) {
if (!this.mg.hasOwner(n) && this.mg.isLand(n)) {
this.sendAttack(this.mg.terraNullius());
return;
}
}
if (ticks % this.attackRate != 0) {
return
}
this.bot.incomingAllianceRequests().forEach(ar => {
if (ar.requestor().isTraitor()) {
ar.reject()
} else {
ar.accept()
}
})
const traitors = this.bot.neighbors().filter(n => n.isPlayer() && n.isTraitor()) as Player[]
if (traitors.length > 0) {
const toAttack = this.random.randElement(traitors)
const odds = this.bot.isAlliedWith(toAttack) ? 6 : 3
if (this.random.chance(odds)) {
this.sendAttack(toAttack)
return
}
}
if (this.neighborsTerraNullius) {
for (const b of this.bot.borderTiles()) {
for (const n of this.mg.neighbors(b)) {
if (!this.mg.hasOwner(n) && this.mg.isLand(n)) {
this.sendAttack(this.mg.terraNullius())
return
}
}
}
this.neighborsTerraNullius = false
}
const border = Array.from(this.bot.borderTiles())
.flatMap(t => this.mg.neighbors(t))
.filter(t => this.mg.hasOwner(t) && this.mg.owner(t) != this.bot)
if (border.length == 0) {
return
}
const toAttack = border[this.random.nextInt(0, border.length)]
const owner = this.mg.owner(toAttack)
if (owner.isPlayer()) {
if (this.bot.isAlliedWith(owner)) {
return
}
if (owner.type() == PlayerType.FakeHuman) {
if (!this.random.chance(2)) {
return
}
}
}
this.sendAttack(owner)
}
this.neighborsTerraNullius = false;
}
sendAttack(toAttack: Player | TerraNullius) {
this.mg.addExecution(new AttackExecution(
this.bot.troops() / 20,
this.bot.id(),
toAttack.isPlayer() ? toAttack.id() : null,
null,
null
))
const border = Array.from(this.bot.borderTiles())
.flatMap((t) => this.mg.neighbors(t))
.filter((t) => this.mg.hasOwner(t) && this.mg.owner(t) != this.bot);
if (border.length == 0) {
return;
}
owner(): Player {
return this.bot
}
const toAttack = border[this.random.nextInt(0, border.length)];
const owner = this.mg.owner(toAttack);
isActive(): boolean {
return this.active
if (owner.isPlayer()) {
if (this.bot.isAlliedWith(owner)) {
return;
}
if (owner.type() == PlayerType.FakeHuman) {
if (!this.random.chance(2)) {
return;
}
}
}
}
this.sendAttack(owner);
}
sendAttack(toAttack: Player | TerraNullius) {
this.mg.addExecution(
new AttackExecution(
this.bot.troops() / 20,
this.bot.id(),
toAttack.isPlayer() ? toAttack.id() : null,
null,
null,
),
);
}
owner(): Player {
return this.bot;
}
isActive(): boolean {
return this.active;
}
}
+52 -50
View File
@@ -6,63 +6,65 @@ import { GameID, SpawnIntent } from "../Schemas";
import { simpleHash } from "../Util";
import { BOT_NAME_PREFIXES, BOT_NAME_SUFFIXES } from "./utils/BotNames";
export class BotSpawner {
private random: PseudoRandom
private bots: SpawnIntent[] = [];
private random: PseudoRandom;
private bots: SpawnIntent[] = [];
constructor(private gs: Game, gameID: GameID) {
this.random = new PseudoRandom(simpleHash(gameID))
}
constructor(
private gs: Game,
gameID: GameID,
) {
this.random = new PseudoRandom(simpleHash(gameID));
}
spawnBots(numBots: number): SpawnIntent[] {
let tries = 0
while (this.bots.length < numBots) {
if (tries > 10000) {
consolex.log('too many retries while spawning bots, giving up')
return this.bots
}
const botName = this.randomBotName();
const spawn = this.spawnBot(botName);
if (spawn != null) {
this.bots.push(spawn);
} else {
tries++
}
}
spawnBots(numBots: number): SpawnIntent[] {
let tries = 0;
while (this.bots.length < numBots) {
if (tries > 10000) {
consolex.log("too many retries while spawning bots, giving up");
return this.bots;
}
const botName = this.randomBotName();
const spawn = this.spawnBot(botName);
if (spawn != null) {
this.bots.push(spawn);
} else {
tries++;
}
}
return this.bots;
}
spawnBot(botName: string): SpawnIntent | null {
const tile = this.randTile()
if (!this.gs.isLand(tile)) {
return null
}
for (const spawn of this.bots) {
if (this.gs.manhattanDist(this.gs.ref(spawn.x, spawn.y), tile) < 30) {
return null
}
}
return {
type: 'spawn',
playerID: this.random.nextID(),
name: botName,
playerType: PlayerType.Bot,
x: this.gs.x(tile),
y: this.gs.y(tile)
};
spawnBot(botName: string): SpawnIntent | null {
const tile = this.randTile();
if (!this.gs.isLand(tile)) {
return null;
}
for (const spawn of this.bots) {
if (this.gs.manhattanDist(this.gs.ref(spawn.x, spawn.y), tile) < 30) {
return null;
}
}
return {
type: "spawn",
playerID: this.random.nextID(),
name: botName,
playerType: PlayerType.Bot,
x: this.gs.x(tile),
y: this.gs.y(tile),
};
}
private randomBotName(): string {
const prefixIndex = this.random.nextInt(0, BOT_NAME_PREFIXES.length);
const suffixIndex = this.random.nextInt(0, BOT_NAME_SUFFIXES.length);
return `${BOT_NAME_PREFIXES[prefixIndex]} ${BOT_NAME_SUFFIXES[suffixIndex]}`;
}
private randomBotName(): string {
const prefixIndex = this.random.nextInt(0, BOT_NAME_PREFIXES.length);
const suffixIndex = this.random.nextInt(0, BOT_NAME_SUFFIXES.length);
return `${BOT_NAME_PREFIXES[prefixIndex]} ${BOT_NAME_SUFFIXES[suffixIndex]}`;
}
private randTile(): TileRef {
return this.gs.ref(
this.random.nextInt(0, this.gs.width()),
this.random.nextInt(0, this.gs.height())
)
}
private randTile(): TileRef {
return this.gs.ref(
this.random.nextInt(0, this.gs.width()),
this.random.nextInt(0, this.gs.height()),
);
}
}
+43 -35
View File
@@ -1,47 +1,55 @@
import { consolex } from "../Consolex";
import { Execution, Game, Player, Unit, PlayerID, UnitType } from "../game/Game";
import {
Execution,
Game,
Player,
Unit,
PlayerID,
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
export class CityExecution implements Execution {
private player: Player;
private mg: Game;
private city: Unit;
private active: boolean = true;
private player: Player
private mg: Game
private city: Unit
private active: boolean = true
constructor(
private ownerId: PlayerID,
private tile: TileRef,
) {}
constructor(private ownerId: PlayerID, private tile: TileRef) { }
init(mg: Game, ticks: number): void {
this.mg = mg;
this.player = mg.player(this.ownerId);
}
init(mg: Game, ticks: number): void {
this.mg = mg
this.player = mg.player(this.ownerId)
tick(ticks: number): void {
if (this.city == null) {
const spawnTile = this.player.canBuild(UnitType.City, this.tile);
if (spawnTile == false) {
consolex.warn("cannot build city");
this.active = false;
return;
}
this.city = this.player.buildUnit(UnitType.City, 0, spawnTile);
}
tick(ticks: number): void {
if (this.city == null) {
const spawnTile = this.player.canBuild(UnitType.City, this.tile)
if (spawnTile == false) {
consolex.warn('cannot build city')
this.active = false
return
}
this.city = this.player.buildUnit(UnitType.City, 0, spawnTile)
}
if (!this.city.isActive()) {
this.active = false
return
}
if (!this.city.isActive()) {
this.active = false;
return;
}
}
owner(): Player {
return null
}
owner(): Player {
return null;
}
isActive(): boolean {
return this.active
}
isActive(): boolean {
return this.active;
}
activeDuringSpawnPhase(): boolean {
return false
}
}
activeDuringSpawnPhase(): boolean {
return false;
}
}
+63 -44
View File
@@ -1,57 +1,76 @@
import { consolex } from "../Consolex";
import { Cell, DefenseBonus, Execution, Game, Player, Unit, PlayerID, UnitType } from "../game/Game";
import {
Cell,
DefenseBonus,
Execution,
Game,
Player,
Unit,
PlayerID,
UnitType,
} from "../game/Game";
import { manhattanDistFN, TileRef } from "../game/GameMap";
export class DefensePostExecution implements Execution {
private player: Player;
private mg: Game;
private post: Unit;
private active: boolean = true;
private player: Player
private mg: Game
private post: Unit
private active: boolean = true
private defenseBonuses: DefenseBonus[] = [];
private defenseBonuses: DefenseBonus[] = []
constructor(
private ownerId: PlayerID,
private tile: TileRef,
) {}
constructor(private ownerId: PlayerID, private tile: TileRef) { }
init(mg: Game, ticks: number): void {
this.mg = mg;
this.player = mg.player(this.ownerId);
}
init(mg: Game, ticks: number): void {
this.mg = mg
this.player = mg.player(this.ownerId)
tick(ticks: number): void {
if (this.post == null) {
const spawnTile = this.player.canBuild(UnitType.DefensePost, this.tile);
if (spawnTile == false) {
consolex.warn("cannot build Defense Post");
this.active = false;
return;
}
this.post = this.player.buildUnit(UnitType.DefensePost, 0, spawnTile);
this.mg
.bfs(
spawnTile,
manhattanDistFN(spawnTile, this.mg.config().defensePostRange()),
)
.forEach((t) => {
if (this.mg.isLake(t)) {
this.defenseBonuses.push(
this.mg.addTileDefenseBonus(
t,
this.post,
this.mg.config().defensePostDefenseBonus(),
),
);
}
});
}
tick(ticks: number): void {
if (this.post == null) {
const spawnTile = this.player.canBuild(UnitType.DefensePost, this.tile)
if (spawnTile == false) {
consolex.warn('cannot build Defense Post')
this.active = false
return
}
this.post = this.player.buildUnit(UnitType.DefensePost, 0, spawnTile)
this.mg.bfs(spawnTile, manhattanDistFN(spawnTile, this.mg.config().defensePostRange())).forEach(t => {
if (this.mg.isLake(t)) {
this.defenseBonuses.push(
this.mg.addTileDefenseBonus(t, this.post, this.mg.config().defensePostDefenseBonus())
)
}
})
}
if (!this.post.isActive()) {
this.defenseBonuses.forEach(df => this.mg.removeTileDefenseBonus(df))
this.active = false
return
}
if (!this.post.isActive()) {
this.defenseBonuses.forEach((df) => this.mg.removeTileDefenseBonus(df));
this.active = false;
return;
}
}
owner(): Player {
return null
}
owner(): Player {
return null;
}
isActive(): boolean {
return this.active
}
isActive(): boolean {
return this.active;
}
activeDuringSpawnPhase(): boolean {
return false
}
}
activeDuringSpawnPhase(): boolean {
return false;
}
}
+158 -128
View File
@@ -1,4 +1,13 @@
import { Cell, Execution, Game, Player, Unit, PlayerID, TerrainType, UnitType } from "../game/Game";
import {
Cell,
Execution,
Game,
Player,
Unit,
PlayerID,
TerrainType,
UnitType,
} from "../game/Game";
import { PathFinder } from "../pathfinding/PathFinding";
import { PathFindResultType } from "../pathfinding/AStar";
import { PseudoRandom } from "../PseudoRandom";
@@ -7,142 +16,163 @@ import { consolex } from "../Consolex";
import { TileRef } from "../game/GameMap";
export class DestroyerExecution implements Execution {
private random: PseudoRandom
private random: PseudoRandom;
private _owner: Player
private active = true
private destroyer: Unit = null
private mg: Game = null
private _owner: Player;
private active = true;
private destroyer: Unit = null;
private mg: Game = null;
private target: Unit = null
private pathfinder: PathFinder
private target: Unit = null;
private pathfinder: PathFinder;
private patrolTile: TileRef;
private patrolTile: TileRef;
// TODO: put in config
private searchRange = 100
// TODO: put in config
private searchRange = 100;
constructor(
private playerID: PlayerID,
private patrolCenterTile: TileRef,
) { }
constructor(
private playerID: PlayerID,
private patrolCenterTile: TileRef,
) {}
init(mg: Game, ticks: number): void {
this.pathfinder = PathFinder.Mini(mg, 5000, false);
this._owner = mg.player(this.playerID);
this.mg = mg;
this.patrolTile = this.patrolCenterTile;
this.random = new PseudoRandom(mg.ticks());
}
init(mg: Game, ticks: number): void {
this.pathfinder = PathFinder.Mini(mg, 5000, false)
this._owner = mg.player(this.playerID)
this.mg = mg
this.patrolTile = this.patrolCenterTile
this.random = new PseudoRandom(mg.ticks())
tick(ticks: number): void {
if (this.destroyer == null) {
const spawn = this._owner.canBuild(UnitType.Destroyer, this.patrolTile);
if (spawn == false) {
this.active = false;
return;
}
this.destroyer = this._owner.buildUnit(UnitType.Destroyer, 0, spawn);
return;
}
if (!this.destroyer.isActive()) {
this.active = false;
return;
}
if (this.target != null && !this.target.isActive()) {
this.target = null;
}
if (this.target == null) {
const ships = this.mg
.units(
UnitType.TransportShip,
UnitType.Destroyer,
UnitType.TradeShip,
UnitType.Battleship,
)
.filter(
(u) => this.mg.manhattanDist(u.tile(), this.destroyer.tile()) < 100,
)
.filter(
(u) =>
u.type() != UnitType.Destroyer ||
u.health() < this.destroyer.health(),
) // only attack Destroyers weaker than it.
.filter((u) => u.owner() != this.destroyer.owner())
.filter((u) => u != this.destroyer)
.filter((u) => !u.owner().isAlliedWith(this.destroyer.owner()));
if (ships.length == 0) {
const result = this.pathfinder.nextTile(
this.destroyer.tile(),
this.patrolTile,
);
switch (result.type) {
case PathFindResultType.Completed:
this.patrolTile = this.randomTile();
break;
case PathFindResultType.NextTile:
this.destroyer.move(result.tile);
break;
case PathFindResultType.Pending:
return;
case PathFindResultType.PathNotFound:
consolex.log(`path not found to patrol tile`);
this.patrolTile = this.randomTile();
break;
}
return;
}
this.target = ships.sort(distSortUnit(this.mg, this.destroyer))[0];
}
if (!this.target.isActive() || this.target.owner() == this._owner) {
// Incase another destroyer captured or destroyed target
this.target = null;
return;
}
tick(ticks: number): void {
if (this.destroyer == null) {
const spawn = this._owner.canBuild(UnitType.Destroyer, this.patrolTile)
if (spawn == false) {
this.active = false
return
}
this.destroyer = this._owner.buildUnit(UnitType.Destroyer, 0, spawn)
return
}
if (!this.destroyer.isActive()) {
this.active = false
return
}
if (this.target != null && !this.target.isActive()) {
this.target = null
}
if (this.target == null) {
const ships = this.mg.units(UnitType.TransportShip, UnitType.Destroyer, UnitType.TradeShip, UnitType.Battleship)
.filter(u => this.mg.manhattanDist(u.tile(), this.destroyer.tile()) < 100)
.filter(u => u.type() != UnitType.Destroyer || u.health() < this.destroyer.health()) // only attack Destroyers weaker than it.
.filter(u => u.owner() != this.destroyer.owner())
.filter(u => u != this.destroyer)
.filter(u => !u.owner().isAlliedWith(this.destroyer.owner()))
if (ships.length == 0) {
const result = this.pathfinder.nextTile(this.destroyer.tile(), this.patrolTile)
switch (result.type) {
case PathFindResultType.Completed:
this.patrolTile = this.randomTile()
break
case PathFindResultType.NextTile:
this.destroyer.move(result.tile)
break
case PathFindResultType.Pending:
return
case PathFindResultType.PathNotFound:
consolex.log(`path not found to patrol tile`)
this.patrolTile = this.randomTile()
break
}
return
}
this.target = ships.sort(distSortUnit(this.mg, this.destroyer))[0]
}
if (!this.target.isActive() || this.target.owner() == this._owner) {
// Incase another destroyer captured or destroyed target
this.target = null
return
}
for (let i = 0; i < 2; i++) {
const result = this.pathfinder.nextTile(this.destroyer.tile(), this.target.tile(), 5)
switch (result.type) {
case PathFindResultType.Completed:
switch (this.target.type()) {
case UnitType.TransportShip:
case UnitType.Battleship:
this.target.delete()
break
case UnitType.TradeShip:
this.owner().captureUnit(this.target)
break
case UnitType.Destroyer:
const health = this.target.health()
this.target.modifyHealth(-this.destroyer.health())
this.destroyer.modifyHealth(-health)
break
}
this.target = null
return
case PathFindResultType.NextTile:
this.destroyer.move(result.tile)
break
case PathFindResultType.Pending:
break
case PathFindResultType.PathNotFound:
consolex.log(`path not found to target`)
break
}
}
for (let i = 0; i < 2; i++) {
const result = this.pathfinder.nextTile(
this.destroyer.tile(),
this.target.tile(),
5,
);
switch (result.type) {
case PathFindResultType.Completed:
switch (this.target.type()) {
case UnitType.TransportShip:
case UnitType.Battleship:
this.target.delete();
break;
case UnitType.TradeShip:
this.owner().captureUnit(this.target);
break;
case UnitType.Destroyer:
const health = this.target.health();
this.target.modifyHealth(-this.destroyer.health());
this.destroyer.modifyHealth(-health);
break;
}
this.target = null;
return;
case PathFindResultType.NextTile:
this.destroyer.move(result.tile);
break;
case PathFindResultType.Pending:
break;
case PathFindResultType.PathNotFound:
consolex.log(`path not found to target`);
break;
}
}
}
owner(): Player {
return this._owner
owner(): Player {
return this._owner;
}
isActive(): boolean {
return this.active;
}
activeDuringSpawnPhase(): boolean {
return false;
}
randomTile(): TileRef {
while (true) {
const x =
this.mg.x(this.patrolCenterTile) +
this.random.nextInt(-this.searchRange / 2, this.searchRange / 2);
const y =
this.mg.y(this.patrolCenterTile) +
this.random.nextInt(-this.searchRange / 2, this.searchRange / 2);
if (!this.mg.isValidCoord(x, y)) {
continue;
}
const tile = this.mg.ref(x, y);
if (!this.mg.isOcean(tile)) {
continue;
}
return tile;
}
isActive(): boolean {
return this.active
}
activeDuringSpawnPhase(): boolean {
return false
}
randomTile(): TileRef {
while (true) {
const x = this.mg.x(this.patrolCenterTile) + this.random.nextInt(-this.searchRange / 2, this.searchRange / 2)
const y = this.mg.y(this.patrolCenterTile) + this.random.nextInt(-this.searchRange / 2, this.searchRange / 2)
if (!this.mg.isValidCoord(x, y)) {
continue
}
const tile = this.mg.ref(x, y)
if (!this.mg.isOcean(tile)) {
continue
}
return tile
}
}
}
}
}
+34 -35
View File
@@ -2,47 +2,46 @@ import { consolex } from "../Consolex";
import { Execution, Game, Player, PlayerID } from "../game/Game";
export class DonateExecution implements Execution {
private sender: Player;
private recipient: Player;
private sender: Player
private recipient: Player
private active = true;
private active = true
constructor(
private senderID: PlayerID,
private recipientID: PlayerID,
private troops: number | null,
) {}
constructor(
private senderID: PlayerID,
private recipientID: PlayerID,
private troops: number | null
) { }
init(mg: Game, ticks: number): void {
this.sender = mg.player(this.senderID)
this.recipient = mg.player(this.recipientID)
if (this.troops == null) {
this.troops = mg.config().defaultDonationAmount(this.sender)
}
init(mg: Game, ticks: number): void {
this.sender = mg.player(this.senderID);
this.recipient = mg.player(this.recipientID);
if (this.troops == null) {
this.troops = mg.config().defaultDonationAmount(this.sender);
}
}
tick(ticks: number): void {
if (this.sender.canDonate(this.recipient)) {
this.sender.donate(this.recipient, this.troops)
this.recipient.updateRelation(this.sender, 50)
} else {
consolex.warn(`cannot send tropps from ${this.sender} to ${this.recipient}`)
}
this.active = false
tick(ticks: number): void {
if (this.sender.canDonate(this.recipient)) {
this.sender.donate(this.recipient, this.troops);
this.recipient.updateRelation(this.sender, 50);
} else {
consolex.warn(
`cannot send tropps from ${this.sender} to ${this.recipient}`,
);
}
this.active = false;
}
owner(): Player {
return null
}
owner(): Player {
return null;
}
isActive(): boolean {
return this.active
}
isActive(): boolean {
return this.active;
}
activeDuringSpawnPhase(): boolean {
return false
}
}
activeDuringSpawnPhase(): boolean {
return false;
}
}
+48 -36
View File
@@ -1,47 +1,59 @@
import { consolex } from "../Consolex";
import { AllPlayers, Execution, Game, Player, PlayerID, PlayerType, UnitType } from "../game/Game";
import {
AllPlayers,
Execution,
Game,
Player,
PlayerID,
PlayerType,
UnitType,
} from "../game/Game";
export class EmojiExecution implements Execution {
private requestor: Player;
private recipient: Player | typeof AllPlayers;
private requestor: Player
private recipient: Player | typeof AllPlayers
private active = true;
private active = true
constructor(
private senderID: PlayerID,
private recipientID: PlayerID | typeof AllPlayers,
private emoji: string,
) {}
constructor(
private senderID: PlayerID,
private recipientID: PlayerID | typeof AllPlayers,
private emoji: string
) { }
init(mg: Game, ticks: number): void {
this.requestor = mg.player(this.senderID);
this.recipient =
this.recipientID == AllPlayers ? AllPlayers : mg.player(this.recipientID);
}
init(mg: Game, ticks: number): void {
this.requestor = mg.player(this.senderID)
this.recipient = 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);
if (
this.emoji == "🖕" &&
this.recipient != AllPlayers &&
this.recipient.type() == PlayerType.FakeHuman
) {
this.recipient.updateRelation(this.requestor, -100);
}
} else {
consolex.warn(
`cannot send emoji from ${this.requestor} to ${this.recipient}`,
);
}
this.active = false;
}
tick(ticks: number): void {
if (this.requestor.canSendEmoji(this.recipient)) {
this.requestor.sendEmoji(this.recipient, this.emoji)
if (this.emoji == "🖕" && this.recipient != AllPlayers && this.recipient.type() == PlayerType.FakeHuman) {
this.recipient.updateRelation(this.requestor, -100)
}
} else {
consolex.warn(`cannot send emoji from ${this.requestor} to ${this.recipient}`)
}
this.active = false
}
owner(): Player {
return null;
}
owner(): Player {
return null
}
isActive(): boolean {
return this.active;
}
isActive(): boolean {
return this.active
}
activeDuringSpawnPhase(): boolean {
return false
}
}
activeDuringSpawnPhase(): boolean {
return false;
}
}
+155 -95
View File
@@ -1,5 +1,21 @@
import { Cell, Execution, Game, Player, PlayerInfo, TerraNullius, PlayerType, Alliance, UnitType } from "../game/Game";
import { AttackIntent, BoatAttackIntentSchema, GameID, Intent, Turn } from "../Schemas";
import {
Cell,
Execution,
Game,
Player,
PlayerInfo,
TerraNullius,
PlayerType,
Alliance,
UnitType,
} from "../game/Game";
import {
AttackIntent,
BoatAttackIntentSchema,
GameID,
Intent,
Turn,
} from "../Schemas";
import { AttackExecution } from "./AttackExecution";
import { SpawnExecution } from "./SpawnExecution";
import { BotSpawner } from "./BotSpawner";
@@ -23,104 +39,148 @@ import { DefensePostExecution } from "./DefensePostExecution";
import { CityExecution } from "./CityExecution";
import { TileRef } from "../game/GameMap";
export class Executor {
// private random = new PseudoRandom(999)
private random: PseudoRandom = null;
constructor(
private mg: Game,
private gameID: GameID,
) {
// Add one to avoid id collisions with bots.
this.random = new PseudoRandom(simpleHash(gameID) + 1);
}
// private random = new PseudoRandom(999)
private random: PseudoRandom = null
createExecs(turn: Turn): Execution[] {
return turn.intents.map((i) => this.createExec(i));
}
constructor(private mg: Game, private gameID: GameID) {
// Add one to avoid id collisions with bots.
this.random = new PseudoRandom(simpleHash(gameID) + 1)
}
createExecs(turn: Turn): Execution[] {
return turn.intents.map(i => this.createExec(i))
}
createExec(intent: Intent): Execution {
switch (intent.type) {
case "attack": {
return new AttackExecution(
intent.troops,
intent.attackerID,
intent.targetID,
null
);
}
case "spawn":
return new SpawnExecution(
new PlayerInfo(sanitize(intent.name), intent.playerType, intent.clientID, intent.playerID),
this.mg.ref(intent.x, intent.y)
);
case "boat":
return new TransportShipExecution(
intent.attackerID,
intent.targetID,
this.mg.ref(intent.x, intent.y),
intent.troops
);
case "allianceRequest":
return new AllianceRequestExecution(intent.requestor, intent.recipient);
case "allianceRequestReply":
return new AllianceRequestReplyExecution(intent.requestor, intent.recipient, intent.accept);
case "breakAlliance":
return new BreakAllianceExecution(intent.requestor, intent.recipient);
case "targetPlayer":
return new TargetPlayerExecution(intent.requestor, intent.target);
case "emoji":
return new EmojiExecution(intent.sender, intent.recipient, intent.emoji);
case "donate":
return new DonateExecution(intent.sender, intent.recipient, intent.troops);
case "troop_ratio":
return new SetTargetTroopRatioExecution(intent.player, intent.ratio);
case "build_unit":
switch (intent.unit) {
case UnitType.AtomBomb:
case UnitType.HydrogenBomb:
return new NukeExecution(intent.unit, intent.player, this.mg.ref(intent.x, intent.y))
case UnitType.Destroyer:
return new DestroyerExecution(intent.player, this.mg.ref(intent.x, intent.y))
case UnitType.Battleship:
return new BattleshipExecution(intent.player, this.mg.ref(intent.x, intent.y))
case UnitType.Port:
return new PortExecution(intent.player, this.mg.ref(intent.x, intent.y))
case UnitType.MissileSilo:
return new MissileSiloExecution(intent.player, this.mg.ref(intent.x, intent.y))
case UnitType.DefensePost:
return new DefensePostExecution(intent.player, this.mg.ref(intent.x, intent.y))
case UnitType.City:
return new CityExecution(intent.player, this.mg.ref(intent.x, intent.y))
default:
throw Error(`unit type ${intent.unit} not supported`)
}
default:
throw new Error(`intent type ${intent} not found`);
createExec(intent: Intent): Execution {
switch (intent.type) {
case "attack": {
return new AttackExecution(
intent.troops,
intent.attackerID,
intent.targetID,
null,
);
}
case "spawn":
return new SpawnExecution(
new PlayerInfo(
sanitize(intent.name),
intent.playerType,
intent.clientID,
intent.playerID,
),
this.mg.ref(intent.x, intent.y),
);
case "boat":
return new TransportShipExecution(
intent.attackerID,
intent.targetID,
this.mg.ref(intent.x, intent.y),
intent.troops,
);
case "allianceRequest":
return new AllianceRequestExecution(intent.requestor, intent.recipient);
case "allianceRequestReply":
return new AllianceRequestReplyExecution(
intent.requestor,
intent.recipient,
intent.accept,
);
case "breakAlliance":
return new BreakAllianceExecution(intent.requestor, intent.recipient);
case "targetPlayer":
return new TargetPlayerExecution(intent.requestor, intent.target);
case "emoji":
return new EmojiExecution(
intent.sender,
intent.recipient,
intent.emoji,
);
case "donate":
return new DonateExecution(
intent.sender,
intent.recipient,
intent.troops,
);
case "troop_ratio":
return new SetTargetTroopRatioExecution(intent.player, intent.ratio);
case "build_unit":
switch (intent.unit) {
case UnitType.AtomBomb:
case UnitType.HydrogenBomb:
return new NukeExecution(
intent.unit,
intent.player,
this.mg.ref(intent.x, intent.y),
);
case UnitType.Destroyer:
return new DestroyerExecution(
intent.player,
this.mg.ref(intent.x, intent.y),
);
case UnitType.Battleship:
return new BattleshipExecution(
intent.player,
this.mg.ref(intent.x, intent.y),
);
case UnitType.Port:
return new PortExecution(
intent.player,
this.mg.ref(intent.x, intent.y),
);
case UnitType.MissileSilo:
return new MissileSiloExecution(
intent.player,
this.mg.ref(intent.x, intent.y),
);
case UnitType.DefensePost:
return new DefensePostExecution(
intent.player,
this.mg.ref(intent.x, intent.y),
);
case UnitType.City:
return new CityExecution(
intent.player,
this.mg.ref(intent.x, intent.y),
);
default:
throw Error(`unit type ${intent.unit} not supported`);
}
default:
throw new Error(`intent type ${intent} not found`);
}
}
spawnBots(numBots: number): Execution[] {
return new BotSpawner(this.mg, this.gameID).spawnBots(numBots).map(i => this.createExec(i))
spawnBots(numBots: number): Execution[] {
return new BotSpawner(this.mg, this.gameID)
.spawnBots(numBots)
.map((i) => this.createExec(i));
}
fakeHumanExecutions(): Execution[] {
const execs = [];
for (const nation of this.mg.nations()) {
execs.push(
new FakeHumanExecution(
this.gameID,
new PlayerInfo(
nation.name,
PlayerType.FakeHuman,
null,
this.random.nextID(),
),
nation.cell,
nation.strength *
this.mg
.config()
.difficultyModifier(this.mg.config().gameConfig().difficulty),
),
);
}
fakeHumanExecutions(): Execution[] {
const execs = []
for (const nation of this.mg.nations()) {
execs.push(new FakeHumanExecution(
this.gameID,
new PlayerInfo(
nation.name,
PlayerType.FakeHuman,
null,
this.random.nextID()
),
nation.cell,
nation.strength * this.mg.config().difficultyModifier(this.mg.config().gameConfig().difficulty)
))
}
return execs
}
}
return execs;
}
}
File diff suppressed because it is too large Load Diff
+42 -35
View File
@@ -1,46 +1,53 @@
import { consolex } from "../Consolex";
import { Cell, Execution, Game, Player, Unit, PlayerID, UnitType } from "../game/Game";
import {
Cell,
Execution,
Game,
Player,
Unit,
PlayerID,
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
export class MissileSiloExecution implements Execution {
private active = true;
private mg: Game;
private player: Player;
private silo: Unit;
private active = true
private mg: Game
private player: Player
private silo: Unit
constructor(
private _owner: PlayerID,
private tile: TileRef,
) {}
constructor(
private _owner: PlayerID,
private tile: TileRef
) { }
init(mg: Game, ticks: number): void {
this.mg = mg;
this.player = mg.player(this._owner);
}
init(mg: Game, ticks: number): void {
this.mg = mg
this.player = mg.player(this._owner)
tick(ticks: number): void {
if (this.silo == null) {
if (!this.player.canBuild(UnitType.MissileSilo, this.tile)) {
consolex.warn(
`player ${this.player} cannot build port at ${this.tile}`,
);
this.active = false;
return;
}
this.silo = this.player.buildUnit(UnitType.MissileSilo, 0, this.tile);
}
}
tick(ticks: number): void {
if (this.silo == null) {
if (!this.player.canBuild(UnitType.MissileSilo, this.tile)) {
consolex.warn(`player ${this.player} cannot build port at ${this.tile}`)
this.active = false
return
}
this.silo = this.player.buildUnit(UnitType.MissileSilo, 0, this.tile)
}
}
owner(): Player {
return null;
}
owner(): Player {
return null
}
isActive(): boolean {
return this.active;
}
isActive(): boolean {
return this.active
}
activeDuringSpawnPhase(): boolean {
return false
}
}
activeDuringSpawnPhase(): boolean {
return false;
}
}
+120 -104
View File
@@ -1,5 +1,14 @@
import { nextTick } from "process";
import { Cell, Execution, Game, Player, PlayerID, Unit, UnitType, TerraNullius } from "../game/Game";
import {
Cell,
Execution,
Game,
Player,
PlayerID,
Unit,
UnitType,
TerraNullius,
} from "../game/Game";
import { PathFinder } from "../pathfinding/PathFinding";
import { PathFindResultType } from "../pathfinding/AStar";
import { PseudoRandom } from "../PseudoRandom";
@@ -7,125 +16,132 @@ import { consolex } from "../Consolex";
import { TileRef } from "../game/GameMap";
export class NukeExecution implements Execution {
private player: Player;
private player: Player
private active = true;
private active = true
private mg: Game;
private mg: Game
private nuke: Unit;
private nuke: Unit
private pathFinder: PathFinder;
constructor(
private type: UnitType.AtomBomb | UnitType.HydrogenBomb,
private senderID: PlayerID,
private dst: TileRef,
) {}
private pathFinder: PathFinder
constructor(
private type: UnitType.AtomBomb | UnitType.HydrogenBomb,
private senderID: PlayerID,
private dst: TileRef,
) { }
init(mg: Game, ticks: number): void {
this.mg = mg;
this.pathFinder = PathFinder.Mini(mg, 10_000, true);
this.player = mg.player(this.senderID);
}
public target(): Player | TerraNullius {
return this.mg.owner(this.dst);
}
init(mg: Game, ticks: number): void {
this.mg = mg
this.pathFinder = PathFinder.Mini(mg, 10_000, true)
this.player = mg.player(this.senderID)
tick(ticks: number): void {
if (this.nuke == null) {
const spawn = this.player.canBuild(this.type, this.dst);
if (spawn == false) {
consolex.warn(`cannot build Nuke`);
this.active = false;
return;
}
this.nuke = this.player.buildUnit(this.type, 0, spawn);
}
public target(): Player | TerraNullius {
return this.mg.owner(this.dst)
for (let i = 0; i < 4; i++) {
const result = this.pathFinder.nextTile(this.nuke.tile(), this.dst);
switch (result.type) {
case PathFindResultType.Completed:
this.nuke.move(result.tile);
this.detonate();
return;
case PathFindResultType.NextTile:
this.nuke.move(result.tile);
break;
case PathFindResultType.Pending:
break;
case PathFindResultType.PathNotFound:
consolex.warn(
`nuke cannot find path from ${this.nuke.tile()} to ${this.dst}`,
);
this.active = false;
return;
}
}
}
tick(ticks: number): void {
if (this.nuke == null) {
const spawn = this.player.canBuild(this.type, this.dst)
if (spawn == false) {
consolex.warn(`cannot build Nuke`)
this.active = false
return
}
this.nuke = this.player.buildUnit(this.type, 0, spawn)
private detonate() {
const magnitude =
this.type == UnitType.AtomBomb
? { inner: 15, outer: 40 }
: { inner: 140, outer: 160 };
const rand = new PseudoRandom(this.mg.ticks());
const toDestroy = this.mg.bfs(this.dst, (_, n: TileRef) => {
const d = this.mg.euclideanDist(this.dst, n);
return (d <= magnitude.inner || rand.chance(2)) && d <= magnitude.outer;
});
const ratio = Object.fromEntries(
this.mg
.players()
.map((p) => [p.id(), (p.troops() + p.workers()) / p.numTilesOwned()]),
);
const attacked = new Map<Player, number>();
for (const tile of toDestroy) {
const owner = this.mg.owner(tile);
if (owner.isPlayer()) {
const mp = this.mg.player(owner.id());
mp.relinquish(tile);
mp.removeTroops(2 * ratio[mp.id()]);
if (!attacked.has(mp)) {
attacked.set(mp, 0);
}
for (let i = 0; i < 4; i++) {
const result = this.pathFinder.nextTile(this.nuke.tile(), this.dst)
switch (result.type) {
case PathFindResultType.Completed:
this.nuke.move(result.tile)
this.detonate()
return
case PathFindResultType.NextTile:
this.nuke.move(result.tile)
break
case PathFindResultType.Pending:
break
case PathFindResultType.PathNotFound:
consolex.warn(`nuke cannot find path from ${this.nuke.tile()} to ${this.dst}`)
this.active = false
return
}
const prev = attacked.get(mp);
attacked.set(mp, prev + 1);
}
if (this.mg.isLand(tile)) {
this.mg.setFallout(tile, true);
}
}
for (const [other, tilesDestroyed] of attacked) {
if (tilesDestroyed > 100) {
const alliance = this.player.allianceWith(other);
if (alliance != null) {
this.player.breakAlliance(alliance);
}
}
private detonate() {
const magnitude = this.type == UnitType.AtomBomb ? { inner: 15, outer: 40 } : { inner: 140, outer: 160 }
const rand = new PseudoRandom(this.mg.ticks())
const toDestroy = this.mg.bfs(this.dst, (_, n: TileRef) => {
const d = this.mg.euclideanDist(this.dst, n)
return (d <= magnitude.inner || rand.chance(2)) && d <= magnitude.outer
})
const ratio = Object.fromEntries(
this.mg.players().map(p => [p.id(), (p.troops() + p.workers()) / p.numTilesOwned()])
)
const attacked = new Map<Player, number>()
for (const tile of toDestroy) {
const owner = this.mg.owner(tile)
if (owner.isPlayer()) {
const mp = this.mg.player(owner.id())
mp.relinquish(tile)
mp.removeTroops(2 * ratio[mp.id()])
if (!attacked.has(mp)) {
attacked.set(mp, 0)
}
const prev = attacked.get(mp)
attacked.set(mp, prev + 1)
}
if (this.mg.isLand(tile)) {
this.mg.setFallout(tile, true)
}
if (other != this.player) {
other.updateRelation(this.player, -100);
}
for (const [other, tilesDestroyed] of attacked) {
if (tilesDestroyed > 100) {
const alliance = this.player.allianceWith(other)
if (alliance != null) {
this.player.breakAlliance(alliance)
}
if (other != this.player) {
other.updateRelation(this.player, -100)
}
}
}
}
for (const unit of this.mg.units()) {
if (
unit.type() != UnitType.AtomBomb &&
unit.type() != UnitType.HydrogenBomb
) {
if (this.mg.euclideanDist(this.dst, unit.tile()) < magnitude.outer) {
unit.delete();
}
for (const unit of this.mg.units()) {
if (unit.type() != UnitType.AtomBomb && unit.type() != UnitType.HydrogenBomb) {
if (this.mg.euclideanDist(this.dst, unit.tile()) < magnitude.outer) {
unit.delete()
}
}
}
this.active = false
this.nuke.delete(false)
}
}
this.active = false;
this.nuke.delete(false);
}
owner(): Player {
return this.player
}
owner(): Player {
return this.player;
}
isActive(): boolean {
return this.active
}
isActive(): boolean {
return this.active;
}
activeDuringSpawnPhase(): boolean {
return false
}
}
activeDuringSpawnPhase(): boolean {
return false;
}
}
+201 -186
View File
@@ -1,211 +1,226 @@
import { Config } from "../configuration/Config"
import { Execution, Game, Player, PlayerID, TerraNullius, UnitType } from "../game/Game"
import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util"
import { GameImpl } from "../game/GameImpl"
import { consolex } from "../Consolex"
import { GameMap, TileRef } from "../game/GameMap"
import { Config } from "../configuration/Config";
import {
Execution,
Game,
Player,
PlayerID,
TerraNullius,
UnitType,
} from "../game/Game";
import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util";
import { GameImpl } from "../game/GameImpl";
import { consolex } from "../Consolex";
import { GameMap, TileRef } from "../game/GameMap";
export class PlayerExecution implements Execution {
private readonly ticksPerClusterCalc = 20;
private readonly ticksPerClusterCalc = 20
private player: Player;
private config: Config;
private lastCalc = 0;
private mg: Game;
private active = true;
private player: Player
private config: Config
private lastCalc = 0
private mg: Game
private active = true
constructor(private playerID: PlayerID) {}
constructor(private playerID: PlayerID) {
activeDuringSpawnPhase(): boolean {
return false;
}
init(mg: Game, ticks: number) {
this.mg = mg;
this.config = mg.config();
this.player = mg.player(this.playerID);
this.lastCalc =
ticks + (simpleHash(this.player.name()) % this.ticksPerClusterCalc);
}
tick(ticks: number) {
this.player.decayRelations();
this.player.units().forEach((u) => {
if (u.health() <= 0) {
u.delete();
return;
}
u.modifyHealth(1);
const tileOwner = this.mg.owner(u.tile());
if (u.info().territoryBound) {
if (tileOwner.isPlayer()) {
if (tileOwner != this.player) {
this.mg.player(tileOwner.id()).captureUnit(u);
}
} else {
u.delete();
}
}
});
if (!this.player.isAlive()) {
this.player.units().forEach((u) => {
if (
u.type() != UnitType.AtomBomb &&
u.type() != UnitType.HydrogenBomb
) {
u.delete();
}
});
this.active = false;
return;
}
activeDuringSpawnPhase(): boolean {
return false
const popInc = this.config.populationIncreaseRate(this.player);
this.player.addWorkers(popInc * (1 - this.player.targetTroopRatio())); // (1 - this.player.targetTroopRatio()))
this.player.addTroops(popInc * this.player.targetTroopRatio());
this.player.addGold(this.config.goldAdditionRate(this.player));
const adjustRate = this.config.troopAdjustmentRate(this.player);
this.player.addTroops(adjustRate);
this.player.removeWorkers(adjustRate);
const alliances = Array.from(this.player.alliances());
for (const alliance of alliances) {
if (
this.mg.ticks() - alliance.createdAt() >
this.mg.config().allianceDuration()
) {
alliance.expire();
}
}
init(mg: Game, ticks: number) {
this.mg = mg
this.config = mg.config()
this.player = mg.player(this.playerID)
this.lastCalc = ticks + (simpleHash(this.player.name()) % this.ticksPerClusterCalc)
if (ticks - this.lastCalc > this.ticksPerClusterCalc) {
if (this.player.lastTileChange() > this.lastCalc) {
this.lastCalc = ticks;
const start = performance.now();
this.removeClusters();
const end = performance.now();
if (end - start > 1000) {
consolex.log(`player ${this.player.name()}, took ${end - start}ms`);
}
}
}
}
private removeClusters() {
const clusters = this.calculateClusters();
clusters.sort((a, b) => b.size - a.size);
const main = clusters.shift();
this.player.largestClusterBoundingBox = calculateBoundingBox(this.mg, main);
const surroundedBy = this.surroundedBySamePlayer(main);
if (surroundedBy && !this.player.isAlliedWith(surroundedBy)) {
this.removeCluster(main);
}
tick(ticks: number) {
this.player.decayRelations()
this.player.units().forEach(u => {
if (u.health() <= 0) {
u.delete()
return
}
u.modifyHealth(1)
const tileOwner = this.mg.owner(u.tile())
if (u.info().territoryBound) {
if (tileOwner.isPlayer()) {
if (tileOwner != this.player) {
this.mg.player(tileOwner.id()).captureUnit(u)
}
} else {
u.delete()
}
}
})
if (!this.player.isAlive()) {
this.player.units().forEach(u => {
if (u.type() != UnitType.AtomBomb && u.type() != UnitType.HydrogenBomb) {
u.delete()
}
})
this.active = false
return
}
const popInc = this.config.populationIncreaseRate(this.player)
this.player.addWorkers(popInc * (1 - this.player.targetTroopRatio()))// (1 - this.player.targetTroopRatio()))
this.player.addTroops(popInc * this.player.targetTroopRatio())
this.player.addGold(this.config.goldAdditionRate(this.player))
const adjustRate = this.config.troopAdjustmentRate(this.player)
this.player.addTroops(adjustRate)
this.player.removeWorkers(adjustRate)
const alliances = Array.from(this.player.alliances())
for (const alliance of alliances) {
if (this.mg.ticks() - alliance.createdAt() > this.mg.config().allianceDuration()) {
alliance.expire()
}
}
if (ticks - this.lastCalc > this.ticksPerClusterCalc) {
if (this.player.lastTileChange() > this.lastCalc) {
this.lastCalc = ticks
const start = performance.now()
this.removeClusters()
const end = performance.now()
if (end - start > 1000) {
consolex.log(`player ${this.player.name()}, took ${end - start}ms`)
}
}
}
for (const cluster of clusters) {
if (this.isSurrounded(cluster)) {
this.removeCluster(cluster);
}
}
}
private removeClusters() {
const clusters = this.calculateClusters()
clusters.sort((a, b) => b.size - a.size);
const main = clusters.shift()
this.player.largestClusterBoundingBox = calculateBoundingBox(this.mg, main)
const surroundedBy = this.surroundedBySamePlayer(main)
if (surroundedBy && !this.player.isAlliedWith(surroundedBy)) {
this.removeCluster(main)
}
for (const cluster of clusters) {
if (this.isSurrounded(cluster)) {
this.removeCluster(cluster)
}
}
private surroundedBySamePlayer(cluster: Set<TileRef>): false | Player {
const enemies = new Set<number>();
for (const ref of cluster) {
if (
this.mg.isOceanShore(ref) ||
this.mg.neighbors(ref).some((n) => !this.mg.hasOwner(n))
) {
return false;
}
this.mg
.neighbors(ref)
.filter((n) => this.mg.ownerID(n) != this.player.smallID())
.forEach((p) => enemies.add(this.mg.ownerID(p)));
if (enemies.size != 1) {
return false;
}
}
private surroundedBySamePlayer(cluster: Set<TileRef>): false | Player {
const enemies = new Set<number>()
for (const ref of cluster) {
if (this.mg.isOceanShore(ref) || this.mg.neighbors(ref).some(n => !this.mg.hasOwner(n))) {
return false
}
this.mg.neighbors(ref)
.filter(n => this.mg.ownerID(n) != this.player.smallID())
.forEach(p => enemies.add(this.mg.ownerID(p)))
if (enemies.size != 1) {
return false
}
}
if (enemies.size != 1) {
return false
}
return this.mg.playerBySmallID(Array.from(enemies)[0]) as Player
if (enemies.size != 1) {
return false;
}
return this.mg.playerBySmallID(Array.from(enemies)[0]) as Player;
}
private isSurrounded(cluster: Set<TileRef>): boolean {
let enemyTiles = new Set<TileRef>()
for (const tr of cluster) {
if (this.mg.isOceanShore(tr)) {
return false
}
this.mg.neighbors(tr)
.filter(n => this.mg.ownerID(n) != this.player.smallID())
.forEach(n => enemyTiles.add(n))
}
if (enemyTiles.size == 0) {
return false
}
const enemyBox = calculateBoundingBox(this.mg, enemyTiles)
const clusterBox = calculateBoundingBox(this.mg, cluster)
return inscribed(enemyBox, clusterBox)
private isSurrounded(cluster: Set<TileRef>): boolean {
let enemyTiles = new Set<TileRef>();
for (const tr of cluster) {
if (this.mg.isOceanShore(tr)) {
return false;
}
this.mg
.neighbors(tr)
.filter((n) => this.mg.ownerID(n) != this.player.smallID())
.forEach((n) => enemyTiles.add(n));
}
private removeCluster(cluster: Set<TileRef>) {
const result = new Set<number>(); // Use Set to automatically deduplicate ownerIDs
for (const t of cluster) {
for (const neighbor of this.mg.neighbors(t)) {
if (this.mg.ownerID(neighbor) != this.player.smallID()) {
result.add(this.mg.ownerID(neighbor));
}
}
}
const mode = getMode(result)
if (!this.mg.playerBySmallID(mode).isPlayer()) {
return
}
const firstTile = cluster.values().next().value
const filter = (_, t: TileRef): boolean => this.mg.ownerID(t) == this.mg.ownerID(firstTile)
const tiles = this.mg.bfs(firstTile, filter)
const modePlayer = this.mg.playerBySmallID(mode)
if (!modePlayer.isPlayer()) {
consolex.warn('mode player is null')
return
}
for (const tile of tiles) {
(modePlayer as Player).conquer(tile)
}
if (enemyTiles.size == 0) {
return false;
}
const enemyBox = calculateBoundingBox(this.mg, enemyTiles);
const clusterBox = calculateBoundingBox(this.mg, cluster);
return inscribed(enemyBox, clusterBox);
}
private calculateClusters(): Set<TileRef>[] {
const seen = new Set<TileRef>()
const border = this.player.borderTiles()
const clusters: Set<TileRef>[] = []
for (const tile of border) {
if (seen.has(tile)) {
continue
}
const cluster = new Set<TileRef>()
const queue: TileRef[] = [tile]
seen.add(tile)
while (queue.length > 0) {
const curr = queue.shift()
cluster.add(curr)
const neighbors = (this.mg as GameImpl).neighborsWithDiag(curr)
for (const neighbor of neighbors) {
if (border.has(neighbor) && !seen.has(neighbor)) {
queue.push(neighbor)
seen.add(neighbor)
}
}
}
clusters.push(cluster)
private removeCluster(cluster: Set<TileRef>) {
const result = new Set<number>(); // Use Set to automatically deduplicate ownerIDs
for (const t of cluster) {
for (const neighbor of this.mg.neighbors(t)) {
if (this.mg.ownerID(neighbor) != this.player.smallID()) {
result.add(this.mg.ownerID(neighbor));
}
return clusters
}
}
const mode = getMode(result);
if (!this.mg.playerBySmallID(mode).isPlayer()) {
return;
}
const firstTile = cluster.values().next().value;
const filter = (_, t: TileRef): boolean =>
this.mg.ownerID(t) == this.mg.ownerID(firstTile);
const tiles = this.mg.bfs(firstTile, filter);
owner(): Player {
return this.player
const modePlayer = this.mg.playerBySmallID(mode);
if (!modePlayer.isPlayer()) {
consolex.warn("mode player is null");
return;
}
for (const tile of tiles) {
(modePlayer as Player).conquer(tile);
}
}
isActive(): boolean {
return this.active
private calculateClusters(): Set<TileRef>[] {
const seen = new Set<TileRef>();
const border = this.player.borderTiles();
const clusters: Set<TileRef>[] = [];
for (const tile of border) {
if (seen.has(tile)) {
continue;
}
const cluster = new Set<TileRef>();
const queue: TileRef[] = [tile];
seen.add(tile);
while (queue.length > 0) {
const curr = queue.shift();
cluster.add(curr);
const neighbors = (this.mg as GameImpl).neighborsWithDiag(curr);
for (const neighbor of neighbors) {
if (border.has(neighbor) && !seen.has(neighbor)) {
queue.push(neighbor);
seen.add(neighbor);
}
}
}
clusters.push(cluster);
}
}
return clusters;
}
owner(): Player {
return this.player;
}
isActive(): boolean {
return this.active;
}
}
+123 -106
View File
@@ -1,4 +1,14 @@
import { AllPlayers, Cell, Execution, Game, Player, Unit, PlayerID, TerrainType, UnitType } from "../game/Game";
import {
AllPlayers,
Cell,
Execution,
Game,
Player,
Unit,
PlayerID,
TerrainType,
UnitType,
} from "../game/Game";
import { PathFinder } from "../pathfinding/PathFinding";
import { PathFindResultType } from "../pathfinding/AStar";
import { PseudoRandom } from "../PseudoRandom";
@@ -8,126 +18,133 @@ import { MiniAStar } from "../pathfinding/MiniAStar";
import { manhattanDistFN, TileRef } from "../game/GameMap";
export class PortExecution implements Execution {
private active = true;
private mg: Game;
private port: Unit;
private random: PseudoRandom;
private portPaths = new Map<Unit, TileRef[]>();
private computingPaths = new Map<Unit, MiniAStar>();
private active = true
private mg: Game
private port: Unit
private random: PseudoRandom
private portPaths = new Map<Unit, TileRef[]>()
private computingPaths = new Map<Unit, MiniAStar>()
constructor(
private _owner: PlayerID,
private tile: TileRef,
) {}
constructor(
private _owner: PlayerID,
private tile: TileRef,
) { }
init(mg: Game, ticks: number): void {
this.mg = mg;
this.random = new PseudoRandom(mg.ticks());
}
tick(ticks: number): void {
if (this.port == null) {
// TODO: use canBuild
const tile = this.tile;
const player = this.mg.player(this._owner);
if (!player.canBuild(UnitType.Port, tile)) {
consolex.warn(`player ${player} cannot build port at ${this.tile}`);
this.active = false;
return;
}
const spawns = Array.from(this.mg.bfs(tile, manhattanDistFN(tile, 20)))
.filter((t) => this.mg.isOceanShore(t) && this.mg.owner(t) == player)
.sort(
(a, b) =>
this.mg.manhattanDist(a, tile) - this.mg.manhattanDist(b, tile),
);
init(mg: Game, ticks: number): void {
this.mg = mg
this.random = new PseudoRandom(mg.ticks())
if (spawns.length == 0) {
consolex.warn(`cannot find spawn for port`);
this.active = false;
return;
}
this.port = player.buildUnit(UnitType.Port, 0, spawns[0]);
}
if (!this.port.isActive()) {
this.active = false;
return;
}
tick(ticks: number): void {
const alliedPorts = this.player()
.alliances()
.map((a) => a.other(this.player()))
.flatMap((p) => p.units(UnitType.Port));
const alliedPortsSet = new Set(alliedPorts);
if (this.port == null) {
// TODO: use canBuild
const tile = this.tile
const player = this.mg.player(this._owner)
if (!player.canBuild(UnitType.Port, tile)) {
consolex.warn(`player ${player} cannot build port at ${this.tile}`)
this.active = false
return
}
const spawns = Array.from(this.mg.bfs(tile, manhattanDistFN(tile, 20)))
.filter(t => this.mg.isOceanShore(t) && this.mg.owner(t) == player)
.sort((a, b) => this.mg.manhattanDist(a, tile) - this.mg.manhattanDist(b, tile))
const allyConnections = new Set(
Array.from(this.portPaths.keys()).map((p) => p.owner()),
);
allyConnections;
if (spawns.length == 0) {
consolex.warn(`cannot find spawn for port`)
this.active = false
return
}
this.port = player.buildUnit(UnitType.Port, 0, spawns[0])
}
if (!this.port.isActive()) {
this.active = false
return
for (const port of alliedPorts) {
if (allyConnections.has(port.owner())) {
continue;
}
allyConnections.add(port.owner());
if (this.computingPaths.has(port)) {
const aStar = this.computingPaths.get(port);
switch (aStar.compute()) {
case PathFindResultType.Completed:
this.portPaths.set(port, aStar.reconstructPath());
this.computingPaths.delete(port);
break;
case PathFindResultType.Pending:
break;
case PathFindResultType.PathNotFound:
consolex.warn(`path not found to port`);
break;
}
continue;
}
const alliedPorts = this.player().alliances().map(a => a.other(this.player())).flatMap(p => p.units(UnitType.Port))
const alliedPortsSet = new Set(alliedPorts)
const allyConnections = new Set(Array.from(this.portPaths.keys()).map(p => p.owner()))
allyConnections
for (const port of alliedPorts) {
if (allyConnections.has(port.owner())) {
continue
}
allyConnections.add(port.owner())
if (this.computingPaths.has(port)) {
const aStar = this.computingPaths.get(port)
switch (aStar.compute()) {
case PathFindResultType.Completed:
this.portPaths.set(port, aStar.reconstructPath())
this.computingPaths.delete(port)
break
case PathFindResultType.Pending:
break
case PathFindResultType.PathNotFound:
consolex.warn(`path not found to port`)
break
}
continue
}
const pf = new MiniAStar(
this.mg.map(),
this.mg.miniMap(),
this.port.tile(),
port.tile(),
(tr: TileRef) => this.mg.miniMap().isOcean(tr),
10_000,
25
)
this.computingPaths.set(port, pf)
}
for (const port of this.portPaths.keys()) {
if (!port.isActive() || !alliedPortsSet.has(port)) {
this.portPaths.delete(port)
this.computingPaths.delete(port)
}
}
const portConnections = Array.from(this.portPaths.keys())
if (portConnections.length > 0 && this.random.chance(this.mg.config().tradeShipSpawnRate())) {
const port = this.random.randElement(portConnections)
const path = this.portPaths.get(port)
if (path != null) {
const pf = PathFinder.Mini(this.mg, 10, false)
this.mg.addExecution(new TradeShipExecution(this.player().id(), this.port, port, pf, path))
}
}
const pf = new MiniAStar(
this.mg.map(),
this.mg.miniMap(),
this.port.tile(),
port.tile(),
(tr: TileRef) => this.mg.miniMap().isOcean(tr),
10_000,
25,
);
this.computingPaths.set(port, pf);
}
owner(): Player {
return null
for (const port of this.portPaths.keys()) {
if (!port.isActive() || !alliedPortsSet.has(port)) {
this.portPaths.delete(port);
this.computingPaths.delete(port);
}
}
isActive(): boolean {
return this.active
}
const portConnections = Array.from(this.portPaths.keys());
activeDuringSpawnPhase(): boolean {
return false
if (
portConnections.length > 0 &&
this.random.chance(this.mg.config().tradeShipSpawnRate())
) {
const port = this.random.randElement(portConnections);
const path = this.portPaths.get(port);
if (path != null) {
const pf = PathFinder.Mini(this.mg, 10, false);
this.mg.addExecution(
new TradeShipExecution(this.player().id(), this.port, port, pf, path),
);
}
}
}
player(): Player {
return this.port.owner()
}
owner(): Player {
return null;
}
}
isActive(): boolean {
return this.active;
}
activeDuringSpawnPhase(): boolean {
return false;
}
player(): Player {
return this.port.owner();
}
}
@@ -2,37 +2,39 @@ import { consolex } from "../Consolex";
import { Execution, Game, Player, PlayerID } from "../game/Game";
export class SetTargetTroopRatioExecution implements Execution {
private player: Player;
private player: Player
private active = true;
private active = true
constructor(
private playerID: PlayerID,
private targetTroopsRatio: number,
) {}
constructor(private playerID: PlayerID, private targetTroopsRatio: number) { }
init(mg: Game, ticks: number): void {
this.player = mg.player(this.playerID);
}
init(mg: Game, ticks: number): void {
this.player = mg.player(this.playerID)
tick(ticks: number): void {
if (this.targetTroopsRatio < 0 || this.targetTroopsRatio > 1) {
consolex.warn(
`target troop ratio of ${this.targetTroopsRatio} for player ${this.player} invalid`,
);
} else {
this.player.setTargetTroopRatio(this.targetTroopsRatio);
}
this.active = false;
}
tick(ticks: number): void {
if (this.targetTroopsRatio < 0 || this.targetTroopsRatio > 1) {
consolex.warn(`target troop ratio of ${this.targetTroopsRatio} for player ${this.player} invalid`)
} else {
this.player.setTargetTroopRatio(this.targetTroopsRatio)
}
this.active = false
}
owner(): Player {
return null;
}
owner(): Player {
return null
}
isActive(): boolean {
return this.active;
}
isActive(): boolean {
return this.active
}
activeDuringSpawnPhase(): boolean {
return false
}
}
activeDuringSpawnPhase(): boolean {
return false;
}
}
+63 -54
View File
@@ -5,62 +5,71 @@ import { consolex } from "../Consolex";
import { TileRef } from "../game/GameMap";
export class ShellExecution implements Execution {
private active = true;
private pathFinder: PathFinder;
private shell: Unit;
private active = true
private pathFinder: PathFinder
private shell: Unit
constructor(
private spawn: TileRef,
private _owner: Player,
private ownerUnit: Unit,
private target: Unit,
) {}
constructor(private spawn: TileRef, private _owner: Player, private ownerUnit: Unit, private target: Unit) {
init(mg: Game, ticks: number): void {
this.pathFinder = PathFinder.Mini(mg, 2000, true, 10);
}
tick(ticks: number): void {
if (this.shell == null) {
this.shell = this._owner.buildUnit(UnitType.Shell, 0, this.spawn);
}
if (!this.shell.isActive()) {
this.active = false;
return;
}
if (
!this.target.isActive() ||
!this.ownerUnit.isActive() ||
this.target.owner() == this.shell.owner()
) {
this.shell.delete(false);
this.active = false;
return;
}
for (let i = 0; i < 3; i++) {
const result = this.pathFinder.nextTile(
this.shell.tile(),
this.target.tile(),
3,
);
switch (result.type) {
case PathFindResultType.Completed:
this.active = false;
this.target.modifyHealth(-this.shell.info().damage);
this.shell.delete(false);
return;
case PathFindResultType.NextTile:
this.shell.move(result.tile);
break;
case PathFindResultType.Pending:
return;
case PathFindResultType.PathNotFound:
consolex.log(`Shell ${this.shell} could not find target`);
this.active = false;
this.shell.delete(false);
return;
}
}
}
init(mg: Game, ticks: number): void {
this.pathFinder = PathFinder.Mini(mg, 2000, true, 10)
}
tick(ticks: number): void {
if (this.shell == null) {
this.shell = this._owner.buildUnit(UnitType.Shell, 0, this.spawn)
}
if (!this.shell.isActive()) {
this.active = false
return
}
if (!this.target.isActive() || !this.ownerUnit.isActive() || this.target.owner() == this.shell.owner()) {
this.shell.delete(false)
this.active = false
return
}
for (let i = 0; i < 3; i++) {
const result = this.pathFinder.nextTile(this.shell.tile(), this.target.tile(), 3)
switch (result.type) {
case PathFindResultType.Completed:
this.active = false
this.target.modifyHealth(-this.shell.info().damage)
this.shell.delete(false)
return
case PathFindResultType.NextTile:
this.shell.move(result.tile)
break
case PathFindResultType.Pending:
return
case PathFindResultType.PathNotFound:
consolex.log(`Shell ${this.shell} could not find target`)
this.active = false
this.shell.delete(false)
return
}
}
}
owner(): Player {
return null
}
isActive(): boolean {
return this.active
}
activeDuringSpawnPhase(): boolean {
return false
}
}
owner(): Player {
return null;
}
isActive(): boolean {
return this.active;
}
activeDuringSpawnPhase(): boolean {
return false;
}
}
+56 -46
View File
@@ -1,58 +1,68 @@
import { Cell, Execution, Game, Player, PlayerInfo, PlayerType } from "../game/Game"
import { TileRef } from "../game/GameMap"
import { BotExecution } from "./BotExecution"
import { PlayerExecution } from "./PlayerExecution"
import { getSpawnTiles } from "./Util"
import {
Cell,
Execution,
Game,
Player,
PlayerInfo,
PlayerType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { BotExecution } from "./BotExecution";
import { PlayerExecution } from "./PlayerExecution";
import { getSpawnTiles } from "./Util";
export class SpawnExecution implements Execution {
active: boolean = true;
private mg: Game;
active: boolean = true
private mg: Game
constructor(
private playerInfo: PlayerInfo,
private tile: TileRef,
) {}
constructor(
private playerInfo: PlayerInfo,
private tile: TileRef
) { }
init(mg: Game, ticks: number) {
this.mg = mg;
}
init(mg: Game, ticks: number) {
this.mg = mg
tick(ticks: number) {
this.active = false;
if (!this.mg.inSpawnPhase()) {
return;
}
tick(ticks: number) {
this.active = false
if (!this.mg.inSpawnPhase()) {
return
}
const existing = this.mg.players().find(p => p.id() == this.playerInfo.id)
if (existing) {
existing.tiles().forEach(t => existing.relinquish(t))
getSpawnTiles(this.mg, this.tile).forEach(t => {
existing.conquer(t)
})
return
}
const player = this.mg.addPlayer(this.playerInfo, this.mg.config().startManpower(this.playerInfo))
getSpawnTiles(this.mg, this.tile).forEach(t => {
player.conquer(t)
})
this.mg.addExecution(new PlayerExecution(player.id()))
if (player.type() == PlayerType.Bot) {
this.mg.addExecution(new BotExecution(player))
}
const existing = this.mg
.players()
.find((p) => p.id() == this.playerInfo.id);
if (existing) {
existing.tiles().forEach((t) => existing.relinquish(t));
getSpawnTiles(this.mg, this.tile).forEach((t) => {
existing.conquer(t);
});
return;
}
owner(): Player {
return null
}
isActive(): boolean {
return this.active
const player = this.mg.addPlayer(
this.playerInfo,
this.mg.config().startManpower(this.playerInfo),
);
getSpawnTiles(this.mg, this.tile).forEach((t) => {
player.conquer(t);
});
this.mg.addExecution(new PlayerExecution(player.id()));
if (player.type() == PlayerType.Bot) {
this.mg.addExecution(new BotExecution(player));
}
}
activeDuringSpawnPhase(): boolean {
return true
}
owner(): Player {
return null;
}
isActive(): boolean {
return this.active;
}
}
activeDuringSpawnPhase(): boolean {
return true;
}
}
+27 -27
View File
@@ -1,38 +1,38 @@
import { Execution, Game, Player, PlayerID } from "../game/Game";
export class TargetPlayerExecution implements Execution {
private requestor: Player;
private target: Player;
private requestor: Player
private target: Player
private active = true;
private active = true
constructor(
private requestorID: PlayerID,
private targetID: PlayerID,
) {}
constructor(private requestorID: PlayerID, private targetID: PlayerID) { }
init(mg: Game, ticks: number): void {
this.requestor = mg.player(this.requestorID);
this.target = mg.player(this.targetID);
}
init(mg: Game, ticks: number): void {
this.requestor = mg.player(this.requestorID)
this.target = mg.player(this.targetID)
tick(ticks: number): void {
if (this.requestor.canTarget(this.target)) {
this.requestor.target(this.target);
this.target.updateRelation(this.requestor, -40);
}
this.active = false;
}
tick(ticks: number): void {
if (this.requestor.canTarget(this.target)) {
this.requestor.target(this.target)
this.target.updateRelation(this.requestor, -40)
}
this.active = false
}
owner(): Player {
return null;
}
owner(): Player {
return null
}
isActive(): boolean {
return this.active;
}
isActive(): boolean {
return this.active
}
activeDuringSpawnPhase(): boolean {
return false
}
}
activeDuringSpawnPhase(): boolean {
return false;
}
}
+137 -111
View File
@@ -1,131 +1,157 @@
import { MessageType } from '../game/Game';
import { MessageType } from "../game/Game";
import { renderNumber } from "../../client/Utils";
import { AllPlayers, Cell, Execution, Game, Unit, Player, PlayerID, UnitType } from "../game/Game";
import {
AllPlayers,
Cell,
Execution,
Game,
Unit,
Player,
PlayerID,
UnitType,
} from "../game/Game";
import { PathFinder } from "../pathfinding/PathFinding";
import { PathFindResultType } from "../pathfinding/AStar";
import { distSortUnit } from "../Util";
import { consolex } from "../Consolex";
import { TileRef } from '../game/GameMap';
import { TileRef } from "../game/GameMap";
export class TradeShipExecution implements Execution {
private active = true;
private mg: Game;
private origOwner: Player;
private tradeShip: Unit;
private index = 0;
private wasCaptured = false;
private active = true
private mg: Game
private origOwner: Player
private tradeShip: Unit
private index = 0
private wasCaptured = false
constructor(
private _owner: PlayerID,
private srcPort: Unit,
private dstPort: Unit,
private pathFinder: PathFinder,
// don't modify
private path: TileRef[],
) {}
constructor(
private _owner: PlayerID,
private srcPort: Unit,
private dstPort: Unit,
private pathFinder: PathFinder,
// don't modify
private path: TileRef[]
) { }
init(mg: Game, ticks: number): void {
this.mg = mg;
this.origOwner = mg.player(this._owner);
}
init(mg: Game, ticks: number): void {
this.mg = mg
this.origOwner = mg.player(this._owner)
tick(ticks: number): void {
if (this.tradeShip == null) {
const spawn = this.origOwner.canBuild(
UnitType.TradeShip,
this.srcPort.tile(),
);
if (spawn == false) {
consolex.warn(`cannot build trade ship`);
this.active = false;
return;
}
this.tradeShip = this.origOwner.buildUnit(UnitType.TradeShip, 0, spawn);
}
tick(ticks: number): void {
if (this.tradeShip == null) {
const spawn = this.origOwner.canBuild(UnitType.TradeShip, this.srcPort.tile())
if (spawn == false) {
consolex.warn(`cannot build trade ship`)
this.active = false
return
}
this.tradeShip = this.origOwner.buildUnit(UnitType.TradeShip, 0, spawn)
}
if (!this.tradeShip.isActive()) {
this.active = false
return
}
if (this.origOwner != this.tradeShip.owner()) {
// Store as vairable in case ship is recaptured by previous owner
this.wasCaptured = true
}
if (!this.wasCaptured && (!this.dstPort.isActive() || !this.tradeShip.owner().isAlliedWith(this.dstPort.owner()))) {
this.tradeShip.delete(false)
this.active = false
return
}
if (this.wasCaptured) {
const ports = this.tradeShip.owner().units(UnitType.Port).sort(distSortUnit(this.mg, this.tradeShip))
if (ports.length == 0) {
this.tradeShip.delete(false)
this.active = false
return
}
const dstPort = ports[0]
const result = this.pathFinder.nextTile(this.tradeShip.tile(), dstPort.tile())
switch (result.type) {
case PathFindResultType.Completed:
const gold = this.mg.config().tradeShipGold(this.mg.manhattanDist(this.srcPort.tile(), dstPort.tile()))
this.tradeShip.owner().addGold(gold)
this.mg.displayMessage(
`Received ${renderNumber(gold)} gold from ship captured from ${this.origOwner.displayName()}`,
MessageType.SUCCESS,
this.tradeShip.owner().id()
)
this.tradeShip.delete(false)
break
case PathFindResultType.Pending:
// Fire unit event to rerender.
this.tradeShip.move(this.tradeShip.tile())
break
case PathFindResultType.NextTile:
this.tradeShip.move(result.tile)
break
case PathFindResultType.PathNotFound:
consolex.warn('captured trade ship cannot find route')
this.active = false
break
}
return
}
if (this.index >= this.path.length) {
this.active = false
const gold = this.mg.config().tradeShipGold(this.mg.manhattanDist(this.srcPort.tile(), this.dstPort.tile()))
this.srcPort.owner().addGold(gold)
this.dstPort.owner().addGold(gold)
this.mg.displayMessage(
`Received ${renderNumber(gold)} gold from trade with ${this.srcPort.owner().displayName()}`,
MessageType.SUCCESS,
this.dstPort.owner().id()
)
this.mg.displayMessage(
`Received ${renderNumber(gold)} gold from trade with ${this.dstPort.owner().displayName()}`,
MessageType.SUCCESS,
this.srcPort.owner().id()
)
this.tradeShip.delete(false)
return
}
this.tradeShip.move(this.path[this.index])
this.index++
if (!this.tradeShip.isActive()) {
this.active = false;
return;
}
owner(): Player {
return null
if (this.origOwner != this.tradeShip.owner()) {
// Store as vairable in case ship is recaptured by previous owner
this.wasCaptured = true;
}
isActive(): boolean {
return this.active
if (
!this.wasCaptured &&
(!this.dstPort.isActive() ||
!this.tradeShip.owner().isAlliedWith(this.dstPort.owner()))
) {
this.tradeShip.delete(false);
this.active = false;
return;
}
activeDuringSpawnPhase(): boolean {
return false
if (this.wasCaptured) {
const ports = this.tradeShip
.owner()
.units(UnitType.Port)
.sort(distSortUnit(this.mg, this.tradeShip));
if (ports.length == 0) {
this.tradeShip.delete(false);
this.active = false;
return;
}
const dstPort = ports[0];
const result = this.pathFinder.nextTile(
this.tradeShip.tile(),
dstPort.tile(),
);
switch (result.type) {
case PathFindResultType.Completed:
const gold = this.mg
.config()
.tradeShipGold(
this.mg.manhattanDist(this.srcPort.tile(), dstPort.tile()),
);
this.tradeShip.owner().addGold(gold);
this.mg.displayMessage(
`Received ${renderNumber(gold)} gold from ship captured from ${this.origOwner.displayName()}`,
MessageType.SUCCESS,
this.tradeShip.owner().id(),
);
this.tradeShip.delete(false);
break;
case PathFindResultType.Pending:
// Fire unit event to rerender.
this.tradeShip.move(this.tradeShip.tile());
break;
case PathFindResultType.NextTile:
this.tradeShip.move(result.tile);
break;
case PathFindResultType.PathNotFound:
consolex.warn("captured trade ship cannot find route");
this.active = false;
break;
}
return;
}
}
if (this.index >= this.path.length) {
this.active = false;
const gold = this.mg
.config()
.tradeShipGold(
this.mg.manhattanDist(this.srcPort.tile(), this.dstPort.tile()),
);
this.srcPort.owner().addGold(gold);
this.dstPort.owner().addGold(gold);
this.mg.displayMessage(
`Received ${renderNumber(gold)} gold from trade with ${this.srcPort.owner().displayName()}`,
MessageType.SUCCESS,
this.dstPort.owner().id(),
);
this.mg.displayMessage(
`Received ${renderNumber(gold)} gold from trade with ${this.dstPort.owner().displayName()}`,
MessageType.SUCCESS,
this.srcPort.owner().id(),
);
this.tradeShip.delete(false);
return;
}
this.tradeShip.move(this.path[this.index]);
this.index++;
}
owner(): Player {
return null;
}
isActive(): boolean {
return this.active;
}
activeDuringSpawnPhase(): boolean {
return false;
}
}
+142 -116
View File
@@ -1,6 +1,16 @@
import { Unit, Cell, Execution, Game, Player, PlayerID, TerraNullius, UnitType, TerrainType } from "../game/Game";
import {
Unit,
Cell,
Execution,
Game,
Player,
PlayerID,
TerraNullius,
UnitType,
TerrainType,
} from "../game/Game";
import { AttackExecution } from "./AttackExecution";
import { MessageType } from '../game/Game';
import { MessageType } from "../game/Game";
import { PathFinder } from "../pathfinding/PathFinding";
import { PathFindResultType } from "../pathfinding/AStar";
import { consolex } from "../Consolex";
@@ -8,138 +18,154 @@ import { TileRef } from "../game/GameMap";
import { targetTransportTile } from "../Util";
export class TransportShipExecution implements Execution {
private lastMove: number;
private lastMove: number
// TODO: make this configurable
private ticksPerMove = 1;
// TODO: make this configurable
private ticksPerMove = 1
private active = true;
private active = true
private mg: Game;
private attacker: Player;
private target: Player | TerraNullius;
private mg: Game
private attacker: Player
private target: Player | TerraNullius
// TODO make private
public path: TileRef[];
private src: TileRef | null;
private dst: TileRef | null;
// TODO make private
public path: TileRef[]
private src: TileRef | null
private dst: TileRef | null
private boat: Unit;
private pathFinder: PathFinder;
private boat: Unit
constructor(
private attackerID: PlayerID,
private targetID: PlayerID | null,
private ref: TileRef,
private troops: number | null,
) {}
private pathFinder: PathFinder
activeDuringSpawnPhase(): boolean {
return false;
}
constructor(
private attackerID: PlayerID,
private targetID: PlayerID | null,
private ref: TileRef,
private troops: number | null,
) { }
init(mg: Game, ticks: number) {
this.lastMove = ticks;
this.mg = mg;
this.pathFinder = PathFinder.Mini(mg, 10_000, false, 2);
activeDuringSpawnPhase(): boolean {
return false
this.attacker = mg.player(this.attackerID);
if (
this.attacker.units(UnitType.TransportShip).length >=
mg.config().boatMaxNumber()
) {
mg.displayMessage(
`No boats available, max ${mg.config().boatMaxNumber()}`,
MessageType.WARN,
this.attackerID,
);
this.active = false;
this.attacker.addTroops(this.troops);
return;
}
init(mg: Game, ticks: number) {
this.lastMove = ticks
this.mg = mg
this.pathFinder = PathFinder.Mini(mg, 10_000, false, 2)
if (this.targetID == null || this.targetID == this.mg.terraNullius().id()) {
this.target = mg.terraNullius();
} else {
this.target = mg.player(this.targetID);
}
this.attacker = mg.player(this.attackerID)
if (this.troops == null) {
this.troops = this.mg
.config()
.boatAttackAmount(this.attacker, this.target);
}
if (this.attacker.units(UnitType.TransportShip).length >= mg.config().boatMaxNumber()) {
mg.displayMessage(`No boats available, max ${mg.config().boatMaxNumber()}`, MessageType.WARN, this.attackerID)
this.active = false
this.attacker.addTroops(this.troops)
return
this.troops = Math.min(this.troops, this.attacker.troops());
this.dst = targetTransportTile(this.mg, this.ref);
if (this.dst == null) {
consolex.warn(
`${this.attacker} cannot send ship to ${this.target}, cannot find attack tile`,
);
this.active = false;
return;
}
const src = this.attacker.canBuild(UnitType.TransportShip, this.dst);
if (src == false) {
consolex.warn(`can't build transport ship`);
this.active = false;
return;
}
this.src = src;
this.boat = this.attacker.buildUnit(
UnitType.TransportShip,
this.troops,
this.src,
);
}
tick(ticks: number) {
if (!this.active) {
return;
}
if (!this.boat.isActive()) {
this.active = false;
return;
}
if (ticks - this.lastMove < this.ticksPerMove) {
return;
}
this.lastMove = ticks;
const result = this.pathFinder.nextTile(this.boat.tile(), this.dst);
switch (result.type) {
case PathFindResultType.Completed:
if (this.mg.owner(this.dst) == this.attacker) {
this.attacker.addTroops(this.troops);
this.boat.delete(false);
this.active = false;
return;
}
if (this.targetID == null || this.targetID == this.mg.terraNullius().id()) {
this.target = mg.terraNullius()
if (this.target.isPlayer() && this.attacker.isAlliedWith(this.target)) {
this.target.addTroops(this.troops);
} else {
this.target = mg.player(this.targetID)
this.attacker.conquer(this.dst);
this.mg.addExecution(
new AttackExecution(
this.troops,
this.attacker.id(),
this.targetID,
this.dst,
false,
),
);
}
if (this.troops == null) {
this.troops = this.mg.config().boatAttackAmount(this.attacker, this.target)
}
this.troops = Math.min(this.troops, this.attacker.troops())
this.dst = targetTransportTile(this.mg, this.ref)
if (this.dst == null) {
consolex.warn(`${this.attacker} cannot send ship to ${this.target}, cannot find attack tile`)
this.active = false
return
}
const src = this.attacker.canBuild(UnitType.TransportShip, this.dst)
if (src == false) {
consolex.warn(`can't build transport ship`)
this.active = false
return
}
this.src = src
this.boat = this.attacker.buildUnit(UnitType.TransportShip, this.troops, this.src)
this.boat.delete(false);
this.active = false;
return;
case PathFindResultType.NextTile:
this.boat.move(result.tile);
break;
case PathFindResultType.Pending:
break;
case PathFindResultType.PathNotFound:
// TODO: add to poisoned port list
consolex.warn(`path not found tot dst`);
this.boat.delete(false);
this.active = false;
return;
}
}
tick(ticks: number) {
if (!this.active) {
return
}
if (!this.boat.isActive()) {
this.active = false
return
}
if (ticks - this.lastMove < this.ticksPerMove) {
return
}
this.lastMove = ticks
const result = this.pathFinder.nextTile(this.boat.tile(), this.dst)
switch (result.type) {
case PathFindResultType.Completed:
if (this.mg.owner(this.dst) == this.attacker) {
this.attacker.addTroops(this.troops)
this.boat.delete(false)
this.active = false
return
}
if (this.target.isPlayer() && this.attacker.isAlliedWith(this.target)) {
this.target.addTroops(this.troops)
} else {
this.attacker.conquer(this.dst)
this.mg.addExecution(
new AttackExecution(this.troops, this.attacker.id(), this.targetID, this.dst, false)
)
}
this.boat.delete(false)
this.active = false
return
case PathFindResultType.NextTile:
this.boat.move(result.tile)
break
case PathFindResultType.Pending:
break
case PathFindResultType.PathNotFound:
// TODO: add to poisoned port list
consolex.warn(`path not found tot dst`)
this.boat.delete(false)
this.active = false
return
}
}
owner(): Player {
return this.attacker
}
isActive(): boolean {
return this.active
}
owner(): Player {
return this.attacker;
}
isActive(): boolean {
return this.active;
}
}
+46 -42
View File
@@ -1,52 +1,56 @@
import { euclDistFN, GameMap, TileRef } from "../game/GameMap";
export function getSpawnTiles(gm: GameMap, tile: TileRef): TileRef[] {
return Array.from(gm.bfs(tile, euclDistFN(tile, 4)))
.filter(t => !gm.hasOwner(t) && gm.isLand(t))
return Array.from(gm.bfs(tile, euclDistFN(tile, 4))).filter(
(t) => !gm.hasOwner(t) && gm.isLand(t),
);
}
export function closestTwoTiles(gm: GameMap, x: Iterable<TileRef>, y: Iterable<TileRef>): { x: TileRef, y: TileRef } {
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));
export function closestTwoTiles(
gm: GameMap,
x: Iterable<TileRef>,
y: Iterable<TileRef>,
): { x: TileRef; y: TileRef } {
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) {
return null;
if (xSorted.length == 0 || ySorted.length == 0) {
return null;
}
let i = 0;
let j = 0;
let minDistance = Infinity;
let result = { x: xSorted[0], y: ySorted[0] };
while (i < xSorted.length && j < ySorted.length) {
const currentX = xSorted[i];
const currentY = ySorted[j];
const distance =
Math.abs(gm.x(currentX) - gm.x(currentY)) +
Math.abs(gm.y(currentX) - gm.y(currentY));
if (distance < minDistance) {
minDistance = distance;
result = { x: currentX, y: currentY };
}
let i = 0;
let j = 0;
let minDistance = Infinity;
let result = { x: xSorted[0], y: ySorted[0] };
while (i < xSorted.length && j < ySorted.length) {
const currentX = xSorted[i];
const currentY = ySorted[j];
const distance =
Math.abs(gm.x(currentX) - gm.x(currentY)) +
Math.abs(gm.y(currentX) - gm.y(currentY));
if (distance < minDistance) {
minDistance = distance;
result = { x: currentX, y: currentY };
}
// If we're at the end of X, must move Y forward
if (i === xSorted.length - 1) {
j++;
}
// If we're at the end of Y, must move X forward
else if (j === ySorted.length - 1) {
i++;
}
// Otherwise, move whichever pointer has smaller x value
else if (gm.x(currentX) < gm.x(currentY)) {
i++;
} else {
j++;
}
// If we're at the end of X, must move Y forward
if (i === xSorted.length - 1) {
j++;
}
// If we're at the end of Y, must move X forward
else if (j === ySorted.length - 1) {
i++;
}
// Otherwise, move whichever pointer has smaller x value
else if (gm.x(currentX) < gm.x(currentY)) {
i++;
} else {
j++;
}
}
return result;
}
return result;
}
+39 -35
View File
@@ -1,49 +1,53 @@
import { EventBus, GameEvent } from "../EventBus"
import { Execution, Game, Player, PlayerID } from "../game/Game"
import { EventBus, GameEvent } from "../EventBus";
import { Execution, Game, Player, PlayerID } from "../game/Game";
export class WinEvent implements GameEvent {
constructor(public readonly winner: Player) { }
constructor(public readonly winner: Player) {}
}
export class WinCheckExecution implements Execution {
private active = true;
private active = true
private mg: Game;
private mg: Game
constructor() {}
constructor() {
init(mg: Game, ticks: number) {
this.mg = mg;
}
tick(ticks: number) {
if (ticks % 10 != 0) {
return;
}
init(mg: Game, ticks: number) {
this.mg = mg
const sorted = this.mg
.players()
.sort((a, b) => b.numTilesOwned() - a.numTilesOwned());
if (sorted.length == 0) {
return;
}
tick(ticks: number) {
if (ticks % 10 != 0) {
return
}
const sorted = this.mg.players().sort((a, b) => b.numTilesOwned() - a.numTilesOwned())
if (sorted.length == 0) {
return
}
const max = sorted[0]
const numTilesWithoutFallout = this.mg.numLandTiles() - this.mg.numTilesWithFallout()
if (max.numTilesOwned() / numTilesWithoutFallout * 100 > this.mg.config().percentageTilesOwnedToWin()) {
this.mg.setWinner(max)
console.log(`${max.name()} has won the game`)
this.active = false
}
const max = sorted[0];
const numTilesWithoutFallout =
this.mg.numLandTiles() - this.mg.numTilesWithFallout();
if (
(max.numTilesOwned() / numTilesWithoutFallout) * 100 >
this.mg.config().percentageTilesOwnedToWin()
) {
this.mg.setWinner(max);
console.log(`${max.name()} has won the game`);
this.active = false;
}
}
owner(): Player {
return null
}
owner(): Player {
return null;
}
isActive(): boolean {
return this.active
}
isActive(): boolean {
return this.active;
}
activeDuringSpawnPhase(): boolean {
return false
}
}
activeDuringSpawnPhase(): boolean {
return false;
}
}
@@ -1,40 +1,51 @@
import { consolex } from "../../Consolex";
import {AllianceRequest, Execution, Game, Player, PlayerID} from "../../game/Game";
import {
AllianceRequest,
Execution,
Game,
Player,
PlayerID,
} from "../../game/Game";
export class AllianceRequestExecution implements Execution {
private active = true
private mg: Game = null
private requestor: Player;
private recipient: Player
private active = true;
private mg: Game = null;
private requestor: Player;
private recipient: Player;
constructor(private requestorID: PlayerID, private recipientID: PlayerID) { }
constructor(
private requestorID: PlayerID,
private recipientID: PlayerID,
) {}
init(mg: Game, ticks: number): void {
this.mg = mg
this.requestor = mg.player(this.requestorID)
this.recipient = mg.player(this.recipientID)
init(mg: Game, ticks: number): void {
this.mg = mg;
this.requestor = mg.player(this.requestorID);
this.recipient = mg.player(this.recipientID);
}
tick(ticks: number): void {
if (this.requestor.isAlliedWith(this.recipient)) {
consolex.warn("already allied");
} else if (
this.requestor.recentOrPendingAllianceRequestWith(this.recipient)
) {
consolex.warn("recent or pending alliance request");
} else {
this.requestor.createAllianceRequest(this.recipient);
}
this.active = false;
}
tick(ticks: number): void {
if (this.requestor.isAlliedWith(this.recipient)) {
consolex.warn('already allied')
} else if (this.requestor.recentOrPendingAllianceRequestWith(this.recipient)) {
consolex.warn('recent or pending alliance request')
} else {
this.requestor.createAllianceRequest(this.recipient)
}
this.active = false
}
owner(): Player {
return null;
}
owner(): Player {
return null
}
isActive(): boolean {
return this.active;
}
isActive(): boolean {
return this.active
}
activeDuringSpawnPhase(): boolean {
return false
}
}
activeDuringSpawnPhase(): boolean {
return false;
}
}
@@ -1,49 +1,61 @@
import { consolex } from "../../Consolex";
import { AllianceRequest, Execution, Game, Player, PlayerID } from "../../game/Game";
import {
AllianceRequest,
Execution,
Game,
Player,
PlayerID,
} from "../../game/Game";
export class AllianceRequestReplyExecution implements Execution {
private active = true
private mg: Game = null
private requestor: Player;
private recipient: Player
private active = true;
private mg: Game = null;
private requestor: Player;
private recipient: Player;
constructor(private requestorID: PlayerID, private recipientID: PlayerID, private accept: boolean) { }
constructor(
private requestorID: PlayerID,
private recipientID: PlayerID,
private accept: boolean,
) {}
init(mg: Game, ticks: number): void {
this.mg = mg
this.requestor = mg.player(this.requestorID)
this.recipient = mg.player(this.recipientID)
}
init(mg: Game, ticks: number): void {
this.mg = mg;
this.requestor = mg.player(this.requestorID);
this.recipient = mg.player(this.recipientID);
}
tick(ticks: number): void {
if (this.requestor.isAlliedWith(this.recipient)) {
consolex.warn('already allied')
tick(ticks: number): void {
if (this.requestor.isAlliedWith(this.recipient)) {
consolex.warn("already allied");
} else {
const request = this.requestor
.outgoingAllianceRequests()
.find((ar) => ar.recipient() == this.recipient);
if (request == null) {
consolex.warn("no alliance request found");
} else {
if (this.accept) {
request.accept();
this.requestor.updateRelation(this.recipient, 100);
this.recipient.updateRelation(this.requestor, 100);
} else {
const request = this.requestor.outgoingAllianceRequests().find(ar => ar.recipient() == this.recipient)
if (request == null) {
consolex.warn('no alliance request found')
} else {
if (this.accept) {
request.accept()
this.requestor.updateRelation(this.recipient, 100)
this.recipient.updateRelation(this.requestor, 100)
} else {
request.reject()
}
}
request.reject();
}
this.active = false
}
}
this.active = false;
}
owner(): Player {
return null
}
owner(): Player {
return null;
}
isActive(): boolean {
return this.active
}
isActive(): boolean {
return this.active;
}
activeDuringSpawnPhase(): boolean {
return false
}
}
activeDuringSpawnPhase(): boolean {
return false;
}
}
@@ -1,45 +1,54 @@
import { consolex } from "../../Consolex";
import { AllianceRequest, Execution, Game, Player, PlayerID } from "../../game/Game";
import {
AllianceRequest,
Execution,
Game,
Player,
PlayerID,
} from "../../game/Game";
export class BreakAllianceExecution implements Execution {
private active = true
private requestor: Player;
private recipient: Player
private mg: Game
private active = true;
private requestor: Player;
private recipient: Player;
private mg: Game;
constructor(private requestorID: PlayerID, private recipientID: PlayerID) { }
constructor(
private requestorID: PlayerID,
private recipientID: PlayerID,
) {}
init(mg: Game, ticks: number): void {
this.requestor = mg.player(this.requestorID)
this.recipient = mg.player(this.recipientID)
this.mg = mg
}
init(mg: Game, ticks: number): void {
this.requestor = mg.player(this.requestorID);
this.recipient = mg.player(this.recipientID);
this.mg = mg;
}
tick(ticks: number): void {
const alliance = this.requestor.allianceWith(this.recipient)
if (alliance == null) {
consolex.warn('cant break alliance, not allied')
} else {
this.requestor.breakAlliance(alliance)
this.recipient.updateRelation(this.requestor, -200)
for (const player of this.mg.players()) {
if (player != this.requestor) {
player.updateRelation(this.requestor, -40)
}
}
tick(ticks: number): void {
const alliance = this.requestor.allianceWith(this.recipient);
if (alliance == null) {
consolex.warn("cant break alliance, not allied");
} else {
this.requestor.breakAlliance(alliance);
this.recipient.updateRelation(this.requestor, -200);
for (const player of this.mg.players()) {
if (player != this.requestor) {
player.updateRelation(this.requestor, -40);
}
this.active = false
}
}
this.active = false;
}
owner(): Player {
return null
}
owner(): Player {
return null;
}
isActive(): boolean {
return this.active
}
isActive(): boolean {
return this.active;
}
activeDuringSpawnPhase(): boolean {
return false
}
}
activeDuringSpawnPhase(): boolean {
return false;
}
}
+230 -40
View File
@@ -1,43 +1,233 @@
export const BOT_NAME_PREFIXES = [
"Akkadian", "Babylonian", "Assyrian", "Sumerian", "Hittite", "Phoenician",
"Canaanite", "Minoan", "Mycenaean", "Etruscan", "Scythian", "Thracian",
"Dacian", "Illyrian", "Median", "Chaldean",
"Roman", "Greek", "Byzantine", "Persian", "Parthian", "Seleucid",
"Ptolemaic", "Palmyrene", "Macedonian", "Carthaginian",
"Ming", "Tang", "Song", "Yuan",
"Mauryan", "Kushan", "Rajput", "Mughal", "Satavahana", "Vijayanagara",
"Egyptian", "Nubian", "Kushite", "Aksumite", "Ethiopian", "Songhai",
"Malian", "Ghanaian", "Benin", "Ashanti", "Zulu", "Tuareg", "Berber",
"Kanem-Bornu", "Buganda", "Mossi", "Swahili", "Somali", "Wolof",
"Umayyad", "Abbasid", "Ayyubid", "Fatimid", "Mamluk", "Seljuk",
"Safavid", "Ottoman", "Almoravid", "Almohad", "Rashidun", "Ziyarid",
"Frankish", "Visigothic", "Ostrogothic", "Viking", "Norman", "Saxon",
"Anglo-Saxon", "Celtic", "Gaulish", "Carolingian", "Merovingian",
"Capetian", "Plantagenet", "Tudor", "Stuart", "Habsburg", "Romanov",
"Lancaster", "York", "Bourbon", "Napoleonic",
"British", "French", "Spanish", "Portuguese", "Dutch", "Russian",
"German", "Italian", "Swedish", "Norwegian", "Danish", "Polish",
"Hungarian", "Austrian", "Swiss", "Czech", "Slovak", "Serbian",
"Croatian", "Bosnian", "Montenegrin", "Bulgarian", "Romanian",
"Apache", "Sioux", "Cherokee", "Navajo", "Iroquois", "Inuit", "Arawak",
"Carib", "Taino", "Aztec", "Mayan", "Incan", "Mapuche", "Guarani",
"Tupi", "Yanomami", "Zuni", "Hopi", "Kiowa", "Comanche", "Shoshone",
"Japanese", "Ryukyu", "Ainu", "Cham", "Khmer", "Thai", "Vietnamese",
"Burmese", "Balinese", "Malay", "Filipino", "Mongolian",
"Korean", "Tibetan", "Manchu", "Uyghur", "Hmong", "Karen", "Pyu",
"Hawaiian", "Fijian", "Tongan", "Samoan", "Maori", "Micronesian",
"Hebrew", "Armenian", "Georgian", "Phoenician", "Assyrian", "Chaldean",
"Kurdish", "Turkic", "Kazakh", "Uzbek", "Kyrgyz", "Tajik", "Uighur",
"Pashtun", "Baloch", "Afghan", "Persian",
]
"Akkadian",
"Babylonian",
"Assyrian",
"Sumerian",
"Hittite",
"Phoenician",
"Canaanite",
"Minoan",
"Mycenaean",
"Etruscan",
"Scythian",
"Thracian",
"Dacian",
"Illyrian",
"Median",
"Chaldean",
"Roman",
"Greek",
"Byzantine",
"Persian",
"Parthian",
"Seleucid",
"Ptolemaic",
"Palmyrene",
"Macedonian",
"Carthaginian",
"Ming",
"Tang",
"Song",
"Yuan",
"Mauryan",
"Kushan",
"Rajput",
"Mughal",
"Satavahana",
"Vijayanagara",
"Egyptian",
"Nubian",
"Kushite",
"Aksumite",
"Ethiopian",
"Songhai",
"Malian",
"Ghanaian",
"Benin",
"Ashanti",
"Zulu",
"Tuareg",
"Berber",
"Kanem-Bornu",
"Buganda",
"Mossi",
"Swahili",
"Somali",
"Wolof",
"Umayyad",
"Abbasid",
"Ayyubid",
"Fatimid",
"Mamluk",
"Seljuk",
"Safavid",
"Ottoman",
"Almoravid",
"Almohad",
"Rashidun",
"Ziyarid",
"Frankish",
"Visigothic",
"Ostrogothic",
"Viking",
"Norman",
"Saxon",
"Anglo-Saxon",
"Celtic",
"Gaulish",
"Carolingian",
"Merovingian",
"Capetian",
"Plantagenet",
"Tudor",
"Stuart",
"Habsburg",
"Romanov",
"Lancaster",
"York",
"Bourbon",
"Napoleonic",
"British",
"French",
"Spanish",
"Portuguese",
"Dutch",
"Russian",
"German",
"Italian",
"Swedish",
"Norwegian",
"Danish",
"Polish",
"Hungarian",
"Austrian",
"Swiss",
"Czech",
"Slovak",
"Serbian",
"Croatian",
"Bosnian",
"Montenegrin",
"Bulgarian",
"Romanian",
"Apache",
"Sioux",
"Cherokee",
"Navajo",
"Iroquois",
"Inuit",
"Arawak",
"Carib",
"Taino",
"Aztec",
"Mayan",
"Incan",
"Mapuche",
"Guarani",
"Tupi",
"Yanomami",
"Zuni",
"Hopi",
"Kiowa",
"Comanche",
"Shoshone",
"Japanese",
"Ryukyu",
"Ainu",
"Cham",
"Khmer",
"Thai",
"Vietnamese",
"Burmese",
"Balinese",
"Malay",
"Filipino",
"Mongolian",
"Korean",
"Tibetan",
"Manchu",
"Uyghur",
"Hmong",
"Karen",
"Pyu",
"Hawaiian",
"Fijian",
"Tongan",
"Samoan",
"Maori",
"Micronesian",
"Hebrew",
"Armenian",
"Georgian",
"Phoenician",
"Assyrian",
"Chaldean",
"Kurdish",
"Turkic",
"Kazakh",
"Uzbek",
"Kyrgyz",
"Tajik",
"Uighur",
"Pashtun",
"Baloch",
"Afghan",
"Persian",
];
export const BOT_NAME_SUFFIXES = [
"Empire", "Dynasty", "Kingdom", "Sultanate", "Confederation", "Union",
"Republic", "Caliphate", "Dominion", "Realm", "State",
"Federation", "Territory", "Commonwealth", "League", "Duchy", "Province",
"Protectorate", "Colony", "Mandate", "Free State","Canton", "Region", "Nation",
"Assembly", "Hierarchy", "Archduchy", "Grand Duchy","Metropolis", "Cluster",
"Alliance", "Tribunal", "Council", "Confederacy", "Order", "Regime",
"Dominion", "Syndicate","Guild", "Corporation", "Patriarchy",
"Matriarchy","Legion", "Horde", "Clan", "Brotherhood", "Sisterhood","Ascendancy", "Supremacy",
"Province","Kingdoms", "Tribes", "Dominion", "Assembly", "Republics"
"Empire",
"Dynasty",
"Kingdom",
"Sultanate",
"Confederation",
"Union",
"Republic",
"Caliphate",
"Dominion",
"Realm",
"State",
"Federation",
"Territory",
"Commonwealth",
"League",
"Duchy",
"Province",
"Protectorate",
"Colony",
"Mandate",
"Free State",
"Canton",
"Region",
"Nation",
"Assembly",
"Hierarchy",
"Archduchy",
"Grand Duchy",
"Metropolis",
"Cluster",
"Alliance",
"Tribunal",
"Council",
"Confederacy",
"Order",
"Regime",
"Dominion",
"Syndicate",
"Guild",
"Corporation",
"Patriarchy",
"Matriarchy",
"Legion",
"Horde",
"Clan",
"Brotherhood",
"Sisterhood",
"Ascendancy",
"Supremacy",
"Province",
"Kingdoms",
"Tribes",
"Dominion",
"Assembly",
"Republics",
];
+27 -28
View File
@@ -1,36 +1,35 @@
import {MutableAlliance, Game, Player, Tick} from "./Game";
import {GameImpl} from "./GameImpl";
import {PlayerImpl} from "./PlayerImpl";
import { MutableAlliance, Game, Player, Tick } from "./Game";
import { GameImpl } from "./GameImpl";
import { PlayerImpl } from "./PlayerImpl";
export class AllianceImpl implements MutableAlliance {
constructor(
private readonly mg: GameImpl,
readonly requestor_: PlayerImpl,
readonly recipient_: PlayerImpl,
readonly createdAtTick_: Tick,
) { }
constructor(
private readonly mg: GameImpl,
readonly requestor_: PlayerImpl,
readonly recipient_: PlayerImpl,
readonly createdAtTick_: Tick,
) {}
other(player: Player): PlayerImpl {
if (this.requestor_ == player) {
return this.recipient_
}
return this.requestor_
other(player: Player): PlayerImpl {
if (this.requestor_ == player) {
return this.recipient_;
}
return this.requestor_;
}
requestor(): Player {
return this.requestor_
}
requestor(): Player {
return this.requestor_;
}
recipient(): Player {
return this.recipient_
}
recipient(): Player {
return this.recipient_;
}
createdAt(): Tick {
return this.createdAtTick_
}
createdAt(): Tick {
return this.createdAtTick_;
}
expire(): void {
this.mg.expireAlliance(this)
}
}
expire(): void {
this.mg.expireAlliance(this);
}
}
+29 -27
View File
@@ -3,37 +3,39 @@ import { AllianceRequestUpdate } from "./GameUpdates";
import { GameUpdateType } from "./GameUpdates";
import { GameImpl } from "./GameImpl";
export class AllianceRequestImpl implements AllianceRequest {
constructor(
private requestor_: Player,
private recipient_: Player,
private tickCreated: number,
private game: GameImpl,
) {}
constructor(private requestor_: Player, private recipient_: Player, private tickCreated: number, private game: GameImpl) { }
requestor(): Player {
return this.requestor_;
}
requestor(): Player {
return this.requestor_;
}
recipient(): Player {
return this.recipient_;
}
recipient(): Player {
return this.recipient_;
}
createdAt(): Tick {
return this.tickCreated;
}
createdAt(): Tick {
return this.tickCreated
}
accept(): void {
this.game.acceptAllianceRequest(this)
}
reject(): void {
this.game.rejectAllianceRequest(this)
}
toUpdate(): AllianceRequestUpdate {
return {
type: GameUpdateType.AllianceRequest,
requestorID: this.requestor_.smallID(),
recipientID: this.recipient_.smallID(),
createdAt: this.tickCreated,
}
}
accept(): void {
this.game.acceptAllianceRequest(this);
}
reject(): void {
this.game.rejectAllianceRequest(this);
}
toUpdate(): AllianceRequestUpdate {
return {
type: GameUpdateType.AllianceRequest,
requestorID: this.requestor_.smallID(),
recipientID: this.recipient_.smallID(),
createdAt: this.tickCreated,
};
}
}
+270 -261
View File
@@ -1,13 +1,18 @@
import { Config } from "../configuration/Config"
import { GameEvent } from "../EventBus"
import { PlayerView } from "./GameView"
import { ClientID, GameConfig, GameID } from "../Schemas"
import { GameMap, GameMapImpl, TileRef } from "./GameMap"
import { GameUpdate, GameUpdateType, PlayerUpdate, UnitUpdate } from "./GameUpdates"
import { Config } from "../configuration/Config";
import { GameEvent } from "../EventBus";
import { PlayerView } from "./GameView";
import { ClientID, GameConfig, GameID } from "../Schemas";
import { GameMap, GameMapImpl, TileRef } from "./GameMap";
import {
GameUpdate,
GameUpdateType,
PlayerUpdate,
UnitUpdate,
} from "./GameUpdates";
export type PlayerID = string
export type Tick = number
export type Gold = number
export type PlayerID = string;
export type Tick = number;
export type Gold = number;
export const AllPlayers = "AllPlayers" as const;
@@ -17,360 +22,364 @@ type UpdateTypeMap<T extends GameUpdateType> = Extract<GameUpdate, { type: T }>;
// Then use it to create the record type
export type GameUpdates = {
[K in GameUpdateType]: UpdateTypeMap<K>[]
}
[K in GameUpdateType]: UpdateTypeMap<K>[];
};
export interface MapPos {
x: number
y: number
x: number;
y: number;
}
export enum Difficulty {
Easy = "Easy",
Medium = "Medium",
Hard = "Hard",
Impossible = "Impossible",
Easy = "Easy",
Medium = "Medium",
Hard = "Hard",
Impossible = "Impossible",
}
export enum GameMapType {
World = "World",
Europe = "Europe",
Mena = "Mena",
NorthAmerica = "North America",
Oceania = "Oceania",
BlackSea = "Black Sea"
World = "World",
Europe = "Europe",
Mena = "Mena",
NorthAmerica = "North America",
Oceania = "Oceania",
BlackSea = "Black Sea",
}
export enum GameType {
Singleplayer = "Singleplayer",
Public = "Public",
Private = "Private",
Singleplayer = "Singleplayer",
Public = "Public",
Private = "Private",
}
export interface UnitInfo {
cost: (player: Player | PlayerView) => Gold
// Determines if its owner changes when its tile is conquered.
territoryBound: boolean
maxHealth?: number,
damage?: number
cost: (player: Player | PlayerView) => Gold;
// Determines if its owner changes when its tile is conquered.
territoryBound: boolean;
maxHealth?: number;
damage?: number;
}
export enum UnitType {
TransportShip = "Transport",
Destroyer = "Destroyer",
Battleship = "Battleship",
Shell = "Shell",
Port = "Port",
AtomBomb = "Atom Bomb",
HydrogenBomb = "Hydrogen Bomb",
TradeShip = "Trade Ship",
MissileSilo = "Missile Silo",
DefensePost = "Defense Post",
City = "City"
TransportShip = "Transport",
Destroyer = "Destroyer",
Battleship = "Battleship",
Shell = "Shell",
Port = "Port",
AtomBomb = "Atom Bomb",
HydrogenBomb = "Hydrogen Bomb",
TradeShip = "Trade Ship",
MissileSilo = "Missile Silo",
DefensePost = "Defense Post",
City = "City",
}
export enum Relation {
Hostile = 0,
Distrustful = 1,
Neutral = 2,
Friendly = 3
Hostile = 0,
Distrustful = 1,
Neutral = 2,
Friendly = 3,
}
export class Nation {
constructor(
public readonly name: string,
public readonly cell: Cell,
public readonly strength: number,
) { }
constructor(
public readonly name: string,
public readonly cell: Cell,
public readonly strength: number,
) {}
}
export class Cell {
public index: number
public index: number;
private strRepr: string
private strRepr: string;
constructor(
public readonly x,
public readonly y
) {
this.strRepr = `Cell[${this.x},${this.y}]`
}
constructor(
public readonly x,
public readonly y,
) {
this.strRepr = `Cell[${this.x},${this.y}]`;
}
pos(): MapPos {
return {
x: this.x,
y: this.y
}
}
pos(): MapPos {
return {
x: this.x,
y: this.y,
};
}
toString(): string { return this.strRepr }
toString(): string {
return this.strRepr;
}
}
export enum TerrainType {
Plains,
Highland,
Mountain,
Lake,
Ocean
Plains,
Highland,
Mountain,
Lake,
Ocean,
}
export enum PlayerType {
Bot = "BOT",
Human = "HUMAN",
FakeHuman = "FAKEHUMAN",
Bot = "BOT",
Human = "HUMAN",
FakeHuman = "FAKEHUMAN",
}
export interface Execution {
isActive(): boolean
activeDuringSpawnPhase(): boolean
init(mg: Game, ticks: number): void
tick(ticks: number): void
owner(): Player
isActive(): boolean;
activeDuringSpawnPhase(): boolean;
init(mg: Game, ticks: number): void;
tick(ticks: number): void;
owner(): Player;
}
export interface AllianceRequest {
accept(): void
reject(): void
requestor(): Player
recipient(): Player
createdAt(): Tick
accept(): void;
reject(): void;
requestor(): Player;
recipient(): Player;
createdAt(): Tick;
}
export interface Alliance {
requestor(): Player
recipient(): Player
createdAt(): Tick
other(player: Player): Player
requestor(): Player;
recipient(): Player;
createdAt(): Tick;
other(player: Player): Player;
}
export interface MutableAlliance extends Alliance {
expire(): void
other(player: Player): Player
expire(): void;
other(player: Player): Player;
}
export class PlayerInfo {
constructor(
public readonly name: string,
public readonly playerType: PlayerType,
// null if bot.
public readonly clientID: ClientID | null,
// TODO: make player id the small id
public readonly id: PlayerID
) { }
constructor(
public readonly name: string,
public readonly playerType: PlayerType,
// null if bot.
public readonly clientID: ClientID | null,
// TODO: make player id the small id
public readonly id: PlayerID,
) {}
}
export interface DefenseBonus {
// Unit providing the defense bonus
unit: Unit
amount: number
tile: TileRef
// Unit providing the defense bonus
unit: Unit;
amount: number;
tile: TileRef;
}
export interface Unit {
// Properties
type(): UnitType
troops(): number
owner(): Player
info(): UnitInfo
// Properties
type(): UnitType;
troops(): number;
owner(): Player;
info(): UnitInfo;
// Location
tile(): TileRef
lastTile(): TileRef
move(tile: TileRef): void
// Location
tile(): TileRef;
lastTile(): TileRef;
move(tile: TileRef): void;
// State
isActive(): boolean
hasHealth(): boolean
health(): number
modifyHealth(delta: number): void
// State
isActive(): boolean;
hasHealth(): boolean;
health(): number;
modifyHealth(delta: number): void;
// Mutations
setTroops(troops: number): void
delete(displayerMessage?: boolean): void
// Mutations
setTroops(troops: number): void;
delete(displayerMessage?: boolean): void;
// Updates
toUpdate(): UnitUpdate
// Updates
toUpdate(): UnitUpdate;
}
export interface TerraNullius {
isPlayer(): false
id(): PlayerID // always zero, maybe make it TerraNulliusID?
clientID(): ClientID
smallID(): number
isPlayer(): false;
id(): PlayerID; // always zero, maybe make it TerraNulliusID?
clientID(): ClientID;
smallID(): number;
}
export interface Player {
// Basic Info
smallID(): number
info(): PlayerInfo
name(): string
displayName(): string
clientID(): ClientID
id(): PlayerID
type(): PlayerType
isPlayer(): this is Player
toString(): string
// Basic Info
smallID(): number;
info(): PlayerInfo;
name(): string;
displayName(): string;
clientID(): ClientID;
id(): PlayerID;
type(): PlayerType;
isPlayer(): this is Player;
toString(): string;
// State & Properties
isAlive(): boolean
isTraitor(): boolean
largestClusterBoundingBox: { min: Cell, max: Cell } | null
lastTileChange(): Tick
// State & Properties
isAlive(): boolean;
isTraitor(): boolean;
largestClusterBoundingBox: { min: Cell; max: Cell } | null;
lastTileChange(): Tick;
// Territory
tiles(): ReadonlySet<TileRef>
borderTiles(): ReadonlySet<TileRef>
numTilesOwned(): number
conquer(tile: TileRef): void
relinquish(tile: TileRef): void
// Territory
tiles(): ReadonlySet<TileRef>;
borderTiles(): ReadonlySet<TileRef>;
numTilesOwned(): number;
conquer(tile: TileRef): void;
relinquish(tile: TileRef): void;
// Resources & Population
gold(): Gold
population(): number
workers(): number
troops(): number
targetTroopRatio(): number
addGold(toAdd: Gold): void
removeGold(toRemove: Gold): void
addWorkers(toAdd: number): void
removeWorkers(toRemove: number): void
setTargetTroopRatio(target: number): void
setTroops(troops: number): void
addTroops(troops: number): void
removeTroops(troops: number): number
// Resources & Population
gold(): Gold;
population(): number;
workers(): number;
troops(): number;
targetTroopRatio(): number;
addGold(toAdd: Gold): void;
removeGold(toRemove: Gold): void;
addWorkers(toAdd: number): void;
removeWorkers(toRemove: number): void;
setTargetTroopRatio(target: number): void;
setTroops(troops: number): void;
addTroops(troops: number): void;
removeTroops(troops: number): number;
// Units
units(...types: UnitType[]): Unit[]
canBuild(type: UnitType, targetTile: TileRef): TileRef | false
buildUnit(type: UnitType, troops: number, tile: TileRef): Unit
captureUnit(unit: Unit): void
// Units
units(...types: UnitType[]): Unit[];
canBuild(type: UnitType, targetTile: TileRef): TileRef | false;
buildUnit(type: UnitType, troops: number, tile: TileRef): Unit;
captureUnit(unit: Unit): void;
// Relations & Diplomacy
neighbors(): (Player | TerraNullius)[]
sharesBorderWith(other: Player | TerraNullius): boolean
relation(other: Player): Relation
allRelationsSorted(): { player: Player, relation: Relation }[]
updateRelation(other: Player, delta: number): void
decayRelations(): void
// Relations & Diplomacy
neighbors(): (Player | TerraNullius)[];
sharesBorderWith(other: Player | TerraNullius): boolean;
relation(other: Player): Relation;
allRelationsSorted(): { player: Player; relation: Relation }[];
updateRelation(other: Player, delta: number): void;
decayRelations(): void;
// Alliances
incomingAllianceRequests(): AllianceRequest[]
outgoingAllianceRequests(): AllianceRequest[]
alliances(): MutableAlliance[]
allies(): Player[]
isAlliedWith(other: Player): boolean
allianceWith(other: Player): MutableAlliance | null
recentOrPendingAllianceRequestWith(other: Player): boolean
breakAlliance(alliance: Alliance): void
createAllianceRequest(recipient: Player): AllianceRequest
// Alliances
incomingAllianceRequests(): AllianceRequest[];
outgoingAllianceRequests(): AllianceRequest[];
alliances(): MutableAlliance[];
allies(): Player[];
isAlliedWith(other: Player): boolean;
allianceWith(other: Player): MutableAlliance | null;
recentOrPendingAllianceRequestWith(other: Player): boolean;
breakAlliance(alliance: Alliance): void;
createAllianceRequest(recipient: Player): AllianceRequest;
// Targeting
canTarget(other: Player): boolean
target(other: Player): void
targets(): Player[]
transitiveTargets(): Player[]
// Targeting
canTarget(other: Player): boolean;
target(other: Player): void;
targets(): Player[];
transitiveTargets(): Player[];
// Communication
canSendEmoji(recipient: Player | typeof AllPlayers): boolean
outgoingEmojis(): EmojiMessage[]
sendEmoji(recipient: Player | typeof AllPlayers, emoji: string): void
// Communication
canSendEmoji(recipient: Player | typeof AllPlayers): boolean;
outgoingEmojis(): EmojiMessage[];
sendEmoji(recipient: Player | typeof AllPlayers, emoji: string): void;
// Trading
canDonate(recipient: Player): boolean
donate(recipient: Player, troops: number): void
// Trading
canDonate(recipient: Player): boolean;
donate(recipient: Player, troops: number): void;
// Misc
executions(): Execution[]
toUpdate(): PlayerUpdate
playerProfile(): PlayerProfile
canBoat(tile: TileRef): boolean
canAttack(tile: TileRef)
// Misc
executions(): Execution[];
toUpdate(): PlayerUpdate;
playerProfile(): PlayerProfile;
canBoat(tile: TileRef): boolean;
canAttack(tile: TileRef);
}
export interface Game extends GameMap {
// Map & Dimensions
isOnMap(cell: Cell): boolean
width(): number
height(): number
map(): GameMap
miniMap(): GameMap
forEachTile(fn: (tile: TileRef) => void): void
// Map & Dimensions
isOnMap(cell: Cell): boolean;
width(): number;
height(): number;
map(): GameMap;
miniMap(): GameMap;
forEachTile(fn: (tile: TileRef) => void): void;
// Player Management
player(id: PlayerID): Player
players(): Player[]
allPlayers(): Player[]
playerByClientID(id: ClientID): Player | null
playerBySmallID(id: number): Player | TerraNullius
hasPlayer(id: PlayerID): boolean
addPlayer(playerInfo: PlayerInfo, manpower: number): Player
terraNullius(): TerraNullius
owner(ref: TileRef): Player | TerraNullius
// Player Management
player(id: PlayerID): Player;
players(): Player[];
allPlayers(): Player[];
playerByClientID(id: ClientID): Player | null;
playerBySmallID(id: number): Player | TerraNullius;
hasPlayer(id: PlayerID): boolean;
addPlayer(playerInfo: PlayerInfo, manpower: number): Player;
terraNullius(): TerraNullius;
owner(ref: TileRef): Player | TerraNullius;
// Game State
ticks(): Tick
inSpawnPhase(): boolean
executeNextTick(): GameUpdates
setWinner(winner: Player): void
config(): Config
// Game State
ticks(): Tick;
inSpawnPhase(): boolean;
executeNextTick(): GameUpdates;
setWinner(winner: Player): void;
config(): Config;
// Units
units(...types: UnitType[]): Unit[]
unitInfo(type: UnitType): UnitInfo
addTileDefenseBonus(tile: TileRef, unit: Unit, amount: number): DefenseBonus
removeTileDefenseBonus(bonus: DefenseBonus): void
// Units
units(...types: UnitType[]): Unit[];
unitInfo(type: UnitType): UnitInfo;
addTileDefenseBonus(tile: TileRef, unit: Unit, amount: number): DefenseBonus;
removeTileDefenseBonus(bonus: DefenseBonus): void;
// Events & Messages
executions(): Execution[]
addExecution(...exec: Execution[]): void
displayMessage(message: string, type: MessageType, playerID: PlayerID | null): void
// Events & Messages
executions(): Execution[];
addExecution(...exec: Execution[]): void;
displayMessage(
message: string,
type: MessageType,
playerID: PlayerID | null,
): void;
// Nations
nations(): Nation[]
// Nations
nations(): Nation[];
numTilesWithFallout(): number
numTilesWithFallout(): number;
}
export interface PlayerActions {
canBoat: boolean
canAttack: boolean
buildableUnits: UnitType[]
canSendEmojiAllPlayers: boolean
interaction?: PlayerInteraction
canBoat: boolean;
canAttack: boolean;
buildableUnits: UnitType[];
canSendEmojiAllPlayers: boolean;
interaction?: PlayerInteraction;
}
export interface PlayerProfile {
relations: Record<number, Relation>
alliances: number[]
relations: Record<number, Relation>;
alliances: number[];
}
export interface PlayerInteraction {
sharedBorder: boolean
canSendEmoji: boolean
canSendAllianceRequest: boolean
canBreakAlliance: boolean
canTarget: boolean
canDonate: boolean
sharedBorder: boolean;
canSendEmoji: boolean;
canSendAllianceRequest: boolean;
canBreakAlliance: boolean;
canTarget: boolean;
canDonate: boolean;
}
export interface EmojiMessage {
message: string
senderID: number
recipientID: number | typeof AllPlayers
createdAt: Tick
message: string;
senderID: number;
recipientID: number | typeof AllPlayers;
createdAt: Tick;
}
export enum MessageType {
SUCCESS,
INFO,
WARN,
ERROR
SUCCESS,
INFO,
WARN,
ERROR,
}
export interface NameViewData {
x: number;
y: number;
size: number;
x: number;
y: number;
size: number;
}
+589 -465
View File
File diff suppressed because it is too large Load Diff
+304 -266
View File
@@ -1,294 +1,332 @@
import { Cell, TerrainType } from "./Game";
export type TileRef = number;
export type TileUpdate = bigint
export type TileUpdate = bigint;
export interface GameMap {
ref(x: number, y: number): TileRef
ref(x: number, y: number): TileRef;
x(ref: TileRef): number
y(ref: TileRef): number
cell(ref: TileRef): Cell
width(): number
height(): number
numLandTiles(): number
x(ref: TileRef): number;
y(ref: TileRef): number;
cell(ref: TileRef): Cell;
width(): number;
height(): number;
numLandTiles(): number;
isValidCoord(x: number, y: number): boolean
// Terrain getters (immutable)
isLand(ref: TileRef): boolean
isOceanShore(ref: TileRef): boolean
isOcean(ref: TileRef): boolean
isShoreline(ref: TileRef): boolean
magnitude(ref: TileRef): number
// State getters and setters (mutable)
ownerID(ref: TileRef): number
hasOwner(ref: TileRef): boolean
isValidCoord(x: number, y: number): boolean;
// Terrain getters (immutable)
isLand(ref: TileRef): boolean;
isOceanShore(ref: TileRef): boolean;
isOcean(ref: TileRef): boolean;
isShoreline(ref: TileRef): boolean;
magnitude(ref: TileRef): number;
// State getters and setters (mutable)
ownerID(ref: TileRef): number;
hasOwner(ref: TileRef): boolean;
setOwnerID(ref: TileRef, playerId: number): void
hasFallout(ref: TileRef): boolean
setFallout(ref: TileRef, value: boolean): void
isBorder(ref: TileRef): boolean
neighbors(ref: TileRef): TileRef[]
isWater(ref: TileRef): boolean
isLake(ref: TileRef): boolean
isShore(ref: TileRef): boolean
cost(ref: TileRef): number
terrainType(ref: TileRef): TerrainType
forEachTile(fn: (tile: TileRef) => void): void
setOwnerID(ref: TileRef, playerId: number): void;
hasFallout(ref: TileRef): boolean;
setFallout(ref: TileRef, value: boolean): void;
isBorder(ref: TileRef): boolean;
neighbors(ref: TileRef): TileRef[];
isWater(ref: TileRef): boolean;
isLake(ref: TileRef): boolean;
isShore(ref: TileRef): boolean;
cost(ref: TileRef): number;
terrainType(ref: TileRef): TerrainType;
forEachTile(fn: (tile: TileRef) => void): void;
manhattanDist(c1: TileRef, c2: TileRef): number;
euclideanDist(c1: TileRef, c2: TileRef): number;
bfs(
tile: TileRef,
filter: (gm: GameMap, tile: TileRef) => boolean,
): Set<TileRef>;
manhattanDist(c1: TileRef, c2: TileRef): number
euclideanDist(c1: TileRef, c2: TileRef): number
bfs(tile: TileRef, filter: (gm: GameMap, tile: TileRef) => boolean): Set<TileRef>
toTileUpdate(tile: TileRef): bigint;
updateTile(tu: TileUpdate): TileRef;
toTileUpdate(tile: TileRef): bigint
updateTile(tu: TileUpdate): TileRef
numTilesWithFallout(): number
numTilesWithFallout(): number;
}
export class GameMapImpl implements GameMap {
private _numTilesWithFallout = 0
private _numTilesWithFallout = 0;
private readonly terrain: Uint8Array; // Immutable terrain data
private readonly state: Uint16Array; // Mutable game state
private readonly width_: number;
private readonly height_: number;
private readonly terrain: Uint8Array; // Immutable terrain data
private readonly state: Uint16Array; // Mutable game state
private readonly width_: number;
private readonly height_: number;
// Terrain bits (Uint8Array)
private static readonly IS_LAND_BIT = 7;
private static readonly SHORELINE_BIT = 6;
private static readonly OCEAN_BIT = 5;
private static readonly MAGNITUDE_OFFSET = 4; // Uses bits 3-7 (5 bits)
private static readonly MAGNITUDE_MASK = 0x1F; // 11111 in binary
// Terrain bits (Uint8Array)
private static readonly IS_LAND_BIT = 7;
private static readonly SHORELINE_BIT = 6;
private static readonly OCEAN_BIT = 5;
private static readonly MAGNITUDE_OFFSET = 4; // Uses bits 3-7 (5 bits)
private static readonly MAGNITUDE_MASK = 0x1f; // 11111 in binary
// State bits (Uint16Array)
private static readonly PLAYER_ID_OFFSET = 0; // Uses bits 0-11 (12 bits)
private static readonly PLAYER_ID_MASK = 0xFFF;
private static readonly FALLOUT_BIT = 13;
private static readonly DEFENSE_BONUS_BIT = 14;
// Bit 15 still reserved
// State bits (Uint16Array)
private static readonly PLAYER_ID_OFFSET = 0; // Uses bits 0-11 (12 bits)
private static readonly PLAYER_ID_MASK = 0xfff;
private static readonly FALLOUT_BIT = 13;
private static readonly DEFENSE_BONUS_BIT = 14;
// Bit 15 still reserved
constructor(width: number, height: number, terrainData: Uint8Array, private numLandTiles_: number) {
if (terrainData.length !== width * height) {
throw new Error(`Terrain data length ${terrainData.length} doesn't match dimensions ${width}x${height}`);
constructor(
width: number,
height: number,
terrainData: Uint8Array,
private numLandTiles_: number,
) {
if (terrainData.length !== width * height) {
throw new Error(
`Terrain data length ${terrainData.length} doesn't match dimensions ${width}x${height}`,
);
}
this.width_ = width;
this.height_ = height;
this.terrain = terrainData;
this.state = new Uint16Array(width * height);
}
numTilesWithFallout(): number {
return this._numTilesWithFallout;
}
ref(x: number, y: number): TileRef {
if (!this.isValidCoord(x, y)) {
throw new Error(`Invalid coordinates: ${x},${y}`);
}
return y * this.width_ + x;
}
x(ref: TileRef): number {
return ref % this.width_;
}
y(ref: TileRef): number {
return Math.floor(ref / this.width_);
}
cell(ref: TileRef): Cell {
return new Cell(this.x(ref), this.y(ref));
}
width(): number {
return this.width_;
}
height(): number {
return this.height_;
}
numLandTiles(): number {
return this.numLandTiles_;
}
isValidCoord(x: number, y: number): boolean {
return x >= 0 && x < this.width_ && y >= 0 && y < this.height_;
}
// Terrain getters (immutable)
isLand(ref: TileRef): boolean {
return Boolean(this.terrain[ref] & (1 << GameMapImpl.IS_LAND_BIT));
}
isOceanShore(ref: TileRef): boolean {
return (
this.isLand(ref) && this.neighbors(ref).some((tr) => this.isOcean(tr))
);
}
isOcean(ref: TileRef): boolean {
return Boolean(this.terrain[ref] & (1 << GameMapImpl.OCEAN_BIT));
}
isShoreline(ref: TileRef): boolean {
return Boolean(this.terrain[ref] & (1 << GameMapImpl.SHORELINE_BIT));
}
magnitude(ref: TileRef): number {
return this.terrain[ref] & GameMapImpl.MAGNITUDE_MASK;
}
// State getters and setters (mutable)
ownerID(ref: TileRef): number {
return this.state[ref] & GameMapImpl.PLAYER_ID_MASK;
}
hasOwner(ref: TileRef): boolean {
return this.ownerID(ref) != 0;
}
setOwnerID(ref: TileRef, playerId: number): void {
if (playerId > GameMapImpl.PLAYER_ID_MASK) {
throw new Error(
`Player ID ${playerId} exceeds maximum value ${GameMapImpl.PLAYER_ID_MASK}`,
);
}
this.state[ref] =
(this.state[ref] & ~GameMapImpl.PLAYER_ID_MASK) | playerId;
}
hasFallout(ref: TileRef): boolean {
return Boolean(this.state[ref] & (1 << GameMapImpl.FALLOUT_BIT));
}
setFallout(ref: TileRef, value: boolean): void {
const existingFallout = this.hasFallout(ref);
if (value) {
if (!existingFallout) {
this._numTilesWithFallout++;
this.state[ref] |= 1 << GameMapImpl.FALLOUT_BIT;
}
} else {
if (existingFallout) {
this._numTilesWithFallout--;
this.state[ref] &= ~(1 << GameMapImpl.FALLOUT_BIT);
}
}
}
isBorder(ref: TileRef): boolean {
return this.neighbors(ref).some(
(tr) => this.ownerID(tr) != this.ownerID(ref),
);
}
hasDefenseBonus(ref: TileRef): boolean {
return Boolean(this.state[ref] & (1 << GameMapImpl.DEFENSE_BONUS_BIT));
}
setDefenseBonus(ref: TileRef, value: boolean): void {
if (value) {
this.state[ref] |= 1 << GameMapImpl.DEFENSE_BONUS_BIT;
} else {
this.state[ref] &= ~(1 << GameMapImpl.DEFENSE_BONUS_BIT);
}
}
// Helper methods
isWater(ref: TileRef): boolean {
return !this.isLand(ref);
}
isLake(ref: TileRef): boolean {
return !this.isLand(ref) && !this.isOcean(ref);
}
isShore(ref: TileRef): boolean {
return this.isLand(ref) && this.isShoreline(ref);
}
cost(ref: TileRef): number {
return this.magnitude(ref) < 10 ? 2 : 1;
}
terrainType(ref: TileRef): TerrainType {
if (this.isLand(ref)) {
const magnitude = this.magnitude(ref);
if (magnitude < 10) return TerrainType.Plains;
if (magnitude < 20) return TerrainType.Highland;
return TerrainType.Mountain;
}
return this.isOcean(ref) ? TerrainType.Ocean : TerrainType.Lake;
}
neighbors(ref: TileRef): TileRef[] {
const neighbors: TileRef[] = [];
const w = this.width_;
if (ref >= w) neighbors.push(ref - w);
if (ref < (this.height_ - 1) * w) neighbors.push(ref + w);
if (ref % w !== 0) neighbors.push(ref - 1);
if (ref % w !== w - 1) neighbors.push(ref + 1);
for (const n of neighbors) {
this.ref(this.x(n), this.y(n));
}
return neighbors;
}
forEachTile(fn: (tile: TileRef) => void): void {
for (let x = 0; x < this.width_; x++) {
for (let y = 0; y < this.height_; y++) {
fn(this.ref(x, y));
}
}
}
manhattanDist(c1: TileRef, c2: TileRef): number {
return (
Math.abs(this.x(c1) - this.x(c2)) + Math.abs(this.y(c1) - this.y(c2))
);
}
euclideanDist(c1: TileRef, c2: TileRef): number {
return Math.sqrt(
Math.pow(this.x(c1) - this.x(c2), 2) +
Math.pow(this.y(c1) - this.y(c2), 2),
);
}
bfs(
tile: TileRef,
filter: (gm: GameMap, tile: TileRef) => boolean,
): Set<TileRef> {
const seen = new Set<TileRef>();
const q: TileRef[] = [];
q.push(tile);
while (q.length > 0) {
const curr = q.pop();
seen.add(curr);
for (const n of this.neighbors(curr)) {
if (!seen.has(n) && filter(this, n)) {
q.push(n);
}
this.width_ = width;
this.height_ = height;
this.terrain = terrainData;
this.state = new Uint16Array(width * height);
}
}
numTilesWithFallout(): number {
return this._numTilesWithFallout
return seen;
}
toTileUpdate(tile: TileRef): bigint {
// Pack the tile reference and state into a bigint
// Format: [32 bits for tile reference][16 bits for state]
return (BigInt(tile) << 16n) | BigInt(this.state[tile]);
}
updateTile(tu: TileUpdate): TileRef {
// Extract tile reference and state from the TileUpdate
// Last 16 bits are state, rest is tile reference
const tileRef = Number(tu >> 16n);
const state = Number(tu & 0xffffn);
const existingFallout = this.hasFallout(tileRef);
this.state[tileRef] = state;
const newFallout = this.hasFallout(tileRef);
if (existingFallout && !newFallout) {
this._numTilesWithFallout--;
}
if (!existingFallout && newFallout) {
this._numTilesWithFallout++;
}
ref(x: number, y: number): TileRef {
if (!this.isValidCoord(x, y)) {
throw new Error(`Invalid coordinates: ${x},${y}`);
}
return y * this.width_ + x;
}
x(ref: TileRef): number {
return ref % this.width_;
}
y(ref: TileRef): number {
return Math.floor(ref / this.width_);
}
cell(ref: TileRef): Cell {
return new Cell(this.x(ref), this.y(ref))
}
width(): number { return this.width_; }
height(): number { return this.height_; }
numLandTiles(): number { return this.numLandTiles_; }
isValidCoord(x: number, y: number): boolean {
return x >= 0 && x < this.width_ && y >= 0 && y < this.height_;
}
// Terrain getters (immutable)
isLand(ref: TileRef): boolean {
return Boolean(this.terrain[ref] & (1 << GameMapImpl.IS_LAND_BIT));
}
isOceanShore(ref: TileRef): boolean {
return this.isLand(ref) && this.neighbors(ref).some(tr => this.isOcean(tr))
}
isOcean(ref: TileRef): boolean {
return Boolean(this.terrain[ref] & (1 << GameMapImpl.OCEAN_BIT));
}
isShoreline(ref: TileRef): boolean {
return Boolean(this.terrain[ref] & (1 << GameMapImpl.SHORELINE_BIT));
}
magnitude(ref: TileRef): number {
return this.terrain[ref] & GameMapImpl.MAGNITUDE_MASK;
}
// State getters and setters (mutable)
ownerID(ref: TileRef): number {
return this.state[ref] & GameMapImpl.PLAYER_ID_MASK;
}
hasOwner(ref: TileRef): boolean {
return this.ownerID(ref) != 0
}
setOwnerID(ref: TileRef, playerId: number): void {
if (playerId > GameMapImpl.PLAYER_ID_MASK) {
throw new Error(`Player ID ${playerId} exceeds maximum value ${GameMapImpl.PLAYER_ID_MASK}`);
}
this.state[ref] = (this.state[ref] & ~GameMapImpl.PLAYER_ID_MASK) | playerId;
}
hasFallout(ref: TileRef): boolean {
return Boolean(this.state[ref] & (1 << GameMapImpl.FALLOUT_BIT));
}
setFallout(ref: TileRef, value: boolean): void {
const existingFallout = this.hasFallout(ref)
if (value) {
if (!existingFallout) {
this._numTilesWithFallout++
this.state[ref] |= 1 << GameMapImpl.FALLOUT_BIT;
}
} else {
if (existingFallout) {
this._numTilesWithFallout--
this.state[ref] &= ~(1 << GameMapImpl.FALLOUT_BIT);
}
}
}
isBorder(ref: TileRef): boolean {
return this.neighbors(ref).some(tr => this.ownerID(tr) != this.ownerID(ref))
}
hasDefenseBonus(ref: TileRef): boolean {
return Boolean(this.state[ref] & (1 << GameMapImpl.DEFENSE_BONUS_BIT));
}
setDefenseBonus(ref: TileRef, value: boolean): void {
if (value) {
this.state[ref] |= 1 << GameMapImpl.DEFENSE_BONUS_BIT;
} else {
this.state[ref] &= ~(1 << GameMapImpl.DEFENSE_BONUS_BIT);
}
}
// Helper methods
isWater(ref: TileRef): boolean {
return !this.isLand(ref);
}
isLake(ref: TileRef): boolean {
return !this.isLand(ref) && !this.isOcean(ref);
}
isShore(ref: TileRef): boolean {
return this.isLand(ref) && this.isShoreline(ref);
}
cost(ref: TileRef): number {
return this.magnitude(ref) < 10 ? 2 : 1;
}
terrainType(ref: TileRef): TerrainType {
if (this.isLand(ref)) {
const magnitude = this.magnitude(ref);
if (magnitude < 10) return TerrainType.Plains;
if (magnitude < 20) return TerrainType.Highland;
return TerrainType.Mountain;
}
return this.isOcean(ref) ? TerrainType.Ocean : TerrainType.Lake;
}
neighbors(ref: TileRef): TileRef[] {
const neighbors: TileRef[] = [];
const w = this.width_;
if (ref >= w) neighbors.push(ref - w);
if (ref < (this.height_ - 1) * w) neighbors.push(ref + w);
if (ref % w !== 0) neighbors.push(ref - 1);
if (ref % w !== w - 1) neighbors.push(ref + 1);
for (const n of neighbors) {
(this.ref(this.x(n), this.y(n)))
}
return neighbors;
}
forEachTile(fn: (tile: TileRef) => void): void {
for (let x = 0; x < this.width_; x++) {
for (let y = 0; y < this.height_; y++) {
fn(this.ref(x, y))
}
}
}
manhattanDist(c1: TileRef, c2: TileRef): number {
return Math.abs(this.x(c1) - this.x(c2)) + Math.abs(this.y(c1) - this.y(c2));
}
euclideanDist(c1: TileRef, c2: TileRef): number {
return Math.sqrt(Math.pow(this.x(c1) - this.x(c2), 2) + Math.pow(this.y(c1) - this.y(c2), 2));
}
bfs(tile: TileRef, filter: (gm: GameMap, tile: TileRef) => boolean): Set<TileRef> {
const seen = new Set<TileRef>()
const q: TileRef[] = []
q.push(tile)
while (q.length > 0) {
const curr = q.pop()
seen.add(curr)
for (const n of this.neighbors(curr)) {
if (!seen.has(n) && filter(this, n)) {
q.push(n)
}
}
}
return seen
}
toTileUpdate(tile: TileRef): bigint {
// Pack the tile reference and state into a bigint
// Format: [32 bits for tile reference][16 bits for state]
return (BigInt(tile) << 16n) | BigInt(this.state[tile]);
}
updateTile(tu: TileUpdate): TileRef {
// Extract tile reference and state from the TileUpdate
// Last 16 bits are state, rest is tile reference
const tileRef = Number(tu >> 16n);
const state = Number(tu & 0xFFFFn);
const existingFallout = this.hasFallout(tileRef)
this.state[tileRef] = state;
const newFallout = this.hasFallout(tileRef)
if (existingFallout && !newFallout) {
this._numTilesWithFallout--
}
if (!existingFallout && newFallout) {
this._numTilesWithFallout++
}
return tileRef;
}
return tileRef;
}
}
export function euclDistFN(root: TileRef, dist: number): (gm: GameMap, tile: TileRef) => boolean {
return (gm: GameMap, n: TileRef) => gm.euclideanDist(root, n) <= dist;
export function euclDistFN(
root: TileRef,
dist: number,
): (gm: GameMap, tile: TileRef) => boolean {
return (gm: GameMap, n: TileRef) => gm.euclideanDist(root, n) <= dist;
}
export function manhattanDistFN(root: TileRef, dist: number): (gm: GameMap, tile: TileRef) => boolean {
return (gm: GameMap, n: TileRef) => gm.manhattanDist(root, n) <= dist;
export function manhattanDistFN(
root: TileRef,
dist: number,
): (gm: GameMap, tile: TileRef) => boolean {
return (gm: GameMap, n: TileRef) => gm.manhattanDist(root, n) <= dist;
}
export function andFN(x: (gm: GameMap, tile: TileRef) => boolean, y: (gm: GameMap, tile: TileRef) => boolean): (gm: GameMap, tile: TileRef) => boolean {
return (gm: GameMap, tile: TileRef) => x(gm, tile) && y(gm, tile)
}
export function andFN(
x: (gm: GameMap, tile: TileRef) => boolean,
y: (gm: GameMap, tile: TileRef) => boolean,
): (gm: GameMap, tile: TileRef) => boolean {
return (gm: GameMap, tile: TileRef) => x(gm, tile) && y(gm, tile);
}
+96 -87
View File
@@ -1,130 +1,139 @@
import { ClientID } from '../Schemas';
import { EmojiMessage, GameUpdates, MapPos, MessageType, NameViewData, PlayerID, PlayerType, Tick, UnitType } from './Game';
import { TileUpdate } from './GameMap';
import { ClientID } from "../Schemas";
import {
EmojiMessage,
GameUpdates,
MapPos,
MessageType,
NameViewData,
PlayerID,
PlayerType,
Tick,
UnitType,
} from "./Game";
import { TileUpdate } from "./GameMap";
export interface GameUpdateViewData {
tick: number;
updates: GameUpdates;
packedTileUpdates: BigUint64Array;
playerNameViewData: Record<number, NameViewData>;
tick: number;
updates: GameUpdates;
packedTileUpdates: BigUint64Array;
playerNameViewData: Record<number, NameViewData>;
}
export interface ErrorUpdate {
errMsg: string
stack?: string
errMsg: string;
stack?: string;
}
export enum GameUpdateType {
Tile,
Unit,
Player,
DisplayEvent,
AllianceRequest,
AllianceRequestReply,
BrokeAlliance,
AllianceExpired,
TargetPlayer,
EmojiUpdate,
WinUpdate
Tile,
Unit,
Player,
DisplayEvent,
AllianceRequest,
AllianceRequestReply,
BrokeAlliance,
AllianceExpired,
TargetPlayer,
EmojiUpdate,
WinUpdate,
}
export type GameUpdate = TileUpdateWrapper |
UnitUpdate |
PlayerUpdate |
AllianceRequestUpdate |
AllianceRequestReplyUpdate |
BrokeAllianceUpdate |
AllianceExpiredUpdate |
DisplayMessageUpdate |
TargetPlayerUpdate |
EmojiUpdate |
WinUpdate
export type GameUpdate =
| TileUpdateWrapper
| UnitUpdate
| PlayerUpdate
| AllianceRequestUpdate
| AllianceRequestReplyUpdate
| BrokeAllianceUpdate
| AllianceExpiredUpdate
| DisplayMessageUpdate
| TargetPlayerUpdate
| EmojiUpdate
| WinUpdate;
export interface TileUpdateWrapper {
type: GameUpdateType.Tile
update: TileUpdate
type: GameUpdateType.Tile;
update: TileUpdate;
}
export interface UnitUpdate {
type: GameUpdateType.Unit
unitType: UnitType
troops: number
id: number
ownerID: number
pos: MapPos
lastPos: MapPos
isActive: boolean
health?: number
type: GameUpdateType.Unit;
unitType: UnitType;
troops: number;
id: number;
ownerID: number;
pos: MapPos;
lastPos: MapPos;
isActive: boolean;
health?: number;
}
export interface PlayerUpdate {
type: GameUpdateType.Player
nameViewData?: NameViewData
clientID: ClientID
name: string
displayName: string
id: PlayerID
smallID: number
playerType: PlayerType
isAlive: boolean
tilesOwned: number
gold: number
population: number
workers: number
troops: number
targetTroopRatio: number
allies: number[]
isTraitor: boolean
targets: number[]
outgoingEmojis: EmojiMessage[]
type: GameUpdateType.Player;
nameViewData?: NameViewData;
clientID: ClientID;
name: string;
displayName: string;
id: PlayerID;
smallID: number;
playerType: PlayerType;
isAlive: boolean;
tilesOwned: number;
gold: number;
population: number;
workers: number;
troops: number;
targetTroopRatio: number;
allies: number[];
isTraitor: boolean;
targets: number[];
outgoingEmojis: EmojiMessage[];
}
export interface AllianceRequestUpdate {
type: GameUpdateType.AllianceRequest
requestorID: number
recipientID: number
createdAt: Tick
type: GameUpdateType.AllianceRequest;
requestorID: number;
recipientID: number;
createdAt: Tick;
}
export interface AllianceRequestReplyUpdate {
type: GameUpdateType.AllianceRequestReply
request: AllianceRequestUpdate
accepted: boolean
type: GameUpdateType.AllianceRequestReply;
request: AllianceRequestUpdate;
accepted: boolean;
}
export interface BrokeAllianceUpdate {
type: GameUpdateType.BrokeAlliance
traitorID: number
betrayedID: number
type: GameUpdateType.BrokeAlliance;
traitorID: number;
betrayedID: number;
}
export interface AllianceExpiredUpdate {
type: GameUpdateType.AllianceExpired
player1ID: number
player2ID: number
type: GameUpdateType.AllianceExpired;
player1ID: number;
player2ID: number;
}
export interface TargetPlayerUpdate {
type: GameUpdateType.TargetPlayer
playerID: number
targetID: number
type: GameUpdateType.TargetPlayer;
playerID: number;
targetID: number;
}
export interface EmojiUpdate {
type: GameUpdateType.EmojiUpdate
emoji: EmojiMessage
type: GameUpdateType.EmojiUpdate;
emoji: EmojiMessage;
}
export interface DisplayMessageUpdate {
type: GameUpdateType.DisplayEvent
message: string
messageType: MessageType
playerID: number | null
type: GameUpdateType.DisplayEvent;
message: string;
messageType: MessageType;
playerID: number | null;
}
export interface WinUpdate {
type: GameUpdateType.WinUpdate
winnerID: number
type: GameUpdateType.WinUpdate;
winnerID: number;
}
+383 -269
View File
@@ -1,318 +1,432 @@
import { GameUpdates, MapPos, MessageType, Player, PlayerActions, PlayerProfile, Unit } from './Game';
import {
GameUpdates,
MapPos,
MessageType,
Player,
PlayerActions,
PlayerProfile,
Unit,
} from "./Game";
import { PlayerUpdate } from "./GameUpdates";
import { UnitUpdate } from "./GameUpdates";
import { NameViewData } from './Game';
import { NameViewData } from "./Game";
import { GameUpdateType } from "./GameUpdates";
import { Config } from "../configuration/Config";
import { Alliance, AllianceRequest, AllPlayers, Cell, DefenseBonus, EmojiMessage, Game, Gold, Nation, PlayerID, PlayerInfo, PlayerType, Relation, TerrainType, TerraNullius, Tick, UnitInfo, UnitType } from "./Game";
import {
Alliance,
AllianceRequest,
AllPlayers,
Cell,
DefenseBonus,
EmojiMessage,
Game,
Gold,
Nation,
PlayerID,
PlayerInfo,
PlayerType,
Relation,
TerrainType,
TerraNullius,
Tick,
UnitInfo,
UnitType,
} from "./Game";
import { ClientID } from "../Schemas";
import { TerraNulliusImpl } from './TerraNulliusImpl';
import { WorkerClient } from '../worker/WorkerClient';
import { GameMap, GameMapImpl, TileRef, TileUpdate } from './GameMap';
import { GameUpdateViewData } from './GameUpdates';
import { TerraNulliusImpl } from "./TerraNulliusImpl";
import { WorkerClient } from "../worker/WorkerClient";
import { GameMap, GameMapImpl, TileRef, TileUpdate } from "./GameMap";
import { GameUpdateViewData } from "./GameUpdates";
export class UnitView {
public _wasUpdated = true
public lastPos: MapPos[] = []
public _wasUpdated = true;
public lastPos: MapPos[] = [];
constructor(private gameView: GameView, private data: UnitUpdate) {
this.lastPos.push(data.pos)
}
constructor(
private gameView: GameView,
private data: UnitUpdate,
) {
this.lastPos.push(data.pos);
}
wasUpdated(): boolean {
return this._wasUpdated
}
wasUpdated(): boolean {
return this._wasUpdated;
}
lastTiles(): TileRef[] {
return this.lastPos.map(pos => this.gameView.ref(pos.x, pos.y))
}
lastTiles(): TileRef[] {
return this.lastPos.map((pos) => this.gameView.ref(pos.x, pos.y));
}
lastTile(): TileRef {
if (this.lastPos.length == 0) {
return this.gameView.ref(this.data.pos.x, this.data.pos.y)
}
return this.gameView.ref(this.lastPos[0].x, this.lastPos[0].y)
lastTile(): TileRef {
if (this.lastPos.length == 0) {
return this.gameView.ref(this.data.pos.x, this.data.pos.y);
}
return this.gameView.ref(this.lastPos[0].x, this.lastPos[0].y);
}
update(data: UnitUpdate) {
this.lastPos.push(data.pos)
this._wasUpdated = true
this.data = data
}
update(data: UnitUpdate) {
this.lastPos.push(data.pos);
this._wasUpdated = true;
this.data = data;
}
id(): number {
return this.data.id
}
id(): number {
return this.data.id;
}
type(): UnitType {
return this.data.unitType
}
troops(): number {
return this.data.troops
}
tile(): TileRef {
return this.gameView.ref(this.data.pos.x, this.data.pos.y)
}
owner(): PlayerView {
return this.gameView.playerBySmallID(this.data.ownerID) as PlayerView
}
isActive(): boolean {
return this.data.isActive
}
hasHealth(): boolean {
return this.data.health != undefined
}
health(): number {
return this.data.health ?? 0
}
type(): UnitType {
return this.data.unitType;
}
troops(): number {
return this.data.troops;
}
tile(): TileRef {
return this.gameView.ref(this.data.pos.x, this.data.pos.y);
}
owner(): PlayerView {
return this.gameView.playerBySmallID(this.data.ownerID) as PlayerView;
}
isActive(): boolean {
return this.data.isActive;
}
hasHealth(): boolean {
return this.data.health != undefined;
}
health(): number {
return this.data.health ?? 0;
}
}
export class PlayerView {
constructor(
private game: GameView,
public data: PlayerUpdate,
public nameData: NameViewData,
) {}
constructor(private game: GameView, public data: PlayerUpdate, public nameData: NameViewData) { }
async actions(tile: TileRef): Promise<PlayerActions> {
return this.game.worker.playerInteraction(
this.id(),
this.game.x(tile),
this.game.y(tile),
);
}
async actions(tile: TileRef): Promise<PlayerActions> {
return this.game.worker.playerInteraction(this.id(), this.game.x(tile), this.game.y(tile))
}
units(): UnitView[] {
return this.game
.units()
.filter((u) => u.owner().smallID() == this.smallID());
}
units(): UnitView[] {
return this.game.units().filter(u => u.owner().smallID() == this.smallID())
}
nameLocation(): NameViewData {
return this.nameData;
}
nameLocation(): NameViewData {
return this.nameData
}
smallID(): number {
return this.data.smallID;
}
name(): string {
return this.data.name;
}
displayName(): string {
return this.data.displayName;
}
clientID(): ClientID {
return this.data.clientID;
}
id(): PlayerID {
return this.data.id;
}
type(): PlayerType {
return this.data.playerType;
}
isAlive(): boolean {
return this.data.isAlive;
}
isPlayer(): this is Player {
return true;
}
numTilesOwned(): number {
return this.data.tilesOwned;
}
allies(): PlayerView[] {
return this.data.allies.map(
(a) => this.game.playerBySmallID(a) as PlayerView,
);
}
targets(): PlayerView[] {
return this.data.targets.map(
(id) => this.game.playerBySmallID(id) as PlayerView,
);
}
gold(): Gold {
return this.data.gold;
}
population(): number {
return this.data.population;
}
workers(): number {
return this.data.workers;
}
targetTroopRatio(): number {
return this.data.targetTroopRatio;
}
troops(): number {
return this.data.troops;
}
smallID(): number {
return this.data.smallID
}
name(): string {
return this.data.name
}
displayName(): string {
return this.data.displayName
}
clientID(): ClientID {
return this.data.clientID
}
id(): PlayerID {
return this.data.id
}
type(): PlayerType {
return this.data.playerType
}
isAlive(): boolean {
return this.data.isAlive
}
isPlayer(): this is Player {
return true
}
numTilesOwned(): number {
return this.data.tilesOwned
}
allies(): PlayerView[] {
return this.data.allies.map(a => this.game.playerBySmallID(a) as PlayerView)
}
targets(): PlayerView[] {
return this.data.targets.map(id => this.game.playerBySmallID(id) as PlayerView)
}
gold(): Gold {
return this.data.gold
}
population(): number {
return this.data.population
}
workers(): number {
return this.data.workers
}
targetTroopRatio(): number {
return this.data.targetTroopRatio
}
troops(): number {
return this.data.troops
}
isAlliedWith(other: PlayerView): boolean {
return this.data.allies.some((n) => other.smallID() == n);
}
isAlliedWith(other: PlayerView): boolean {
return this.data.allies.some(n => other.smallID() == n)
}
profile(): Promise<PlayerProfile> {
return this.game.worker.playerProfile(this.smallID());
}
profile(): Promise<PlayerProfile> {
return this.game.worker.playerProfile(this.smallID())
}
transitiveTargets(): PlayerView[] {
return [...this.targets(), ...this.allies().flatMap((p) => p.targets())];
}
transitiveTargets(): PlayerView[] {
return [...this.targets(), ...this.allies().flatMap(p => p.targets())]
}
isTraitor(): boolean {
return this.data.isTraitor
}
outgoingEmojis(): EmojiMessage[] {
return this.data.outgoingEmojis
}
info(): PlayerInfo {
return new PlayerInfo(this.name(), this.type(), this.clientID(), this.id())
}
isTraitor(): boolean {
return this.data.isTraitor;
}
outgoingEmojis(): EmojiMessage[] {
return this.data.outgoingEmojis;
}
info(): PlayerInfo {
return new PlayerInfo(this.name(), this.type(), this.clientID(), this.id());
}
}
export class GameView implements GameMap {
private lastUpdate: GameUpdateViewData
private smallIDToID = new Map<number, PlayerID>()
private _players = new Map<PlayerID, PlayerView>()
private _units = new Map<number, UnitView>()
private updatedTiles: TileRef[] = []
private lastUpdate: GameUpdateViewData;
private smallIDToID = new Map<number, PlayerID>();
private _players = new Map<PlayerID, PlayerView>();
private _units = new Map<number, UnitView>();
private updatedTiles: TileRef[] = [];
private _myPlayer: PlayerView | null = null;
private _myPlayer: PlayerView | null = null
constructor(
public worker: WorkerClient,
private _config: Config,
private _map: GameMap,
private _myClientID: ClientID,
) {
this.lastUpdate = {
tick: 0,
packedTileUpdates: new BigUint64Array([]),
// TODO: make this empty map instead of null?
updates: null,
playerNameViewData: {},
};
}
constructor(
public worker: WorkerClient,
private _config: Config,
private _map: GameMap,
private _myClientID: ClientID
) {
this.lastUpdate = {
tick: 0,
packedTileUpdates: new BigUint64Array([]),
// TODO: make this empty map instead of null?
updates: null,
playerNameViewData: {},
}
}
public updatesSinceLastTick(): GameUpdates {
return this.lastUpdate.updates;
}
public updatesSinceLastTick(): GameUpdates {
return this.lastUpdate.updates
}
public update(gu: GameUpdateViewData) {
this.lastUpdate = gu;
public update(gu: GameUpdateViewData) {
this.lastUpdate = gu
this.updatedTiles = [];
this.lastUpdate.packedTileUpdates.forEach((tu) => {
this.updatedTiles.push(this.updateTile(tu));
});
this.updatedTiles = []
this.lastUpdate.packedTileUpdates.forEach(tu => {
this.updatedTiles.push(this.updateTile(tu))
})
gu.updates[GameUpdateType.Player].forEach((pu) => {
this.smallIDToID.set(pu.smallID, pu.id);
if (this._players.has(pu.id)) {
this._players.get(pu.id).data = pu;
this._players.get(pu.id).nameData = gu.playerNameViewData[pu.id];
} else {
this._players.set(
pu.id,
new PlayerView(this, pu, gu.playerNameViewData[pu.id]),
);
}
});
for (const unit of this._units.values()) {
unit._wasUpdated = false;
unit.lastPos = unit.lastPos.slice(-1);
}
gu.updates[GameUpdateType.Unit].forEach((unit) => {
if (this._units.has(unit.id)) {
this._units.get(unit.id).update(unit);
} else {
this._units.set(unit.id, new UnitView(this, unit));
}
});
}
gu.updates[GameUpdateType.Player].forEach((pu) => {
this.smallIDToID.set(pu.smallID, pu.id);
if (this._players.has(pu.id)) {
this._players.get(pu.id).data = pu
this._players.get(pu.id).nameData = gu.playerNameViewData[pu.id]
} else {
this._players.set(pu.id, new PlayerView(this, pu, gu.playerNameViewData[pu.id]))
}
});
for (const unit of this._units.values()) {
unit._wasUpdated = false
unit.lastPos = unit.lastPos.slice(-1)
}
gu.updates[GameUpdateType.Unit].forEach(unit => {
if (this._units.has(unit.id)) {
this._units.get(unit.id).update(unit)
} else {
this._units.set(unit.id, new UnitView(this, unit))
}
})
}
recentlyUpdatedTiles(): TileRef[] {
return this.updatedTiles;
}
recentlyUpdatedTiles(): TileRef[] {
return this.updatedTiles
}
myClientID(): ClientID {
return this._myClientID;
}
myClientID(): ClientID {
return this._myClientID
myPlayer(): PlayerView | null {
if (this._myPlayer == null) {
this._myPlayer = this.playerByClientID(this._myClientID);
}
return this._myPlayer;
}
myPlayer(): PlayerView | null {
if (this._myPlayer == null) {
this._myPlayer = this.playerByClientID(this._myClientID)
}
return this._myPlayer
player(id: PlayerID): PlayerView {
if (this._players.has(id)) {
return this._players.get(id);
}
throw Error(`player id ${id} not found`);
}
player(id: PlayerID): PlayerView {
if (this._players.has(id)) {
return this._players.get(id)
}
throw Error(`player id ${id} not found`)
playerBySmallID(id: number): PlayerView | TerraNullius {
if (id == 0) {
return new TerraNulliusImpl();
}
if (!this.smallIDToID.has(id)) {
throw new Error(`small id ${id} not found`);
}
return this.player(this.smallIDToID.get(id));
}
playerBySmallID(id: number): PlayerView | TerraNullius {
if (id == 0) {
return new TerraNulliusImpl()
}
if (!this.smallIDToID.has(id)) {
throw new Error(`small id ${id} not found`)
}
return this.player(this.smallIDToID.get(id))
playerByClientID(id: ClientID): PlayerView | null {
const player =
Array.from(this._players.values()).filter((p) => p.clientID() == id)[0] ??
null;
if (player == null) {
return null;
}
return player;
}
hasPlayer(id: PlayerID): boolean {
return false;
}
playerViews(): PlayerView[] {
return Array.from(this._players.values());
}
playerByClientID(id: ClientID): PlayerView | null {
const player = Array.from(this._players.values()).filter(p => p.clientID() == id)[0] ?? null
if (player == null) {
return null
}
return player
}
hasPlayer(id: PlayerID): boolean {
return false
}
playerViews(): PlayerView[] {
return Array.from(this._players.values())
}
owner(tile: TileRef): PlayerView | TerraNullius {
return this.playerBySmallID(this.ownerID(tile));
}
owner(tile: TileRef): PlayerView | TerraNullius {
return this.playerBySmallID(this.ownerID(tile))
}
ticks(): Tick {
return this.lastUpdate.tick;
}
inSpawnPhase(): boolean {
return this.lastUpdate.tick <= this._config.numSpawnPhaseTurns();
}
config(): Config {
return this._config;
}
units(...types: UnitType[]): UnitView[] {
return Array.from(this._units.values());
}
unit(id: number): UnitView {
return this._units.get(id);
}
unitInfo(type: UnitType): UnitInfo {
return this._config.unitInfo(type);
}
ticks(): Tick {
return this.lastUpdate.tick
}
inSpawnPhase(): boolean {
return this.lastUpdate.tick <= this._config.numSpawnPhaseTurns()
}
config(): Config {
return this._config
}
units(...types: UnitType[]): UnitView[] {
return Array.from(this._units.values())
}
unit(id: number): UnitView {
return this._units.get(id)
}
unitInfo(type: UnitType): UnitInfo {
return this._config.unitInfo(type)
}
ref(x: number, y: number): TileRef { return this._map.ref(x, y) }
x(ref: TileRef): number { return this._map.x(ref) }
y(ref: TileRef): number { return this._map.y(ref) }
cell(ref: TileRef): Cell { return this._map.cell(ref) }
width(): number { return this._map.width() }
height(): number { return this._map.height() }
numLandTiles(): number { return this._map.numLandTiles() }
isValidCoord(x: number, y: number): boolean { return this._map.isValidCoord(x, y) }
isLand(ref: TileRef): boolean { return this._map.isLand(ref) }
isOceanShore(ref: TileRef): boolean { return this._map.isOceanShore(ref) }
isOcean(ref: TileRef): boolean { return this._map.isOcean(ref) }
isShoreline(ref: TileRef): boolean { return this._map.isShoreline(ref) }
magnitude(ref: TileRef): number { return this._map.magnitude(ref) }
ownerID(ref: TileRef): number { return this._map.ownerID(ref) }
hasOwner(ref: TileRef): boolean { return this._map.hasOwner(ref) }
setOwnerID(ref: TileRef, playerId: number): void { return this._map.setOwnerID(ref, playerId) }
hasFallout(ref: TileRef): boolean { return this._map.hasFallout(ref) }
setFallout(ref: TileRef, value: boolean): void { return this._map.setFallout(ref, value) }
isBorder(ref: TileRef): boolean { return this._map.isBorder(ref) }
neighbors(ref: TileRef): TileRef[] { return this._map.neighbors(ref) }
isWater(ref: TileRef): boolean { return this._map.isWater(ref) }
isLake(ref: TileRef): boolean { return this._map.isLake(ref) }
isShore(ref: TileRef): boolean { return this._map.isShore(ref) }
cost(ref: TileRef): number { return this._map.cost(ref) }
terrainType(ref: TileRef): TerrainType { return this._map.terrainType(ref) }
forEachTile(fn: (tile: TileRef) => void): void { return this._map.forEachTile(fn) }
manhattanDist(c1: TileRef, c2: TileRef): number { return this._map.manhattanDist(c1, c2) }
euclideanDist(c1: TileRef, c2: TileRef): number { return this._map.euclideanDist(c1, c2) }
bfs(tile: TileRef, filter: (gm: GameMap, tile: TileRef) => boolean): Set<TileRef> { return this._map.bfs(tile, filter) }
toTileUpdate(tile: TileRef): bigint { return this._map.toTileUpdate(tile) }
updateTile(tu: TileUpdate): TileRef { return this._map.updateTile(tu) }
numTilesWithFallout(): number { return this._map.numTilesWithFallout() }
ref(x: number, y: number): TileRef {
return this._map.ref(x, y);
}
x(ref: TileRef): number {
return this._map.x(ref);
}
y(ref: TileRef): number {
return this._map.y(ref);
}
cell(ref: TileRef): Cell {
return this._map.cell(ref);
}
width(): number {
return this._map.width();
}
height(): number {
return this._map.height();
}
numLandTiles(): number {
return this._map.numLandTiles();
}
isValidCoord(x: number, y: number): boolean {
return this._map.isValidCoord(x, y);
}
isLand(ref: TileRef): boolean {
return this._map.isLand(ref);
}
isOceanShore(ref: TileRef): boolean {
return this._map.isOceanShore(ref);
}
isOcean(ref: TileRef): boolean {
return this._map.isOcean(ref);
}
isShoreline(ref: TileRef): boolean {
return this._map.isShoreline(ref);
}
magnitude(ref: TileRef): number {
return this._map.magnitude(ref);
}
ownerID(ref: TileRef): number {
return this._map.ownerID(ref);
}
hasOwner(ref: TileRef): boolean {
return this._map.hasOwner(ref);
}
setOwnerID(ref: TileRef, playerId: number): void {
return this._map.setOwnerID(ref, playerId);
}
hasFallout(ref: TileRef): boolean {
return this._map.hasFallout(ref);
}
setFallout(ref: TileRef, value: boolean): void {
return this._map.setFallout(ref, value);
}
isBorder(ref: TileRef): boolean {
return this._map.isBorder(ref);
}
neighbors(ref: TileRef): TileRef[] {
return this._map.neighbors(ref);
}
isWater(ref: TileRef): boolean {
return this._map.isWater(ref);
}
isLake(ref: TileRef): boolean {
return this._map.isLake(ref);
}
isShore(ref: TileRef): boolean {
return this._map.isShore(ref);
}
cost(ref: TileRef): number {
return this._map.cost(ref);
}
terrainType(ref: TileRef): TerrainType {
return this._map.terrainType(ref);
}
forEachTile(fn: (tile: TileRef) => void): void {
return this._map.forEachTile(fn);
}
manhattanDist(c1: TileRef, c2: TileRef): number {
return this._map.manhattanDist(c1, c2);
}
euclideanDist(c1: TileRef, c2: TileRef): number {
return this._map.euclideanDist(c1, c2);
}
bfs(
tile: TileRef,
filter: (gm: GameMap, tile: TileRef) => boolean,
): Set<TileRef> {
return this._map.bfs(tile, filter);
}
toTileUpdate(tile: TileRef): bigint {
return this._map.toTileUpdate(tile);
}
updateTile(tu: TileUpdate): TileRef {
return this._map.updateTile(tu);
}
numTilesWithFallout(): number {
return this._map.numTilesWithFallout();
}
}
File diff suppressed because it is too large Load Diff
+13 -15
View File
@@ -3,22 +3,20 @@ import { TerraNullius, Cell, PlayerID } from "./Game";
import { GameImpl } from "./GameImpl";
import { TileRef } from "./GameMap";
export class TerraNulliusImpl implements TerraNullius {
constructor() {}
smallID(): number {
return 0;
}
clientID(): ClientID {
return "TERRA_NULLIUS_CLIENT_ID";
}
id(): PlayerID {
return null;
}
constructor() {
}
smallID(): number {
return 0
}
clientID(): ClientID {
return "TERRA_NULLIUS_CLIENT_ID"
}
id(): PlayerID {
return null
}
isPlayer(): false { return false as const; }
isPlayer(): false {
return false as const;
}
}
+65 -59
View File
@@ -1,88 +1,94 @@
import { Cell, GameMapType, TerrainType } from './Game';
import { consolex } from '../Consolex';
import { NationMap } from './TerrainMapLoader';
import { Cell, GameMapType, TerrainType } from "./Game";
import { consolex } from "../Consolex";
import { NationMap } from "./TerrainMapLoader";
interface MapData {
mapBin: string;
miniMapBin: string;
nationMap: NationMap;
mapBin: string;
miniMapBin: string;
nationMap: NationMap;
}
interface MapCache {
bin?: string;
miniMapBin?: string
nationMap?: NationMap;
bin?: string;
miniMapBin?: string;
nationMap?: NationMap;
}
interface BinModule {
default: string;
default: string;
}
interface NationMapModule {
default: NationMap;
default: NationMap;
}
// Mapping from GameMap enum values to file names
const MAP_FILE_NAMES: Record<GameMapType, string> = {
[GameMapType.World]: 'WorldMap',
[GameMapType.Europe]: 'Europe',
[GameMapType.Mena]: 'Mena',
[GameMapType.NorthAmerica]: 'NorthAmerica',
[GameMapType.Oceania]: 'Oceania',
[GameMapType.BlackSea]: 'BlackSea',
[GameMapType.World]: "WorldMap",
[GameMapType.Europe]: "Europe",
[GameMapType.Mena]: "Mena",
[GameMapType.NorthAmerica]: "NorthAmerica",
[GameMapType.Oceania]: "Oceania",
[GameMapType.BlackSea]: "BlackSea",
};
class GameMapLoader {
private maps: Map<GameMapType, MapCache>;
private loadingPromises: Map<GameMapType, Promise<MapData>>;
private maps: Map<GameMapType, MapCache>;
private loadingPromises: Map<GameMapType, Promise<MapData>>;
constructor() {
this.maps = new Map<GameMapType, MapCache>();
this.loadingPromises = new Map<GameMapType, Promise<MapData>>();
constructor() {
this.maps = new Map<GameMapType, MapCache>();
this.loadingPromises = new Map<GameMapType, Promise<MapData>>();
}
public async getMapData(map: GameMapType): Promise<MapData> {
const cachedMap = this.maps.get(map);
if (cachedMap?.bin && cachedMap?.nationMap) {
return cachedMap as MapData;
}
public async getMapData(map: GameMapType): Promise<MapData> {
const cachedMap = this.maps.get(map);
if (cachedMap?.bin && cachedMap?.nationMap) {
return cachedMap as MapData;
}
if (!this.loadingPromises.has(map)) {
this.loadingPromises.set(map, this.loadMapData(map));
}
const data = await this.loadingPromises.get(map)!;
this.maps.set(map, data);
return data;
if (!this.loadingPromises.has(map)) {
this.loadingPromises.set(map, this.loadMapData(map));
}
private async loadMapData(map: GameMapType): Promise<MapData> {
const fileName = MAP_FILE_NAMES[map];
if (!fileName) {
throw new Error(`No file name mapping found for map: ${map}`);
}
const data = await this.loadingPromises.get(map)!;
this.maps.set(map, data);
return data;
}
const [binModule, miniBinModule, infoModule] = await Promise.all([
import(`!!binary-loader!../../../resources/maps/${fileName}.bin`) as Promise<BinModule>,
import(`!!binary-loader!../../../resources/maps/${fileName}Mini.bin`) as Promise<BinModule>,
import(`../../../resources/maps/${fileName}.json`) as Promise<NationMapModule>
]);
return {
mapBin: binModule.default,
miniMapBin: miniBinModule.default,
nationMap: infoModule.default
};
private async loadMapData(map: GameMapType): Promise<MapData> {
const fileName = MAP_FILE_NAMES[map];
if (!fileName) {
throw new Error(`No file name mapping found for map: ${map}`);
}
public isMapLoaded(map: GameMapType): boolean {
const mapData = this.maps.get(map);
return !!mapData?.bin && !!mapData?.nationMap;
}
const [binModule, miniBinModule, infoModule] = await Promise.all([
import(
`!!binary-loader!../../../resources/maps/${fileName}.bin`
) as Promise<BinModule>,
import(
`!!binary-loader!../../../resources/maps/${fileName}Mini.bin`
) as Promise<BinModule>,
import(
`../../../resources/maps/${fileName}.json`
) as Promise<NationMapModule>,
]);
public getLoadedMaps(): GameMapType[] {
return Array.from(this.maps.keys()).filter(map => this.isMapLoaded(map));
}
return {
mapBin: binModule.default,
miniMapBin: miniBinModule.default,
nationMap: infoModule.default,
};
}
public isMapLoaded(map: GameMapType): boolean {
const mapData = this.maps.get(map);
return !!mapData?.bin && !!mapData?.nationMap;
}
public getLoadedMaps(): GameMapType[] {
return Array.from(this.maps.keys()).filter((map) => this.isMapLoaded(map));
}
}
export const terrainMapFileLoader = new GameMapLoader();
export const terrainMapFileLoader = new GameMapLoader();
+57 -49
View File
@@ -1,69 +1,77 @@
import { consolex } from '../Consolex';
import { Cell, GameMapType, TerrainType } from './Game';
import { GameMap, GameMapImpl } from './GameMap';
import { terrainMapFileLoader } from './TerrainMapFileLoader';
import { consolex } from "../Consolex";
import { Cell, GameMapType, TerrainType } from "./Game";
import { GameMap, GameMapImpl } from "./GameMap";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
const loadedMaps = new Map<GameMapType, { nationMap: NationMap, gameMap: GameMap, miniGameMap: GameMap }>()
const loadedMaps = new Map<
GameMapType,
{ nationMap: NationMap; gameMap: GameMap; miniGameMap: GameMap }
>();
export interface NationMap {
name: string;
width: number;
height: number;
nations: Nation[];
name: string;
width: number;
height: number;
nations: Nation[];
}
export interface Nation {
coordinates: [number, number];
name: string;
strength: number;
coordinates: [number, number];
name: string;
strength: number;
}
export async function loadTerrainMap(map: GameMapType): Promise<{ nationMap: NationMap, gameMap: GameMap, miniGameMap: GameMap }> {
if (loadedMaps.has(map)) {
return loadedMaps.get(map)
}
const mapFiles = await terrainMapFileLoader.getMapData(map)
export async function loadTerrainMap(
map: GameMapType,
): Promise<{ nationMap: NationMap; gameMap: GameMap; miniGameMap: GameMap }> {
if (loadedMaps.has(map)) {
return loadedMaps.get(map);
}
const mapFiles = await terrainMapFileLoader.getMapData(map);
const gameMap = await loadTerrainFromFile(mapFiles.mapBin)
const miniGameMap = await loadTerrainFromFile(mapFiles.miniMapBin)
const result = { nationMap: mapFiles.nationMap, gameMap: gameMap, miniGameMap: miniGameMap }
loadedMaps.set(map, result)
return result
const gameMap = await loadTerrainFromFile(mapFiles.mapBin);
const miniGameMap = await loadTerrainFromFile(mapFiles.miniMapBin);
const result = {
nationMap: mapFiles.nationMap,
gameMap: gameMap,
miniGameMap: miniGameMap,
};
loadedMaps.set(map, result);
return result;
}
export async function loadTerrainFromFile(fileData: string): Promise<GameMap> {
const width = (fileData.charCodeAt(1) << 8) | fileData.charCodeAt(0);
const height = (fileData.charCodeAt(3) << 8) | fileData.charCodeAt(2);
const width = (fileData.charCodeAt(1) << 8) | fileData.charCodeAt(0);
const height = (fileData.charCodeAt(3) << 8) | fileData.charCodeAt(2);
if (fileData.length != width * height + 4) {
throw new Error(`Invalid data: buffer size ${fileData.length} incorrect for ${width}x${height} terrain plus 4 bytes for dimensions.`);
}
if (fileData.length != width * height + 4) {
throw new Error(
`Invalid data: buffer size ${fileData.length} incorrect for ${width}x${height} terrain plus 4 bytes for dimensions.`,
);
}
// Store raw data in Uint8Array
const rawData = new Uint8Array(width * height);
let numLand = 0;
// Store raw data in Uint8Array
const rawData = new Uint8Array(width * height);
let numLand = 0;
// Copy data starting after the header
for (let i = 0; i < width * height; i++) {
const packedByte = fileData.charCodeAt(i + 4);
rawData[i] = packedByte;
if (packedByte & 0b10000000) numLand++;
}
return new GameMapImpl(width, height, rawData, numLand)
// Copy data starting after the header
for (let i = 0; i < width * height; i++) {
const packedByte = fileData.charCodeAt(i + 4);
rawData[i] = packedByte;
if (packedByte & 0b10000000) numLand++;
}
return new GameMapImpl(width, height, rawData, numLand);
}
function logBinaryAsAscii(data: string, length: number = 8) {
consolex.log('Binary data (1 = set bit, 0 = unset bit):');
for (let i = 0; i < Math.min(length, data.length); i++) {
let byte = data.charCodeAt(i);
let byteString = '';
for (let j = 7; j >= 0; j--) {
byteString += (byte & (1 << j)) ? '1' : '0';
}
consolex.log(`Byte ${i}: ${byteString}`);
consolex.log("Binary data (1 = set bit, 0 = unset bit):");
for (let i = 0; i < Math.min(length, data.length); i++) {
let byte = data.charCodeAt(i);
let byteString = "";
for (let j = 7; j >= 0; j--) {
byteString += byte & (1 << j) ? "1" : "0";
}
}
consolex.log(`Byte ${i}: ${byteString}`);
}
}
+53 -49
View File
@@ -1,63 +1,67 @@
export enum SearchMapTileType {
Land,
Shore,
Water,
Land,
Shore,
Water,
}
export class TerrainSearchMap {
private width: number;
private height: number;
private mapData: Uint8Array;
private width: number;
private height: number;
private mapData: Uint8Array;
constructor(buffer: SharedArrayBuffer) {
this.mapData = new Uint8Array(buffer);
this.width = (this.mapData[1] << 8) | this.mapData[0];
this.height = (this.mapData[3] << 8) | this.mapData[2];
constructor(buffer: SharedArrayBuffer) {
this.mapData = new Uint8Array(buffer);
this.width = (this.mapData[1] << 8) | this.mapData[0];
this.height = (this.mapData[3] << 8) | this.mapData[2];
}
node(x: number, y: number): SearchMapTileType {
const packedByte = this.mapData[4 + y * this.width + x];
const isLand = packedByte & 0b10000000;
const shoreline = !!(packedByte & 0b01000000);
const ocean = !!(packedByte & 0b00100000);
const magnitude = packedByte & 0b00011111;
if (isLand) {
return SearchMapTileType.Land;
}
node(x: number, y: number): SearchMapTileType {
const packedByte = this.mapData[4 + y * this.width + x];
const isLand = (packedByte & 0b10000000)
const shoreline = !!(packedByte & 0b01000000);
const ocean = !!(packedByte & 0b00100000);
const magnitude = packedByte & 0b00011111;
if (isLand) {
return SearchMapTileType.Land
}
if (magnitude < 10) {
return SearchMapTileType.Shore
}
return SearchMapTileType.Water
if (magnitude < 10) {
return SearchMapTileType.Shore;
}
return SearchMapTileType.Water;
}
neighbors(x: number, y: number): Array<{ x: number; y: number }> {
const result: Array<{ x: number; y: number }> = [];
neighbors(x: number, y: number): Array<{ x: number; y: number }> {
const result: Array<{ x: number; y: number }> = [];
// Check all 8 adjacent tiles
const dirs = [
[-1, -1], [0, -1], [1, -1],
[-1, 0], [1, 0],
[-1, 1], [0, 1], [1, 1]
];
// Check all 8 adjacent tiles
const dirs = [
[-1, -1],
[0, -1],
[1, -1],
[-1, 0],
[1, 0],
[-1, 1],
[0, 1],
[1, 1],
];
for (const [dx, dy] of dirs) {
const newX = x + dx;
const newY = y + dy;
for (const [dx, dy] of dirs) {
const newX = x + dx;
const newY = y + dy;
// Check bounds
if (newX >= 0 && newX < this.width &&
newY >= 0 && newY < this.height) {
result.push({ x: newX, y: newY });
}
}
return result;
// Check bounds
if (newX >= 0 && newX < this.width && newY >= 0 && newY < this.height) {
result.push({ x: newX, y: newY });
}
}
return result;
}
getWidth(): number {
return this.width;
}
getWidth(): number {
return this.width;
}
getHeight(): number {
return this.height;
}
}
getHeight(): number {
return this.height;
}
}
+100 -104
View File
@@ -1,126 +1,122 @@
import { MessageType } from './Game';
import { MessageType } from "./Game";
import { UnitUpdate } from "./GameUpdates";
import { GameUpdateType } from "./GameUpdates";
import { simpleHash, within } from "../Util";
import { Unit, TerraNullius, UnitType, Player, UnitInfo } from "./Game";
import { GameImpl } from "./GameImpl";
import { PlayerImpl } from "./PlayerImpl";
import { TileRef } from './GameMap';
import { TileRef } from "./GameMap";
export class UnitImpl implements Unit {
private _active = true;
private _health: number
private _lastTile: TileRef = null
private _active = true;
private _health: number;
private _lastTile: TileRef = null;
constructor(
private _type: UnitType,
private mg: GameImpl,
private _tile: TileRef,
private _troops: number,
private _id: number,
public _owner: PlayerImpl,
) {
// default to half health (or 1 is no health specified)
this._health = (this.mg.unitInfo(_type).maxHealth ?? 2) / 2
this._lastTile = _tile
}
constructor(
private _type: UnitType,
private mg: GameImpl,
private _tile: TileRef,
private _troops: number,
private _id: number,
public _owner: PlayerImpl,
) {
// default to half health (or 1 is no health specified)
this._health = (this.mg.unitInfo(_type).maxHealth ?? 2) / 2;
this._lastTile = _tile;
}
toUpdate(): UnitUpdate {
return {
type: GameUpdateType.Unit,
unitType: this._type,
id: this._id,
troops: this._troops,
ownerID: this._owner.smallID(),
isActive: this._active,
pos: { x: this.mg.x(this._tile), y: this.mg.y(this._tile) },
lastPos: { x: this.mg.x(this._lastTile), y: this.mg.y(this._lastTile) },
};
}
toUpdate(): UnitUpdate {
return {
type: GameUpdateType.Unit,
unitType: this._type,
id: this._id,
troops: this._troops,
ownerID: this._owner.smallID(),
isActive: this._active,
pos: { x: this.mg.x(this._tile), y: this.mg.y(this._tile) },
lastPos: { x: this.mg.x(this._lastTile), y: this.mg.y(this._lastTile) }
type(): UnitType {
return this._type;
}
}
}
lastTile(): TileRef {
return this._lastTile;
}
type(): UnitType {
return this._type
move(tile: TileRef): void {
if (tile == null) {
throw new Error("tile cannot be null");
}
this._lastTile = this._tile;
this._tile = tile;
this.mg.addUpdate(this.toUpdate());
}
setTroops(troops: number): void {
this._troops = troops;
}
troops(): number {
return this._troops;
}
health(): number {
return this._health;
}
hasHealth(): boolean {
return this.info().maxHealth != undefined;
}
tile(): TileRef {
return this._tile;
}
owner(): PlayerImpl {
return this._owner;
}
lastTile(): TileRef {
return this._lastTile
}
info(): UnitInfo {
return this.mg.unitInfo(this._type);
}
move(tile: TileRef): void {
if (tile == null) {
throw new Error("tile cannot be null")
}
this._lastTile = this._tile
this._tile = tile;
this.mg.addUpdate(this.toUpdate());
}
setTroops(troops: number): void {
this._troops = troops;
}
troops(): number {
return this._troops;
}
health(): number {
return this._health
}
hasHealth(): boolean {
return this.info().maxHealth != undefined
}
tile(): TileRef {
return this._tile
}
owner(): PlayerImpl {
return this._owner;
}
setOwner(newOwner: Player): void {
const oldOwner = this._owner;
oldOwner._units = oldOwner._units.filter((u) => u != this);
this._owner = newOwner as PlayerImpl;
this.mg.addUpdate(this.toUpdate());
this.mg.displayMessage(
`Your ${this.type()} was captured by ${newOwner.displayName()}`,
MessageType.ERROR,
oldOwner.id(),
);
}
info(): UnitInfo {
return this.mg.unitInfo(this._type)
}
modifyHealth(delta: number): void {
this._health = within(this._health + delta, 0, this.info().maxHealth ?? 1);
}
setOwner(newOwner: Player): void {
const oldOwner = this._owner
oldOwner._units = oldOwner._units.filter(u => u != this)
this._owner = newOwner as PlayerImpl
this.mg.addUpdate(this.toUpdate())
this.mg.displayMessage(
`Your ${this.type()} was captured by ${newOwner.displayName()}`,
MessageType.ERROR,
oldOwner.id()
)
delete(displayMessage: boolean = true): void {
if (!this.isActive()) {
throw new Error(`cannot delete ${this} not active`);
}
this._owner._units = this._owner._units.filter((b) => b != this);
this._active = false;
this.mg.addUpdate(this.toUpdate());
if (displayMessage) {
this.mg.displayMessage(
`Your ${this.type()} was destroyed`,
MessageType.ERROR,
this.owner().id(),
);
}
}
isActive(): boolean {
return this._active;
}
modifyHealth(delta: number): void {
this._health = within(
this._health + delta,
0,
this.info().maxHealth ?? 1
)
}
hash(): number {
return this.tile() + simpleHash(this.type());
}
delete(displayMessage: boolean = true): void {
if (!this.isActive()) {
throw new Error(`cannot delete ${this} not active`)
}
this._owner._units = this._owner._units.filter(b => b != this);
this._active = false;
this.mg.addUpdate(this.toUpdate());
if (displayMessage) {
this.mg.displayMessage(`Your ${this.type()} was destroyed`, MessageType.ERROR, this.owner().id())
}
}
isActive(): boolean {
return this._active;
}
hash(): number {
return this.tile() + simpleHash(this.type())
}
toString(): string {
return `Unit:${this._type},owner:${this.owner().name()}`
}
toString(): string {
return `Unit:${this._type},owner:${this.owner().name()}`;
}
}
+24 -20
View File
@@ -1,29 +1,33 @@
import { TileRef } from "../game/GameMap";
export interface AStar {
compute(): PathFindResultType
reconstructPath(): TileRef[]
compute(): PathFindResultType;
reconstructPath(): TileRef[];
}
export enum PathFindResultType {
NextTile,
Pending,
Completed,
PathNotFound
} export type TileResult = {
type: PathFindResultType.NextTile;
tile: TileRef;
} | {
type: PathFindResultType.Pending;
} | {
type: PathFindResultType.Completed;
tile: TileRef;
} | {
type: PathFindResultType.PathNotFound;
};
NextTile,
Pending,
Completed,
PathNotFound,
}
export type TileResult =
| {
type: PathFindResultType.NextTile;
tile: TileRef;
}
| {
type: PathFindResultType.Pending;
}
| {
type: PathFindResultType.Completed;
tile: TileRef;
}
| {
type: PathFindResultType.PathNotFound;
};
export interface Point {
x: number;
y: number;
x: number;
y: number;
}
+72 -69
View File
@@ -1,89 +1,92 @@
import { Cell, } from "../game/Game";
import { Cell } from "../game/Game";
import { GameMap, GameMapImpl, TileRef } from "../game/GameMap";
import { AStar, PathFindResultType, } from "./AStar";
import { AStar, PathFindResultType } from "./AStar";
import { SerialAStar } from "./SerialAStar";
// TODO: test this, get it work
export class MiniAStar implements AStar {
private aStar: SerialAStar;
private aStar: SerialAStar
constructor(
private gameMap: GameMap,
private miniMap: GameMap,
private src: TileRef,
private dst: TileRef,
private canMove: (t: TileRef) => boolean,
private iterations: number,
private maxTries: number,
) {
const miniSrc = this.miniMap.ref(
Math.floor(gameMap.x(src) / 2),
Math.floor(gameMap.y(src) / 2),
);
const miniDst = this.miniMap.ref(
Math.floor(gameMap.x(dst) / 2),
Math.floor(gameMap.y(dst) / 2),
);
this.aStar = new SerialAStar(
miniSrc,
miniDst,
canMove,
iterations,
maxTries,
this.miniMap,
);
}
constructor(
private gameMap: GameMap,
private miniMap: GameMap,
private src: TileRef,
private dst: TileRef,
private canMove: (t: TileRef) => boolean,
private iterations: number,
private maxTries: number
) {
const miniSrc = this.miniMap.ref(
Math.floor(gameMap.x(src) / 2),
Math.floor(gameMap.y(src) / 2)
)
const miniDst = this.miniMap.ref(
Math.floor(gameMap.x(dst) / 2),
Math.floor(gameMap.y(dst) / 2)
)
this.aStar = new SerialAStar(
miniSrc,
miniDst,
canMove,
iterations,
maxTries,
this.miniMap
)
}
compute(): PathFindResultType {
return this.aStar.compute()
}
reconstructPath(): TileRef[] {
const upscaled = upscalePath(this.aStar.reconstructPath().map(tr => new Cell(this.miniMap.x(tr), this.miniMap.y(tr))))
upscaled.push(new Cell(this.gameMap.x(this.dst), this.gameMap.y(this.dst)))
return upscaled.map(c => this.gameMap.ref(c.x, c.y))
}
compute(): PathFindResultType {
return this.aStar.compute();
}
reconstructPath(): TileRef[] {
const upscaled = upscalePath(
this.aStar
.reconstructPath()
.map((tr) => new Cell(this.miniMap.x(tr), this.miniMap.y(tr))),
);
upscaled.push(new Cell(this.gameMap.x(this.dst), this.gameMap.y(this.dst)));
return upscaled.map((c) => this.gameMap.ref(c.x, c.y));
}
}
function upscalePath(path: Cell[], scaleFactor: number = 2): Cell[] {
// Scale up each point
const scaledPath = path.map(point => (new Cell(
point.x * scaleFactor,
point.y * scaleFactor
)));
// Scale up each point
const scaledPath = path.map(
(point) => new Cell(point.x * scaleFactor, point.y * scaleFactor),
);
const smoothPath: Cell[] = [];
const smoothPath: Cell[] = [];
for (let i = 0; i < scaledPath.length - 1; i++) {
const current = scaledPath[i];
const next = scaledPath[i + 1];
for (let i = 0; i < scaledPath.length - 1; i++) {
const current = scaledPath[i];
const next = scaledPath[i + 1];
// Add the current point
smoothPath.push(current);
// Add the current point
smoothPath.push(current);
// Always interpolate between scaled points
const dx = next.x - current.x;
const dy = next.y - current.y;
// Always interpolate between scaled points
const dx = next.x - current.x;
const dy = next.y - current.y;
// Calculate number of steps needed
const distance = Math.max(Math.abs(dx), Math.abs(dy));
const steps = distance;
// Calculate number of steps needed
const distance = Math.max(Math.abs(dx), Math.abs(dy));
const steps = distance;
// Add intermediate points
for (let step = 1; step < steps; step++) {
smoothPath.push(new Cell(
Math.round(current.x + (dx * step) / steps),
Math.round(current.y + (dy * step) / steps)
));
}
// Add intermediate points
for (let step = 1; step < steps; step++) {
smoothPath.push(
new Cell(
Math.round(current.x + (dx * step) / steps),
Math.round(current.y + (dy * step) / steps),
),
);
}
}
// Add the last point
if (scaledPath.length > 0) {
smoothPath.push(scaledPath[scaledPath.length - 1]);
}
// Add the last point
if (scaledPath.length > 0) {
smoothPath.push(scaledPath[scaledPath.length - 1]);
}
return smoothPath;
}
return smoothPath;
}
+86 -86
View File
@@ -6,98 +6,98 @@ import { consolex } from "../Consolex";
import { TileRef } from "../game/GameMap";
export class PathFinder {
private curr: TileRef = null;
private dst: TileRef = null;
private path: TileRef[];
private aStar: AStar;
private computeFinished = true;
private curr: TileRef = null
private dst: TileRef = null
private path: TileRef[]
private aStar: AStar
private computeFinished = true
private constructor(
private game: Game,
private newAStar: (curr: TileRef, dst: TileRef) => AStar,
) {}
private constructor(
private game: Game,
private newAStar: (curr: TileRef, dst: TileRef) => AStar
) { }
public static Mini(
game: Game,
iterations: number,
canMoveOnLand: boolean,
maxTries: number = 20,
) {
return new PathFinder(game, (curr: TileRef, dst: TileRef) => {
return new MiniAStar(
game.map(),
game.miniMap(),
curr,
dst,
(tr: TileRef): boolean => {
if (canMoveOnLand) {
return true;
}
return game.miniMap().isOcean(tr);
},
iterations,
maxTries,
);
});
}
public static Mini(game: Game, iterations: number, canMoveOnLand: boolean, maxTries: number = 20) {
return new PathFinder(
game,
(curr: TileRef, dst: TileRef) => {
return new MiniAStar(
game.map(),
game.miniMap(),
curr,
dst,
(tr: TileRef): boolean => {
if (canMoveOnLand) {
return true
}
return game.miniMap().isOcean(tr)
},
iterations,
maxTries
)
}
)
nextTile(curr: TileRef, dst: TileRef, dist: number = 1): TileResult {
if (curr == null) {
consolex.error("curr is null");
}
if (dst == null) {
consolex.error("dst is null");
}
nextTile(curr: TileRef, dst: TileRef, dist: number = 1): TileResult {
if (curr == null) {
consolex.error('curr is null')
}
if (dst == null) {
consolex.error('dst is null')
}
if (this.game.manhattanDist(curr, dst) < dist) {
return { type: PathFindResultType.Completed, tile: curr }
}
if (this.computeFinished) {
if (this.shouldRecompute(curr, dst)) {
this.curr = curr
this.dst = dst
this.path = null
this.aStar = this.newAStar(curr, dst)
this.computeFinished = false
return this.nextTile(curr, dst)
} else {
return { type: PathFindResultType.NextTile, tile: this.path.shift() }
}
}
switch (this.aStar.compute()) {
case PathFindResultType.Completed:
this.computeFinished = true
this.path = this.aStar.reconstructPath()
// Remove the start tile
this.path.shift()
return this.nextTile(curr, dst)
case PathFindResultType.Pending:
return { type: PathFindResultType.Pending }
case PathFindResultType.PathNotFound:
return { type: PathFindResultType.PathNotFound }
}
if (this.game.manhattanDist(curr, dst) < dist) {
return { type: PathFindResultType.Completed, tile: curr };
}
private shouldRecompute(curr: TileRef, dst: TileRef) {
if (this.path == null || this.curr == null || this.dst == null) {
return true
}
const dist = this.game.manhattanDist(curr, dst)
let tolerance = 10
if (dist > 50) {
tolerance = 10
} else if (dist > 25) {
tolerance = 5
} else if (dist > 10) {
tolerance = 3
} else {
tolerance = 0
}
if (this.game.manhattanDist(this.dst, dst) > tolerance) {
return true
}
return false
if (this.computeFinished) {
if (this.shouldRecompute(curr, dst)) {
this.curr = curr;
this.dst = dst;
this.path = null;
this.aStar = this.newAStar(curr, dst);
this.computeFinished = false;
return this.nextTile(curr, dst);
} else {
return { type: PathFindResultType.NextTile, tile: this.path.shift() };
}
}
switch (this.aStar.compute()) {
case PathFindResultType.Completed:
this.computeFinished = true;
this.path = this.aStar.reconstructPath();
// Remove the start tile
this.path.shift();
return this.nextTile(curr, dst);
case PathFindResultType.Pending:
return { type: PathFindResultType.Pending };
case PathFindResultType.PathNotFound:
return { type: PathFindResultType.PathNotFound };
}
}
private shouldRecompute(curr: TileRef, dst: TileRef) {
if (this.path == null || this.curr == null || this.dst == null) {
return true;
}
const dist = this.game.manhattanDist(curr, dst);
let tolerance = 10;
if (dist > 50) {
tolerance = 10;
} else if (dist > 25) {
tolerance = 5;
} else if (dist > 10) {
tolerance = 3;
} else {
tolerance = 0;
}
if (this.game.manhattanDist(this.dst, dst) > tolerance) {
return true;
}
return false;
}
}
+123 -116
View File
@@ -4,137 +4,144 @@ import { PathFindResultType } from "./AStar";
import { consolex } from "../Consolex";
import { GameMap, GameMapImpl, TileRef } from "../game/GameMap";
export class SerialAStar implements AStar {
private fwdOpenSet: PriorityQueue<{ tile: TileRef; fScore: number; }>;
private bwdOpenSet: PriorityQueue<{ tile: TileRef; fScore: number; }>;
private fwdCameFrom: Map<TileRef, TileRef>;
private bwdCameFrom: Map<TileRef, TileRef>;
private fwdGScore: Map<TileRef, number>;
private bwdGScore: Map<TileRef, number>;
private meetingPoint: TileRef | null;
public completed: boolean;
private fwdOpenSet: PriorityQueue<{ tile: TileRef; fScore: number }>;
private bwdOpenSet: PriorityQueue<{ tile: TileRef; fScore: number }>;
private fwdCameFrom: Map<TileRef, TileRef>;
private bwdCameFrom: Map<TileRef, TileRef>;
private fwdGScore: Map<TileRef, number>;
private bwdGScore: Map<TileRef, number>;
private meetingPoint: TileRef | null;
public completed: boolean;
constructor(
private src: TileRef,
private dst: TileRef,
private canMove: (t: TileRef) => boolean,
private iterations: number,
private maxTries: number,
private gameMap: GameMap
) {
this.fwdOpenSet = new PriorityQueue<{ tile: TileRef; fScore: number; }>(
(a, b) => a.fScore - b.fScore
);
this.bwdOpenSet = new PriorityQueue<{ tile: TileRef; fScore: number; }>(
(a, b) => a.fScore - b.fScore
);
this.fwdCameFrom = new Map<TileRef, TileRef>();
this.bwdCameFrom = new Map<TileRef, TileRef>();
this.fwdGScore = new Map<TileRef, number>();
this.bwdGScore = new Map<TileRef, number>();
this.meetingPoint = null;
this.completed = false;
constructor(
private src: TileRef,
private dst: TileRef,
private canMove: (t: TileRef) => boolean,
private iterations: number,
private maxTries: number,
private gameMap: GameMap,
) {
this.fwdOpenSet = new PriorityQueue<{ tile: TileRef; fScore: number }>(
(a, b) => a.fScore - b.fScore,
);
this.bwdOpenSet = new PriorityQueue<{ tile: TileRef; fScore: number }>(
(a, b) => a.fScore - b.fScore,
);
this.fwdCameFrom = new Map<TileRef, TileRef>();
this.bwdCameFrom = new Map<TileRef, TileRef>();
this.fwdGScore = new Map<TileRef, number>();
this.bwdGScore = new Map<TileRef, number>();
this.meetingPoint = null;
this.completed = false;
// Initialize forward search
this.fwdGScore.set(src, 0);
this.fwdOpenSet.enqueue({ tile: src, fScore: this.heuristic(src, dst) });
// Initialize forward search
this.fwdGScore.set(src, 0);
this.fwdOpenSet.enqueue({ tile: src, fScore: this.heuristic(src, dst) });
// Initialize backward search
this.bwdGScore.set(dst, 0);
this.bwdOpenSet.enqueue({ tile: dst, fScore: this.heuristic(dst, src) });
// Initialize backward search
this.bwdGScore.set(dst, 0);
this.bwdOpenSet.enqueue({ tile: dst, fScore: this.heuristic(dst, src) });
}
compute(): PathFindResultType {
if (this.completed) return PathFindResultType.Completed;
this.maxTries -= 1;
let iterations = this.iterations;
while (!this.fwdOpenSet.isEmpty() && !this.bwdOpenSet.isEmpty()) {
iterations--;
if (iterations <= 0) {
if (this.maxTries <= 0) {
return PathFindResultType.PathNotFound;
}
return PathFindResultType.Pending;
}
// Process forward search
const fwdCurrent = this.fwdOpenSet.dequeue()!.tile;
if (this.bwdGScore.has(fwdCurrent)) {
// We found a meeting point!
this.meetingPoint = fwdCurrent;
this.completed = true;
return PathFindResultType.Completed;
}
this.expandTileRef(fwdCurrent, true);
// Process backward search
const bwdCurrent = this.bwdOpenSet.dequeue()!.tile;
if (this.fwdGScore.has(bwdCurrent)) {
// We found a meeting point!
this.meetingPoint = bwdCurrent;
this.completed = true;
return PathFindResultType.Completed;
}
this.expandTileRef(bwdCurrent, false);
}
compute(): PathFindResultType {
if (this.completed) return PathFindResultType.Completed;
return this.completed
? PathFindResultType.Completed
: PathFindResultType.PathNotFound;
}
this.maxTries -= 1;
let iterations = this.iterations;
private expandTileRef(current: TileRef, isForward: boolean) {
for (const neighbor of this.gameMap.neighbors(current)) {
if (
neighbor != (isForward ? this.dst : this.src) &&
!this.canMove(neighbor)
)
continue;
while (!this.fwdOpenSet.isEmpty() && !this.bwdOpenSet.isEmpty()) {
iterations--;
if (iterations <= 0) {
if (this.maxTries <= 0) {
return PathFindResultType.PathNotFound;
}
return PathFindResultType.Pending;
}
const gScore = isForward ? this.fwdGScore : this.bwdGScore;
const openSet = isForward ? this.fwdOpenSet : this.bwdOpenSet;
const cameFrom = isForward ? this.fwdCameFrom : this.bwdCameFrom;
// Process forward search
const fwdCurrent = this.fwdOpenSet.dequeue()!.tile;
if (this.bwdGScore.has(fwdCurrent)) {
// We found a meeting point!
this.meetingPoint = fwdCurrent;
this.completed = true;
return PathFindResultType.Completed;
}
let tentativeGScore = gScore.get(current)! + this.gameMap.cost(neighbor);
this.expandTileRef(fwdCurrent, true);
if (!gScore.has(neighbor) || tentativeGScore < gScore.get(neighbor)!) {
cameFrom.set(neighbor, current);
gScore.set(neighbor, tentativeGScore);
const fScore =
tentativeGScore +
this.heuristic(neighbor, isForward ? this.dst : this.src);
openSet.enqueue({ tile: neighbor, fScore: fScore });
}
}
}
// Process backward search
const bwdCurrent = this.bwdOpenSet.dequeue()!.tile;
if (this.fwdGScore.has(bwdCurrent)) {
// We found a meeting point!
this.meetingPoint = bwdCurrent;
this.completed = true;
return PathFindResultType.Completed;
}
private heuristic(a: TileRef, b: TileRef): number {
// TODO use wrapped
try {
return (
1.1 * Math.abs(this.gameMap.x(a) - this.gameMap.x(b)) +
Math.abs(this.gameMap.y(a) - this.gameMap.y(b))
);
} catch {
consolex.log("uh oh");
}
}
this.expandTileRef(bwdCurrent, false);
}
public reconstructPath(): TileRef[] {
if (!this.meetingPoint) return [];
return this.completed ? PathFindResultType.Completed : PathFindResultType.PathNotFound;
// Reconstruct path from start to meeting point
const fwdPath: TileRef[] = [this.meetingPoint];
let current = this.meetingPoint;
while (this.fwdCameFrom.has(current)) {
current = this.fwdCameFrom.get(current)!;
fwdPath.unshift(current);
}
private expandTileRef(current: TileRef, isForward: boolean) {
for (const neighbor of this.gameMap.neighbors(current)) {
if (neighbor != (isForward ? this.dst : this.src) && !this.canMove(neighbor)) continue;
const gScore = isForward ? this.fwdGScore : this.bwdGScore;
const openSet = isForward ? this.fwdOpenSet : this.bwdOpenSet;
const cameFrom = isForward ? this.fwdCameFrom : this.bwdCameFrom;
let tentativeGScore = gScore.get(current)! + this.gameMap.cost(neighbor);
if (!gScore.has(neighbor) || tentativeGScore < gScore.get(neighbor)!) {
cameFrom.set(neighbor, current);
gScore.set(neighbor, tentativeGScore);
const fScore = tentativeGScore + this.heuristic(
neighbor,
isForward ? this.dst : this.src
);
openSet.enqueue({ tile: neighbor, fScore: fScore });
}
}
// Reconstruct path from meeting point to goal
current = this.meetingPoint;
while (this.bwdCameFrom.has(current)) {
current = this.bwdCameFrom.get(current)!;
fwdPath.push(current);
}
private heuristic(a: TileRef, b: TileRef): number {
// TODO use wrapped
try {
return 1.1 * Math.abs(this.gameMap.x(a) - this.gameMap.x(b)) + Math.abs(this.gameMap.y(a) - this.gameMap.y(b));
} catch {
consolex.log('uh oh')
}
}
public reconstructPath(): TileRef[] {
if (!this.meetingPoint) return [];
// Reconstruct path from start to meeting point
const fwdPath: TileRef[] = [this.meetingPoint];
let current = this.meetingPoint;
while (this.fwdCameFrom.has(current)) {
current = this.fwdCameFrom.get(current)!;
fwdPath.unshift(current);
}
// Reconstruct path from meeting point to goal
current = this.meetingPoint;
while (this.bwdCameFrom.has(current)) {
current = this.bwdCameFrom.get(current)!;
fwdPath.push(current);
}
return fwdPath
}
return fwdPath;
}
}
+32 -28
View File
@@ -3,38 +3,42 @@ export const MAX_USERNAME_LENGTH = 20;
const validPattern = /^[a-zA-Z0-9_ ]+$/;
export function validateUsername(username: string): { isValid: boolean; error?: string } {
export function validateUsername(username: string): {
isValid: boolean;
error?: string;
} {
if (typeof username !== "string") {
return { isValid: false, error: "Username must be a string." };
}
if (typeof username !== 'string') {
return { isValid: false, error: "Username must be a string." };
}
if (username.length < MIN_USERNAME_LENGTH) {
return {
isValid: false,
error: `Username must be at least ${MIN_USERNAME_LENGTH} characters long.`,
};
}
if (username.length < MIN_USERNAME_LENGTH) {
return {
isValid: false,
error: `Username must be at least ${MIN_USERNAME_LENGTH} characters long.`,
};
}
if (username.length > MAX_USERNAME_LENGTH) {
return {
isValid: false,
error: `Username must not exceed ${MAX_USERNAME_LENGTH} characters.`,
};
}
if (username.length > MAX_USERNAME_LENGTH) {
return {
isValid: false,
error: `Username must not exceed ${MAX_USERNAME_LENGTH} characters.`,
};
}
if (!validPattern.test(username)) {
return {
isValid: false,
error: "Username can only contain letters, numbers, and underscores.",
};
}
if (!validPattern.test(username)) {
return {
isValid: false,
error: "Username can only contain letters, numbers, and underscores.",
};
}
// All checks passed
return { isValid: true };
// All checks passed
return { isValid: true };
}
export function sanitizeUsername(str: string): string {
const sanitized = str.replace(/[^a-zA-Z0-9]/g, '').slice(0, MAX_USERNAME_LENGTH);
return sanitized.padEnd(MIN_USERNAME_LENGTH, 'x')
};
const sanitized = str
.replace(/[^a-zA-Z0-9]/g, "")
.slice(0, MAX_USERNAME_LENGTH);
return sanitized.padEnd(MIN_USERNAME_LENGTH, "x");
}
+91 -87
View File
@@ -1,110 +1,114 @@
import { createGameRunner, GameRunner } from "../GameRunner";
import { GameUpdateViewData } from '../game/GameUpdates';
import { GameUpdateViewData } from "../game/GameUpdates";
import {
MainThreadMessage,
WorkerMessage,
InitializedMessage,
PlayerActionsResultMessage,
PlayerProfileResultMessage,
} from './WorkerMessages';
MainThreadMessage,
WorkerMessage,
InitializedMessage,
PlayerActionsResultMessage,
PlayerProfileResultMessage,
} from "./WorkerMessages";
const ctx: Worker = self as any;
let gameRunner: Promise<GameRunner> | null = null;
function gameUpdate(gu: GameUpdateViewData) {
sendMessage({
type: "game_update",
gameUpdate: gu
});
sendMessage({
type: "game_update",
gameUpdate: gu,
});
}
function sendMessage(message: WorkerMessage) {
ctx.postMessage(message);
ctx.postMessage(message);
}
ctx.addEventListener('message', async (e: MessageEvent<MainThreadMessage>) => {
const message = e.data;
ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
const message = e.data;
switch (message.type) {
case 'heartbeat':
break
case 'init':
try {
gameRunner = createGameRunner(
message.gameID,
message.gameConfig,
gameUpdate
).then(gr => {
sendMessage({
type: 'initialized',
id: message.id
} as InitializedMessage);
return gr;
});
} catch (error) {
console.error('Failed to initialize game runner:', error);
throw error;
}
break;
switch (message.type) {
case "heartbeat":
break;
case "init":
try {
gameRunner = createGameRunner(
message.gameID,
message.gameConfig,
gameUpdate,
).then((gr) => {
sendMessage({
type: "initialized",
id: message.id,
} as InitializedMessage);
return gr;
});
} catch (error) {
console.error("Failed to initialize game runner:", error);
throw error;
}
break;
case 'turn':
if (!gameRunner) {
throw new Error('Game runner not initialized');
}
case "turn":
if (!gameRunner) {
throw new Error("Game runner not initialized");
}
try {
const gr = await gameRunner;
await gr.addTurn(message.turn);
} catch (error) {
console.error('Failed to process turn:', error);
throw error;
}
break;
try {
const gr = await gameRunner;
await gr.addTurn(message.turn);
} catch (error) {
console.error("Failed to process turn:", error);
throw error;
}
break;
case 'player_actions':
if (!gameRunner) {
throw new Error('Game runner not initialized');
}
case "player_actions":
if (!gameRunner) {
throw new Error("Game runner not initialized");
}
try {
const actions = (await gameRunner).playerActions(message.playerID, message.x, message.y)
sendMessage({
type: 'player_actions_result',
id: message.id,
result: actions
} as PlayerActionsResultMessage);
} catch (error) {
console.error('Failed to check borders:', error);
throw error;
}
break;
case 'player_profile':
if (!gameRunner) {
throw new Error('Game runner not initialized');
}
try {
const actions = (await gameRunner).playerActions(
message.playerID,
message.x,
message.y,
);
sendMessage({
type: "player_actions_result",
id: message.id,
result: actions,
} as PlayerActionsResultMessage);
} catch (error) {
console.error("Failed to check borders:", error);
throw error;
}
break;
case "player_profile":
if (!gameRunner) {
throw new Error("Game runner not initialized");
}
try {
const profile = (await gameRunner).playerProfile(message.playerID)
sendMessage({
type: 'player_profile_result',
id: message.id,
result: profile
} as PlayerProfileResultMessage);
} catch (error) {
console.error('Failed to check borders:', error);
throw error;
}
break;
default:
console.warn('Unknown message :', message);
}
try {
const profile = (await gameRunner).playerProfile(message.playerID);
sendMessage({
type: "player_profile_result",
id: message.id,
result: profile,
} as PlayerProfileResultMessage);
} catch (error) {
console.error("Failed to check borders:", error);
throw error;
}
break;
default:
console.warn("Unknown message :", message);
}
});
// Error handling
ctx.addEventListener('error', (error) => {
console.error('Worker error:', error);
ctx.addEventListener("error", (error) => {
console.error("Worker error:", error);
});
ctx.addEventListener('unhandledrejection', (event) => {
console.error('Unhandled promise rejection in worker:', event);
});
ctx.addEventListener("unhandledrejection", (event) => {
console.error("Unhandled promise rejection in worker:", event);
});
+144 -124
View File
@@ -1,150 +1,170 @@
import { PlayerActions, PlayerID, PlayerInfo, PlayerProfile } from "../game/Game";
import { ErrorUpdate, GameUpdateViewData } from '../game/GameUpdates';
import {
PlayerActions,
PlayerID,
PlayerInfo,
PlayerProfile,
} from "../game/Game";
import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
import { GameConfig, GameID, Turn } from "../Schemas";
import { generateID } from "../Util";
import { WorkerMessage } from "./WorkerMessages";
export class WorkerClient {
private worker: Worker;
private isInitialized = false;
private messageHandlers: Map<string, (message: WorkerMessage) => void>;
private gameUpdateCallback?: (update: GameUpdateViewData | ErrorUpdate) => void;
private worker: Worker;
private isInitialized = false;
private messageHandlers: Map<string, (message: WorkerMessage) => void>;
private gameUpdateCallback?: (
update: GameUpdateViewData | ErrorUpdate,
) => void;
constructor(private gameID: GameID, private gameConfig: GameConfig) {
this.worker = new Worker(new URL('./Worker.worker.ts', import.meta.url));
this.messageHandlers = new Map();
constructor(
private gameID: GameID,
private gameConfig: GameConfig,
) {
this.worker = new Worker(new URL("./Worker.worker.ts", import.meta.url));
this.messageHandlers = new Map();
// Set up global message handler
this.worker.addEventListener('message', this.handleWorkerMessage.bind(this));
}
// Set up global message handler
this.worker.addEventListener(
"message",
this.handleWorkerMessage.bind(this),
);
}
private handleWorkerMessage(event: MessageEvent<WorkerMessage>) {
const message = event.data;
private handleWorkerMessage(event: MessageEvent<WorkerMessage>) {
const message = event.data;
switch (message.type) {
case 'game_update':
if (this.gameUpdateCallback && message.gameUpdate) {
this.gameUpdateCallback(message.gameUpdate);
}
break;
case 'initialized':
default:
if (message.id && this.messageHandlers.has(message.id)) {
const handler = this.messageHandlers.get(message.id)!;
handler(message);
this.messageHandlers.delete(message.id);
}
break;
switch (message.type) {
case "game_update":
if (this.gameUpdateCallback && message.gameUpdate) {
this.gameUpdateCallback(message.gameUpdate);
}
break;
case "initialized":
default:
if (message.id && this.messageHandlers.has(message.id)) {
const handler = this.messageHandlers.get(message.id)!;
handler(message);
this.messageHandlers.delete(message.id);
}
break;
}
}
initialize(): Promise<void> {
return new Promise((resolve, reject) => {
const messageId = generateID()
initialize(): Promise<void> {
return new Promise((resolve, reject) => {
const messageId = generateID();
this.messageHandlers.set(messageId, (message) => {
if (message.type === 'initialized') {
this.isInitialized = true;
resolve();
}
});
this.messageHandlers.set(messageId, (message) => {
if (message.type === "initialized") {
this.isInitialized = true;
resolve();
}
});
this.worker.postMessage({
type: 'init',
id: messageId,
gameID: this.gameID,
gameConfig: this.gameConfig
});
this.worker.postMessage({
type: "init",
id: messageId,
gameID: this.gameID,
gameConfig: this.gameConfig,
});
// Add timeout for initialization
setTimeout(() => {
if (!this.isInitialized) {
this.messageHandlers.delete(messageId);
reject(new Error('Worker initialization timeout'));
}
}, 5000); // 5 second timeout
});
}
start(gameUpdate: (gu: GameUpdateViewData | ErrorUpdate) => void) {
// Add timeout for initialization
setTimeout(() => {
if (!this.isInitialized) {
throw new Error('Failed to initialize pathfinder');
this.messageHandlers.delete(messageId);
reject(new Error("Worker initialization timeout"));
}
this.gameUpdateCallback = gameUpdate;
}, 5000); // 5 second timeout
});
}
start(gameUpdate: (gu: GameUpdateViewData | ErrorUpdate) => void) {
if (!this.isInitialized) {
throw new Error("Failed to initialize pathfinder");
}
this.gameUpdateCallback = gameUpdate;
}
sendTurn(turn: Turn) {
if (!this.isInitialized) {
throw new Error("Worker not initialized");
}
sendTurn(turn: Turn) {
if (!this.isInitialized) {
throw new Error('Worker not initialized');
this.worker.postMessage({
type: "turn",
turn,
});
}
sendHeartbeat() {
this.worker.postMessage({
type: "heartbeat",
});
}
playerProfile(playerID: number): Promise<PlayerProfile> {
return new Promise((resolve, reject) => {
if (!this.isInitialized) {
reject(new Error("Worker not initialized"));
return;
}
const messageId = generateID();
this.messageHandlers.set(messageId, (message) => {
if (
message.type === "player_profile_result" &&
message.result !== undefined
) {
resolve(message.result);
}
});
this.worker.postMessage({
type: 'turn',
turn
});
}
this.worker.postMessage({
type: "player_profile",
id: messageId,
playerID: playerID,
});
});
}
sendHeartbeat() {
this.worker.postMessage({
type: 'heartbeat'
});
}
playerInteraction(
playerID: PlayerID,
x: number,
y: number,
): Promise<PlayerActions> {
return new Promise((resolve, reject) => {
if (!this.isInitialized) {
reject(new Error("Worker not initialized"));
return;
}
playerProfile(playerID: number): Promise<PlayerProfile> {
return new Promise((resolve, reject) => {
if (!this.isInitialized) {
reject(new Error('Worker not initialized'));
return;
}
const messageId = generateID();
const messageId = generateID()
this.messageHandlers.set(messageId, (message) => {
if (
message.type === "player_actions_result" &&
message.result !== undefined
) {
resolve(message.result);
}
});
this.messageHandlers.set(messageId, (message) => {
if (message.type === 'player_profile_result' && message.result !== undefined) {
resolve(message.result);
}
});
this.worker.postMessage({
type: "player_actions",
id: messageId,
playerID: playerID,
x: x,
y: y,
});
});
}
this.worker.postMessage({
type: 'player_profile',
id: messageId,
playerID: playerID,
});
})
}
playerInteraction(playerID: PlayerID, x: number, y: number): Promise<PlayerActions> {
return new Promise((resolve, reject) => {
if (!this.isInitialized) {
reject(new Error('Worker not initialized'));
return;
}
const messageId = generateID()
this.messageHandlers.set(messageId, (message) => {
if (message.type === 'player_actions_result' && message.result !== undefined) {
resolve(message.result);
}
});
this.worker.postMessage({
type: 'player_actions',
id: messageId,
playerID: playerID,
x: x,
y: y
});
});
}
cleanup() {
this.worker.terminate();
this.messageHandlers.clear();
this.gameUpdateCallback = undefined;
}
}
cleanup() {
this.worker.terminate();
this.messageHandlers.clear();
this.gameUpdateCallback = undefined;
}
}
+42 -33
View File
@@ -1,74 +1,83 @@
import { GameUpdateViewData } from '../game/GameUpdates';
import { GameUpdateViewData } from "../game/GameUpdates";
import { GameConfig, GameID, Turn } from "../Schemas";
import { PlayerActions, PlayerID, PlayerProfile } from "../game/Game";
export type WorkerMessageType =
| 'heartbeat'
| 'init'
| 'initialized'
| 'turn'
| 'game_update'
| 'player_actions'
| 'player_actions_result'
| 'player_profile'
| 'player_profile_result'
| "heartbeat"
| "init"
| "initialized"
| "turn"
| "game_update"
| "player_actions"
| "player_actions_result"
| "player_profile"
| "player_profile_result";
// Base interface for all messages
interface BaseWorkerMessage {
type: WorkerMessageType;
id?: string;
type: WorkerMessageType;
id?: string;
}
export interface HeartbeatMessage extends BaseWorkerMessage {
type: 'heartbeat'
type: "heartbeat";
}
// Messages from main thread to worker
export interface InitMessage extends BaseWorkerMessage {
type: 'init';
gameID: GameID;
gameConfig: GameConfig;
type: "init";
gameID: GameID;
gameConfig: GameConfig;
}
export interface TurnMessage extends BaseWorkerMessage {
type: 'turn';
turn: Turn;
type: "turn";
turn: Turn;
}
// Messages from worker to main thread
export interface InitializedMessage extends BaseWorkerMessage {
type: 'initialized';
type: "initialized";
}
export interface GameUpdateMessage extends BaseWorkerMessage {
type: 'game_update';
gameUpdate: GameUpdateViewData;
type: "game_update";
gameUpdate: GameUpdateViewData;
}
export interface PlayerActionsMessage extends BaseWorkerMessage {
type: 'player_actions'
playerID: PlayerID
x: number,
y: number
type: "player_actions";
playerID: PlayerID;
x: number;
y: number;
}
export interface PlayerActionsResultMessage extends BaseWorkerMessage {
type: 'player_actions_result';
result: PlayerActions;
type: "player_actions_result";
result: PlayerActions;
}
export interface PlayerProfileMessage extends BaseWorkerMessage {
type: 'player_profile'
playerID: number
type: "player_profile";
playerID: number;
}
export interface PlayerProfileResultMessage extends BaseWorkerMessage {
type: 'player_profile_result'
result: PlayerProfile
type: "player_profile_result";
result: PlayerProfile;
}
// Union types for type safety
export type MainThreadMessage = HeartbeatMessage | InitMessage | TurnMessage | PlayerActionsMessage | PlayerProfileMessage
export type MainThreadMessage =
| HeartbeatMessage
| InitMessage
| TurnMessage
| PlayerActionsMessage
| PlayerProfileMessage;
// Message send from worker
export type WorkerMessage = InitializedMessage | GameUpdateMessage | PlayerActionsResultMessage | PlayerProfileResultMessage
export type WorkerMessage =
| InitializedMessage
| GameUpdateMessage
| PlayerActionsResultMessage
| PlayerProfileResultMessage;
+25 -25
View File
@@ -1,33 +1,33 @@
declare module '*.png' {
const content: string;
export default content;
declare module "*.png" {
const content: string;
export default content;
}
declare module '*.jpg' {
const value: string;
export default value;
declare module "*.jpg" {
const value: string;
export default value;
}
declare module '*.jpeg' {
const value: string;
export default value;
declare module "*.jpeg" {
const value: string;
export default value;
}
declare module '*.svg' {
const value: string;
export default value;
declare module "*.svg" {
const value: string;
export default value;
}
declare module '*.bin' {
const value: string;
export default value;
declare module "*.bin" {
const value: string;
export default value;
}
declare module '*.txt' {
const value: string;
export default value;
declare module "*.txt" {
const value: string;
export default value;
}
declare module '*.html' {
const content: string;
export default content;
declare module "*.html" {
const content: string;
export default content;
}
declare module "*.json" {
const value: any;
export default value;
}
declare module '*.json' {
const value: any;
export default value;
}
+233 -190
View File
@@ -1,267 +1,310 @@
import { decodePNGFromStream } from 'pureimage'; import path from 'path';
import fs from 'fs/promises';
import { createReadStream } from 'fs';
import { fileURLToPath } from 'url';
import { decodePNGFromStream } from "pureimage";
import path from "path";
import fs from "fs/promises";
import { createReadStream } from "fs";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const mapName = "Europe"
const mapName = "Europe";
interface Coord {
x: number;
y: number;
x: number;
y: number;
}
enum TerrainType {
Land,
Water
Land,
Water,
}
class Terrain {
public shoreline: boolean = false
public magnitude: number = 0
public ocean: boolean
constructor(public type: TerrainType) { }
public shoreline: boolean = false;
public magnitude: number = 0;
public ocean: boolean;
constructor(public type: TerrainType) {}
}
export async function loadTerrainMap(): Promise<void> {
const imagePath = path.resolve(__dirname, '..', '..', 'resources', 'maps', mapName + '.png');
const imagePath = path.resolve(
__dirname,
"..",
"..",
"resources",
"maps",
mapName + ".png",
);
const readStream = createReadStream(imagePath);
const img = await decodePNGFromStream(readStream);
const readStream = createReadStream(imagePath);
const img = await decodePNGFromStream(readStream);
console.log('Image loaded successfully');
console.log('Image dimensions:', img.width, 'x', img.height);
console.log("Image loaded successfully");
console.log("Image dimensions:", img.width, "x", img.height);
const terrain: Terrain[][] = Array(img.width).fill(null).map(() => Array(img.height).fill(null));
const terrain: Terrain[][] = Array(img.width)
.fill(null)
.map(() => Array(img.height).fill(null));
// Iterate through each pixel
for (let x = 0; x < img.width; x++) {
for (let y = 0; y < img.height; y++) {
const color = img.getPixelRGBA(x, y);
const alpha = color & 0xff;
const blue = (color >> 8) & 0xff;
// Iterate through each pixel
for (let x = 0; x < img.width; x++) {
for (let y = 0; y < img.height; y++) {
const color = img.getPixelRGBA(x, y);
const alpha = color & 0xff;
const blue = (color >> 8) & 0xff;
if (alpha < 20 || blue == 106) { // transparent
terrain[x][y] = new Terrain(TerrainType.Water);
} else {
terrain[x][y] = new Terrain(TerrainType.Land)
terrain[x][y].magnitude = 0
if (alpha < 20 || blue == 106) {
// transparent
terrain[x][y] = new Terrain(TerrainType.Water);
} else {
terrain[x][y] = new Terrain(TerrainType.Land);
terrain[x][y].magnitude = 0;
// 140 -> 200 = 60
const mag = Math.min(200, Math.max(140, blue)) - 140
terrain[x][y].magnitude = mag / 2
}
}
// 140 -> 200 = 60
const mag = Math.min(200, Math.max(140, blue)) - 140;
terrain[x][y].magnitude = mag / 2;
}
}
}
removeSmallLakes(terrain)
const shorelineWaters = processShore(terrain)
processDistToLand(shorelineWaters, terrain)
processOcean(terrain)
const outputPath = path.join(__dirname, '..', '..', 'resources', 'maps', mapName + '.bin');
fs.writeFile(outputPath, packTerrain(terrain));
removeSmallLakes(terrain);
const shorelineWaters = processShore(terrain);
processDistToLand(shorelineWaters, terrain);
processOcean(terrain);
const outputPath = path.join(
__dirname,
"..",
"..",
"resources",
"maps",
mapName + ".bin",
);
fs.writeFile(outputPath, packTerrain(terrain));
const miniTerrain = await createMiniMap(terrain)
const miniOutputPath = path.join(__dirname, '..', '..', 'resources', 'maps', mapName + 'Mini.bin');
fs.writeFile(miniOutputPath, packTerrain(miniTerrain))
const miniTerrain = await createMiniMap(terrain);
const miniOutputPath = path.join(
__dirname,
"..",
"..",
"resources",
"maps",
mapName + "Mini.bin",
);
fs.writeFile(miniOutputPath, packTerrain(miniTerrain));
}
export async function createMiniMap(tm: Terrain[][]): Promise<Terrain[][]> {
// Create 2D array properly with correct dimensions
const miniMap: Terrain[][] = Array(Math.floor(tm.length / 2))
.fill(null)
.map(() => Array(Math.floor(tm[0].length / 2)).fill(null));
// Create 2D array properly with correct dimensions
const miniMap: Terrain[][] = Array(Math.floor(tm.length / 2))
.fill(null)
.map(() => Array(Math.floor(tm[0].length / 2)).fill(null));
for (let x = 0; x < tm.length; x++) {
for (let y = 0; y < tm[0].length; y++) {
const miniX = Math.floor(x / 2);
const miniY = Math.floor(y / 2);
for (let x = 0; x < tm.length; x++) {
for (let y = 0; y < tm[0].length; y++) {
const miniX = Math.floor(x / 2);
const miniY = Math.floor(y / 2);
if (miniMap[miniX][miniY] == null || miniMap[miniX][miniY].type != TerrainType.Water) {
// We shrink 4 tiles into 1 tile. If any of the 4 large tiles
// has water, then the mini tile is considered water.
miniMap[miniX][miniY] = tm[x][y]
}
}
if (
miniMap[miniX][miniY] == null ||
miniMap[miniX][miniY].type != TerrainType.Water
) {
// We shrink 4 tiles into 1 tile. If any of the 4 large tiles
// has water, then the mini tile is considered water.
miniMap[miniX][miniY] = tm[x][y];
}
}
return miniMap
}
return miniMap;
}
function processShore(map: Terrain[][]): Coord[] {
const shorelineWaters: Coord[] = []
for (let x = 0; x < map.length; x++) {
for (let y = 0; y < map[0].length; y++) {
const terrain = map[x][y]
const ns = neighbors(x, y, map)
if (terrain.type == TerrainType.Land) {
if (ns.filter(t => t.type == TerrainType.Water).length > 0) {
terrain.shoreline = true
}
} else {
if (ns.filter(t => t.type == TerrainType.Land).length > 0) {
terrain.shoreline = true
shorelineWaters.push({ x, y })
}
}
const shorelineWaters: Coord[] = [];
for (let x = 0; x < map.length; x++) {
for (let y = 0; y < map[0].length; y++) {
const terrain = map[x][y];
const ns = neighbors(x, y, map);
if (terrain.type == TerrainType.Land) {
if (ns.filter((t) => t.type == TerrainType.Water).length > 0) {
terrain.shoreline = true;
}
} else {
if (ns.filter((t) => t.type == TerrainType.Land).length > 0) {
terrain.shoreline = true;
shorelineWaters.push({ x, y });
}
}
}
return shorelineWaters
}
return shorelineWaters;
}
function processDistToLand(shorelineWaters: Coord[], map: Terrain[][]) {
const queue: [Coord, number][] = shorelineWaters.map(coord => [coord, 0]);
const visited = new Set<string>();
const queue: [Coord, number][] = shorelineWaters.map((coord) => [coord, 0]);
const visited = new Set<string>();
while (queue.length > 0) {
const [coord, distance] = queue.shift()!;
const key = `${coord.x},${coord.y}`;
while (queue.length > 0) {
const [coord, distance] = queue.shift()!;
const key = `${coord.x},${coord.y}`;
if (visited.has(key)) continue;
visited.add(key);
if (visited.has(key)) continue;
visited.add(key);
const terrain = map[coord.x][coord.y];
if (terrain.type === TerrainType.Water) {
terrain.magnitude = distance;
const terrain = map[coord.x][coord.y];
if (terrain.type === TerrainType.Water) {
terrain.magnitude = distance;
for (const [dx, dy] of [[-1, 0], [1, 0], [0, -1], [0, 1]]) {
const newX = coord.x + dx;
const newY = coord.y + dy;
for (const [dx, dy] of [
[-1, 0],
[1, 0],
[0, -1],
[0, 1],
]) {
const newX = coord.x + dx;
const newY = coord.y + dy;
if (newX >= 0 && newX < map.length && newY >= 0 && newY < map[0].length) {
queue.push([{ x: newX, y: newY }, distance + 1]);
}
}
if (
newX >= 0 &&
newX < map.length &&
newY >= 0 &&
newY < map[0].length
) {
queue.push([{ x: newX, y: newY }, distance + 1]);
}
}
}
}
}
function neighbors(x: number, y: number, map: Terrain[][]): Terrain[] {
const ns: Terrain[] = []
if (x > 0) {
ns.push(map[x - 1][y])
}
if (x < map.length - 1) {
ns.push(map[x + 1][y])
}
if (y > 0) {
ns.push(map[x][y - 1])
}
if (y < map[0].length - 1) {
ns.push(map[x][y + 1])
}
return ns
const ns: Terrain[] = [];
if (x > 0) {
ns.push(map[x - 1][y]);
}
if (x < map.length - 1) {
ns.push(map[x + 1][y]);
}
if (y > 0) {
ns.push(map[x][y - 1]);
}
if (y < map[0].length - 1) {
ns.push(map[x][y + 1]);
}
return ns;
}
function packTerrain(map: Terrain[][]): Uint8Array {
const width = map.length;
const height = map[0].length;
const packedData = new Uint8Array(4 + width * height);
const width = map.length;
const height = map[0].length;
const packedData = new Uint8Array(4 + width * height);
// Add width and height to the first 4 bytes
packedData[0] = width & 0xFF;
packedData[1] = (width >> 8) & 0xFF;
packedData[2] = height & 0xFF;
packedData[3] = (height >> 8) & 0xFF;
// Add width and height to the first 4 bytes
packedData[0] = width & 0xff;
packedData[1] = (width >> 8) & 0xff;
packedData[2] = height & 0xff;
packedData[3] = (height >> 8) & 0xff;
for (let x = 0; x < width; x++) {
for (let y = 0; y < height; y++) {
const terrain = map[x][y];
let packedByte = 0;
if (terrain == null) {
throw new Error(`terrain null at ${x}:${y}`);
}
for (let x = 0; x < width; x++) {
for (let y = 0; y < height; y++) {
const terrain = map[x][y];
let packedByte = 0;
if (terrain == null) {
throw new Error(`terrain null at ${x}:${y}`)
}
if (terrain.type === TerrainType.Land) {
packedByte |= 0b10000000;
}
if (terrain.shoreline) {
packedByte |= 0b01000000;
}
if (terrain.ocean) {
packedByte |= 0b00100000;
}
if (terrain.type == TerrainType.Land) {
packedByte |= Math.min(Math.ceil(terrain.magnitude), 31);
} else {
packedByte |= Math.min(Math.ceil(terrain.magnitude / 2), 31);
}
if (terrain.type === TerrainType.Land) {
packedByte |= 0b10000000;
}
if (terrain.shoreline) {
packedByte |= 0b01000000;
}
if (terrain.ocean) {
packedByte |= 0b00100000;
}
if (terrain.type == TerrainType.Land) {
packedByte |= Math.min(Math.ceil(terrain.magnitude), 31);
} else {
packedByte |= Math.min(Math.ceil(terrain.magnitude / 2), 31);
}
packedData[4 + y * width + x] = packedByte;
}
packedData[4 + y * width + x] = packedByte;
}
logBinaryAsBits(packedData)
return packedData;
}
logBinaryAsBits(packedData);
return packedData;
}
function processOcean(map: Terrain[][]) {
const queue: Coord[] = [];
if (map[0][0].type == TerrainType.Water) {
queue.push({ x: 0, y: 0 })
} else if (map[map.length - 1][map[0].length - 1].type == TerrainType.Water) {
queue.push({ x: map.length - 1, y: map[0].length - 1 })
} else {
queue.push({ x: 0, y: map[0].length - 1 })
}
const visited = new Set<string>();
const queue: Coord[] = [];
if (map[0][0].type == TerrainType.Water) {
queue.push({ x: 0, y: 0 });
} else if (map[map.length - 1][map[0].length - 1].type == TerrainType.Water) {
queue.push({ x: map.length - 1, y: map[0].length - 1 });
} else {
queue.push({ x: 0, y: map[0].length - 1 });
}
const visited = new Set<string>();
while (queue.length > 0) {
const coord = queue.shift()!;
const key = `${coord.x},${coord.y}`;
while (queue.length > 0) {
const coord = queue.shift()!;
const key = `${coord.x},${coord.y}`;
if (visited.has(key)) continue;
visited.add(key);
if (visited.has(key)) continue;
visited.add(key);
const terrain = map[coord.x][coord.y];
if (terrain.type === TerrainType.Water) {
terrain.ocean = true;
const terrain = map[coord.x][coord.y];
if (terrain.type === TerrainType.Water) {
terrain.ocean = true;
// Check neighbors
for (const [dx, dy] of [[-1, 0], [1, 0], [0, -1], [0, 1]]) {
const newX = coord.x + dx;
const newY = coord.y + dy;
// Check neighbors
for (const [dx, dy] of [
[-1, 0],
[1, 0],
[0, -1],
[0, 1],
]) {
const newX = coord.x + dx;
const newY = coord.y + dy;
if (newX >= 0 && newX < map.length && newY >= 0 && newY < map[0].length) {
queue.push({ x: newX, y: newY });
}
}
if (
newX >= 0 &&
newX < map.length &&
newY >= 0 &&
newY < map[0].length
) {
queue.push({ x: newX, y: newY });
}
}
}
}
}
function removeSmallLakes(map: Terrain[][]) {
console.log(`removing lakes ${map.length}, ${map[0].length}`)
console.log(`removing lakes ${map.length}, ${map[0].length}`);
for (let x = 0; x < map.length; x++) {
for (let y = 0; y < map[0].length; y++) {
if (map[x][y].type != TerrainType.Water) {
continue
}
let allLand = true
for (const neighbor of neighbors(x, y, map)) {
if (neighbor.type != TerrainType.Land) {
allLand = false
}
}
if (allLand) {
map[x][y].type = TerrainType.Land
map[x][y].magnitude = 0
}
for (let x = 0; x < map.length; x++) {
for (let y = 0; y < map[0].length; y++) {
if (map[x][y].type != TerrainType.Water) {
continue;
}
let allLand = true;
for (const neighbor of neighbors(x, y, map)) {
if (neighbor.type != TerrainType.Land) {
allLand = false;
}
}
if (allLand) {
map[x][y].type = TerrainType.Land;
map[x][y].magnitude = 0;
}
}
}
}
function logBinaryAsBits(data: Uint8Array, length: number = 8) {
const bits = Array.from(data.slice(0, length))
.map(b => b.toString(2).padStart(8, '0'))
.join(' ');
console.log('Binary data (bits):', bits);
const bits = Array.from(data.slice(0, length))
.map((b) => b.toString(2).padStart(8, "0"))
.join(" ");
console.log("Binary data (bits):", bits);
}
await loadTerrainMap()
await loadTerrainMap();
+136 -120
View File
@@ -1,7 +1,12 @@
import { GameConfig, GameID, GameRecord, GameRecordSchema, Turn } from "../core/Schemas";
import { Storage } from '@google-cloud/storage';
import { BigQuery } from '@google-cloud/bigquery';
import {
GameConfig,
GameID,
GameRecord,
GameRecordSchema,
Turn,
} from "../core/Schemas";
import { Storage } from "@google-cloud/storage";
import { BigQuery } from "@google-cloud/bigquery";
const storage = new Storage();
const bucket = storage.bucket("openfront-games");
@@ -11,166 +16,177 @@ const MAX_RETRIES = 5;
const INITIAL_RETRY_DELAY_MS = 1000; // Start with 1 second delay
export async function archive(gameRecord: GameRecord) {
try {
// First archive to BigQuery with retries
await withRetry(
() => archiveToBigQuery(gameRecord),
'BigQuery archive',
gameRecord.id
);
try {
// First archive to BigQuery with retries
await withRetry(
() => archiveToBigQuery(gameRecord),
"BigQuery archive",
gameRecord.id,
);
// Then archive to GCS with retries if there are turns
if (gameRecord.turns.length > 0) {
console.log(`${gameRecord.id}: game has more than zero turns, attempting to write to GCS`);
await withRetry(
() => archiveToGCS(gameRecord),
'GCS archive',
gameRecord.id
);
}
} catch (error) {
console.error(`${gameRecord.id}: Final archive error: ${error}`, {
message: error?.message || error,
stack: error?.stack,
name: error?.name,
...(error && typeof error === 'object' ? error : {})
});
// Then archive to GCS with retries if there are turns
if (gameRecord.turns.length > 0) {
console.log(
`${gameRecord.id}: game has more than zero turns, attempting to write to GCS`,
);
await withRetry(
() => archiveToGCS(gameRecord),
"GCS archive",
gameRecord.id,
);
}
} catch (error) {
console.error(`${gameRecord.id}: Final archive error: ${error}`, {
message: error?.message || error,
stack: error?.stack,
name: error?.name,
...(error && typeof error === "object" ? error : {}),
});
}
}
async function delay(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function withRetry<T>(
operation: () => Promise<T>,
operationName: string,
gameId: string,
operation: () => Promise<T>,
operationName: string,
gameId: string,
): Promise<T> {
let lastError: Error | null = null;
let lastError: Error | null = null;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error as Error;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error as Error;
if (attempt < MAX_RETRIES) {
const backoffDelay = INITIAL_RETRY_DELAY_MS * Math.pow(2, attempt);
const jitter = Math.random() * 1000;
const totalDelay = backoffDelay + jitter;
if (attempt < MAX_RETRIES) {
const backoffDelay = INITIAL_RETRY_DELAY_MS * Math.pow(2, attempt);
const jitter = Math.random() * 1000;
const totalDelay = backoffDelay + jitter;
console.log(`${gameId}: ${operationName} attempt ${attempt + 1} failed with ${error}. Retrying in ${Math.round(totalDelay)}ms...`);
await delay(totalDelay);
}
}
console.log(
`${gameId}: ${operationName} attempt ${attempt + 1} failed with ${error}. Retrying in ${Math.round(totalDelay)}ms...`,
);
await delay(totalDelay);
}
}
}
console.error(`${gameId}: All ${MAX_RETRIES + 1} ${operationName} attempts failed. Last error:`, lastError);
throw lastError;
console.error(
`${gameId}: All ${MAX_RETRIES + 1} ${operationName} attempts failed. Last error:`,
lastError,
);
throw lastError;
}
async function archiveToBigQuery(gameRecord: GameRecord) {
const row = {
id: gameRecord.id,
start: new Date(gameRecord.startTimestampMS),
end: new Date(gameRecord.endTimestampMS),
duration_seconds: gameRecord.durationSeconds,
number_turns: gameRecord.num_turns,
game_mode: gameRecord.gameConfig.gameType,
winner: gameRecord.winner,
difficulty: gameRecord.gameConfig.difficulty,
map: gameRecord.gameConfig.gameMap,
players: gameRecord.players.map(p => ({
username: p.username,
ip: anonymizeIP(p.ip),
persistentID: p.persistentID,
clientID: p.clientID,
})),
};
const row = {
id: gameRecord.id,
start: new Date(gameRecord.startTimestampMS),
end: new Date(gameRecord.endTimestampMS),
duration_seconds: gameRecord.durationSeconds,
number_turns: gameRecord.num_turns,
game_mode: gameRecord.gameConfig.gameType,
winner: gameRecord.winner,
difficulty: gameRecord.gameConfig.difficulty,
map: gameRecord.gameConfig.gameMap,
players: gameRecord.players.map((p) => ({
username: p.username,
ip: anonymizeIP(p.ip),
persistentID: p.persistentID,
clientID: p.clientID,
})),
};
const [apiResponse] = await bigquery
.dataset('game_archive')
.table('game_results')
.insert([row]);
const [apiResponse] = await bigquery
.dataset("game_archive")
.table("game_results")
.insert([row]);
console.log(`${gameRecord.id}: wrote game metadata to BigQuery`);
return apiResponse;
console.log(`${gameRecord.id}: wrote game metadata to BigQuery`);
return apiResponse;
}
async function archiveToGCS(gameRecord: GameRecord) {
// Create a deep copy to avoid modifying the original
const recordCopy = JSON.parse(JSON.stringify(gameRecord));
// Create a deep copy to avoid modifying the original
const recordCopy = JSON.parse(JSON.stringify(gameRecord));
// Players may see this so make sure to clear PII
recordCopy.players.forEach(p => {
p.ip = "REDACTED";
p.persistentID = "REDACTED";
});
// Players may see this so make sure to clear PII
recordCopy.players.forEach((p) => {
p.ip = "REDACTED";
p.persistentID = "REDACTED";
});
const file = bucket.file(recordCopy.id);
await file.save(JSON.stringify(GameRecordSchema.parse(recordCopy)), {
contentType: 'application/json'
});
const file = bucket.file(recordCopy.id);
await file.save(JSON.stringify(GameRecordSchema.parse(recordCopy)), {
contentType: "application/json",
});
console.log(`${gameRecord.id}: game record successfully written to GCS`);
console.log(`${gameRecord.id}: game record successfully written to GCS`);
}
function anonymizeIPv4(ipv4: string): string | null {
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
if (!ipv4Regex.test(ipv4)) {
return null;
}
if (!ipv4Regex.test(ipv4)) {
return null;
}
const octets = ipv4.split('.');
const octets = ipv4.split(".");
if (!octets.every(octet => {
const num = parseInt(octet);
return num >= 0 && num <= 255;
})) {
return null;
}
if (
!octets.every((octet) => {
const num = parseInt(octet);
return num >= 0 && num <= 255;
})
) {
return null;
}
octets[3] = 'xxx';
octets[3] = "xxx";
return octets.join('.');
return octets.join(".");
}
function anonymizeIPv6(ipv6: string): string | null {
const ipv6Regex = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i;
const ipv6Regex = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i;
const normalizedIPv6 = ipv6.toUpperCase()
.replace(/([^:]):([^:])/g, '$1:0$2')
.replace(/::/, ':0000:');
const normalizedIPv6 = ipv6
.toUpperCase()
.replace(/([^:]):([^:])/g, "$1:0$2")
.replace(/::/, ":0000:");
if (!ipv6Regex.test(normalizedIPv6)) {
return null;
}
if (!ipv6Regex.test(normalizedIPv6)) {
return null;
}
const segments = normalizedIPv6.split(':');
const segments = normalizedIPv6.split(":");
if (!segments.every(segment => {
const hex = parseInt(segment, 16);
return hex >= 0 && hex <= 65535;
})) {
return null;
}
if (
!segments.every((segment) => {
const hex = parseInt(segment, 16);
return hex >= 0 && hex <= 65535;
})
) {
return null;
}
for (let i = 4; i < 8; i++) {
segments[i] = 'xxxx';
}
for (let i = 4; i < 8; i++) {
segments[i] = "xxxx";
}
return segments.join(':');
return segments.join(":");
}
function anonymizeIP(ip: string): string | null {
const ipv4Result = anonymizeIPv4(ip);
if (ipv4Result) {
return ipv4Result;
}
const ipv4Result = anonymizeIPv4(ip);
if (ipv4Result) {
return ipv4Result;
}
const ipv6 = anonymizeIPv6(ip);
return ipv6
const ipv6 = anonymizeIPv6(ip);
return ipv6;
}
+11 -13
View File
@@ -1,16 +1,14 @@
import WebSocket from 'ws';
import { ClientID } from '../core/Schemas';
import WebSocket from "ws";
import { ClientID } from "../core/Schemas";
export class Client {
public lastPing: number;
public lastPing: number
constructor(
public readonly clientID: ClientID,
public readonly persistentID: string,
public readonly ip: string | null,
public readonly username: string,
public readonly ws: WebSocket,
) { }
}
constructor(
public readonly clientID: ClientID,
public readonly persistentID: string,
public readonly ip: string | null,
public readonly username: string,
public readonly ws: WebSocket,
) {}
}
+56 -55
View File
@@ -1,60 +1,61 @@
import { SecretManagerServiceClient } from '@google-cloud/secret-manager';
import { Client, Events, GatewayIntentBits } from 'discord.js';
import { SecretManagerServiceClient } from "@google-cloud/secret-manager";
import { Client, Events, GatewayIntentBits } from "discord.js";
export class DiscordBot {
private client: Client;
private secretManager: SecretManagerServiceClient;
private client: Client;
private secretManager: SecretManagerServiceClient;
constructor() {
this.client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
});
this.secretManager = new SecretManagerServiceClient();
this.setupEventHandlers();
constructor() {
this.client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
});
this.secretManager = new SecretManagerServiceClient();
this.setupEventHandlers();
}
private setupEventHandlers(): void {
this.client.once(Events.ClientReady, (c) => {
console.log(`Ready! Logged in as ${c.user.tag}`);
});
this.client.on(Events.MessageCreate, async (message) => {
if (message.author.bot) return;
if (message.content === "!ping") {
await message.reply("Pong! 🏓");
}
if (message.content === "!hello") {
await message.reply(`Hello ${message.author.username}! 👋`);
}
});
}
private async getToken(): Promise<string | undefined> {
const name =
"projects/openfrontio/secrets/discord-bot-token/versions/latest";
const [version] = await this.secretManager.accessSecretVersion({ name });
return version.payload?.data?.toString().trim();
}
public async start(): Promise<void> {
try {
const token = await this.getToken();
if (!token) {
throw new Error("Failed to retrieve Discord token");
}
await this.client.login(token);
} catch (error) {
console.error("Failed to start bot:", error);
throw error;
}
}
private setupEventHandlers(): void {
this.client.once(Events.ClientReady, (c) => {
console.log(`Ready! Logged in as ${c.user.tag}`);
});
this.client.on(Events.MessageCreate, async (message) => {
if (message.author.bot) return;
if (message.content === '!ping') {
await message.reply('Pong! 🏓');
}
if (message.content === '!hello') {
await message.reply(`Hello ${message.author.username}! 👋`);
}
});
}
private async getToken(): Promise<string | undefined> {
const name = 'projects/openfrontio/secrets/discord-bot-token/versions/latest';
const [version] = await this.secretManager.accessSecretVersion({ name });
return version.payload?.data?.toString().trim();
}
public async start(): Promise<void> {
try {
const token = await this.getToken();
if (!token) {
throw new Error('Failed to retrieve Discord token');
}
await this.client.login(token);
} catch (error) {
console.error('Failed to start bot:', error);
throw error;
}
}
public stop(): void {
this.client.destroy();
}
}
public stop(): void {
this.client.destroy();
}
}
+85 -90
View File
@@ -1,104 +1,99 @@
import { Config, ServerConfig } from "../core/configuration/Config";
import { ClientID, GameConfig, GameID } from "../core/Schemas";
import { v4 as uuidv4 } from 'uuid';
import { v4 as uuidv4 } from "uuid";
import { Client } from "./Client";
import { GamePhase, GameServer } from "./GameServer";
import { Difficulty, GameMapType, GameType } from "../core/game/Game";
import { generateID } from "../core/Util";
export class GameManager {
private lastNewLobby: number = 0;
private lastNewLobby: number = 0
private games: GameServer[] = [];
private games: GameServer[] = []
constructor(private config: ServerConfig) {}
constructor(private config: ServerConfig) { }
public game(id: GameID): GameServer | null {
return this.games.find((g) => g.id == id);
}
public game(id: GameID): GameServer | null {
return this.games.find(g => g.id == id)
gamesByPhase(phase: GamePhase): GameServer[] {
return this.games.filter((g) => g.phase() == phase);
}
addClient(client: Client, gameID: GameID, lastTurn: number) {
const game = this.games.find((g) => g.id == gameID);
if (!game) {
console.log(`game id ${gameID} not found`);
return;
}
game.addClient(client, lastTurn);
}
updateGameConfig(gameID: GameID, gameConfig: GameConfig) {
const game = this.games.find((g) => g.id == gameID);
if (game == null) {
console.warn(`game ${gameID} not found`);
return;
}
game.updateGameConfig(gameConfig);
}
createPrivateGame(): string {
const id = generateID();
this.games.push(
new GameServer(id, Date.now(), false, this.config, {
gameMap: GameMapType.World,
gameType: GameType.Private,
difficulty: Difficulty.Medium,
}),
);
return id;
}
hasActiveGame(gameID: GameID): boolean {
const game = this.games
.filter(
(g) => g.phase() == GamePhase.Lobby || g.phase() == GamePhase.Active,
)
.find((g) => g.id == gameID);
return game != null;
}
// TODO: stop private games to prevent memory leak.
startPrivateGame(gameID: GameID) {
const game = this.games.find((g) => g.id == gameID);
console.log(`found game ${game}`);
if (game) {
game.start();
} else {
throw new Error(`cannot start private game, game ${gameID} not found`);
}
}
tick() {
const lobbies = this.gamesByPhase(GamePhase.Lobby);
const active = this.gamesByPhase(GamePhase.Active);
const finished = this.gamesByPhase(GamePhase.Finished);
const now = Date.now();
if (now > this.lastNewLobby + this.config.gameCreationRate()) {
this.lastNewLobby = now;
lobbies.push(
new GameServer(generateID(), now, true, this.config, {
gameMap: GameMapType.World,
gameType: GameType.Public,
difficulty: Difficulty.Medium,
}),
);
}
gamesByPhase(phase: GamePhase): GameServer[] {
return this.games.filter(g => g.phase() == phase)
}
addClient(client: Client, gameID: GameID, lastTurn: number) {
const game = this.games.find(g => g.id == gameID)
if (!game) {
console.log(`game id ${gameID} not found`)
return
}
game.addClient(client, lastTurn)
}
updateGameConfig(gameID: GameID, gameConfig: GameConfig) {
const game = this.games.find(g => g.id == gameID)
if (game == null) {
console.warn(`game ${gameID} not found`)
return
}
game.updateGameConfig(gameConfig)
}
createPrivateGame(): string {
const id = generateID()
this.games.push(new GameServer(
id,
Date.now(),
false,
this.config,
{
gameMap: GameMapType.World,
gameType: GameType.Private,
difficulty: Difficulty.Medium
}
))
return id
}
hasActiveGame(gameID: GameID): boolean {
const game = this.games.filter(g => g.phase() == GamePhase.Lobby || g.phase() == GamePhase.Active).find(g => g.id == gameID)
return game != null
}
// TODO: stop private games to prevent memory leak.
startPrivateGame(gameID: GameID) {
const game = this.games.find(g => g.id == gameID)
console.log(`found game ${game}`)
if (game) {
game.start()
} else {
throw new Error(`cannot start private game, game ${gameID} not found`)
}
}
tick() {
const lobbies = this.gamesByPhase(GamePhase.Lobby)
const active = this.gamesByPhase(GamePhase.Active)
const finished = this.gamesByPhase(GamePhase.Finished)
const now = Date.now()
if (now > this.lastNewLobby + this.config.gameCreationRate()) {
this.lastNewLobby = now
lobbies.push(new GameServer(
generateID(),
now,
true,
this.config,
{
gameMap: GameMapType.World,
gameType: GameType.Public,
difficulty: Difficulty.Medium
}
))
}
active.filter(g => !g.hasStarted() && g.isPublic).forEach(g => {
g.start()
})
finished.map(g => g.endGame()); // Fire and forget
this.games = [...lobbies, ...active]
}
}
active
.filter((g) => !g.hasStarted() && g.isPublic)
.forEach((g) => {
g.start();
});
finished.map((g) => g.endGame()); // Fire and forget
this.games = [...lobbies, ...active];
}
}
+270 -236
View File
@@ -1,280 +1,314 @@
import { ClientID, ClientMessage, ClientMessageSchema, GameConfig, GameRecordSchema, Intent, PlayerRecord, ServerPingMessageSchema, ServerStartGameMessage, ServerStartGameMessageSchema, ServerTurnMessageSchema, Turn } from "../core/Schemas";
import {
ClientID,
ClientMessage,
ClientMessageSchema,
GameConfig,
GameRecordSchema,
Intent,
PlayerRecord,
ServerPingMessageSchema,
ServerStartGameMessage,
ServerStartGameMessageSchema,
ServerTurnMessageSchema,
Turn,
} from "../core/Schemas";
import { Config, ServerConfig } from "../core/configuration/Config";
import { Client } from "./Client";
import WebSocket from 'ws';
import WebSocket from "ws";
import { slog } from "./StructuredLog";
import { CreateGameRecord } from "../core/Util";
import { archive } from "./Archive";
export enum GamePhase {
Lobby = 'LOBBY',
Active = 'ACTIVE',
Finished = 'FINISHED'
Lobby = "LOBBY",
Active = "ACTIVE",
Finished = "FINISHED",
}
export class GameServer {
private maxGameDuration = 5 * 60 * 60 * 1000; // 5 hours
private turns: Turn[] = [];
private intents: Intent[] = [];
public activeClients: Client[] = [];
// Used for record record keeping
private allClients: Map<ClientID, Client> = new Map();
private _hasStarted = false;
private _startTime: number = null;
private maxGameDuration = 5 * 60 * 60 * 1000 // 5 hours
private endTurnIntervalID;
private turns: Turn[] = []
private intents: Intent[] = []
public activeClients: Client[] = []
// Used for record record keeping
private allClients: Map<ClientID, Client> = new Map()
private _hasStarted = false
private _startTime: number = null
private lastPingUpdate = 0;
private endTurnIntervalID
private winner: ClientID | null = null;
private lastPingUpdate = 0
constructor(
public readonly id: string,
public readonly createdAt: number,
public readonly isPublic: boolean,
private config: ServerConfig,
private gameConfig: GameConfig,
) {}
private winner: ClientID | null = null
public updateGameConfig(gameConfig: GameConfig): void {
if (gameConfig.gameMap != null) {
this.gameConfig.gameMap = gameConfig.gameMap;
}
if (gameConfig.difficulty != null) {
this.gameConfig.difficulty = gameConfig.difficulty;
}
}
constructor(
public readonly id: string,
public readonly createdAt: number,
public readonly isPublic: boolean,
private config: ServerConfig,
private gameConfig: GameConfig,
public addClient(client: Client, lastTurn: number) {
console.log(`${this.id}: adding client ${client.clientID}`);
slog({
logKey: "client_joined_game",
msg: `client ${client.clientID} (re)joining game ${this.id}`,
data: {
clientID: client.clientID,
clientIP: client.ip,
gameID: this.id,
isRejoin: lastTurn > 0,
},
clientID: client.clientID,
persistentID: client.persistentID,
gameID: this.id,
});
// Remove stale client if this is a reconnect
const existing = this.activeClients.find(
(c) => c.clientID == client.clientID,
);
if (existing != null) {
existing.ws.removeAllListeners("message");
}
this.activeClients = this.activeClients.filter(
(c) => c.clientID != client.clientID,
);
this.activeClients.push(client);
client.lastPing = Date.now();
) { }
this.allClients.set(client.clientID, client);
public updateGameConfig(gameConfig: GameConfig): void {
if (gameConfig.gameMap != null) {
this.gameConfig.gameMap = gameConfig.gameMap
client.ws.on("message", (message: string) => {
try {
const clientMsg: ClientMessage = ClientMessageSchema.parse(
JSON.parse(message),
);
if (clientMsg.type == "intent") {
if (clientMsg.gameID == this.id) {
this.addIntent(clientMsg.intent);
} else {
console.warn(
`${this.id}: client ${clientMsg.clientID} sent to wrong game`,
);
}
}
if (gameConfig.difficulty != null) {
this.gameConfig.difficulty = gameConfig.difficulty
if (clientMsg.type == "ping") {
this.lastPingUpdate = Date.now();
client.lastPing = Date.now();
}
}
public addClient(client: Client, lastTurn: number) {
console.log(`${this.id}: adding client ${client.clientID}`)
slog({
logKey: 'client_joined_game',
msg: `client ${client.clientID} (re)joining game ${this.id}`,
data: {
clientID: client.clientID,
clientIP: client.ip,
gameID: this.id,
isRejoin: lastTurn > 0
},
clientID: client.clientID,
persistentID: client.persistentID,
gameID: this.id,
})
// Remove stale client if this is a reconnect
const existing = this.activeClients.find(c => c.clientID == client.clientID)
if (existing != null) {
existing.ws.removeAllListeners('message')
if (clientMsg.type == "winner") {
this.winner = clientMsg.winner;
}
this.activeClients = this.activeClients.filter(c => c.clientID != client.clientID)
this.activeClients.push(client)
client.lastPing = Date.now()
} catch (error) {
console.log(
`error handline websocket request in game server: ${error}`,
);
}
});
client.ws.on("close", () => {
console.log(`${this.id}: client ${client.clientID} disconnected`);
this.activeClients = this.activeClients.filter(
(c) => c.clientID != client.clientID,
);
});
this.allClients.set(client.clientID, client)
client.ws.on('message', (message: string) => {
try {
const clientMsg: ClientMessage = ClientMessageSchema.parse(JSON.parse(message))
if (clientMsg.type == "intent") {
if (clientMsg.gameID == this.id) {
this.addIntent(clientMsg.intent)
} else {
console.warn(`${this.id}: client ${clientMsg.clientID} sent to wrong game`)
}
}
if (clientMsg.type == "ping") {
this.lastPingUpdate = Date.now()
client.lastPing = Date.now()
}
if (clientMsg.type == "winner") {
this.winner = clientMsg.winner
}
} catch (error) {
console.log(`error handline websocket request in game server: ${error}`)
}
})
client.ws.on('close', () => {
console.log(`${this.id}: client ${client.clientID} disconnected`)
this.activeClients = this.activeClients.filter(c => c.clientID != client.clientID)
})
// In case a client joined the game late and missed the start message.
if (this._hasStarted) {
this.sendStartGameMsg(client.ws, lastTurn)
}
// In case a client joined the game late and missed the start message.
if (this._hasStarted) {
this.sendStartGameMsg(client.ws, lastTurn);
}
}
public numClients(): number {
return this.activeClients.length
public numClients(): number {
return this.activeClients.length;
}
public startTime(): number {
if (this._startTime > 0) {
return this._startTime;
} else {
//game hasn't started yet, only works for public games
return this.createdAt + this.config.lobbyLifetime();
}
}
public startTime(): number {
if (this._startTime > 0) {
return this._startTime
} else {
//game hasn't started yet, only works for public games
return this.createdAt + this.config.lobbyLifetime()
}
}
public start() {
this._hasStarted = true;
this._startTime = Date.now();
// Set last ping to start so we don't immediately stop the game
// if no client connects/pings.
this.lastPingUpdate = Date.now();
public start() {
this._hasStarted = true
this._startTime = Date.now()
// Set last ping to start so we don't immediately stop the game
// if no client connects/pings.
this.lastPingUpdate = Date.now()
this.endTurnIntervalID = setInterval(
() => this.endTurn(),
this.config.turnIntervalMs(),
);
this.activeClients.forEach((c) => {
console.log(`${this.id}: sending start message to ${c.clientID}`);
this.sendStartGameMsg(c.ws, 0);
});
}
this.endTurnIntervalID = setInterval(() => this.endTurn(), this.config.turnIntervalMs());
this.activeClients.forEach(c => {
console.log(`${this.id}: sending start message to ${c.clientID}`)
this.sendStartGameMsg(c.ws, 0)
})
}
private addIntent(intent: Intent) {
this.intents.push(intent);
}
private addIntent(intent: Intent) {
this.intents.push(intent)
}
private sendStartGameMsg(ws: WebSocket, lastTurn: number) {
ws.send(
JSON.stringify(
ServerStartGameMessageSchema.parse({
type: "start",
turns: this.turns.slice(lastTurn),
config: this.gameConfig,
}),
),
);
}
private sendStartGameMsg(ws: WebSocket, lastTurn: number) {
ws.send(JSON.stringify(ServerStartGameMessageSchema.parse(
{
type: "start",
turns: this.turns.slice(lastTurn),
config: this.gameConfig
}
)))
}
private endTurn() {
const pastTurn: Turn = {
turnNumber: this.turns.length,
gameID: this.id,
intents: this.intents,
};
this.turns.push(pastTurn);
this.intents = [];
private endTurn() {
const pastTurn: Turn = {
turnNumber: this.turns.length,
gameID: this.id,
intents: this.intents
}
this.turns.push(pastTurn)
this.intents = []
const msg = JSON.stringify(
ServerTurnMessageSchema.parse({
type: "turn",
turn: pastTurn,
}),
);
this.activeClients.forEach((c) => {
c.ws.send(msg);
});
}
const msg = JSON.stringify(ServerTurnMessageSchema.parse(
{
type: "turn",
turn: pastTurn
}
))
this.activeClients.forEach(c => {
c.ws.send(msg)
})
}
async endGame() {
// Close all WebSocket connections
clearInterval(this.endTurnIntervalID);
this.activeClients.forEach(client => {
client.ws.removeAllListeners('message'); // TODO: remove this?
if (client.ws.readyState === WebSocket.OPEN) {
client.ws.close(1000, "game has ended");
}
});
console.log(`${this.id}: ending game ${this.id} with ${this.turns.length} turns`)
async endGame() {
// Close all WebSocket connections
clearInterval(this.endTurnIntervalID);
this.activeClients.forEach((client) => {
client.ws.removeAllListeners("message"); // TODO: remove this?
if (client.ws.readyState === WebSocket.OPEN) {
client.ws.close(1000, "game has ended");
}
});
console.log(
`${this.id}: ending game ${this.id} with ${this.turns.length} turns`,
);
try {
if (this.allClients.size > 0) {
const playerRecords: PlayerRecord[] = Array.from(
this.allClients.values(),
).map((client) => ({
ip: client.ip,
clientID: client.clientID,
username: client.username,
persistentID: client.persistentID,
}));
archive(
CreateGameRecord(
this.id,
this.gameConfig,
playerRecords,
this.turns,
this._startTime,
Date.now(),
this.winner,
),
);
} else {
console.log(`${this.id}: no clients joined, not archiving game`);
}
} catch (error) {
let errorDetails;
if (error instanceof Error) {
errorDetails = {
message: error.message,
stack: error.stack,
};
} else if (Array.isArray(error)) {
errorDetails = error; // Now we'll actually see the array contents
} else {
try {
if (this.allClients.size > 0) {
const playerRecords: PlayerRecord[] = Array.from(this.allClients.values()).map(client => ({
ip: client.ip,
clientID: client.clientID,
username: client.username,
persistentID: client.persistentID,
}));
archive(
CreateGameRecord(
this.id,
this.gameConfig,
playerRecords,
this.turns,
this._startTime,
Date.now(),
this.winner
)
)
} else {
console.log(`${this.id}: no clients joined, not archiving game`)
}
} catch (error) {
let errorDetails;
if (error instanceof Error) {
errorDetails = {
message: error.message,
stack: error.stack
};
} else if (Array.isArray(error)) {
errorDetails = error; // Now we'll actually see the array contents
} else {
try {
errorDetails = JSON.stringify(error, null, 2);
} catch (e) {
errorDetails = String(error);
}
}
console.error("Error archiving game record details:", {
gameId: this.id,
errorType: typeof error,
error: errorDetails
});
errorDetails = JSON.stringify(error, null, 2);
} catch (e) {
errorDetails = String(error);
}
}
console.error("Error archiving game record details:", {
gameId: this.id,
errorType: typeof error,
error: errorDetails,
});
}
}
phase(): GamePhase {
const now = Date.now();
const alive = [];
for (const client of this.activeClients) {
if (now - client.lastPing > 60_000) {
console.log(
`${this.id}: no pings from ${client.clientID}, terminating connection`,
);
if (client.ws.readyState === WebSocket.OPEN) {
client.ws.close(1000, "no heartbeats received, closing connection");
}
} else {
alive.push(client);
}
}
this.activeClients = alive;
if (
now >
this.createdAt + this.config.lobbyLifetime() + this.maxGameDuration
) {
console.warn(`${this.id}: game past max duration ${this.id}`);
return GamePhase.Finished;
}
phase(): GamePhase {
const now = Date.now()
const alive = []
for (const client of this.activeClients) {
if (now - client.lastPing > 60_000) {
console.log(`${this.id}: no pings from ${client.clientID}, terminating connection`)
if (client.ws.readyState === WebSocket.OPEN) {
client.ws.close(1000, "no heartbeats received, closing connection");
}
} else {
alive.push(client)
}
}
this.activeClients = alive
if (now > this.createdAt + this.config.lobbyLifetime() + this.maxGameDuration) {
console.warn(`${this.id}: game past max duration ${this.id}`)
return GamePhase.Finished
}
const noRecentPings = now > this.lastPingUpdate + 20 * 1000;
const noActive = this.activeClients.length == 0;
const noRecentPings = now > this.lastPingUpdate + 20 * 1000
const noActive = this.activeClients.length == 0
if (!this.isPublic) {
if (this._hasStarted) {
if (noActive && noRecentPings) {
console.log(`${this.id}: private game: ${this.id} complete`)
return GamePhase.Finished
} else {
return GamePhase.Active
}
} else {
return GamePhase.Lobby
}
if (!this.isPublic) {
if (this._hasStarted) {
if (noActive && noRecentPings) {
console.log(`${this.id}: private game: ${this.id} complete`);
return GamePhase.Finished;
} else {
return GamePhase.Active;
}
if (now - this.createdAt < this.config.lobbyLifetime()) {
return GamePhase.Lobby
}
const warmupOver = now > this.createdAt + this.config.lobbyLifetime() + 30 * 1000
if (noActive && warmupOver && noRecentPings) {
return GamePhase.Finished
}
return GamePhase.Active
} else {
return GamePhase.Lobby;
}
}
hasStarted(): boolean {
return this._hasStarted
if (now - this.createdAt < this.config.lobbyLifetime()) {
return GamePhase.Lobby;
}
const warmupOver =
now > this.createdAt + this.config.lobbyLifetime() + 30 * 1000;
if (noActive && warmupOver && noRecentPings) {
return GamePhase.Finished;
}
return GamePhase.Active;
}
}
hasStarted(): boolean {
return this._hasStarted;
}
}
+169 -150
View File
@@ -1,17 +1,26 @@
import express, { json } from 'express';
import http from 'http';
import { WebSocketServer } from 'ws';
import path from 'path';
import { fileURLToPath } from 'url';
import { GameManager } from './GameManager';
import { ClientMessage, ClientMessageSchema, GameRecord, GameRecordSchema, LogSeverity } from '../core/Schemas';
import { getConfig, getServerConfig } from '../core/configuration/Config';
import { slog } from './StructuredLog';
import { Client } from './Client';
import { GamePhase, GameServer } from './GameServer';
import { archive } from './Archive';
import { DiscordBot } from './DiscordBot';
import { sanitizeUsername, validateUsername } from "../core/validations/username";
import express, { json } from "express";
import http from "http";
import { WebSocketServer } from "ws";
import path from "path";
import { fileURLToPath } from "url";
import { GameManager } from "./GameManager";
import {
ClientMessage,
ClientMessageSchema,
GameRecord,
GameRecordSchema,
LogSeverity,
} from "../core/Schemas";
import { getConfig, getServerConfig } from "../core/configuration/Config";
import { slog } from "./StructuredLog";
import { Client } from "./Client";
import { GamePhase, GameServer } from "./GameServer";
import { archive } from "./Archive";
import { DiscordBot } from "./DiscordBot";
import {
sanitizeUsername,
validateUsername,
} from "../core/validations/username";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -21,170 +30,180 @@ const server = http.createServer(app);
const wss = new WebSocketServer({ server });
// Serve static files from the 'out' directory
app.use(express.static(path.join(__dirname, '../../out')));
app.use(express.json())
app.use(express.static(path.join(__dirname, "../../out")));
app.use(express.json());
const gm = new GameManager(getServerConfig())
const gm = new GameManager(getServerConfig());
const bot = new DiscordBot();
try {
await bot.start();
await bot.start();
} catch (error) {
console.error('Failed to start bot:', error);
console.error("Failed to start bot:", error);
}
// New GET endpoint to list lobbies
app.get('/lobbies', (req, res) => {
const now = Date.now()
res.json({
lobbies: gm.gamesByPhase(GamePhase.Lobby)
.filter(g => g.isPublic)
.map(g => ({ id: g.id, msUntilStart: g.startTime() - now, numClients: g.numClients() }))
.sort((a, b) => a.msUntilStart - b.msUntilStart),
});
app.get("/lobbies", (req, res) => {
const now = Date.now();
res.json({
lobbies: gm
.gamesByPhase(GamePhase.Lobby)
.filter((g) => g.isPublic)
.map((g) => ({
id: g.id,
msUntilStart: g.startTime() - now,
numClients: g.numClients(),
}))
.sort((a, b) => a.msUntilStart - b.msUntilStart),
});
});
app.post('/private_lobby', (req, res) => {
const id = gm.createPrivateGame()
console.log('creating private lobby with id ${id}')
res.json({
id: id
});
app.post("/private_lobby", (req, res) => {
const id = gm.createPrivateGame();
console.log("creating private lobby with id ${id}");
res.json({
id: id,
});
});
app.post('/archive_singleplayer_game', (req, res) => {
app.post("/archive_singleplayer_game", (req, res) => {
try {
const gameRecord: GameRecord = req.body;
const clientIP = req.ip || req.socket.remoteAddress || "unknown"; // Added this line
if (!gameRecord) {
console.log("game record not found in request");
res.status(404).json({ error: "Game record not found" });
return;
}
gameRecord.players.forEach((p) => (p.ip = clientIP));
GameRecordSchema.parse(gameRecord);
archive(gameRecord);
res.json({
success: true,
});
} catch (error) {
slog({
logKey: "complete_single_player_game_record",
msg: `Failed to complete game record: ${error}`,
severity: LogSeverity.Error,
});
res.status(400).json({ error: "Invalid game record format" });
}
});
app.post("/start_private_lobby/:id", (req, res) => {
console.log(`starting private lobby with id ${req.params.id}`);
gm.startPrivateGame(req.params.id);
});
app.put("/private_lobby/:id", (req, res) => {
const lobbyID = req.params.id;
gm.updateGameConfig(lobbyID, {
gameMap: req.body.gameMap,
difficulty: req.body.difficulty,
});
});
app.get("/lobby/:id/exists", (req, res) => {
const lobbyId = req.params.id;
console.log(`checking lobby ${lobbyId} exists`);
const lobbyExists = gm.hasActiveGame(lobbyId);
res.json({
exists: lobbyExists,
});
});
app.get("/lobby/:id", (req, res) => {
const game = gm.game(req.params.id);
if (game == null) {
console.log(`lobby ${req.params.id} not found`);
return res.status(404).json({ error: "Game not found" });
}
res.json({
players: game.activeClients.map((c) => ({
username: c.username,
clientID: c.clientID,
})),
});
});
app.get("/private_lobby/:id", (req, res) => {
res.json({
hi: "5",
});
});
wss.on("connection", (ws, req) => {
ws.on("message", (message: string) => {
try {
const gameRecord: GameRecord = req.body
const clientIP = req.ip || req.socket.remoteAddress || 'unknown'; // Added this line
if (!gameRecord) {
console.log('game record not found in request')
res.status(404).json({ error: 'Game record not found' });
return;
const clientMsg: ClientMessage = ClientMessageSchema.parse(
JSON.parse(message),
);
slog({
logKey: "websocket_msg",
msg: "server received websocket message",
data: clientMsg,
severity: LogSeverity.Debug,
});
if (clientMsg.type == "join") {
const forwarded = req.headers["x-forwarded-for"];
let ip = Array.isArray(forwarded)
? forwarded[0] // Get the first IP if it's an array
: forwarded || req.socket.remoteAddress;
if (Array.isArray(ip)) {
ip = ip[0];
}
gameRecord.players.forEach(p => p.ip = clientIP)
GameRecordSchema.parse(gameRecord);
archive(gameRecord)
res.json({
success: true,
});
} catch (error) {
const { isValid, error } = validateUsername(clientMsg.username);
if (!isValid) {
console.log(
`game ${clientMsg.gameID}, client ${clientMsg.clientID} received invalid username, ${error}`,
);
return;
}
clientMsg.username = sanitizeUsername(clientMsg.username);
gm.addClient(
new Client(
clientMsg.clientID,
clientMsg.persistentID,
ip,
clientMsg.username,
ws,
),
clientMsg.gameID,
clientMsg.lastTurn,
);
}
if (clientMsg.type == "log") {
slog({
logKey: 'complete_single_player_game_record',
msg: `Failed to complete game record: ${error}`,
severity: LogSeverity.Error,
logKey: "client_console_log",
msg: clientMsg.log,
severity: clientMsg.severity,
clientID: clientMsg.clientID,
gameID: clientMsg.gameID,
persistentID: clientMsg.persistentID,
});
res.status(400).json({ error: 'Invalid game record format' });
}
} catch (error) {
console.log(`errror handling websocket message: ${error}`);
}
})
app.post('/start_private_lobby/:id', (req, res) => {
console.log(`starting private lobby with id ${req.params.id}`)
gm.startPrivateGame(req.params.id)
});
app.put('/private_lobby/:id', (req, res) => {
const lobbyID = req.params.id
gm.updateGameConfig(lobbyID, { gameMap: req.body.gameMap, difficulty: req.body.difficulty })
});
app.get('/lobby/:id/exists', (req, res) => {
const lobbyId = req.params.id;
console.log(`checking lobby ${lobbyId} exists`)
const lobbyExists = gm.hasActiveGame(lobbyId);
res.json({
exists: lobbyExists
});
});
app.get('/lobby/:id', (req, res) => {
const game = gm.game(req.params.id)
if (game == null) {
console.log(`lobby ${req.params.id} not found`)
return res.status(404).json({ error: 'Game not found' });
}
res.json({
players: game.activeClients.map(c => ({
username: c.username,
clientID: c.clientID
}))
});
});
app.get('/private_lobby/:id', (req, res) => {
res.json({
hi: '5'
});
});
wss.on('connection', (ws, req) => {
ws.on('message', (message: string) => {
try {
const clientMsg: ClientMessage = ClientMessageSchema.parse(JSON.parse(message))
slog({
logKey: 'websocket_msg',
msg: 'server received websocket message',
data: clientMsg,
severity: LogSeverity.Debug
})
if (clientMsg.type == "join") {
const forwarded = req.headers['x-forwarded-for']
let ip = Array.isArray(forwarded)
? forwarded[0] // Get the first IP if it's an array
: forwarded || req.socket.remoteAddress;
if (Array.isArray(ip)) {
ip = ip[0]
}
const { isValid, error } = validateUsername(clientMsg.username);
if (!isValid) {
console.log(`game ${clientMsg.gameID}, client ${clientMsg.clientID} received invalid username, ${error}`)
return;
}
clientMsg.username = sanitizeUsername(clientMsg.username)
gm.addClient(
new Client(
clientMsg.clientID,
clientMsg.persistentID,
ip,
clientMsg.username,
ws
),
clientMsg.gameID,
clientMsg.lastTurn
)
}
if (clientMsg.type == "log") {
slog({
logKey: "client_console_log",
msg: clientMsg.log,
severity: clientMsg.severity,
clientID: clientMsg.clientID,
gameID: clientMsg.gameID,
persistentID: clientMsg.persistentID,
})
}
} catch (error) {
console.log(`errror handling websocket message: ${error}`)
}
})
});
});
function runGame() {
setInterval(() => tick(), 1000);
setInterval(() => tick(), 1000);
}
function tick() {
gm.tick()
gm.tick();
}
const PORT = process.env.PORT || 3000;
console.log(`Server will try to run on http://localhost:${PORT}`);
server.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
console.log(`Server is running on http://localhost:${PORT}`);
});
runGame()
runGame();
+25 -25
View File
@@ -1,33 +1,33 @@
import { ClientID, GameID, LogSeverity } from "../core/Schemas";
export interface slogMsg {
logKey: string,
msg: string,
data?: any
severity?: LogSeverity
gameID?: GameID
clientID?: ClientID
persistentID?: string
logKey: string;
msg: string;
data?: any;
severity?: LogSeverity;
gameID?: GameID;
clientID?: ClientID;
persistentID?: string;
}
export function slog(msg: slogMsg): void {
msg.severity = msg.severity ?? LogSeverity.Info;
msg.severity = msg.severity ?? LogSeverity.Info;
if (process.env.GAME_ENV == 'dev') {
// Avoid blowing up the log during development.
if (msg.logKey == 'client_console_log') {
return
}
if (msg.severity != LogSeverity.Debug) {
console.log(msg.msg)
}
} else {
try {
console.log(JSON.stringify(msg));
} catch (error) {
console.error('Failed to stringify log message:', error);
// Fallback to basic logging
console.log(`${msg.severity}: ${msg.msg}`);
}
if (process.env.GAME_ENV == "dev") {
// Avoid blowing up the log during development.
if (msg.logKey == "client_console_log") {
return;
}
}
if (msg.severity != LogSeverity.Debug) {
console.log(msg.msg);
}
} else {
try {
console.log(JSON.stringify(msg));
} catch (error) {
console.error("Failed to stringify log message:", error);
// Fallback to basic logging
console.log(`${msg.severity}: ${msg.msg}`);
}
}
}