feat: Attacking Troops Overlay (#3427)

## Description:

https://troop-advantage-layer.openfront.dev/

Hey OpenFront dev team, I've been really enjoying the game, and the
v0.30 changes have felt great so far. Happy to start contributing!

This PR introduces `AttackingTroopsOverlay`, a layer that renders live
attacker vs. defender troop counts directly on active front lines.
Players can immediately gauge combat strength without leaving the map
view.


![troop-advantage-layer](https://github.com/user-attachments/assets/9e862812-84b4-46cb-a0c6-65fa50320198)

A recent change updates the layer to just the # of attackers and a
symbol for attack/defence:

![visual-front-line](https://github.com/user-attachments/assets/46bc7117-2314-44c9-96fc-8a7e9c6ab5cd)

Left: Perspective of Anon 667 (Blue) | Right: Perspective of Anon332
(Red)

![ezgif-6261e6669d6b972b](https://github.com/user-attachments/assets/734d90c1-8f22-44dc-8f2f-b22e46676f46)

**How it works:**
- Attacker count shown for ground invasions. When attacking, your troop
count will display amber for disadvantageous, and green for advantageous
battles. When defending, the enemy troop count will switch to red if you
are at a severe disadvantage.
- Label position recalculates every tick at 200ms, tracking the front
line as it moves.
- Automatically hidden during Terrain view (spacebar)
- Labels clean up when an attack ends or its target becomes invalid

**Settings:** An "Attacking Troops Overlay" toggle is added to Settings,
enabled by default.
--> the screenshot is old, but the text has been updated
<img width="448" height="410" alt="Settings toggle"
src="https://github.com/user-attachments/assets/2df8ec7a-3f77-48b7-a9b5-ee4a6eed0412"
/>

## Checklist

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Discord
Radyus
This commit is contained in:
Ralfi Salhon
2026-03-19 22:04:33 +00:00
committed by GitHub
parent 755e7b76cc
commit 015e3c7d19
14 changed files with 526 additions and 84 deletions
+2
View File
@@ -540,6 +540,8 @@
"territory_patterns_desc": "Choose whether to display territory skin designs in game",
"coordinate_grid_label": "Coordinate Grid",
"coordinate_grid_desc": "Toggle the alphanumeric grid overlay",
"attacking_troops_overlay_label": "Attacking Troops Overlay",
"attacking_troops_overlay_desc": "Show attacker vs defender troop counts on active front lines.",
"performance_overlay_label": "Performance Overlay",
"performance_overlay_desc": "Toggle the performance overlay. When enabled, the performance overlay will be displayed. Press shift-D during game to toggle.",
"easter_writing_speed_label": "Writing Speed Multiplier",
+2
View File
@@ -7,6 +7,7 @@ import { FrameProfiler } from "./FrameProfiler";
import { TransformHandler } from "./TransformHandler";
import { UIState } from "./UIState";
import { AlertFrame } from "./layers/AlertFrame";
import { AttackingTroopsOverlay } from "./layers/AttackingTroopsOverlay";
import { AttacksDisplay } from "./layers/AttacksDisplay";
import { BuildMenu } from "./layers/BuildMenu";
import { ChatDisplay } from "./layers/ChatDisplay";
@@ -284,6 +285,7 @@ export function createRenderer(
new StructureIconsLayer(game, eventBus, uiState, transformHandler),
new DynamicUILayer(game, transformHandler, eventBus),
new NameLayer(game, transformHandler, eventBus),
new AttackingTroopsOverlay(game, transformHandler, eventBus, userSettings),
eventsDisplay,
attacksDisplay,
chatDisplay,
@@ -0,0 +1,305 @@
import { EventBus } from "../../../core/EventBus";
import { Cell } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { UserSettings } from "../../../core/game/UserSettings";
import { AlternateViewEvent } from "../../InputHandler";
import { renderTroops } from "../../Utils";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
import shieldIcon from "/images/ShieldIconWhite.svg?url";
import swordIcon from "/images/SwordIconWhite.svg?url";
export function troopAttackColor(
attackerTroops: number,
defenderTroops: number,
): string {
return attackerTroops > defenderTroops ? "#66ff66" : "#ffbe3c";
}
export function troopDefenceColor(
attackerTroops: number,
myTroops: number,
): string {
return attackerTroops > myTroops ? "#ff4444" : "#ff9944";
}
// An attack can have multiple disconnected front-line segments, so elements
// and positions are parallel arrays with one entry per segment.
interface AttackLabel {
elements: HTMLDivElement[];
positions: (Cell | null)[];
isIncoming: boolean;
attackerTroops: number;
defenderTroops: number;
}
export class AttackingTroopsOverlay implements Layer {
private container: HTMLDivElement;
private labels = new Map<string, AttackLabel>();
// Guard against queuing multiple worker requests in the same tick window.
private inFlightRequest = false;
private isVisible = true;
private onAlternateView: (e: AlternateViewEvent) => void;
constructor(
private readonly game: GameView,
private readonly transformHandler: TransformHandler,
private readonly eventBus: EventBus,
private readonly userSettings: UserSettings,
) {}
shouldTransform(): boolean {
return false;
}
init() {
this.container = document.createElement("div");
this.container.style.position = "fixed";
this.container.style.left = "50%";
this.container.style.top = "50%";
this.container.style.pointerEvents = "none";
// z-index 4 places labels above NameLayer (z-index 3).
this.container.style.zIndex = "4";
document.body.appendChild(this.container);
this.onAlternateView = (e) => {
this.isVisible = !e.alternateView;
this.container.style.display = this.isVisible ? "" : "none";
};
this.eventBus.on(AlternateViewEvent, this.onAlternateView);
}
destroy() {
if (!this.container) return;
this.clearAllLabels();
this.container.remove();
this.eventBus.off(AlternateViewEvent, this.onAlternateView);
}
getTickIntervalMs() {
return 200;
}
tick() {
if (!this.userSettings.attackingTroopsOverlay() || !this.isVisible) {
if (this.labels.size > 0) this.clearAllLabels();
return;
}
const myPlayer = this.game.myPlayer();
if (!myPlayer) {
this.clearAllLabels();
return;
}
const activeIDs = new Set<string>();
// Outgoing attacks — green if winning, amber if losing.
for (const attack of myPlayer.outgoingAttacks()) {
activeIDs.add(attack.id);
if (!attack.targetID) {
this.removeLabel(attack.id);
continue;
}
const defender = this.game.playerBySmallID(attack.targetID);
if (!defender || !defender.isPlayer()) {
this.removeLabel(attack.id);
continue;
}
this.ensureLabel(attack.id, attack.troops, defender.troops(), false);
}
// Incoming attacks — red if the attacker outnumbers the player, orange otherwise.
for (const attack of myPlayer.incomingAttacks()) {
activeIDs.add(attack.id);
const attacker = this.game.playerBySmallID(attack.attackerID);
if (!attacker || !attacker.isPlayer()) {
this.removeLabel(attack.id);
continue;
}
this.ensureLabel(attack.id, attack.troops, myPlayer.troops(), true);
}
for (const [id] of this.labels) {
if (!activeIDs.has(id)) this.removeLabel(id);
}
// Single worker request per tick; skip if the previous one is still in flight.
if (this.inFlightRequest) return;
this.inFlightRequest = true;
void myPlayer
.attackClusteredPositions()
.then((attacks) => {
for (const { id, positions } of attacks) {
const lbl = this.labels.get(id);
if (!lbl) continue;
this.reconcileLabelPositions(lbl, positions);
}
})
.catch(() => {
// On error, hide all labels until the next successful response.
for (const lbl of this.labels.values()) lbl.positions.fill(null);
})
.finally(() => {
this.inFlightRequest = false;
});
}
private ensureLabel(
attackID: string,
attackerTroops: number,
defenderTroops: number,
isIncoming: boolean,
) {
let label = this.labels.get(attackID);
if (!label) {
label = {
elements: [],
positions: [],
isIncoming,
attackerTroops,
defenderTroops,
};
this.labels.set(attackID, label);
} else {
label.attackerTroops = attackerTroops;
label.defenderTroops = defenderTroops;
}
for (const el of label.elements) {
this.updateLabelContent(el, attackerTroops, defenderTroops, isIncoming);
}
}
renderLayer(_context: CanvasRenderingContext2D) {
const screenPosOld = this.transformHandler.worldToScreenCoordinates(
new Cell(0, 0),
);
const screenPos = new Cell(
screenPosOld.x - window.innerWidth / 2,
screenPosOld.y - window.innerHeight / 2,
);
this.container.style.transform = `translate(${screenPos.x}px, ${screenPos.y}px) scale(${this.transformHandler.scale})`;
for (const label of this.labels.values()) {
for (let i = 0; i < label.elements.length; i++) {
const el = label.elements[i];
const pos = label.positions[i];
if (!pos || !this.transformHandler.isOnScreen(pos)) {
el.style.display = "none";
continue;
}
el.style.display = "inline-flex";
// Centre the label on its world position and counter-scale so text
// stays the same screen size regardless of zoom level.
el.style.transform = `translate(${pos.x}px, ${pos.y}px) translate(-50%, -50%) scale(${1 / this.transformHandler.scale})`;
}
}
}
private reconcileLabelPositions(lbl: AttackLabel, positions: Cell[]) {
// Add elements for new clusters.
while (lbl.elements.length < positions.length) {
lbl.elements.push(
this.createLabelElement(
lbl.attackerTroops,
lbl.defenderTroops,
lbl.isIncoming,
),
);
lbl.positions.push(null);
}
// Remove elements for clusters that no longer exist.
while (lbl.elements.length > positions.length) {
lbl.elements.pop()!.remove();
lbl.positions.pop();
}
// Snap large jumps instantly; let the CSS transition handle small advances.
for (let i = 0; i < positions.length; i++) {
const old = lbl.positions[i];
const next = positions[i];
if (old && Math.hypot(next.x - old.x, next.y - old.y) > 50) {
const el = lbl.elements[i];
el.style.transition = "none";
el.style.transform = `translate(${next.x}px, ${next.y}px) translate(-50%, -50%) scale(${1 / this.transformHandler.scale})`;
requestAnimationFrame(() => {
el.style.transition = "transform 0.2s ease-out";
});
}
lbl.positions[i] = next;
}
}
private createLabelElement(
attackerTroops: number,
defenderTroops: number,
isIncoming: boolean,
): HTMLDivElement {
const el = document.createElement("div");
el.style.position = "absolute";
el.style.display = "none";
el.style.alignItems = "center";
el.style.gap = "3px";
el.style.width = "max-content";
el.style.whiteSpace = "nowrap";
el.style.fontSize = "11px";
el.style.fontWeight = "bold";
el.style.fontFamily = this.game.config().theme().font();
el.style.padding = "1px 4px";
el.style.borderRadius = "3px";
el.style.backgroundColor = "rgba(0,0,0,0.55)";
el.style.pointerEvents = "none";
el.style.lineHeight = "1.3";
// Smooth the label to its new position as the front line advances.
el.style.transition = "transform 0.2s ease-out";
this.updateLabelContent(el, attackerTroops, defenderTroops, isIncoming);
this.container.appendChild(el);
return el;
}
private updateLabelContent(
el: HTMLDivElement,
attackerTroops: number,
defenderTroops: number,
isIncoming: boolean,
) {
// Reuse existing children to avoid DOM churn on every tick.
let icon = el.querySelector("img") as HTMLImageElement | null;
let span = el.querySelector("span") as HTMLSpanElement | null;
if (!icon || !span) {
icon = document.createElement("img");
icon.style.width = "10px";
icon.style.height = "10px";
span = document.createElement("span");
el.replaceChildren(icon, span);
}
if (isIncoming) {
icon.src = shieldIcon;
span.style.color = troopDefenceColor(attackerTroops, defenderTroops);
span.textContent = renderTroops(attackerTroops);
} else {
icon.src = swordIcon;
span.style.color = troopAttackColor(attackerTroops, defenderTroops);
span.textContent = renderTroops(attackerTroops);
}
}
private removeLabel(attackID: string) {
const label = this.labels.get(attackID);
if (!label) return;
for (const el of label.elements) el.remove();
this.labels.delete(attackID);
}
private clearAllLabels() {
for (const label of this.labels.values()) {
for (const el of label.elements) el.remove();
}
this.labels.clear();
}
}
+4 -8
View File
@@ -184,17 +184,13 @@ export class AttacksDisplay extends LitElement implements Layer {
const playerView = this.game.playerBySmallID(attack.attackerID);
if (playerView !== undefined) {
if (playerView instanceof PlayerView) {
const averagePosition = await playerView.attackAveragePosition(
attack.attackerID,
attack.id,
);
const attacks = await playerView.attackClusteredPositions(attack.id);
const pos = attacks[0]?.positions[0];
if (averagePosition === null) {
if (!pos) {
this.emitGoToPlayerEvent(attack.attackerID);
} else {
this.eventBus.emit(
new GoToPositionEvent(averagePosition.x, averagePosition.y),
);
this.eventBus.emit(new GoToPositionEvent(pos.x, pos.y));
}
}
} else {
@@ -18,6 +18,7 @@ import mouseIcon from "/images/MouseIconWhite.svg?url";
import ninjaIcon from "/images/NinjaIconWhite.svg?url";
import settingsIcon from "/images/SettingIconWhite.svg?url";
import sirenIcon from "/images/SirenIconWhite.svg?url";
import swordIcon from "/images/SwordIconWhite.svg?url";
import treeIcon from "/images/TreeIconWhite.svg?url";
import musicIcon from "/images/music.svg?url";
@@ -163,6 +164,11 @@ export class SettingsModal extends LitElement implements Layer {
this.requestUpdate();
}
private onToggleAttackingTroopsOverlayButtonClick() {
this.userSettings.toggleAttackingTroopsOverlay();
this.requestUpdate();
}
private onTogglePerformanceOverlayButtonClick() {
this.userSettings.togglePerformanceOverlay();
this.requestUpdate();
@@ -408,6 +414,28 @@ export class SettingsModal extends LitElement implements Layer {
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click="${this.onToggleAttackingTroopsOverlayButtonClick}"
>
<img src=${swordIcon} alt="swordIcon" width="20" height="20" />
<div class="flex-1">
<div class="font-medium">
${translateText(
"user_setting.attacking_troops_overlay_label",
)}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.attacking_troops_overlay_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.attackingTroopsOverlay()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click="${this.onToggleCursorCostLabelButtonClick}"
+13 -16
View File
@@ -5,9 +5,7 @@ import { RecomputeRailClusterExecution } from "./execution/RecomputeRailClusterE
import { WinCheckExecution } from "./execution/WinCheckExecution";
import {
AllPlayers,
Attack,
BuildableUnit,
Cell,
Game,
GameUpdates,
NameViewData,
@@ -255,24 +253,23 @@ export class GameRunner {
} as PlayerBorderTiles;
}
public attackAveragePosition(
public attackClusteredPositions(
playerID: number,
attackID: string,
): Cell | null {
attackID?: string,
): { id: string; positions: { x: number; y: number }[] }[] {
const player = this.game.playerBySmallID(playerID);
if (!player.isPlayer()) {
if (!player.isPlayer())
throw new Error(`player with id ${playerID} not found`);
}
const all = [...player.outgoingAttacks(), ...player.incomingAttacks()];
const attacks = attackID ? all.filter((a) => a.id() === attackID) : all;
const condition = (a: Attack) => a.id() === attackID;
const attack =
player.outgoingAttacks().find(condition) ??
player.incomingAttacks().find(condition);
if (attack === undefined) {
return null;
}
return attack.averagePosition();
return attacks.map((a) => ({
id: a.id(),
positions: a.clusteredPositions().map((tile) => ({
x: this.game.map().x(tile),
y: this.game.map().y(tile),
})),
}));
}
public bestTransportShipSpawn(
+79 -18
View File
@@ -1,4 +1,4 @@
import { Attack, Cell, Player, TerraNullius } from "./Game";
import { Attack, Player, TerraNullius } from "./Game";
import { GameImpl } from "./GameImpl";
import { TileRef } from "./GameMap";
import { PlayerImpl } from "./PlayerImpl";
@@ -97,28 +97,89 @@ export class AttackImpl implements Attack {
}
}
averagePosition(): Cell | null {
// Returns the top 2 clustered positions of the attack's border.
// If the second cluster is too small, only returns the largest one.
clusteredPositions(): TileRef[] {
if (this._borderSize === 0) {
if (this.sourceTile() === null) {
// No border tiles and no source tile—return a default position or throw an error
return null;
const tile = this.sourceTile();
return tile !== null ? [tile] : [];
}
return this.clusterBorderTiles(30, 2);
}
// Partitions the attack's border tiles into disconnected segments using BFS,
// then returns one representative tile per segment.
//
// Border tiles naturally fragment when fighting across non-contiguous
// territory (e.g. islands, chokepoints).
//
// Results are sorted largest-first, small clusters below minSize are
// dropped (the largest is always kept as a fallback), and the list is capped
// at maxClusters to avoid label clutter on heavily fragmented borders.
private clusterBorderTiles(minSize: number, maxClusters: number): TileRef[] {
const map = this._mg.map();
const visited = new Set<TileRef>();
const clusters: { tile: TileRef; size: number }[] = [];
for (const startTile of this._border) {
if (visited.has(startTile)) continue;
const queue: TileRef[] = [startTile];
visited.add(startTile);
let qi = 0;
let sumX = 0;
let sumY = 0;
let count = 0;
while (qi < queue.length) {
const t = queue[qi++];
sumX += map.x(t);
sumY += map.y(t);
count++;
this._mg.forEachNeighborWithDiag(t, (neighbor) => {
if (this._border.has(neighbor) && !visited.has(neighbor)) {
visited.add(neighbor);
queue.push(neighbor);
}
});
}
// No border tiles yet—use the source tile's location
const tile: number = this.sourceTile()!;
return new Cell(this._mg.map().x(tile), this._mg.map().y(tile));
// The centroid (sumX/count, sumY/count) may not be a real border tile,
// so we pick whichever tile in the cluster is closest to it. This ensures
// the representative always sits on an actual front-line tile.
const cx = sumX / count;
const cy = sumY / count;
let best = queue[0];
let bestDist = Infinity;
for (const t of queue) {
const dx = map.x(t) - cx;
const dy = map.y(t) - cy;
const dist = dx * dx + dy * dy;
if (dist < bestDist) {
bestDist = dist;
best = t;
}
}
clusters.push({ tile: best, size: count });
}
let averageX = 0;
let averageY = 0;
clusters.sort((a, b) => b.size - a.size);
for (const t of this._border) {
averageX += this._mg.map().x(t);
averageY += this._mg.map().y(t);
switch (clusters.length) {
case 0:
return [];
case 1:
// If there's only one cluster, return it even if it's smaller than minSize.
return [clusters[0].tile];
default: {
const significant = clusters.filter((c) => c.size >= minSize);
if (significant.length === 0) {
// Always keep at least the largest cluster even if it falls below minSize.
return [clusters[0].tile];
}
return significant.slice(0, maxClusters).map((c) => c.tile);
}
}
averageX = averageX / this._borderSize;
averageY = averageY / this._borderSize;
return new Cell(averageX, averageY);
}
}
+1 -1
View File
@@ -470,7 +470,7 @@ export interface Attack {
removeBorderTile(tile: TileRef): void;
clearBorder(): void;
borderSize(): number;
averagePosition(): Cell | null;
clusteredPositions(): TileRef[];
}
export interface AllianceRequest {
+4 -5
View File
@@ -453,11 +453,10 @@ export class PlayerView {
return this.data.incomingAttacks;
}
async attackAveragePosition(
playerID: number,
attackID: string,
): Promise<Cell | null> {
return this.game.worker.attackAveragePosition(playerID, attackID);
async attackClusteredPositions(
attackID?: string,
): Promise<{ id: string; positions: Cell[] }[]> {
return this.game.worker.attackClusteredPositions(this.smallID(), attackID);
}
units(...types: UnitType[]): UnitView[] {
+8
View File
@@ -89,6 +89,14 @@ export class UserSettings {
return this.get("settings.territoryPatterns", true);
}
attackingTroopsOverlay() {
return this.get("settings.attackingTroopsOverlay", true);
}
toggleAttackingTroopsOverlay() {
this.set("settings.attackingTroopsOverlay", !this.attackingTroopsOverlay());
}
cursorCostLabel() {
const legacy = this.get("settings.ghostPricePill", true);
return this.get("settings.cursorCostLabel", legacy);
+12 -9
View File
@@ -3,7 +3,7 @@ import { createGameRunner, GameRunner } from "../GameRunner";
import { FetchGameMapLoader } from "../game/FetchGameMapLoader";
import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
import {
AttackAveragePositionResultMessage,
AttackClusteredPositionsResultMessage,
InitializedMessage,
MainThreadMessage,
PlayerActionsResultMessage,
@@ -243,25 +243,28 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
throw error;
}
break;
case "attack_average_position":
case "attack_clustered_positions":
if (!gameRunner) {
throw new Error("Game runner not initialized");
}
try {
const averagePosition = (await gameRunner).attackAveragePosition(
const attacks = (await gameRunner).attackClusteredPositions(
message.playerID,
message.attackID,
);
sendMessage({
type: "attack_average_position_result",
type: "attack_clustered_positions_result",
id: message.id,
x: averagePosition ? averagePosition.x : null,
y: averagePosition ? averagePosition.y : null,
} as AttackAveragePositionResultMessage);
attacks,
} as AttackClusteredPositionsResultMessage);
} catch (error) {
console.error("Failed to get attack average position:", error);
throw error;
console.error("Failed to get attack front line centers:", error);
sendMessage({
type: "attack_clustered_positions_result",
id: message.id,
attacks: [],
} as AttackClusteredPositionsResultMessage);
}
break;
case "transport_ship_spawn":
+25 -16
View File
@@ -231,10 +231,10 @@ export class WorkerClient {
});
}
attackAveragePosition(
attackClusteredPositions(
playerID: number,
attackID: string,
): Promise<Cell | null> {
attackID?: string,
): Promise<{ id: string; positions: Cell[] }[]> {
return new Promise((resolve, reject) => {
if (!this.isInitialized) {
reject(new Error("Worker not initialized"));
@@ -243,25 +243,34 @@ export class WorkerClient {
const messageId = generateID();
const timeout = setTimeout(() => {
this.messageHandlers.delete(messageId);
reject(new Error("attack_clustered_positions request timed out"));
}, 5000);
this.messageHandlers.set(messageId, (message) => {
if (
message.type === "attack_average_position_result" &&
message.x !== undefined &&
message.y !== undefined
) {
if (message.x === null || message.y === null) {
resolve(null);
} else {
resolve(new Cell(message.x, message.y));
}
clearTimeout(timeout);
if (message.type !== "attack_clustered_positions_result") {
reject(
new Error(
`Unexpected message type for attackClusteredPositions: ${message.type}`,
),
);
return;
}
resolve(
message.attacks.map((a) => ({
id: a.id,
positions: a.positions.map((c) => new Cell(c.x, c.y)),
})),
);
});
this.worker.postMessage({
type: "attack_average_position",
type: "attack_clustered_positions",
id: messageId,
playerID: playerID,
attackID: attackID,
playerID,
attackID,
});
});
}
+11 -11
View File
@@ -24,8 +24,8 @@ export type WorkerMessageType =
| "player_profile_result"
| "player_border_tiles"
| "player_border_tiles_result"
| "attack_average_position"
| "attack_average_position_result"
| "attack_clustered_positions"
| "attack_clustered_positions_result"
| "transport_ship_spawn"
| "transport_ship_spawn_result";
@@ -108,16 +108,16 @@ export interface PlayerBorderTilesResultMessage extends BaseWorkerMessage {
result: PlayerBorderTiles;
}
export interface AttackAveragePositionMessage extends BaseWorkerMessage {
type: "attack_average_position";
export interface AttackClusteredPositionsMessage extends BaseWorkerMessage {
type: "attack_clustered_positions";
playerID: number;
attackID: string;
attackID?: string;
}
export interface AttackAveragePositionResultMessage extends BaseWorkerMessage {
type: "attack_average_position_result";
x: number | null;
y: number | null;
export interface AttackClusteredPositionsResultMessage
extends BaseWorkerMessage {
type: "attack_clustered_positions_result";
attacks: { id: string; positions: { x: number; y: number }[] }[];
}
export interface TransportShipSpawnMessage extends BaseWorkerMessage {
@@ -139,7 +139,7 @@ export type MainThreadMessage =
| PlayerBuildablesMessage
| PlayerProfileMessage
| PlayerBorderTilesMessage
| AttackAveragePositionMessage
| AttackClusteredPositionsMessage
| TransportShipSpawnMessage;
// Message send from worker
@@ -151,5 +151,5 @@ export type WorkerMessage =
| PlayerBuildablesResultMessage
| PlayerProfileResultMessage
| PlayerBorderTilesResultMessage
| AttackAveragePositionResultMessage
| AttackClusteredPositionsResultMessage
| TransportShipSpawnResultMessage;
@@ -0,0 +1,32 @@
import {
troopAttackColor,
troopDefenceColor,
} from "../../../../src/client/graphics/layers/AttackingTroopsOverlay";
describe("troopAttackColor", () => {
test("returns green when attacker has more troops", () => {
expect(troopAttackColor(1000, 500)).toBe("#66ff66");
});
test("returns amber when defender has more troops", () => {
expect(troopAttackColor(500, 1000)).toBe("#ffbe3c");
});
test("returns amber when troops are equal", () => {
expect(troopAttackColor(500, 500)).toBe("#ffbe3c");
});
});
describe("troopDefenceColor", () => {
test("returns red when attacker has more troops than defender", () => {
expect(troopDefenceColor(1000, 500)).toBe("#ff4444");
});
test("returns orange when defender has more troops", () => {
expect(troopDefenceColor(500, 1000)).toBe("#ff9944");
});
test("returns orange when troops are equal", () => {
expect(troopDefenceColor(500, 500)).toBe("#ff9944");
});
});