Merge branch 'main' into configure-cors

This commit is contained in:
evanpelle
2025-06-11 13:41:31 -07:00
committed by GitHub
62 changed files with 2941 additions and 442 deletions
+3 -3
View File
@@ -24,7 +24,7 @@ FROM base
COPY --from=dependencies / /
ARG GIT_COMMIT=unknown
ENV GIT_COMMIT=$GIT_COMMIT
ENV GIT_COMMIT="$GIT_COMMIT"
# Set the working directory in the container
WORKDIR /usr/src/app
@@ -35,7 +35,7 @@ COPY package*.json ./
# Install dependencies while bypassing Husky hooks
ENV HUSKY=0
ENV NPM_CONFIG_IGNORE_SCRIPTS=1
RUN mkdir -p .git && npm install
RUN mkdir -p .git && npm ci
# Copy the rest of the application code
COPY . .
@@ -45,7 +45,7 @@ RUN npm run build-prod
# So we can see which commit was used to build the container
# https://openfront.io/commit.txt
RUN echo $GIT_COMMIT > static/commit.txt
RUN echo "$GIT_COMMIT" > static/commit.txt
# Copy Nginx configuration and ensure it's used instead of the default
COPY nginx.conf /etc/nginx/conf.d/default.conf
+1428 -19
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -44,6 +44,7 @@
"autoprefixer": "^10.4.20",
"babel-jest": "^29.7.0",
"binary-base64-loader": "^1.0.0",
"canvas": "^3.1.0",
"chai": "^5.1.1",
"concurrently": "^8.2.2",
"cross-env": "^7.0.3",
@@ -58,6 +59,7 @@
"html-loader": "^5.1.0",
"husky": "^9.1.7",
"jest": "^29.7.0",
"jest-environment-jsdom": "^30.0.0-beta.3",
"lint-staged": "^15.4.3",
"mrmime": "^2.0.0",
"postcss": "^8.5.1",
@@ -123,6 +125,7 @@
"jimp": "^0.22.12",
"jose": "^6.0.10",
"lit": "^3.2.1",
"lit-markdown": "^1.3.2",
"msgpack5": "^6.0.2",
"nanoid": "^3.3.6",
"node-addon-api": "^8.1.0",
+3
View File
@@ -0,0 +1,3 @@
# header
changelog here

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 19 KiB

+15 -1
View File
@@ -400,12 +400,22 @@
"sams": "SAMs",
"warships": "Warships",
"health": "Health",
"attitude": "Attitude"
"attitude": "Attitude",
"levels": "Levels"
},
"events_display": {
"retreating": "retreating",
"boat": "Boat"
},
"unit_info_modal": {
"structure_info": "Structure Info",
"unit_type_unknown": "Unknown",
"close": "Close",
"cooldown": "Cooldown",
"type": "Type",
"upgrade": "Upgrade",
"level": "Level"
},
"relation": {
"hostile": "Hostile",
"distrustful": "Distrustful",
@@ -435,6 +445,10 @@
"none": "None",
"alliances": "Alliances"
},
"replay_panel": {
"replay_speed": "Replay speed",
"game_speed": "Game speed"
},
"error_modal": {
"crashed": "Game crashed!",
"paste_discord": "Please paste the following in your bug report in Discord:",
+2 -2
View File
@@ -115,7 +115,7 @@
"coordinates": [564, 845],
"name": "Southern Uí Néill",
"strength": 3,
"flag": "1_Southern Uí Néill"
"flag": "1_Southern Ui Neill"
},
{
"coordinates": [639, 680],
@@ -133,7 +133,7 @@
"coordinates": [416, 678],
"name": "Northern Uí Néill",
"strength": 3,
"flag": "1_Northern Uí Néill"
"flag": "1_Northern Ui Neill"
},
{
"coordinates": [1869, 1308],
+1
View File
@@ -0,0 +1 @@
v0.24.0-dev
+23 -19
View File
@@ -268,8 +268,10 @@ export class ClientGameRunner {
});
const worker = this.worker;
const keepWorkerAlive = () => {
worker.sendHeartbeat();
requestAnimationFrame(keepWorkerAlive);
if (this.isActive) {
worker.sendHeartbeat();
requestAnimationFrame(keepWorkerAlive);
}
};
requestAnimationFrame(keepWorkerAlive);
@@ -329,8 +331,10 @@ export class ClientGameRunner {
}
public stop(saveFullGame: boolean = false) {
this.worker.cleanup();
if (!this.isActive) return;
this.isActive = false;
this.worker.cleanup();
this.transport.leaveGame(saveFullGame);
if (this.connectionCheckInterval) {
clearInterval(this.connectionCheckInterval);
@@ -516,12 +520,13 @@ export class ClientGameRunner {
if (this.transport.isLocal) {
return;
}
const timeSinceLastMessage = Date.now() - this.lastMessageTime;
const now = Date.now();
const timeSinceLastMessage = now - this.lastMessageTime;
if (timeSinceLastMessage > 5000) {
console.log(
`No message from server for ${timeSinceLastMessage} ms, reconnecting`,
);
this.lastMessageTime = Date.now();
this.lastMessageTime = now;
this.transport.reconnect();
}
}
@@ -554,26 +559,25 @@ function showErrorModal(
const button = document.createElement("button");
button.textContent = translateText("error_modal.copy_clipboard");
button.className = "copy-btn";
button.addEventListener("click", () => {
navigator.clipboard
.writeText(content)
.then(() => (button.textContent = translateText("error_modal.copied")))
.catch(
() => (button.textContent = translateText("error_modal.failed_copy")),
);
});
const closeButton = document.createElement("button");
closeButton.textContent = "X";
closeButton.className = "close-btn";
closeButton.addEventListener("click", () => {
modal.style.display = "none";
button.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(content);
button.textContent = translateText("error_modal.copied");
} catch {
button.textContent = translateText("error_modal.failed_copy");
}
});
// Add to modal
modal.appendChild(pre);
modal.appendChild(button);
if (closable) {
const closeButton = document.createElement("button");
closeButton.textContent = "X";
closeButton.className = "close-btn";
closeButton.addEventListener("click", () => {
modal.remove();
});
modal.appendChild(closeButton);
}
+23 -6
View File
@@ -1,6 +1,6 @@
import { LitElement, html } from "lit";
import { customElement, query } from "lit/decorators.js";
import { translateText } from "../client/Utils";
import { getAltKey, getModifierKey, translateText } from "../client/Utils";
import "./components/Difficulties";
import "./components/Maps";
@@ -41,7 +41,7 @@ export class HelpModal extends LitElement {
<tr>
<td>
<div class="scroll-combo-horizontal">
<span class="key">Shift</span>
<span class="key">Shift</span>
<span class="plus">+</span>
<div class="mouse-shell alt-left-click">
<div class="mouse-left-corner"></div>
@@ -54,7 +54,7 @@ export class HelpModal extends LitElement {
<tr>
<td>
<div class="scroll-combo-horizontal">
<span class="key">Ctrl</span>
<span class="key">${getModifierKey()}</span>
<span class="plus">+</span>
<div class="mouse-shell alt-left-click">
<div class="mouse-left-corner"></div>
@@ -67,7 +67,7 @@ export class HelpModal extends LitElement {
<tr>
<td>
<div class="scroll-combo-horizontal">
<span class="key">Alt</span>
<span class="key">${getAltKey()}</span>
<span class="plus">+</span>
<div class="mouse-shell alt-left-click">
<div class="mouse-left-corner"></div>
@@ -99,7 +99,7 @@ export class HelpModal extends LitElement {
<tr>
<td>
<div class="scroll-combo-horizontal">
<span class="key">Shift</span>
<span class="key">Shift</span>
<span class="plus">+</span>
<div class="mouse-with-arrows">
<div class="mouse-shell">
@@ -116,7 +116,8 @@ export class HelpModal extends LitElement {
</tr>
<tr>
<td>
<span class="key">ALT</span> + <span class="key">R</span>
<span class="key">${getAltKey()}</span> +
<span class="key">R</span>
</td>
<td>${translateText("help_modal.action_reset_gfx")}</td>
</tr>
@@ -139,6 +140,7 @@ export class HelpModal extends LitElement {
alt="Leaderboard"
title="Leaderboard"
class="default-image"
loading="lazy"
/>
</div>
<div>
@@ -158,6 +160,7 @@ export class HelpModal extends LitElement {
alt="Control panel"
title="Control panel"
class="default-image"
loading="lazy"
/>
</div>
<div>
@@ -188,12 +191,14 @@ export class HelpModal extends LitElement {
alt="Event panel"
title="Event panel"
class="default-image"
loading="lazy"
/>
<img
src="/images/helpModal/eventsPanelAttack.webp"
alt="Event panel"
title="Event panel"
class="default-image"
loading="lazy"
/>
</div>
</div>
@@ -225,6 +230,7 @@ export class HelpModal extends LitElement {
alt="Options"
title="Options"
class="default-image"
loading="lazy"
/>
</div>
<div>
@@ -252,6 +258,7 @@ export class HelpModal extends LitElement {
alt="Player info overlay"
title="Player info overlay"
class="default-image"
loading="lazy"
/>
</div>
<div>
@@ -274,12 +281,14 @@ export class HelpModal extends LitElement {
alt="Radial menu"
title="Radial menu"
class="default-image"
loading="lazy"
/>
<img
src="/images/helpModal/radialMenuAlly.webp"
alt="Radial menu ally"
title="Radial menu ally"
class="default-image"
loading="lazy"
/>
</div>
<div>
@@ -294,6 +303,7 @@ export class HelpModal extends LitElement {
src="/images/InfoIcon.svg"
class="inline-block icon"
style="fill: white; background: transparent;"
loading="lazy"
/>
<span>${translateText("help_modal.radial_info")}</span>
</li>
@@ -330,6 +340,7 @@ export class HelpModal extends LitElement {
alt="Enemy info panel"
title="Enemy info panel"
class="info-panel-img"
loading="lazy"
/>
</div>
<div class="pt-4">
@@ -373,6 +384,7 @@ export class HelpModal extends LitElement {
alt="Ally info panel"
title="Ally info panel"
class="info-panel-img"
loading="lazy"
/>
</div>
<div class="pt-4">
@@ -482,6 +494,7 @@ export class HelpModal extends LitElement {
alt="Number 1 player"
title="Number 1 player"
class="player-icon-img w-full"
loading="lazy"
/>
</div>
@@ -498,6 +511,7 @@ export class HelpModal extends LitElement {
alt="Traitor"
title="Traitor"
class="player-icon-img w-full"
loading="lazy"
/>
</div>
@@ -514,6 +528,7 @@ export class HelpModal extends LitElement {
alt="Ally"
title="Ally"
class="player-icon-img w-full"
loading="lazy"
/>
</div>
</div>
@@ -532,6 +547,7 @@ export class HelpModal extends LitElement {
alt="Stopped trading"
title="Stopped trading"
class="player-icon-img w-full"
loading="lazy"
/>
</div>
@@ -548,6 +564,7 @@ export class HelpModal extends LitElement {
alt="Alliance Request"
title="Alliance Request"
class="player-icon-img w-full"
loading="lazy"
/>
</div>
</div>
+57 -24
View File
@@ -1,6 +1,7 @@
import { EventBus, GameEvent } from "../core/EventBus";
import { UnitView } from "../core/game/GameView";
import { UserSettings } from "../core/game/UserSettings";
import { ReplaySpeedMultiplier } from "./utilities/ReplaySpeedMultiplier";
export class MouseUpEvent implements GameEvent {
constructor(
@@ -82,6 +83,10 @@ export class AttackRatioEvent implements GameEvent {
constructor(public readonly attackRatio: number) {}
}
export class ReplaySpeedChangeEvent implements GameEvent {
constructor(public readonly replaySpeedMultiplier: ReplaySpeedMultiplier) {}
}
export class CenterCameraEvent implements GameEvent {
constructor() {}
}
@@ -103,6 +108,7 @@ export class InputHandler {
private moveInterval: NodeJS.Timeout | null = null;
private activeKeys = new Set<string>();
private keybinds: Record<string, string> = {};
private readonly PAN_SPEED = 5;
private readonly ZOOM_SPEED = 10;
@@ -115,7 +121,7 @@ export class InputHandler {
) {}
initialize() {
const keybinds = {
this.keybinds = {
toggleView: "Space",
centerCamera: "KeyC",
moveUp: "KeyW",
@@ -127,8 +133,17 @@ export class InputHandler {
attackRatioDown: "Digit1",
attackRatioUp: "Digit2",
boatAttack: "KeyB",
modifierKey: "ControlLeft",
altKey: "AltLeft",
...JSON.parse(localStorage.getItem("settings.keybinds") ?? "{}"),
};
// Mac users might have different keybinds
const isMac = /Mac/.test(navigator.userAgent);
if (isMac) {
this.keybinds.modifierKey = "MetaLeft"; // Use Command key on Mac
}
this.canvas.addEventListener("pointerdown", (e) => this.onPointerDown(e));
window.addEventListener("pointerup", (e) => this.onPointerUp(e));
this.canvas.addEventListener(
@@ -154,22 +169,22 @@ export class InputHandler {
let deltaY = 0;
if (
this.activeKeys.has(keybinds.moveUp) ||
this.activeKeys.has(this.keybinds.moveUp) ||
this.activeKeys.has("ArrowUp")
)
deltaY += this.PAN_SPEED;
if (
this.activeKeys.has(keybinds.moveDown) ||
this.activeKeys.has(this.keybinds.moveDown) ||
this.activeKeys.has("ArrowDown")
)
deltaY -= this.PAN_SPEED;
if (
this.activeKeys.has(keybinds.moveLeft) ||
this.activeKeys.has(this.keybinds.moveLeft) ||
this.activeKeys.has("ArrowLeft")
)
deltaX += this.PAN_SPEED;
if (
this.activeKeys.has(keybinds.moveRight) ||
this.activeKeys.has(this.keybinds.moveRight) ||
this.activeKeys.has("ArrowRight")
)
deltaX -= this.PAN_SPEED;
@@ -182,13 +197,13 @@ export class InputHandler {
const cy = window.innerHeight / 2;
if (
this.activeKeys.has(keybinds.zoomOut) ||
this.activeKeys.has(this.keybinds.zoomOut) ||
this.activeKeys.has("Minus")
) {
this.eventBus.emit(new ZoomEvent(cx, cy, this.ZOOM_SPEED));
}
if (
this.activeKeys.has(keybinds.zoomIn) ||
this.activeKeys.has(this.keybinds.zoomIn) ||
this.activeKeys.has("Equal")
) {
this.eventBus.emit(new ZoomEvent(cx, cy, -this.ZOOM_SPEED));
@@ -196,7 +211,7 @@ export class InputHandler {
}, 1);
window.addEventListener("keydown", (e) => {
if (e.code === keybinds.toggleView) {
if (e.code === this.keybinds.toggleView) {
e.preventDefault();
if (!this.alternateView) {
this.alternateView = true;
@@ -211,21 +226,21 @@ export class InputHandler {
if (
[
keybinds.moveUp,
keybinds.moveDown,
keybinds.moveLeft,
keybinds.moveRight,
keybinds.zoomOut,
keybinds.zoomIn,
this.keybinds.moveUp,
this.keybinds.moveDown,
this.keybinds.moveLeft,
this.keybinds.moveRight,
this.keybinds.zoomOut,
this.keybinds.zoomIn,
"ArrowUp",
"ArrowLeft",
"ArrowDown",
"ArrowRight",
"Minus",
"Equal",
keybinds.attackRatioDown,
keybinds.attackRatioUp,
keybinds.centerCamera,
this.keybinds.attackRatioDown,
this.keybinds.attackRatioUp,
this.keybinds.centerCamera,
"ControlLeft",
"ControlRight",
].includes(e.code)
@@ -234,7 +249,7 @@ export class InputHandler {
}
});
window.addEventListener("keyup", (e) => {
if (e.code === keybinds.toggleView) {
if (e.code === this.keybinds.toggleView) {
e.preventDefault();
this.alternateView = false;
this.eventBus.emit(new AlternateViewEvent(false));
@@ -245,22 +260,22 @@ export class InputHandler {
this.eventBus.emit(new RefreshGraphicsEvent());
}
if (e.code === keybinds.boatAttack) {
if (e.code === this.keybinds.boatAttack) {
e.preventDefault();
this.eventBus.emit(new DoBoatAttackEvent());
}
if (e.code === keybinds.attackRatioDown) {
if (e.code === this.keybinds.attackRatioDown) {
e.preventDefault();
this.eventBus.emit(new AttackRatioEvent(-10));
}
if (e.code === keybinds.attackRatioUp) {
if (e.code === this.keybinds.attackRatioUp) {
e.preventDefault();
this.eventBus.emit(new AttackRatioEvent(10));
}
if (e.code === keybinds.centerCamera) {
if (e.code === this.keybinds.centerCamera) {
e.preventDefault();
this.eventBus.emit(new CenterCameraEvent());
}
@@ -297,11 +312,11 @@ export class InputHandler {
this.pointerDown = false;
this.pointers.clear();
if (event.ctrlKey) {
if (this.isModifierKeyPressed(event)) {
this.eventBus.emit(new ShowBuildMenuEvent(event.clientX, event.clientY));
return;
}
if (event.altKey) {
if (this.isAltKeyPressed(event)) {
this.eventBus.emit(new ShowEmojiMenuEvent(event.clientX, event.clientY));
return;
}
@@ -400,4 +415,22 @@ export class InputHandler {
}
this.activeKeys.clear();
}
isModifierKeyPressed(event: PointerEvent): boolean {
return (
(this.keybinds.modifierKey === "AltLeft" && event.altKey) ||
(this.keybinds.modifierKey === "ControlLeft" && event.ctrlKey) ||
(this.keybinds.modifierKey === "ShiftLeft" && event.shiftKey) ||
(this.keybinds.modifierKey === "MetaLeft" && event.metaKey)
);
}
isAltKeyPressed(event: PointerEvent): boolean {
return (
(this.keybinds.altKey === "AltLeft" && event.altKey) ||
(this.keybinds.altKey === "ControlLeft" && event.ctrlKey) ||
(this.keybinds.altKey === "ShiftLeft" && event.shiftKey) ||
(this.keybinds.altKey === "MetaLeft" && event.metaKey)
);
}
}
+1
View File
@@ -186,6 +186,7 @@ export class LangSelector extends LitElement {
"game-starting-modal",
"top-bar",
"player-panel",
"replay-panel",
"help-modal",
"username-input",
"public-lobby",
+20 -10
View File
@@ -1,3 +1,4 @@
import { EventBus } from "../core/EventBus";
import {
AllPlayersStats,
ClientMessage,
@@ -12,7 +13,9 @@ import {
} from "../core/Schemas";
import { createGameRecord, decompressGameRecord, replacer } from "../core/Util";
import { LobbyConfig } from "./ClientGameRunner";
import { ReplaySpeedChangeEvent } from "./InputHandler";
import { getPersistentID } from "./Main";
import { defaultReplaySpeedMultiplier } from "./utilities/ReplaySpeedMultiplier";
export class LocalServer {
// All turns from the game record on replay.
@@ -24,6 +27,7 @@ export class LocalServer {
private startedAt: number;
private paused = false;
private replaySpeedMultiplier = defaultReplaySpeedMultiplier;
private winner: ClientSendWinnerMessage | null = null;
private allPlayersStats: AllPlayersStats = {};
@@ -38,23 +42,29 @@ export class LocalServer {
private clientConnect: () => void,
private clientMessage: (message: ServerMessage) => void,
private isReplay: boolean,
private eventBus: EventBus,
) {}
start() {
this.turnCheckInterval = setInterval(() => {
if (this.turnsExecuted === this.turns.length) {
if (
this.isReplay ||
Date.now() >
this.turnStartTime + this.lobbyConfig.serverConfig.turnIntervalMs()
) {
this.turnStartTime = Date.now();
// End turn on the server means the client will start processing the turn.
this.endTurn();
}
const turnIntervalMs =
this.lobbyConfig.serverConfig.turnIntervalMs() *
this.replaySpeedMultiplier;
if (
this.turnsExecuted === this.turns.length &&
Date.now() > this.turnStartTime + turnIntervalMs
) {
this.turnStartTime = Date.now();
// End turn on the server means the client will start processing the turn.
this.endTurn();
}
}, 5);
this.eventBus.on(ReplaySpeedChangeEvent, (event) => {
this.replaySpeedMultiplier = event.replaySpeedMultiplier;
});
this.startedAt = Date.now();
this.clientConnect();
if (this.lobbyConfig.gameRecord) {
+16 -5
View File
@@ -59,11 +59,19 @@ class Client {
constructor() {}
initialize(): void {
const gameVersion = document.getElementById(
"game-version",
) as HTMLDivElement;
if (!gameVersion) {
console.warn("Game version element not found");
}
fetch("/version.txt")
.then((response) => (response.ok ? response.text() : "Failed to load"))
.then((version) => (gameVersion.innerText = version));
const newsModal = document.querySelector("news-modal") as NewsModal;
if (!newsModal) {
console.warn("News modal element not found");
} else {
console.log("News modal element found");
}
newsModal instanceof NewsModal;
const newsButton = document.querySelector("news-button") as NewsButton;
@@ -72,6 +80,9 @@ class Client {
} else {
console.log("News button element found");
}
fetch("/changelog.md")
.then((response) => (response.ok ? response.text() : "Failed to load"))
.then((changelog) => (newsModal.markdown = changelog));
// Comment out to show news button.
// newsButton.hidden = true;
@@ -79,13 +90,13 @@ class Client {
const langSelector = document.querySelector(
"lang-selector",
) as LangSelector;
const LanguageModal = document.querySelector(
"lang-selector",
const languageModal = document.querySelector(
"language-modal",
) as LanguageModal;
if (!langSelector) {
console.warn("Lang selector element not found");
}
if (!LanguageModal) {
if (!languageModal) {
console.warn("Language modal element not found");
}
+8 -17
View File
@@ -1,5 +1,6 @@
import { LitElement, css, html } from "lit";
import { customElement, query } from "lit/decorators.js";
import { resolveMarkdown } from "lit-markdown";
import { customElement, property, query } from "lit/decorators.js";
import { translateText } from "../client/Utils";
import "./components/baseComponents/Button";
import "./components/baseComponents/Modal";
@@ -11,6 +12,8 @@ export class NewsModal extends LitElement {
close: () => void;
};
@property({ type: String }) markdown = "Loading...";
static styles = css`
:host {
display: block;
@@ -51,22 +54,10 @@ export class NewsModal extends LitElement {
<div class="options-section">
<div class="news-container">
<div class="news-content">
<h3>Main things to note:</h3>
<br />
<ul>
<li>Workers reproduce faster than troops.</li>
<li>Defense = troops divided how much land you have.</li>
<li>Attacking troops count toward your population limit.</li>
</ul>
<br />
<br />
See full changelog
<a
href="https://discord.com/channels/1284581928254701718/1286745902320713780"
target="_blank"
style="color: #4a9eff; font-weight: bold;"
>here</a
>.
${resolveMarkdown(this.markdown, {
includeImages: true,
includeCodeBlockClassNames: true,
})}
</div>
</div>
</div>
+20
View File
@@ -44,6 +44,13 @@ export class SendBreakAllianceIntentEvent implements GameEvent {
) {}
}
export class SendUpgradeStructureIntentEvent implements GameEvent {
constructor(
public readonly unitId: number,
public readonly unitType: UnitType,
) {}
}
export class SendAllianceReplyIntentEvent implements GameEvent {
constructor(
// The original alliance requestor
@@ -187,6 +194,9 @@ export class Transport {
this.onSendSpawnIntentEvent(e),
);
this.eventBus.on(SendAttackIntentEvent, (e) => this.onSendAttackIntent(e));
this.eventBus.on(SendUpgradeStructureIntentEvent, (e) =>
this.onSendUpgradeStructureIntent(e),
);
this.eventBus.on(SendBoatAttackIntentEvent, (e) =>
this.onSendBoatAttackIntent(e),
);
@@ -266,6 +276,7 @@ export class Transport {
onconnect,
onmessage,
this.lobbyConfig.gameRecord !== undefined,
this.eventBus,
);
this.localServer.start();
}
@@ -426,6 +437,15 @@ export class Transport {
});
}
private onSendUpgradeStructureIntent(event: SendUpgradeStructureIntentEvent) {
this.sendIntent({
type: "upgrade_structure",
unit: event.unitType,
clientID: this.lobbyConfig.clientID,
unitId: event.unitId,
});
}
private onSendTargetPlayerIntent(event: SendTargetPlayerIntentEvent) {
this.sendIntent({
type: "targetPlayer",
+18
View File
@@ -150,3 +150,21 @@ export function getMessageTypeClasses(type: MessageType): string {
return severityColors["white"];
}
}
export function getModifierKey(): string {
const isMac = /Mac/.test(navigator.userAgent);
if (isMac) {
return "⌘"; // Command key
} else {
return "Ctrl";
}
}
export function getAltKey(): string {
const isMac = /Mac/.test(navigator.userAgent);
if (isMac) {
return "⌥"; // Option key
} else {
return "Alt";
}
}
+6 -6
View File
@@ -404,7 +404,7 @@
"name": "Cayman Islands"
},
{
"code": "Ceará",
"code": "Ceara",
"continent": "South America",
"name": "Ceará"
},
@@ -1238,7 +1238,7 @@
"name": "Malta"
},
{
"code": "Māori flag",
"code": "Maori flag",
"continent": "Oceania",
"name": "Māori Flag"
},
@@ -1501,7 +1501,7 @@
"name": "Northern Mariana Islands"
},
{
"code": "1_Northern Uí Néill",
"code": "1_Northern Ui Neill",
"continent": "Europe",
"name": "Northern Uí Néill"
},
@@ -1566,7 +1566,7 @@
"name": "Papua New Guinea"
},
{
"code": "Pará",
"code": "Para",
"continent": "South America",
"name": "Pará"
},
@@ -1803,7 +1803,7 @@
"name": "Sao Tome and Principe"
},
{
"code": "São Paulo",
"code": "Sao Paulo",
"continent": "South America",
"name": "São Paulo"
},
@@ -1941,7 +1941,7 @@
"name": "South Sudan"
},
{
"code": "1_Southern Uí Néill",
"code": "1_Southern Ui Neill",
"continent": "Europe",
"name": "Southern Uí Néill"
},
+9
View File
@@ -21,6 +21,7 @@ import { OptionsMenu } from "./layers/OptionsMenu";
import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay";
import { PlayerPanel } from "./layers/PlayerPanel";
import { PlayerTeamLabel } from "./layers/PlayerTeamLabel";
import { ReplayPanel } from "./layers/ReplayPanel";
import { SpawnTimer } from "./layers/SpawnTimer";
import { StructureLayer } from "./layers/StructureLayer";
import { TeamStats } from "./layers/TeamStats";
@@ -126,6 +127,13 @@ export function createRenderer(
optionsMenu.eventBus = eventBus;
optionsMenu.game = game;
const replayPanel = document.querySelector("replay-panel") as ReplayPanel;
if (!(replayPanel instanceof ReplayPanel)) {
console.error("ReplayPanel element not found in the DOM");
}
replayPanel.eventBus = eventBus;
replayPanel.game = game;
const topBar = document.querySelector("top-bar") as TopBar;
if (!(topBar instanceof TopBar)) {
console.error("top bar not found");
@@ -215,6 +223,7 @@ export function createRenderer(
playerInfo,
winModel,
optionsMenu,
replayPanel,
teamStats,
topBar,
playerPanel,
+61
View File
@@ -0,0 +1,61 @@
export class ProgressBar {
private static readonly CLEAR_PADDING = 2;
constructor(
private colors: string[] = [],
private ctx: CanvasRenderingContext2D,
private x: number,
private y: number,
private w: number,
private h: number,
private progress: number = 0, // Progress from 0 to 1
) {
this.setProgress(progress);
}
setProgress(progress: number): void {
progress = Math.max(0, Math.min(1, progress));
this.clear();
// Draw the loading bar background
this.ctx.fillStyle = "rgba(0, 0, 0, 1)";
this.ctx.fillRect(this.x - 1, this.y - 1, this.w, this.h);
// Draw the loading progress
if (this.colors.length === 0) {
this.ctx.fillStyle = "#808080"; // default gray
} else {
const idx = Math.min(
this.colors.length - 1,
Math.floor(progress * this.colors.length),
);
this.ctx.fillStyle = this.colors[idx];
}
this.ctx.fillRect(
this.x,
this.y,
Math.max(1, Math.floor(progress * (this.w - 2))),
this.h - 2,
);
this.progress = progress;
}
clear() {
this.ctx.clearRect(
this.x - ProgressBar.CLEAR_PADDING,
this.y - ProgressBar.CLEAR_PADDING,
this.w + ProgressBar.CLEAR_PADDING,
this.h + ProgressBar.CLEAR_PADDING,
);
}
getX(): number {
return this.x;
}
getY(): number {
return this.y;
}
getProgress(): number {
return this.progress;
}
}
+2 -2
View File
@@ -12,7 +12,7 @@ import targetIcon from "../../../../resources/images/TargetIcon.svg";
import traitorIcon from "../../../../resources/images/TraitorIcon.svg";
import { PseudoRandom } from "../../../core/PseudoRandom";
import { Theme } from "../../../core/configuration/Config";
import { AllPlayers, Cell, nukeTypes, UnitType } from "../../../core/game/Game";
import { AllPlayers, Cell, nukeTypes } from "../../../core/game/Game";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { UserSettings } from "../../../core/game/UserSettings";
import { createCanvas, renderNumber, renderTroops } from "../../Utils";
@@ -516,7 +516,7 @@ export class NameLayer implements Layer {
const isSendingNuke = render.player.id() === unit.owner().id();
const notMyPlayer = !myPlayer || unit.owner().id() !== myPlayer.id();
return (
(nukeTypes as UnitType[]).includes(unit.type()) &&
nukeTypes.includes(unit.type()) &&
isSendingNuke &&
notMyPlayer &&
unit.isActive()
+1 -1
View File
@@ -1,4 +1,4 @@
import { LitElement, html } from "lit";
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { GameType } from "../../../core/game/Game";
@@ -240,18 +240,58 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
<div class="text-sm opacity-80" translate="no">
${translateText("player_info_overlay.ports")}:
${player.units(UnitType.Port).length}
${player
.units(UnitType.Port)
.map((unit) => unit.level())
.reduce((a, b) => a + b, 0) > 1
? html`(${translateText("player_info_overlay.levels")}:
${player
.units(UnitType.Port)
.map((unit) => unit.level())
.reduce((a, b) => a + b, 0)})`
: ""}
</div>
<div class="text-sm opacity-80" translate="no">
${translateText("player_info_overlay.cities")}:
${player.units(UnitType.City).length}
${player
.units(UnitType.City)
.map((unit) => unit.level())
.reduce((a, b) => a + b, 0) > 1
? html`(${translateText("player_info_overlay.levels")}:
${player
.units(UnitType.City)
.map((unit) => unit.level())
.reduce((a, b) => a + b, 0)})`
: ""}
</div>
<div class="text-sm opacity-80" translate="no">
${translateText("player_info_overlay.missile_launchers")}:
${player.units(UnitType.MissileSilo).length}
${player
.units(UnitType.MissileSilo)
.map((unit) => unit.level())
.reduce((a, b) => a + b, 0) > 1
? html`(${translateText("player_info_overlay.levels")}:
${player
.units(UnitType.MissileSilo)
.map((unit) => unit.level())
.reduce((a, b) => a + b, 0)})`
: ""}
</div>
<div class="text-sm opacity-80" translate="no">
${translateText("player_info_overlay.sams")}:
${player.units(UnitType.SAMLauncher).length}
${player
.units(UnitType.SAMLauncher)
.map((unit) => unit.level())
.reduce((a, b) => a + b, 0) > 1
? html`(${translateText("player_info_overlay.levels")}:
${player
.units(UnitType.SAMLauncher)
.map((unit) => unit.level())
.reduce((a, b) => a + b, 0)})`
: ""}
</div>
<div class="text-sm opacity-80" translate="no">
${translateText("player_info_overlay.warships")}:
+128
View File
@@ -0,0 +1,128 @@
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { GameType } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { ReplaySpeedChangeEvent } from "../../InputHandler";
import {
defaultReplaySpeedMultiplier,
ReplaySpeedMultiplier,
} from "../../utilities/ReplaySpeedMultiplier";
import { translateText } from "../../Utils";
import { Layer } from "./Layer";
@customElement("replay-panel")
export class ReplayPanel extends LitElement implements Layer {
public game: GameView | undefined;
public eventBus: EventBus | undefined;
@state()
private _replaySpeedMultiplier: number = defaultReplaySpeedMultiplier;
private _isSinglePlayer: boolean = false;
@state()
private _isVisible = false;
init() {
this._isSinglePlayer =
this.game?.config().gameConfig().gameType === GameType.Singleplayer;
if (this._isSinglePlayer) {
this.setVisible(true);
}
}
tick() {
if (!this._isVisible && this.game?.config().isReplay()) {
this.setVisible(true);
}
this.requestUpdate();
}
onReplaySpeedChange(value: ReplaySpeedMultiplier) {
this._replaySpeedMultiplier = value;
this.eventBus?.emit(new ReplaySpeedChangeEvent(value));
}
renderLayer(context: CanvasRenderingContext2D) {
// Render any necessary canvas elements
}
shouldTransform(): boolean {
return false;
}
setVisible(visible: boolean) {
this._isVisible = visible;
this.requestUpdate();
}
render() {
if (!this._isVisible) {
return html``;
}
return html`
<div
class="bg-opacity-60 bg-gray-900 p-1 lg:p-2 rounded-es-sm lg:rounded-lg backdrop-blur-md"
@contextmenu=${(e) => e.preventDefault()}
>
<label class="block mb-1 text-white" translate="no">
${this._isSinglePlayer
? translateText("replay_panel.game_speed")
: translateText("replay_panel.replay_speed")}
</label>
<div class="grid grid-cols-2 gap-1">
<button
class="text-white font-bold py-0 rounded border transition ${this
._replaySpeedMultiplier === ReplaySpeedMultiplier.slow
? "bg-blue-500 border-gray-400"
: "border-gray-500"}"
@click=${() => {
this.onReplaySpeedChange(ReplaySpeedMultiplier.slow);
}}
>
×0.5
</button>
<button
class="text-white font-bold py-0 rounded border transition ${this
._replaySpeedMultiplier === ReplaySpeedMultiplier.normal
? "bg-blue-500 border-gray-400"
: "border-gray-500"}"
@click=${() => {
this.onReplaySpeedChange(ReplaySpeedMultiplier.normal);
}}
>
×1
</button>
<button
class="text-white font-bold py-0 rounded border transition ${this
._replaySpeedMultiplier === ReplaySpeedMultiplier.fast
? "bg-blue-500 border-gray-400"
: "border-gray-500"}"
@click=${() => {
this.onReplaySpeedChange(ReplaySpeedMultiplier.fast);
}}
>
×2
</button>
<button
class="text-white font-bold py-0 rounded border transition ${this
._replaySpeedMultiplier === ReplaySpeedMultiplier.fastest
? "bg-blue-500 border-gray-400"
: "border-gray-500"}"
@click=${() => {
this.onReplaySpeedChange(ReplaySpeedMultiplier.fastest);
}}
>
max
</button>
</div>
</div>
`;
}
createRenderRoot() {
return this; // Disable shadow DOM to allow Tailwind styles
}
}
+5 -4
View File
@@ -242,13 +242,13 @@ export class StructureLayer implements Layer {
const config = this.unitConfigs[unitType];
let icon: ImageData | undefined;
if (unitType === UnitType.SAMLauncher && unit.isCooldown()) {
if (unitType === UnitType.SAMLauncher && unit.isInCooldown()) {
icon = this.unitIcons.get("reloadingSam");
} else {
icon = this.unitIcons.get(iconType);
}
if (unitType === UnitType.MissileSilo && unit.isCooldown()) {
if (unitType === UnitType.MissileSilo && unit.isInCooldown()) {
icon = this.unitIcons.get("reloadingSilo");
} else {
icon = this.unitIcons.get(iconType);
@@ -268,13 +268,13 @@ export class StructureLayer implements Layer {
if (!unit.isActive()) return;
let borderColor = this.theme.borderColor(unit.owner());
if (unitType === UnitType.SAMLauncher && unit.isCooldown()) {
if (unitType === UnitType.SAMLauncher && unit.isInCooldown()) {
borderColor = reloadingColor;
} else if (unit.type() === UnitType.Construction) {
borderColor = underConstructionColor;
}
if (unitType === UnitType.MissileSilo && unit.isCooldown()) {
if (unitType === UnitType.MissileSilo && unit.isInCooldown()) {
borderColor = reloadingColor;
} else if (unit.type() === UnitType.Construction) {
borderColor = underConstructionColor;
@@ -391,6 +391,7 @@ export class StructureLayer implements Layer {
const screenPos = this.transformHandler.worldToScreenCoordinates(cell);
const unitTile = clickedUnit.tile();
this.unitInfoModal?.onOpenStructureModal({
eventBus: this.eventBus,
unit: clickedUnit,
x: screenPos.x,
y: screenPos.y,
+127 -5
View File
@@ -1,12 +1,24 @@
import { Colord } from "colord";
import { EventBus } from "../../../core/EventBus";
import { Theme } from "../../../core/configuration/Config";
import { UnitType } from "../../../core/game/Game";
import { Tick, UnitType } from "../../../core/game/Game";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, UnitView } from "../../../core/game/GameView";
import { UnitSelectionEvent } from "../../InputHandler";
import { ProgressBar } from "../ProgressBar";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
const COLOR_PROGRESSION = [
"rgb(232, 25, 25)",
"rgb(240, 122, 25)",
"rgb(202, 231, 15)",
"rgb(44, 239, 18)",
];
const HEALTHBAR_WIDTH = 11; // Width of the health bar
const LOADINGBAR_WIDTH = 18; // Width of the loading bar
const PROGRESSBAR_HEIGHT = 3; // Height of a bar
/**
* Layer responsible for drawing UI elements that overlay the game
* such as selection boxes, health bars, etc.
@@ -17,7 +29,11 @@ export class UILayer implements Layer {
private theme: Theme | null = null;
private selectionAnimTime = 0;
private allProgressBars: Map<
number,
{ unit: UnitView; startTick: Tick; endTick: Tick; progressBar: ProgressBar }
> = new Map();
private allHealthBars: Map<number, ProgressBar> = new Map();
// Keep track of currently selected unit
private selectedUnit: UnitView | null = null;
@@ -51,6 +67,16 @@ export class UILayer implements Layer {
if (this.selectedUnit && this.selectedUnit.type() === UnitType.Warship) {
this.drawSelectionBox(this.selectedUnit);
}
this.game
.updatesSinceLastTick()
?.[GameUpdateType.Unit]?.map((unit) => this.game.unit(unit.id))
?.forEach((unitView) => {
if (unitView === undefined) return;
this.onUnitEvent(unitView);
});
this.updateProgressBars();
}
init() {
@@ -76,6 +102,42 @@ export class UILayer implements Layer {
this.canvas.height = this.game.height();
}
onUnitEvent(unit: UnitView) {
switch (unit.type()) {
case UnitType.Construction: {
const playerId = this.game.myPlayer()?.id();
if (
unit.isActive() &&
playerId !== undefined &&
unit.owner().id() === playerId
) {
const constructionType = unit.constructionType();
if (constructionType === undefined) {
// Skip units without construction type
return;
}
const endTick =
this.game.unitInfo(constructionType).constructionDuration || 0;
this.drawLoadingBar(unit, endTick);
}
break;
}
case UnitType.Warship: {
this.drawHealthBar(unit);
break;
}
case UnitType.SAMLauncher:
case UnitType.MissileSilo:
if (unit.isActive() && unit.isInCooldown()) {
const endTick = unit.ticksLeftInCooldown() || 0;
this.drawLoadingBar(unit, endTick);
}
break;
default:
return;
}
}
/**
* Handle the unit selection event
*/
@@ -187,11 +249,71 @@ export class UILayer implements Layer {
}
/**
* Draw health bar for a unit (placeholder for future implementation)
* Draw health bar for a unit
*/
public drawHealthBar(unit: UnitView) {
// This is a placeholder for future health bar implementation
// It would draw a health bar above units that have health
const maxHealth = this.game.unitInfo(unit.type()).maxHealth;
if (maxHealth === undefined || this.context === null) {
return;
}
if (
this.allHealthBars.has(unit.id()) &&
(unit.health() >= maxHealth || unit.health() <= 0 || !unit.isActive())
) {
// full hp/dead warships dont need a hp bar
this.allHealthBars.get(unit.id())?.clear();
this.allHealthBars.delete(unit.id());
} else if (unit.health() < maxHealth && unit.health() > 0) {
this.allHealthBars.get(unit.id())?.clear();
const healthBar = new ProgressBar(
COLOR_PROGRESSION,
this.context,
this.game.x(unit.tile()) - 4,
this.game.y(unit.tile()) - 6,
HEALTHBAR_WIDTH,
PROGRESSBAR_HEIGHT,
unit.health() / maxHealth,
);
// keep track of units that have health bars for clearing purposes
this.allHealthBars.set(unit.id(), healthBar);
}
}
private updateProgressBars() {
const currentTick = this.game.ticks();
this.allProgressBars.forEach((progressBarInfo, unitId) => {
const progress =
(currentTick - progressBarInfo.startTick) / progressBarInfo.endTick;
if (progress >= 1 || !progressBarInfo.unit.isActive()) {
this.allProgressBars.get(unitId)?.progressBar.clear();
this.allProgressBars.delete(unitId);
return;
}
progressBarInfo.progressBar.setProgress(progress);
});
}
public drawLoadingBar(unit: UnitView, endTick: Tick) {
if (!this.context) {
return;
}
if (!this.allProgressBars.has(unit.id())) {
const progressBar = new ProgressBar(
COLOR_PROGRESSION,
this.context,
this.game.x(unit.tile()) - 8,
this.game.y(unit.tile()) - 10,
LOADINGBAR_WIDTH,
PROGRESSBAR_HEIGHT,
0,
);
this.allProgressBars.set(unit.id(), {
unit,
startTick: this.game.ticks(),
endTick,
progressBar,
});
}
}
paintCell(x: number, y: number, color: Colord, alpha: number) {
+82 -7
View File
@@ -1,7 +1,10 @@
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { translateText } from "../../../client/Utils";
import { EventBus } from "../../../core/EventBus";
import { UnitType } from "../../../core/game/Game";
import { GameView, UnitView } from "../../../core/game/GameView";
import { SendUpgradeStructureIntentEvent } from "../../Transport";
import { Layer } from "./Layer";
import { StructureLayer } from "./StructureLayer";
@@ -14,6 +17,7 @@ export class UnitInfoModal extends LitElement implements Layer {
public game: GameView;
public structureLayer: StructureLayer | null = null;
private eventBus: EventBus;
constructor() {
super();
@@ -28,12 +32,14 @@ export class UnitInfoModal extends LitElement implements Layer {
}
public onOpenStructureModal = ({
eventBus,
unit,
x,
y,
tileX,
tileY,
}: {
eventBus: EventBus;
unit: UnitView;
x: number;
y: number;
@@ -43,6 +49,7 @@ export class UnitInfoModal extends LitElement implements Layer {
if (!this.game) return;
this.x = x;
this.y = y;
this.eventBus = eventBus;
const targetRef = this.game.ref(tileX, tileY);
const allUnitTypes = Object.values(UnitType);
@@ -118,12 +125,44 @@ export class UnitInfoModal extends LitElement implements Layer {
.close-button:hover {
background: #a00;
}
.upgrade-button {
background: #3a0;
color: #fff;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
padding: 6px 12px;
}
.upgrade-button:hover {
background: #0a0;
}
`;
render() {
if (!this.unit) return null;
const cooldown = this.unit.ticksLeftInCooldown() ?? 0;
const ticksLeftInCooldown = this.unit.ticksLeftInCooldown();
let configTimer;
switch (this.unit.type()) {
case UnitType.MissileSilo:
configTimer = this.game.config().SiloCooldown();
break;
case UnitType.SAMLauncher:
configTimer = this.game.config().SAMCooldown();
break;
}
let cooldown = 0;
if (ticksLeftInCooldown !== undefined && configTimer !== undefined) {
cooldown = configTimer - (this.game.ticks() - ticksLeftInCooldown);
}
const secondsLeft = Math.ceil(cooldown / 10);
return html`
@@ -133,17 +172,53 @@ export class UnitInfoModal extends LitElement implements Layer {
.x}px; top: ${this.y}px; position: absolute;"
>
<div style="margin-bottom: 8px; font-size: 16px; font-weight: bold;">
Structure Info
${translateText("unit_info_modal.structure_info")}
</div>
<div style="margin-bottom: 4px;">
<strong>Type:</strong> ${this.unit.type?.() ?? "Unknown"}
<strong>${translateText("unit_info_modal.type")}:</strong>
${translateText(+"unit_type." + this.unit.type?.().toLowerCase()) ??
translateText("unit_info_modal.unit_type_unknown")}
<strong
style="display: ${this.game.unitInfo(this.unit.type()).upgradable
? "inline"
: "none"};"
>${translateText("unit_info_modal.level")}:</strong
>
${this.game.unitInfo(this.unit.type()).upgradable &&
this.unit.level?.()
? this.unit.level?.()
: ""}
</div>
${secondsLeft > 0
? html`<div style="margin-bottom: 4px;">
<strong>Cooldown:</strong> ${secondsLeft}s
<strong>${translateText("unit_info_modal.cooldown")}</strong>
${secondsLeft}s
</div>`
: ""}
<div style="margin-top: 14px; display: flex; justify-content: center;">
<div
style="margin-top: 14px; display: flex; justify-content: space-between;"
>
<button
@click=${() => {
if (this.unit) {
this.eventBus.emit(
new SendUpgradeStructureIntentEvent(
this.unit.id(),
this.unit.type(),
),
);
}
}}
class="upgrade-button"
title="${translateText("unit_info_modal.upgrade")}"
style="width: 100px; height: 32px; display: ${this.game.unitInfo(
this.unit.type(),
).upgradable
? "block"
: "none"};"
>
${translateText("unit_info_modal.upgrade")}
</button>
<button
@click=${() => {
this.onCloseStructureModal();
@@ -152,10 +227,10 @@ export class UnitInfoModal extends LitElement implements Layer {
}
}}
class="close-button"
title="Close"
title="${translateText("unit_info_modal.close")}"
style="width: 100px; height: 32px;"
>
CLOSE
${translateText("unit_info_modal.close")}
</button>
</div>
</div>
+4 -1
View File
@@ -203,7 +203,9 @@
/>
</g>
</svg>
<div class="l-header__highlightText">v23.0</div>
<div id="game-version" class="l-header__highlightText">
Loading version...
</div>
</div>
</header>
<div class="bg-image"></div>
@@ -304,6 +306,7 @@
class="flex flex-column gap-2 fixed right-[10px] top-[10px] z-50 flex flex-col w-32 sm:w-32 lg:w-48"
>
<options-menu></options-menu>
<replay-panel></replay-panel>
<player-info-overlay></player-info-overlay>
</div>
<div
@@ -0,0 +1,8 @@
export enum ReplaySpeedMultiplier {
slow = 2,
normal = 1,
fast = 0.5,
fastest = 0,
}
export const defaultReplaySpeedMultiplier = ReplaySpeedMultiplier.normal;
+13 -1
View File
@@ -34,7 +34,8 @@ export type Intent =
| EmbargoIntent
| QuickChatIntent
| MoveWarshipIntent
| MarkDisconnectedIntent;
| MarkDisconnectedIntent
| UpgradeStructureIntent;
export type AttackIntent = z.infer<typeof AttackIntentSchema>;
export type CancelAttackIntent = z.infer<typeof CancelAttackIntentSchema>;
@@ -55,6 +56,9 @@ export type TargetTroopRatioIntent = z.infer<
typeof TargetTroopRatioIntentSchema
>;
export type BuildUnitIntent = z.infer<typeof BuildUnitIntentSchema>;
export type UpgradeStructureIntent = z.infer<
typeof UpgradeStructureIntentSchema
>;
export type MoveWarshipIntent = z.infer<typeof MoveWarshipIntentSchema>;
export type QuickChatIntent = z.infer<typeof QuickChatIntentSchema>;
export type MarkDisconnectedIntent = z.infer<
@@ -178,6 +182,7 @@ const BaseIntentSchema = z.object({
"emoji",
"troop_ratio",
"build_unit",
"upgrade_structure",
"embargo",
"move_warship",
]),
@@ -266,6 +271,12 @@ export const BuildUnitIntentSchema = BaseIntentSchema.extend({
y: z.number(),
});
export const UpgradeStructureIntentSchema = BaseIntentSchema.extend({
type: z.literal("upgrade_structure"),
unit: z.nativeEnum(UnitType),
unitId: z.number(),
});
export const CancelAttackIntentSchema = BaseIntentSchema.extend({
type: z.literal("cancel_attack"),
attackID: z.string(),
@@ -316,6 +327,7 @@ const IntentSchema = z.union([
DonateTroopIntentSchema,
TargetTroopRatioIntentSchema,
BuildUnitIntentSchema,
UpgradeStructureIntentSchema,
EmbargoIntentSchema,
MoveWarshipIntentSchema,
QuickChatIntentSchema,
+1
View File
@@ -83,6 +83,7 @@ export const OTHER_INDEX_BUILT = 0; // Structures and warships built
export const OTHER_INDEX_DESTROY = 1; // Structures and warships destroyed
export const OTHER_INDEX_CAPTURE = 2; // Structures captured
export const OTHER_INDEX_LOST = 3; // Structures/warships destroyed/captured by others
export const OTHER_INDEX_UPGRADE = 4; // Structures upgraded
const BigIntStringSchema = z.preprocess((val) => {
if (typeof val === "string" && /^\d+$/.test(val)) return BigInt(val);
+215 -99
View File
@@ -1,4 +1,6 @@
import { colord, Colord } from "colord";
import { ColoredTeams, Team } from "../game/Game";
import { simpleHash } from "../Util";
export const red: Colord = colord({ r: 235, g: 53, b: 53 }); // Bright Red
export const blue: Colord = colord({ r: 41, g: 98, b: 255 }); // Royal Blue
@@ -9,7 +11,7 @@ export const orange = colord({ h: 25, s: 95, l: 53 });
export const green = colord({ h: 128, s: 49, l: 50 });
export const botColor: Colord = colord({ r: 210, g: 206, b: 200 }); // Muted Beige Gray
export const territoryColors: Colord[] = [
export const nationColors: 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
@@ -109,110 +111,71 @@ export const territoryColors: Colord[] = [
colord({ r: 170, g: 150, b: 170 }), // Dusty Rose
];
// Bright pastel theme with 64 colors
export const humanColors: Colord[] = [
// Original set
colord({ r: 235, g: 75, b: 75 }), // Bright Red
colord({ r: 67, g: 190, b: 84 }), // Fresh Green
colord({ r: 59, g: 130, b: 246 }), // Royal Blue
colord({ r: 245, g: 158, b: 11 }), // Amber
colord({ r: 236, g: 72, b: 153 }), // Deep Pink
colord({ r: 48, g: 178, b: 180 }), // Teal
colord({ r: 168, g: 85, b: 247 }), // Vibrant Purple
colord({ r: 251, g: 191, b: 36 }), // Marigold
colord({ r: 74, g: 222, b: 128 }), // Mint
colord({ r: 239, g: 68, b: 68 }), // Crimson
colord({ r: 34, g: 197, b: 94 }), // Emerald
colord({ r: 96, g: 165, b: 250 }), // Sky Blue
colord({ r: 249, g: 115, b: 22 }), // Tangerine
colord({ r: 192, g: 132, b: 252 }), // Lavender
colord({ r: 45, g: 212, b: 191 }), // Turquoise
colord({ r: 244, g: 114, b: 182 }), // Rose
colord({ r: 132, g: 204, b: 22 }), // Lime
colord({ r: 56, g: 189, b: 248 }), // Light Blue
colord({ r: 234, g: 179, b: 8 }), // Sunflower
colord({ r: 217, g: 70, b: 239 }), // Fuchsia
colord({ r: 16, g: 185, b: 129 }), // Sea Green
colord({ r: 251, g: 146, b: 60 }), // Light Orange
colord({ r: 147, g: 51, b: 234 }), // Bright Purple
colord({ r: 79, g: 70, b: 229 }), // Indigo
colord({ r: 245, g: 101, b: 101 }), // Coral
colord({ r: 134, g: 239, b: 172 }), // Light Green
colord({ r: 59, g: 130, b: 246 }), // Cerulean
colord({ r: 253, g: 164, b: 175 }), // Salmon Pink
colord({ r: 147, g: 197, b: 253 }), // Powder Blue
colord({ r: 252, g: 211, b: 77 }), // Golden
colord({ r: 190, g: 92, b: 251 }), // Amethyst
colord({ r: 82, g: 183, b: 136 }), // Jade
colord({ r: 248, g: 113, b: 113 }), // Warm Red
colord({ r: 99, g: 202, b: 253 }), // Azure
colord({ r: 240, g: 171, b: 252 }), // Orchid
colord({ r: 163, g: 230, b: 53 }), // Yellow Green
colord({ r: 234, g: 88, b: 12 }), // Burnt Orange
colord({ r: 125, g: 211, b: 252 }), // Crystal Blue
colord({ r: 251, g: 113, b: 133 }), // Watermelon
colord({ r: 34, g: 197, b: 94 }), // Emerald
colord({ r: 45, g: 212, b: 191 }), // Turquoise
colord({ r: 48, g: 178, b: 180 }), // Teal
colord({ r: 52, g: 211, b: 153 }), // Spearmint
colord({ r: 167, g: 139, b: 250 }), // Periwinkle
colord({ r: 245, g: 158, b: 11 }), // Honey
colord({ r: 56, g: 189, b: 248 }), // Light Blue
colord({ r: 59, g: 130, b: 246 }), // Royal Blue
colord({ r: 67, g: 190, b: 84 }), // Fresh Green
colord({ r: 74, g: 222, b: 128 }), // Mint
colord({ r: 79, g: 70, b: 229 }), // Indigo
colord({ r: 82, g: 183, b: 136 }), // Jade
colord({ r: 96, g: 165, b: 250 }), // Sky Blue
colord({ r: 99, g: 202, b: 253 }), // Azure
colord({ r: 110, g: 231, b: 183 }), // Seafoam
colord({ r: 233, g: 213, b: 255 }), // Light Lilac
colord({ r: 202, g: 138, b: 4 }), // Rich Gold
colord({ r: 151, g: 255, b: 187 }), // Fresh Mint
colord({ r: 220, g: 38, b: 38 }), // Ruby
colord({ r: 124, g: 58, b: 237 }), // Royal Purple
colord({ r: 45, g: 212, b: 191 }), // Ocean
colord({ r: 252, g: 165, b: 165 }), // Peach
// Additional 50 colors
colord({ r: 179, g: 136, b: 255 }), // Light Purple
colord({ r: 125, g: 211, b: 252 }), // Crystal Blue
colord({ r: 132, g: 204, b: 22 }), // Lime
colord({ r: 133, g: 77, b: 14 }), // Chocolate
colord({ r: 52, g: 211, b: 153 }), // Aquamarine
colord({ r: 234, g: 179, b: 8 }), // Mustard
colord({ r: 236, g: 72, b: 153 }), // Hot Pink
colord({ r: 147, g: 197, b: 253 }), // Sky
colord({ r: 249, g: 115, b: 22 }), // Pumpkin
colord({ r: 167, g: 139, b: 250 }), // Iris
colord({ r: 16, g: 185, b: 129 }), // Pine
colord({ r: 251, g: 146, b: 60 }), // Mango
colord({ r: 192, g: 132, b: 252 }), // Wisteria
colord({ r: 79, g: 70, b: 229 }), // Sapphire
colord({ r: 245, g: 101, b: 101 }), // Salmon
colord({ r: 134, g: 239, b: 172 }), // Spring Green
colord({ r: 59, g: 130, b: 246 }), // Ocean Blue
colord({ r: 253, g: 164, b: 175 }), // Rose Gold
colord({ r: 16, g: 185, b: 129 }), // Forest
colord({ r: 252, g: 211, b: 77 }), // Sunshine
colord({ r: 190, g: 92, b: 251 }), // Grape
colord({ r: 82, g: 183, b: 136 }), // Eucalyptus
colord({ r: 248, g: 113, b: 113 }), // Cherry
colord({ r: 99, g: 202, b: 253 }), // Arctic
colord({ r: 240, g: 171, b: 252 }), // Lilac
colord({ r: 163, g: 230, b: 53 }), // Chartreuse
colord({ r: 234, g: 88, b: 12 }), // Rust
colord({ r: 125, g: 211, b: 252 }), // Ice Blue
colord({ r: 251, g: 113, b: 133 }), // Strawberry
colord({ r: 52, g: 211, b: 153 }), // Sage
colord({ r: 167, g: 139, b: 250 }), // Violet
colord({ r: 245, g: 158, b: 11 }), // Apricot
colord({ r: 110, g: 231, b: 183 }), // Mint Green
colord({ r: 233, g: 213, b: 255 }), // Thistle
colord({ r: 202, g: 138, b: 4 }), // Bronze
colord({ r: 151, g: 255, b: 187 }), // Pistachio
colord({ r: 220, g: 38, b: 38 }), // Fire Engine
colord({ r: 124, g: 58, b: 237 }), // Electric Purple
colord({ r: 45, g: 212, b: 191 }), // Caribbean
colord({ r: 252, g: 165, b: 165 }), // Melon
colord({ r: 168, g: 85, b: 247 }), // Byzantium
colord({ r: 74, g: 222, b: 128 }), // Kelly Green
colord({ r: 239, g: 68, b: 68 }), // Cardinal
colord({ r: 34, g: 197, b: 94 }), // Shamrock
colord({ r: 96, g: 165, b: 250 }), // Marina
colord({ r: 249, g: 115, b: 22 }), // Carrot
colord({ r: 192, g: 132, b: 252 }), // Heliotrope
colord({ r: 45, g: 212, b: 191 }), // Lagoon
colord({ r: 244, g: 114, b: 182 }), // Bubble Gum
colord({ r: 132, g: 204, b: 22 }), // Apple
colord({ r: 56, g: 189, b: 248 }), // Electric Blue
colord({ r: 234, g: 179, b: 8 }), // Daffodil
colord({ r: 134, g: 239, b: 172 }), // Light Green
colord({ r: 147, g: 51, b: 234 }), // Bright Purple
colord({ r: 147, g: 197, b: 253 }), // Powder Blue
colord({ r: 151, g: 255, b: 187 }), // Fresh Mint
colord({ r: 163, g: 230, b: 53 }), // Yellow Green
colord({ r: 167, g: 139, b: 250 }), // Periwinkle
colord({ r: 168, g: 85, b: 247 }), // Vibrant Purple
colord({ r: 179, g: 136, b: 255 }), // Light Purple
colord({ r: 186, g: 255, b: 201 }), // Pale Emerald
colord({ r: 190, g: 92, b: 251 }), // Amethyst
colord({ r: 192, g: 132, b: 252 }), // Lavender
colord({ r: 202, g: 138, b: 4 }), // Rich Gold
colord({ r: 202, g: 225, b: 255 }), // Baby Blue
colord({ r: 204, g: 204, b: 255 }), // Soft Lavender Blue
colord({ r: 217, g: 70, b: 239 }), // Fuchsia
colord({ r: 220, g: 38, b: 38 }), // Ruby
colord({ r: 220, g: 220, b: 255 }), // Meringue Blue
colord({ r: 220, g: 240, b: 250 }), // Ice Blue
colord({ r: 230, g: 250, b: 210 }), // Pastel Lime
colord({ r: 230, g: 255, b: 250 }), // Mint Whisper
colord({ r: 233, g: 213, b: 255 }), // Light Lilac
colord({ r: 234, g: 88, b: 12 }), // Burnt Orange
colord({ r: 234, g: 179, b: 8 }), // Sunflower
colord({ r: 235, g: 75, b: 75 }), // Bright Red
colord({ r: 236, g: 72, b: 153 }), // Deep Pink
colord({ r: 239, g: 68, b: 68 }), // Crimson
colord({ r: 240, g: 171, b: 252 }), // Orchid
colord({ r: 240, g: 240, b: 200 }), // Light Khaki
colord({ r: 244, g: 114, b: 182 }), // Rose
colord({ r: 245, g: 101, b: 101 }), // Coral
colord({ r: 245, g: 158, b: 11 }), // Amber
colord({ r: 248, g: 113, b: 113 }), // Warm Red
colord({ r: 249, g: 115, b: 22 }), // Tangerine
colord({ r: 250, g: 215, b: 225 }), // Cotton Candy
colord({ r: 250, g: 250, b: 210 }), // Pastel Lemon
colord({ r: 251, g: 113, b: 133 }), // Watermelon
colord({ r: 251, g: 146, b: 60 }), // Light Orange
colord({ r: 251, g: 191, b: 36 }), // Marigold
colord({ r: 251, g: 235, b: 245 }), // Rose Powder
colord({ r: 252, g: 165, b: 165 }), // Peach
colord({ r: 252, g: 211, b: 77 }), // Golden
colord({ r: 253, g: 164, b: 175 }), // Salmon Pink
colord({ r: 255, g: 204, b: 229 }), // Blush Pink
colord({ r: 255, g: 223, b: 186 }), // Apricot Cream
colord({ r: 255, g: 240, b: 200 }), // Vanilla
];
export const botColors: Colord[] = [
@@ -266,3 +229,156 @@ export const botColors: Colord[] = [
colord({ r: 150, g: 160, b: 140 }), // Muted Dark Olive Green
colord({ r: 150, g: 140, b: 150 }), // Muted Dusty Rose
];
// Fallback colors for when the color palette is exhausted. Currently 100 colors.
export const fallbackColors: Colord[] = [
colord({ r: 0, g: 5, b: 0 }), // Black Mint
colord({ r: 0, g: 15, b: 0 }), // Deep Forest
colord({ r: 0, g: 25, b: 0 }), // Jungle
colord({ r: 0, g: 35, b: 0 }), // Dark Emerald
colord({ r: 0, g: 45, b: 0 }), // Green Moss
colord({ r: 0, g: 55, b: 0 }), // Moss Shadow
colord({ r: 0, g: 65, b: 0 }), // Dark Meadow
colord({ r: 0, g: 75, b: 0 }), // Forest Fern
colord({ r: 0, g: 85, b: 0 }), // Pine Leaf
colord({ r: 0, g: 95, b: 0 }), // Shadow Grass
colord({ r: 0, g: 105, b: 0 }), // Classic Green
colord({ r: 0, g: 115, b: 0 }), // Deep Lime
colord({ r: 0, g: 125, b: 0 }), // Dense Leaf
colord({ r: 0, g: 135, b: 0 }), // Basil Green
colord({ r: 0, g: 145, b: 0 }), // Organic Green
colord({ r: 0, g: 155, b: 0 }), // Bitter Herb
colord({ r: 0, g: 165, b: 0 }), // Raw Spinach
colord({ r: 0, g: 175, b: 0 }), // Woodland
colord({ r: 0, g: 185, b: 0 }), // Spring Weed
colord({ r: 0, g: 195, b: 5 }), // Apple Stem
colord({ r: 0, g: 205, b: 10 }), // Crisp Lettuce
colord({ r: 0, g: 215, b: 15 }), // Vibrant Green
colord({ r: 0, g: 225, b: 20 }), // Bright Herb
colord({ r: 0, g: 235, b: 25 }), // Green Splash
colord({ r: 0, g: 245, b: 30 }), // Mint Leaf
colord({ r: 0, g: 255, b: 35 }), // Fresh Mint
colord({ r: 10, g: 255, b: 45 }), // Neon Grass
colord({ r: 20, g: 255, b: 55 }), // Lemon Balm
colord({ r: 30, g: 255, b: 65 }), // Juicy Green
colord({ r: 40, g: 255, b: 75 }), // Pear Tint
colord({ r: 50, g: 255, b: 85 }), // Avocado Pastel
colord({ r: 60, g: 255, b: 95 }), // Lime Glow
colord({ r: 70, g: 255, b: 105 }), // Light Leaf
colord({ r: 80, g: 255, b: 115 }), // Soft Fern
colord({ r: 90, g: 255, b: 125 }), // Pastel Green
colord({ r: 100, g: 255, b: 135 }), // Green Melon
colord({ r: 110, g: 255, b: 145 }), // Herbal Mist
colord({ r: 120, g: 255, b: 155 }), // Kiwi Foam
colord({ r: 130, g: 255, b: 165 }), // Aloe Fresh
colord({ r: 140, g: 255, b: 175 }), // Light Mint
colord({ r: 150, g: 200, b: 255 }), // Cornflower Mist
colord({ r: 150, g: 255, b: 185 }), // Green Sorbet
colord({ r: 160, g: 215, b: 255 }), // Powder Blue
colord({ r: 160, g: 255, b: 195 }), // Pastel Apple
colord({ r: 170, g: 190, b: 255 }), // Periwinkle Ice
colord({ r: 170, g: 225, b: 255 }), // Baby Sky
colord({ r: 170, g: 255, b: 205 }), // Aloe Breeze
colord({ r: 180, g: 180, b: 255 }), // Pale Indigo
colord({ r: 180, g: 235, b: 250 }), // Aqua Pastel
colord({ r: 180, g: 255, b: 215 }), // Pale Mint
colord({ r: 190, g: 140, b: 195 }), // Fuchsia Tint
colord({ r: 190, g: 245, b: 240 }), // Ice Mint
colord({ r: 190, g: 255, b: 225 }), // Mint Water
colord({ r: 195, g: 145, b: 200 }), // Dusky Rose
colord({ r: 200, g: 150, b: 205 }), // Plum Frost
colord({ r: 200, g: 170, b: 255 }), // Lilac Bloom
colord({ r: 200, g: 255, b: 215 }), // Cool Aloe
colord({ r: 200, g: 255, b: 235 }), // Cool Mist
colord({ r: 205, g: 155, b: 210 }), // Berry Foam
colord({ r: 210, g: 160, b: 215 }), // Grape Cloud
colord({ r: 210, g: 255, b: 245 }), // Sea Mist
colord({ r: 215, g: 165, b: 220 }), // Light Bloom
colord({ r: 215, g: 255, b: 200 }), // Fresh Mint
colord({ r: 220, g: 160, b: 255 }), // Violet Mist
colord({ r: 220, g: 170, b: 225 }), // Cherry Blossom
colord({ r: 220, g: 255, b: 255 }), // Pale Aqua
colord({ r: 225, g: 175, b: 230 }), // Faded Rose
colord({ r: 225, g: 255, b: 175 }), // Soft Lime
colord({ r: 230, g: 180, b: 235 }), // Dreamy Mauve
colord({ r: 230, g: 250, b: 255 }), // Sky Haze
colord({ r: 235, g: 150, b: 255 }), // Orchid Glow
colord({ r: 235, g: 185, b: 240 }), // Powder Violet
colord({ r: 240, g: 190, b: 245 }), // Pastel Violet
colord({ r: 240, g: 240, b: 255 }), // Frosted Lilac
colord({ r: 240, g: 250, b: 160 }), // Citrus Wash
colord({ r: 245, g: 160, b: 240 }), // Rose Lilac
colord({ r: 245, g: 195, b: 250 }), // Soft Magenta
colord({ r: 245, g: 245, b: 175 }), // Lemon Mist
colord({ r: 250, g: 200, b: 255 }), // Lilac Cream
colord({ r: 250, g: 230, b: 255 }), // Misty Mauve
colord({ r: 255, g: 170, b: 225 }), // Bubblegum Pink
colord({ r: 255, g: 185, b: 215 }), // Blush Mist
colord({ r: 255, g: 195, b: 235 }), // Faded Fuchsia
colord({ r: 255, g: 200, b: 220 }), // Cotton Rose
colord({ r: 255, g: 205, b: 245 }), // Pastel Orchid
colord({ r: 255, g: 205, b: 255 }), // Violet Bloom
colord({ r: 255, g: 210, b: 230 }), // Pastel Blush
colord({ r: 255, g: 210, b: 250 }), // Lavender Mist
colord({ r: 255, g: 210, b: 255 }), // Orchid Mist
colord({ r: 255, g: 215, b: 195 }), // Apricot Glow
colord({ r: 255, g: 215, b: 245 }), // Rose Whisper
colord({ r: 255, g: 220, b: 235 }), // Pink Mist
colord({ r: 255, g: 220, b: 250 }), // Powder Petal
colord({ r: 255, g: 225, b: 180 }), // Butter Peach
colord({ r: 255, g: 225, b: 255 }), // Petal Mist
colord({ r: 255, g: 230, b: 245 }), // Light Rose
colord({ r: 255, g: 235, b: 200 }), // Cream Peach
colord({ r: 255, g: 235, b: 235 }), // Blushed Petal
colord({ r: 255, g: 240, b: 220 }), // Pastel Sand
colord({ r: 255, g: 245, b: 210 }), // Soft Banana
];
export class ColorAllocator {
private availableColors: Colord[];
private fallbackColors: Colord[];
private assigned = new Map<string, Colord>();
constructor(colors: Colord[], fallback: Colord[]) {
this.availableColors = [...colors];
this.fallbackColors = [...fallback];
}
assignColor(id: string): Colord {
if (this.assigned.has(id)) {
return this.assigned.get(id)!;
}
if (this.availableColors.length === 0) {
this.availableColors = [...this.fallbackColors];
}
const index = 0;
const color = this.availableColors.splice(index, 1)[0];
this.assigned.set(id, color);
return color;
}
assignTeamColor(team: Team): Colord {
switch (team) {
case ColoredTeams.Blue:
return blue;
case ColoredTeams.Red:
return red;
case ColoredTeams.Teal:
return teal;
case ColoredTeams.Purple:
return purple;
case ColoredTeams.Yellow:
return yellow;
case ColoredTeams.Orange:
return orange;
case ColoredTeams.Green:
return green;
case ColoredTeams.Bot:
return botColor;
default:
return this.availableColors[
simpleHash(team) % this.availableColors.length
];
}
}
}
+12 -4
View File
@@ -359,6 +359,7 @@ export class DefaultConfig implements Config {
),
territoryBound: true,
constructionDuration: this.instantBuild() ? 0 : 2 * 10,
upgradable: true,
};
case UnitType.AtomBomb:
return {
@@ -402,6 +403,7 @@ export class DefaultConfig implements Config {
: 1_000_000n,
territoryBound: true,
constructionDuration: this.instantBuild() ? 0 : 10 * 10,
upgradable: true,
};
case UnitType.DefensePost:
return {
@@ -418,6 +420,7 @@ export class DefaultConfig implements Config {
),
territoryBound: true,
constructionDuration: this.instantBuild() ? 0 : 5 * 10,
upgradable: true,
};
case UnitType.SAMLauncher:
return {
@@ -434,6 +437,7 @@ export class DefaultConfig implements Config {
),
territoryBound: true,
constructionDuration: this.instantBuild() ? 0 : 30 * 10,
upgradable: true,
};
case UnitType.City:
return {
@@ -451,6 +455,7 @@ export class DefaultConfig implements Config {
),
territoryBound: true,
constructionDuration: this.instantBuild() ? 0 : 2 * 10,
upgradable: true,
};
case UnitType.Construction:
return {
@@ -678,7 +683,11 @@ export class DefaultConfig implements Config {
player.type() === PlayerType.Human && this.infiniteTroops()
? 1_000_000_000
: 2 * (Math.pow(player.numTilesOwned(), 0.6) * 1000 + 50000) +
player.units(UnitType.City).length * this.cityPopulationIncrease();
player
.units(UnitType.City)
.map((city) => city.level())
.reduce((a, b) => a + b, 0) *
this.cityPopulationIncrease();
if (player.type() === PlayerType.Bot) {
return maxPop / 2;
@@ -764,7 +773,7 @@ export class DefaultConfig implements Config {
}
defaultNukeSpeed(): number {
return 4;
return 6;
}
// Humans can be population, soldiers attacking, soldiers in boat etc.
@@ -773,8 +782,7 @@ export class DefaultConfig implements Config {
}
structureMinDist(): number {
// TODO: Increase this to ~15 once upgradable structures are implemented.
return 1;
return 15;
}
shellLifetime(): number {
+12 -35
View File
@@ -1,21 +1,14 @@
import { Colord, colord } from "colord";
import { PseudoRandom } from "../PseudoRandom";
import { simpleHash } from "../Util";
import { ColoredTeams, PlayerType, Team, TerrainType } from "../game/Game";
import { PlayerType, Team, TerrainType } from "../game/Game";
import { GameMap, TileRef } from "../game/GameMap";
import { PlayerView } from "../game/GameView";
import {
blue,
botColor,
botColors,
green,
ColorAllocator,
fallbackColors,
humanColors,
orange,
purple,
red,
teal,
territoryColors,
yellow,
nationColors,
} from "./Colors";
import { Theme } from "./Config";
@@ -24,9 +17,12 @@ type ColorCache = Map<string, Colord>;
export class PastelTheme implements Theme {
private borderColorCache: ColorCache = new Map<string, Colord>();
private rand = new PseudoRandom(123);
private humanColorAllocator = new ColorAllocator(humanColors, fallbackColors);
private botColorAllocator = new ColorAllocator(botColors, botColors);
private teamColorAllocator = new ColorAllocator(humanColors, fallbackColors);
private nationColorAllocator = new ColorAllocator(nationColors, nationColors);
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
@@ -45,26 +41,7 @@ export class PastelTheme implements Theme {
private _spawnHighlightColor = colord({ r: 255, g: 213, b: 79 });
teamColor(team: Team): Colord {
switch (team) {
case ColoredTeams.Blue:
return blue;
case ColoredTeams.Red:
return red;
case ColoredTeams.Teal:
return teal;
case ColoredTeams.Purple:
return purple;
case ColoredTeams.Yellow:
return yellow;
case ColoredTeams.Orange:
return orange;
case ColoredTeams.Green:
return green;
case ColoredTeams.Bot:
return botColor;
default:
return humanColors[simpleHash(team) % humanColors.length];
}
return this.teamColorAllocator.assignTeamColor(team);
}
territoryColor(player: PlayerView): Colord {
@@ -73,12 +50,12 @@ export class PastelTheme implements Theme {
return this.teamColor(team);
}
if (player.type() === PlayerType.Human) {
return humanColors[simpleHash(player.id()) % humanColors.length];
return this.humanColorAllocator.assignColor(player.id());
}
if (player.type() === PlayerType.Bot) {
return botColors[simpleHash(player.id()) % botColors.length];
return this.botColorAllocator.assignColor(player.id());
}
return territoryColors[simpleHash(player.id()) % territoryColors.length];
return this.nationColorAllocator.assignColor(player.id());
}
textColor(player: PlayerView): string {
+12 -35
View File
@@ -1,21 +1,14 @@
import { Colord, colord } from "colord";
import { PseudoRandom } from "../PseudoRandom";
import { simpleHash } from "../Util";
import { ColoredTeams, PlayerType, Team, TerrainType } from "../game/Game";
import { PlayerType, Team, TerrainType } from "../game/Game";
import { GameMap, TileRef } from "../game/GameMap";
import { PlayerView } from "../game/GameView";
import {
blue,
botColor,
botColors,
green,
ColorAllocator,
fallbackColors,
humanColors,
orange,
purple,
red,
teal,
territoryColors,
yellow,
nationColors,
} from "./Colors";
import { Theme } from "./Config";
@@ -24,9 +17,12 @@ type ColorCache = Map<string, Colord>;
export class PastelThemeDark implements Theme {
private borderColorCache: ColorCache = new Map<string, Colord>();
private rand = new PseudoRandom(123);
private humanColorAllocator = new ColorAllocator(humanColors, fallbackColors);
private botColorAllocator = new ColorAllocator(botColors, botColors);
private teamColorAllocator = new ColorAllocator(humanColors, fallbackColors);
private nationColorAllocator = new ColorAllocator(nationColors, nationColors);
private background = colord({ r: 0, g: 0, b: 0 });
private land = colord({ r: 194, g: 193, b: 148 });
private shore = colord({ r: 134, g: 133, b: 88 });
private falloutColors = [
colord({ r: 120, g: 255, b: 71 }), // Original color
@@ -45,26 +41,7 @@ export class PastelThemeDark implements Theme {
private _spawnHighlightColor = colord({ r: 255, g: 213, b: 79 });
teamColor(team: Team): Colord {
switch (team) {
case ColoredTeams.Blue:
return blue;
case ColoredTeams.Red:
return red;
case ColoredTeams.Teal:
return teal;
case ColoredTeams.Purple:
return purple;
case ColoredTeams.Yellow:
return yellow;
case ColoredTeams.Orange:
return orange;
case ColoredTeams.Green:
return green;
case ColoredTeams.Bot:
return botColor;
default:
return humanColors[simpleHash(team) % humanColors.length];
}
return this.teamColorAllocator.assignTeamColor(team);
}
territoryColor(player: PlayerView): Colord {
@@ -73,12 +50,12 @@ export class PastelThemeDark implements Theme {
return this.teamColor(team);
}
if (player.type() === PlayerType.Human) {
return humanColors[simpleHash(player.id()) % humanColors.length];
return this.humanColorAllocator.assignColor(player.id());
}
if (player.type() === PlayerType.Bot) {
return botColors[simpleHash(player.id()) % botColors.length];
return this.botColorAllocator.assignColor(player.id());
}
return territoryColors[simpleHash(player.id()) % territoryColors.length];
return this.nationColorAllocator.assignColor(player.id());
}
textColor(player: PlayerView): string {
+4 -20
View File
@@ -122,31 +122,15 @@ export class AttackExecution implements Execution {
// Record stats
this.mg.stats().attack(this._owner, this.target, this.startTroops);
for (const incoming of this._owner.incomingAttacks()) {
if (incoming.attacker() === this.target) {
// Target has opposing attack, cancel them out
if (incoming.troops() > this.attack.troops()) {
incoming.setTroops(incoming.troops() - this.attack.troops());
this.attack.delete();
this.active = false;
return;
} else {
this.attack.setTroops(this.attack.troops() - incoming.troops());
incoming.delete();
}
}
}
for (const outgoing of this._owner.outgoingAttacks()) {
if (
outgoing !== this.attack &&
outgoing.target() === this.attack.target() &&
outgoing.sourceTile() === this.attack.sourceTile()
// Boat attacks (sourceTile is not null) are not combined with other attacks
this.attack.sourceTile() === null
) {
// Existing attack on same target, add troops
outgoing.setTroops(outgoing.troops() + this.attack.troops());
this.active = false;
this.attack.delete();
return;
this.attack.setTroops(this.attack.troops() + outgoing.troops());
outgoing.delete();
}
}
+2 -5
View File
@@ -58,11 +58,8 @@ export class BotExecution implements Execution {
if (this.behavior === null) {
throw new Error("not initialized");
}
const traitors = this.bot
.neighbors()
.filter((n) => n.isPlayer() && n.isTraitor()) as Player[];
if (traitors.length > 0) {
const toAttack = this.random.randElement(traitors);
const toAttack = this.behavior.getNeighborTraitorToAttack();
if (toAttack !== null) {
const odds = this.bot.isFriendly(toAttack) ? 6 : 3;
if (this.random.chance(odds)) {
this.behavior.sendAttack(toAttack);
+3
View File
@@ -24,6 +24,7 @@ import { SetTargetTroopRatioExecution } from "./SetTargetTroopRatioExecution";
import { SpawnExecution } from "./SpawnExecution";
import { TargetPlayerExecution } from "./TargetPlayerExecution";
import { TransportShipExecution } from "./TransportShipExecution";
import { UpgradeStructureExecution } from "./UpgradeStructureExecution";
export class Executor {
// private random = new PseudoRandom(999)
@@ -114,6 +115,8 @@ export class Executor {
this.mg.ref(intent.x, intent.y),
intent.unit,
);
case "upgrade_structure":
return new UpgradeStructureExecution(player, intent.unitId);
case "quick_chat":
return new QuickChatExecution(
player,
+11 -1
View File
@@ -34,10 +34,20 @@ export class MissileSiloExecution implements Execution {
}
}
const cooldown = this.silo.ticksLeftInCooldown();
const frontTime = this.silo.ticksLeftInCooldown();
if (frontTime === undefined) {
return;
}
const cooldown =
this.mg.config().SiloCooldown() - (this.mg.ticks() - frontTime);
if (typeof cooldown === "number" && cooldown >= 0) {
this.silo.touch();
}
if (cooldown <= 0) {
this.silo.reloadMissile();
}
}
isActive(): boolean {
+15 -5
View File
@@ -155,11 +155,6 @@ export class SAMLauncherExecution implements Execution {
target = this.getSingleTarget();
}
const cooldown = this.sam.ticksLeftInCooldown();
if (typeof cooldown === "number" && cooldown >= 0) {
this.sam.touch();
}
const isSingleTarget = target && !target.targetedBySAM();
if (
(isSingleTarget || mirvWarheadTargets.length > 0) &&
@@ -204,6 +199,21 @@ export class SAMLauncherExecution implements Execution {
}
}
}
const frontTime = this.sam.ticksLeftInCooldown();
if (frontTime === undefined) {
return;
}
const cooldown =
this.mg.config().SAMCooldown() - (this.mg.ticks() - frontTime);
if (typeof cooldown === "number" && cooldown >= 0) {
this.sam.touch();
}
if (cooldown <= 0) {
this.sam.reloadMissile();
}
}
isActive(): boolean {
@@ -0,0 +1,44 @@
import { Execution, Game, Player, Unit } from "../game/Game";
export class UpgradeStructureExecution implements Execution {
private structure: Unit | undefined;
private cost: bigint;
constructor(
private player: Player,
private unitId: number,
) {}
init(mg: Game, ticks: number): void {
this.structure = this.player
.units()
.find((unit) => unit.id() === this.unitId);
if (this.structure === undefined) {
console.warn(`structure is undefined`);
return;
}
if (!this.structure.info().upgradable) {
console.warn(`unit type ${this.structure} cannot be upgraded`);
return;
}
this.cost = this.structure.info().cost(this.player);
if (this.player.gold() < this.cost) {
return;
}
this.player.upgradeUnit(this.structure);
return;
}
tick(ticks: number): void {
return;
}
isActive(): boolean {
return false;
}
activeDuringSpawnPhase(): boolean {
return false;
}
}
+97 -62
View File
@@ -43,22 +43,48 @@ export class BotBehavior {
this.game.addExecution(new EmojiExecution(this.player, player.id(), emoji));
}
private setNewEnemy(newEnemy: Player | null) {
this.enemy = newEnemy;
this.enemyUpdated = this.game.ticks();
}
private clearEnemy() {
this.enemy = null;
}
forgetOldEnemies() {
// Forget old enemies
if (this.game.ticks() - this.enemyUpdated > 100) {
this.enemy = null;
this.clearEnemy();
}
}
private hasSufficientTroops(): boolean {
const maxPop = this.game.config().maxPopulation(this.player);
const ratio = this.player.population() / maxPop;
return ratio >= this.triggerRatio;
}
private checkIncomingAttacks() {
// Switch enemies if we're under attack
const incomingAttacks = this.player.incomingAttacks();
if (incomingAttacks.length > 0) {
this.enemy = incomingAttacks
.sort((a, b) => b.troops() - a.troops())[0]
.attacker();
this.enemyUpdated = this.game.ticks();
let largestAttack = 0;
let largestAttacker: Player | undefined;
for (const attack of incomingAttacks) {
if (attack.troops() <= largestAttack) continue;
largestAttack = attack.troops();
largestAttacker = attack.attacker();
}
if (largestAttacker !== undefined) {
this.setNewEnemy(largestAttacker);
}
}
getNeighborTraitorToAttack(): Player | null {
const traitors = this.player
.neighbors()
.filter((n): n is Player => n.isPlayer() && n.isTraitor());
return traitors.length > 0 ? this.random.randElement(traitors) : null;
}
assistAllies() {
@@ -79,8 +105,7 @@ export class BotBehavior {
}
// All checks passed, assist them
this.player.updateRelation(ally, -20);
this.enemy = target;
this.enemyUpdated = this.game.ticks();
this.setNewEnemy(target);
this.emoji(ally, this.assistAcceptEmoji);
break outer;
}
@@ -90,50 +115,57 @@ export class BotBehavior {
selectEnemy(): Player | null {
if (this.enemy === null) {
// Save up troops until we reach the trigger ratio
const maxPop = this.game.config().maxPopulation(this.player);
const ratio = this.player.population() / maxPop;
if (ratio < this.triggerRatio) return null;
}
if (!this.hasSufficientTroops()) return null;
// Prefer neighboring bots
if (this.enemy === null) {
// Prefer neighboring bots
const bots = this.player
.neighbors()
.filter((n) => n.isPlayer() && n.type() === PlayerType.Bot) as Player[];
.filter(
(n): n is Player => n.isPlayer() && n.type() === PlayerType.Bot,
);
if (bots.length > 0) {
const density = (p: Player) => p.troops() / p.numTilesOwned();
this.enemy = bots.sort((a, b) => density(a) - density(b))[0];
this.enemyUpdated = this.game.ticks();
let lowestDensityBot: Player | undefined;
let lowestDensity = Infinity;
for (const bot of bots) {
const currentDensity = density(bot);
if (currentDensity < lowestDensity) {
lowestDensity = currentDensity;
lowestDensityBot = bot;
}
}
if (lowestDensityBot !== undefined) {
this.setNewEnemy(lowestDensityBot);
}
}
}
// Retaliate against incoming attacks
if (this.enemy === null) {
this.checkIncomingAttacks();
}
// Retaliate against incoming attacks
if (this.enemy === null) {
this.checkIncomingAttacks();
}
// Select the most hated player
if (this.enemy === null) {
const mostHated = this.player.allRelationsSorted()[0];
if (mostHated !== undefined && mostHated.relation === Relation.Hostile) {
this.enemy = mostHated.player;
this.enemyUpdated = this.game.ticks();
// Select the most hated player
if (this.enemy === null) {
const mostHated = this.player.allRelationsSorted()[0];
if (
mostHated !== undefined &&
mostHated.relation === Relation.Hostile
) {
this.setNewEnemy(mostHated.player);
}
}
}
// Sanity check, don't attack our allies or teammates
if (this.enemy && this.player.isFriendly(this.enemy)) {
this.enemy = null;
}
return this.enemy;
return this.enemySanityCheck();
}
selectRandomEnemy(): Player | TerraNullius | null {
if (this.enemy === null) {
// Save up troops until we reach the trigger ratio
const maxPop = this.game.config().maxPopulation(this.player);
const ratio = this.player.population() / maxPop;
if (ratio < this.triggerRatio) return null;
if (!this.hasSufficientTroops()) return null;
// Choose a new enemy randomly
const neighbors = this.player.neighbors();
@@ -145,34 +177,32 @@ export class BotBehavior {
continue;
}
}
this.enemy = neighbor;
this.enemyUpdated = this.game.ticks();
this.setNewEnemy(neighbor);
}
}
// Retaliate against incoming attacks
if (this.enemy === null) {
this.checkIncomingAttacks();
}
// Retaliate against incoming attacks
if (this.enemy === null) {
this.checkIncomingAttacks();
}
// Select a traitor as an enemy
if (this.enemy === null) {
const traitors = this.player
.neighbors()
.filter((n) => n.isPlayer() && n.isTraitor()) as Player[];
if (traitors.length > 0) {
const toAttack = this.random.randElement(traitors);
const odds = this.player.isFriendly(toAttack) ? 6 : 3;
if (this.random.chance(odds)) {
this.enemy = toAttack;
this.enemyUpdated = this.game.ticks();
// Select a traitor as an enemy
if (this.enemy === null) {
const toAttack = this.getNeighborTraitorToAttack();
if (toAttack !== null) {
if (!this.player.isFriendly(toAttack) && this.random.chance(3)) {
this.setNewEnemy(toAttack);
}
}
}
}
// Sanity check, don't attack our allies or teammates
return this.enemySanityCheck();
}
private enemySanityCheck(): Player | null {
if (this.enemy && this.player.isFriendly(this.enemy)) {
this.enemy = null;
this.clearEnemy();
}
return this.enemy;
}
@@ -200,12 +230,17 @@ export class BotBehavior {
}
function shouldAcceptAllianceRequest(player: Player, request: AllianceRequest) {
const isTraitor = request.requestor().isTraitor();
const hasMalice = player.relation(request.requestor()) < Relation.Neutral;
const requestorIsMuchLarger =
request.requestor().numTilesOwned() > player.numTilesOwned() * 3;
const tooManyAlliances = request.requestor().alliances().length >= 3;
return (
!isTraitor && !hasMalice && (requestorIsMuchLarger || !tooManyAlliances)
);
if (player.relation(request.requestor()) < Relation.Neutral) {
return false; // Reject if hasMalice
}
if (request.requestor().isTraitor()) {
return false; // Reject if isTraitor
}
if (request.requestor().numTilesOwned() > player.numTilesOwned() * 3) {
return true; // Accept if requestorIsMuchLarger
}
if (request.requestor().alliances().length >= 3) {
return false; // Reject if tooManyAlliances
}
return true; // Accept otherwise
}
+8 -1
View File
@@ -130,6 +130,7 @@ export interface UnitInfo {
maxHealth?: number;
damage?: number;
constructionDuration?: number;
upgradable?: boolean;
}
export enum UnitType {
@@ -385,8 +386,9 @@ export interface Unit {
// SAMs & Missile Silos
launch(): void;
ticksLeftInCooldown(): Tick | undefined;
reloadMissile(): void;
isInCooldown(): boolean;
ticksLeftInCooldown(): Tick | undefined;
// Trade Ships
setSafeFromPirates(): void; // Only for trade ships
@@ -396,6 +398,10 @@ export interface Unit {
constructionType(): UnitType | null;
setConstructionType(type: UnitType): void;
// Upgradable Structures
level(): number;
increaseLevel(): void;
// Warships
setPatrolTile(tile: TileRef): void;
patrolTile(): TileRef | undefined;
@@ -471,6 +477,7 @@ export interface Player {
spawnTile: TileRef,
params: UnitParams<T>,
): Unit;
upgradeUnit(unit: Unit): void;
captureUnit(unit: Unit): void;
+3 -1
View File
@@ -80,7 +80,9 @@ export interface UnitUpdate {
targetTile?: TileRef; // Only for nukes
health?: number;
constructionType?: UnitType;
ticksLeftInCooldown?: Tick;
missileTimerQueue: number[];
readyMissileCount: number;
level: number;
}
export interface AttackUpdate {
+6 -4
View File
@@ -112,11 +112,13 @@ export class UnitView {
return this.data.targetTile;
}
ticksLeftInCooldown(): Tick | undefined {
return this.data.ticksLeftInCooldown;
return this.data.missileTimerQueue?.[0];
}
isCooldown(): boolean {
if (this.data.ticksLeftInCooldown === undefined) return false;
return this.data.ticksLeftInCooldown > 0;
isInCooldown(): boolean {
return this.data.readyMissileCount === 0;
}
level(): number {
return this.data.level;
}
}
+16 -8
View File
@@ -157,7 +157,7 @@ export class PlayerImpl implements Player {
troops: a.troops(),
id: a.id(),
retreating: a.retreating(),
} as AttackUpdate;
} satisfies AttackUpdate;
}),
incomingAttacks: this._incomingAttacks.map((a) => {
return {
@@ -166,7 +166,7 @@ export class PlayerImpl implements Player {
troops: a.troops(),
id: a.id(),
retreating: a.retreating(),
} as AttackUpdate;
} satisfies AttackUpdate;
}),
outgoingAllianceRequests: outgoingAllianceRequests,
hasSpawned: this.hasSpawned(),
@@ -252,7 +252,9 @@ export class PlayerImpl implements Player {
if (this.mg.map().isLand(neighbor)) {
const owner = this.mg.map().ownerID(neighbor);
if (owner !== this.smallID()) {
ns.add(this.mg.playerBySmallID(owner) as Player | TerraNullius);
ns.add(
this.mg.playerBySmallID(owner) satisfies Player | TerraNullius,
);
}
}
}
@@ -394,7 +396,7 @@ export class PlayerImpl implements Player {
if (this.isAlliedWith(recipient)) {
throw new Error(`cannot create alliance request, already allies`);
}
return this.mg.createAllianceRequest(this, recipient as Player);
return this.mg.createAllianceRequest(this, recipient satisfies Player);
}
relation(other: Player): Relation {
@@ -481,7 +483,7 @@ export class PlayerImpl implements Player {
.map((a) => a.other(this))
.flatMap((ally) => ally.targets());
ts.push(...this.targets());
return [...new Set(ts)] as Player[];
return [...new Set(ts)] satisfies Player[];
}
sendEmoji(recipient: Player | typeof AllPlayers, emoji: string): void {
@@ -752,6 +754,12 @@ export class PlayerImpl implements Player {
return b;
}
upgradeUnit(unit: Unit) {
const cost = this.mg.unitInfo(unit.type()).cost(this);
this.removeGold(cost);
unit.increaseLevel();
}
public buildableUnits(tile: TileRef): BuildableUnit[] {
const validTiles = this.validStructureSpawnTiles(tile);
return Object.values(UnitType).map((u) => {
@@ -1007,8 +1015,8 @@ export class PlayerImpl implements Player {
if (this.mg.owner(tile) === this) {
return false;
}
if (this.mg.hasOwner(tile)) {
const other = this.mg.owner(tile) as Player;
const other = this.mg.owner(tile);
if (other.isPlayer()) {
if (this.isFriendly(other)) {
return false;
}
@@ -1018,7 +1026,7 @@ export class PlayerImpl implements Player {
return false;
}
if (this.mg.hasOwner(tile)) {
return this.sharesBorderWith(this.mg.owner(tile));
return this.sharesBorderWith(other);
} else {
for (const t of this.mg.bfs(
tile,
+3
View File
@@ -85,6 +85,9 @@ export interface Stats {
// Player captures a unit of type
unitCapture(player: Player, type: OtherUnitType): void;
// Player upgrades a unit of type
unitUpgrade(player: Player, type: OtherUnitType): void;
// Player destroys a unit of type
unitDestroy(player: Player, type: OtherUnitType): void;
+5
View File
@@ -20,6 +20,7 @@ import {
OTHER_INDEX_CAPTURE,
OTHER_INDEX_DESTROY,
OTHER_INDEX_LOST,
OTHER_INDEX_UPGRADE,
OtherUnitType,
PlayerStats,
unitTypeToBombUnit,
@@ -234,6 +235,10 @@ export class StatsImpl implements Stats {
this._addOtherUnit(player, type, OTHER_INDEX_CAPTURE, 1);
}
unitUpgrade(player: Player, type: OtherUnitType): void {
this._addOtherUnit(player, type, OTHER_INDEX_UPGRADE, 1);
}
unitDestroy(player: Player, type: OtherUnitType): void {
this._addOtherUnit(player, type, OTHER_INDEX_DESTROY, 1);
}
+28 -19
View File
@@ -26,8 +26,10 @@ export class UnitImpl implements Unit {
private _constructionType: UnitType | undefined;
private _lastOwner: PlayerImpl | null = null;
private _troops: number;
private _cooldownStartTick: Tick | null = null;
private _missileTimerQueue: number[] = [];
private _readyMissileCount: number = 1;
private _patrolTile: TileRef | undefined;
private _level: number = 1;
constructor(
private _type: UnitType,
private mg: GameImpl,
@@ -104,7 +106,9 @@ export class UnitImpl implements Unit {
constructionType: this._constructionType,
targetUnitId: this._targetUnit?.id() ?? undefined,
targetTile: this.targetTile() ?? undefined,
ticksLeftInCooldown: this.ticksLeftInCooldown() ?? undefined,
missileTimerQueue: this._missileTimerQueue,
readyMissileCount: this._readyMissileCount,
level: this.level(),
};
}
@@ -267,30 +271,23 @@ export class UnitImpl implements Unit {
}
launch(): void {
this._cooldownStartTick = this.mg.ticks();
this._missileTimerQueue.push(this.mg.ticks());
this._readyMissileCount--;
this.mg.addUpdate(this.toUpdate());
}
ticksLeftInCooldown(): Tick | undefined {
let cooldownDuration = 0;
if (this.type() === UnitType.SAMLauncher) {
cooldownDuration = this.mg.config().SAMCooldown();
} else if (this.type() === UnitType.MissileSilo) {
cooldownDuration = this.mg.config().SiloCooldown();
} else {
return undefined;
}
if (!this._cooldownStartTick) {
return undefined;
}
return cooldownDuration - (this.mg.ticks() - this._cooldownStartTick);
return this._missileTimerQueue[0];
}
isInCooldown(): boolean {
const ticksLeft = this.ticksLeftInCooldown();
return ticksLeft !== undefined && ticksLeft > 0;
return this._readyMissileCount === 0;
}
reloadMissile(): void {
this._missileTimerQueue.shift();
this._readyMissileCount++;
this.mg.addUpdate(this.toUpdate());
}
setTargetTile(targetTile: TileRef | undefined) {
@@ -335,4 +332,16 @@ export class UnitImpl implements Unit {
this.mg.config().safeFromPiratesCooldownMax()
);
}
level(): number {
return this._level;
}
increaseLevel(): void {
this._level++;
if ([UnitType.MissileSilo, UnitType.SAMLauncher].includes(this.type())) {
this._readyMissileCount++;
}
this.mg.addUpdate(this.toUpdate());
}
}
+3
View File
@@ -182,6 +182,7 @@ export class GameServer {
this.allClients.set(client.clientID, client);
client.ws.removeAllListeners("message");
client.ws.on(
"message",
gatekeeper.wsHandler(client.ip, async (message: string) => {
@@ -239,6 +240,7 @@ export class GameServer {
}
}),
);
client.ws.removeAllListeners("close");
client.ws.on("close", () => {
this.log.info("client disconnected", {
clientID: client.clientID,
@@ -248,6 +250,7 @@ export class GameServer {
(c) => c.clientID !== client.clientID,
);
});
client.ws.removeAllListeners("error");
client.ws.on("error", (error: Error) => {
if ((error as any).code === "WS_ERR_UNEXPECTED_RSV_1") {
client.ws.close(1002);
+81
View File
@@ -0,0 +1,81 @@
import { colord, Colord } from "colord";
import {
blue,
botColor,
ColorAllocator,
red,
teal,
} from "../src/core/configuration/Colors";
import { ColoredTeams } from "../src/core/game/Game";
const mockColors: Colord[] = [
colord({ r: 255, g: 0, b: 0 }),
colord({ r: 0, g: 255, b: 0 }),
colord({ r: 0, g: 0, b: 255 }),
];
const fallbackMockColors: Colord[] = [
colord({ r: 0, g: 0, b: 0 }),
colord({ r: 255, g: 255, b: 255 }),
];
describe("ColorAllocator", () => {
let allocator: ColorAllocator;
beforeEach(() => {
allocator = new ColorAllocator(mockColors, fallbackMockColors);
});
test("returns a unique color for each new ID", () => {
const c1 = allocator.assignColor("a");
const c2 = allocator.assignColor("b");
const c3 = allocator.assignColor("c");
expect(c1.isEqual(c2)).toBe(false);
expect(c1.isEqual(c3)).toBe(false);
expect(c2.isEqual(c3)).toBe(false);
});
test("returns the same color for the same ID", () => {
const c1 = allocator.assignColor("a");
const c2 = allocator.assignColor("a");
expect(c1.isEqual(c2)).toBe(true);
});
test("falls back when colors are exhausted", () => {
allocator.assignColor("1");
allocator.assignColor("2");
allocator.assignColor("3");
const fallback = allocator.assignColor("4");
const fallback2 = allocator.assignColor("5");
const match = fallbackMockColors.some((color) => color.isEqual(fallback));
expect(match).toBe(true);
const match2 = fallback.isEqual(fallback2);
expect(match2).toBe(false);
});
test("assignBotColor returns deterministic color from botColors", () => {
const allocator = new ColorAllocator(mockColors, mockColors);
const id1 = "bot123";
const id2 = "bot456";
const c1 = allocator.assignColor(id1);
const c2 = allocator.assignColor(id2);
const c1Again = allocator.assignColor(id1);
const c2Again = allocator.assignColor(id2);
expect(c1.isEqual(c1Again)).toBe(true);
expect(c2.isEqual(c2Again)).toBe(true);
});
test("assignTeamColor returns the expected static color for known teams", () => {
expect(allocator.assignTeamColor(ColoredTeams.Blue)).toEqual(blue);
expect(allocator.assignTeamColor(ColoredTeams.Red)).toEqual(red);
expect(allocator.assignTeamColor(ColoredTeams.Teal)).toEqual(teal);
expect(allocator.assignTeamColor(ColoredTeams.Bot)).toEqual(botColor);
});
});
+4 -4
View File
@@ -15,8 +15,8 @@ describe("getMessageTypeClasses", () => {
it("should return a valid CSS class for every MessageType", () => {
const messageTypes = Object.values(MessageType).filter(
(value) => typeof value === "number",
) as MessageType[];
(value): value is MessageType => typeof value === "number",
);
messageTypes.forEach((messageType) => {
const result = getMessageTypeClasses(messageType);
@@ -30,8 +30,8 @@ describe("getMessageTypeClasses", () => {
it("should not trigger console.warn for any MessageType", () => {
const messageTypes = Object.values(MessageType).filter(
(value) => typeof value === "number",
) as MessageType[];
(value): value is MessageType => typeof value === "number",
);
messageTypes.forEach((messageType) => {
getMessageTypeClasses(messageType);
+17 -2
View File
@@ -1,5 +1,6 @@
import { NukeExecution } from "../src/core/execution/NukeExecution";
import { SpawnExecution } from "../src/core/execution/SpawnExecution";
import { UpgradeStructureExecution } from "../src/core/execution/UpgradeStructureExecution";
import {
Game,
Player,
@@ -9,7 +10,7 @@ import {
} from "../src/core/game/Game";
import { TileRef } from "../src/core/game/GameMap";
import { setup } from "./util/Setup";
import { constructionExecution } from "./util/utils";
import { constructionExecution, executeTicks } from "./util/utils";
let game: Game;
let attacker: Player;
@@ -85,7 +86,21 @@ describe("MissileSilo", () => {
).toBeTruthy();
}
game.executeNextTick();
executeTicks(game, 2);
expect(attacker.units(UnitType.MissileSilo)[0].isInCooldown()).toBeFalsy();
});
test("missilesilo should have increased level after upgrade", async () => {
expect(attacker.units(UnitType.MissileSilo)[0].level()).toEqual(1);
const upgradeStructureExecution = new UpgradeStructureExecution(
attacker,
attacker.units(UnitType.MissileSilo)[0].id(),
);
game.addExecution(upgradeStructureExecution);
executeTicks(game, 2);
expect(attacker.units(UnitType.MissileSilo)[0].level()).toEqual(2);
});
});
+17
View File
@@ -1,6 +1,7 @@
import { NukeExecution } from "../src/core/execution/NukeExecution";
import { SAMLauncherExecution } from "../src/core/execution/SAMLauncherExecution";
import { SpawnExecution } from "../src/core/execution/SpawnExecution";
import { UpgradeStructureExecution } from "../src/core/execution/UpgradeStructureExecution";
import {
Game,
Player,
@@ -94,6 +95,7 @@ describe("SAM", () => {
test("sam should cooldown as long as configured", async () => {
const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {});
game.addExecution(new SAMLauncherExecution(defender, null, sam));
expect(sam.isInCooldown()).toBeFalsy();
const nuke = attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 2), {
@@ -103,6 +105,7 @@ describe("SAM", () => {
executeTicks(game, 3);
expect(nuke.isActive()).toBeFalsy();
for (let i = 0; i < game.config().SAMCooldown() - 3; i++) {
game.executeNextTick();
expect(sam.isInCooldown()).toBeTruthy();
@@ -161,4 +164,18 @@ describe("SAM", () => {
expect(sam1.isInCooldown()).toBeFalsy();
expect(sam2.isInCooldown()).toBeTruthy();
});
test("SAM should have increased level after upgrade", async () => {
defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {});
expect(defender.units(UnitType.SAMLauncher)[0].level()).toEqual(1);
const upgradeStructureExecution = new UpgradeStructureExecution(
defender,
defender.units(UnitType.SAMLauncher)[0].id(),
);
game.addExecution(upgradeStructureExecution);
executeTicks(game, 2);
expect(defender.units(UnitType.SAMLauncher)[0].level()).toEqual(2);
});
});
+58
View File
@@ -0,0 +1,58 @@
/**
* @jest-environment jsdom
*/
import { ProgressBar } from "../../../src/client/graphics/ProgressBar";
describe("ProgressBar", () => {
let ctx: CanvasRenderingContext2D;
let canvas: HTMLCanvasElement;
beforeEach(() => {
canvas = document.createElement("canvas");
canvas.width = 100;
canvas.height = 20;
ctx = canvas.getContext("2d")!;
});
it("should initialize and draw the background", () => {
const spyClearRect = jest.spyOn(ctx, "clearRect");
const spyFillRect = jest.spyOn(ctx, "fillRect");
const spyFillStyle = jest.spyOn(ctx, "fillStyle", "set");
const bar = new ProgressBar(["#ff0000", "#00ff00"], ctx, 2, 2, 80, 10, 0.5);
expect(spyClearRect).toHaveBeenCalledWith(0, 0, 82, 12);
expect(spyFillRect).toHaveBeenCalledWith(1, 1, 80, 10);
expect(spyFillStyle).toHaveBeenCalledWith("#00ff00");
expect(bar.getX()).toBe(2);
expect(bar.getY()).toBe(2);
});
it("should set progress and draw the progress bar", () => {
const bar = new ProgressBar(["#ff0000", "#00ff00"], ctx, 2, 2, 80, 10);
const spyFillRect = jest.spyOn(ctx, "fillRect");
bar.setProgress(0.5);
expect(bar.getProgress()).toBe(0.5);
expect(spyFillRect).toHaveBeenCalledWith(
2,
2,
Math.floor(0.5 * (80 - 2)),
8,
);
expect(ctx.fillStyle).toBe("#00ff00");
bar.setProgress(0.1);
expect(ctx.fillStyle).toBe("#ff0000");
});
it("should clamp progress between 0 and 1 on init", () => {
const bar = new ProgressBar(["#ff0000", "#00ff00"], ctx, 2, 2, 80, 10, -1);
expect(bar.getProgress()).toBe(0);
const bar2 = new ProgressBar(["#ff0000", "#00ff00"], ctx, 2, 2, 80, 10, 2);
expect(bar2.getProgress()).toBe(1);
});
it("should handle empty colors array gracefully", () => {
const bar = new ProgressBar([], ctx, 2, 2, 80, 10, 0.5);
expect(() => bar.setProgress(0.5)).not.toThrow();
expect(ctx.fillStyle).toBe("#808080");
});
});
+136
View File
@@ -0,0 +1,136 @@
/**
* @jest-environment jsdom
*/
import { UILayer } from "../../../src/client/graphics/layers/UILayer";
import { UnitSelectionEvent } from "../../../src/client/InputHandler";
import { UnitView } from "../../../src/core/game/GameView";
describe("UILayer", () => {
let game: any;
let eventBus: any;
let transformHandler: any;
beforeEach(() => {
game = {
width: () => 100,
height: () => 100,
config: () => ({
theme: () => ({
territoryColor: () => ({
lighten: () => ({ alpha: () => ({ toRgbString: () => "#fff" }) }),
}),
}),
}),
x: () => 10,
y: () => 10,
unitInfo: () => ({ maxHealth: 10, constructionDuration: 5 }),
myPlayer: () => ({ id: () => 1 }),
ticks: () => 1,
updatesSinceLastTick: () => undefined,
};
eventBus = { on: jest.fn() };
transformHandler = {};
});
it("should initialize and redraw canvas", () => {
const ui = new UILayer(game, eventBus, transformHandler);
ui.redraw();
expect(ui["canvas"].width).toBe(100);
expect(ui["canvas"].height).toBe(100);
expect(ui["context"]).not.toBeNull();
});
it("should handle unit selection event", () => {
const ui = new UILayer(game, eventBus, transformHandler);
ui.redraw();
const unit = {
type: () => "Warship",
isActive: () => true,
tile: () => ({}),
owner: () => ({}),
};
const event = { isSelected: true, unit };
ui.drawSelectionBox = jest.fn();
ui["onUnitSelection"](event as UnitSelectionEvent);
expect(ui.drawSelectionBox).toHaveBeenCalledWith(unit);
});
it("should add and clear health bars", () => {
const ui = new UILayer(game, eventBus, transformHandler);
ui.redraw();
const unit = {
id: () => 1,
type: () => "Warship",
health: () => 5,
tile: () => ({}),
owner: () => ({}),
isActive: () => true,
} as unknown as UnitView;
ui.drawHealthBar(unit);
expect(ui["allHealthBars"].has(1)).toBe(true);
// a full hp unit doesnt have a health bar
unit.health = () => 10;
ui.drawHealthBar(unit);
expect(ui["allHealthBars"].has(1)).toBe(false);
// a dead unit doesnt have a health bar
unit.health = () => 5;
ui.drawHealthBar(unit);
expect(ui["allHealthBars"].has(1)).toBe(true);
unit.health = () => 0;
ui.drawHealthBar(unit);
expect(ui["allHealthBars"].has(1)).toBe(false);
});
it("should add loading bar for unit", () => {
const ui = new UILayer(game, eventBus, transformHandler);
ui.redraw();
const unit = {
id: () => 2,
tile: () => ({}),
isActive: () => true,
} as unknown as UnitView;
ui.drawLoadingBar(unit, 5);
expect(ui["allProgressBars"].has(2)).toBe(true);
});
it("should remove loading bar for inactive unit", () => {
const ui = new UILayer(game, eventBus, transformHandler);
ui.redraw();
const unit = {
id: () => 2,
type: () => "Construction",
constructionType: () => "City",
owner: () => ({ id: () => 1 }),
tile: () => ({}),
isActive: () => true,
} as unknown as UnitView;
ui.onUnitEvent(unit);
expect(ui["allProgressBars"].has(2)).toBe(true);
// an inactive unit should not have a loading bar
unit.isActive = () => false;
ui.tick();
expect(ui["allProgressBars"].has(2)).toBe(false);
});
it("should remove loading bar for a finished progress bar", () => {
const ui = new UILayer(game, eventBus, transformHandler);
ui.redraw();
const unit = {
id: () => 2,
type: () => "Construction",
constructionType: () => "City",
owner: () => ({ id: () => 1 }),
tile: () => ({}),
isActive: () => true,
} as unknown as UnitView;
ui.onUnitEvent(unit);
expect(ui["allProgressBars"].has(2)).toBe(true);
game.ticks = () => 6; // simulate enough ticks for completion
ui.tick();
expect(ui["allProgressBars"].has(2)).toBe(false);
});
});