Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions src/client/ClientGameRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
PlayerRecord,
ServerMessage,
} from "../core/Schemas";
import { createPartialGameRecord, replacer } from "../core/Util";
import { createPartialGameRecord, findClosestBy, replacer } from "../core/Util";
import { ServerConfig } from "../core/configuration/Config";
import { getConfig } from "../core/configuration/ConfigLoader";
import { BuildableUnit, Structures, UnitType } from "../core/game/Game";
Expand Down Expand Up @@ -621,15 +621,15 @@ export class ClientGameRunner {
}

if (upgradeUnits.length > 0) {
upgradeUnits.sort((a, b) => a.distance - b.distance);
const bestUpgrade = upgradeUnits[0];

this.eventBus.emit(
new SendUpgradeStructureIntentEvent(
bestUpgrade.unitId,
bestUpgrade.unitType,
),
);
const bestUpgrade = findClosestBy(upgradeUnits, (u) => u.distance);
if (bestUpgrade) {
this.eventBus.emit(
new SendUpgradeStructureIntentEvent(
bestUpgrade.unitId,
bestUpgrade.unitType,
),
);
}
}
});
}
Expand Down
15 changes: 6 additions & 9 deletions src/client/graphics/layers/RadialMenuElements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
} from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { Emoji, flattenedEmojiTable } from "../../../core/Util";
import { Emoji, findClosestBy, flattenedEmojiTable } from "../../../core/Util";
import { renderNumber, translateText } from "../../Utils";
import { UIState } from "../UIState";
import { BuildItemDisplay, BuildMenu, flattenedBuildTable } from "./BuildMenu";
Expand Down Expand Up @@ -554,14 +554,11 @@ export const deleteUnitElement: MenuElement = {
DELETE_SELECTION_RADIUS,
);

if (myUnits.length > 0) {
myUnits.sort(
(a, b) =>
params.game.manhattanDist(a.tile(), params.tile) -
params.game.manhattanDist(b.tile(), params.tile),
);

params.playerActionHandler.handleDeleteUnit(myUnits[0].id());
const closestUnit = findClosestBy(myUnits, (unit) =>
params.game.manhattanDist(unit.tile(), params.tile),
);
if (closestUnit) {
params.playerActionHandler.handleDeleteUnit(closestUnit.id());
}

params.closeMenu();
Expand Down
54 changes: 54 additions & 0 deletions src/core/Util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,60 @@ export function distSortUnit(
};
}

/**
Comment thread
evanpelle marked this conversation as resolved.
* Finds minimum, by score, with single pass search
* Faster than array.reduce()
*/
export function findMinimumBy<T>(
values: readonly T[],
score: (value: T) => number,
isCandidate?: (value: T) => boolean,
): T | null {
let best: T | null = null;
let bestScore = Infinity;

if (isCandidate === undefined) {
for (let i = 0, len = values.length; i < len; i++) {
const value = values[i];
const currentScore = score(value);
if (currentScore < bestScore) {
bestScore = currentScore;
best = value;
}
}
return best;
}

for (let i = 0, len = values.length; i < len; i++) {
const value = values[i];
if (!isCandidate(value)) continue;

const currentScore = score(value);
if (currentScore < bestScore) {
bestScore = currentScore;
best = value;
}
}

return best;
}

/**
* Finds closest by fast. Example usage:
* findClosestBy(
* this.units(UnitType.MissileSilo),
* (silo) => mg.manhattanDist(silo.tile(), tile),
* (silo) => !silo.isInCooldown() && !silo.isUnderConstruction(),
* )
*/
Comment thread
VariableVince marked this conversation as resolved.
export function findClosestBy<T>(
values: readonly T[],
distance: (value: T) => number,
isCandidate?: (value: T) => boolean,
): T | null {
return findMinimumBy(values, distance, isCandidate);
Comment thread
VariableVince marked this conversation as resolved.
}

export function simpleHash(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
Expand Down
21 changes: 13 additions & 8 deletions src/core/execution/TradeShipExecution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
import { TileRef } from "../game/GameMap";
import { PathFinding } from "../pathfinding/PathFinder";
import { PathStatus, SteppingPathFinder } from "../pathfinding/types";
import { distSortUnit } from "../Util";
import { findClosestBy } from "../Util";

export class TradeShipExecution implements Execution {
private active = true;
Expand Down Expand Up @@ -80,27 +80,32 @@ export class TradeShipExecution implements Execution {
return;
}

const curTile = this.tradeShip.tile();

if (
this.wasCaptured &&
(tradeShipOwner !== dstPortOwner || !this._dstPort.isActive())
) {
const ports = this.tradeShip
.owner()
.units(UnitType.Port)
.sort(distSortUnit(this.mg, this.tradeShip));
if (ports.length === 0) {
const nearestPort = findClosestBy(
tradeShipOwner.units(UnitType.Port),
(port) => this.mg.manhattanDist(port.tile(), curTile),
(port) =>
port.isActive() &&
!port.isMarkedForDeletion() &&
!port.isUnderConstruction(),
);
if (nearestPort === null) {
this.tradeShip.delete(false);
this.active = false;
return;
} else {
this._dstPort = ports[0];
this._dstPort = nearestPort;
this.tradeShip.setTargetUnit(this._dstPort);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Plan-driven units don't emit per-tick unit updates, so force a sync for the new target.
this.tradeShip.touch();
}
}
Comment thread
VariableVince marked this conversation as resolved.

const curTile = this.tradeShip.tile();
if (curTile === this.dstPort()) {
this.complete();
return;
Expand Down
66 changes: 35 additions & 31 deletions src/core/game/PlayerImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { PseudoRandom } from "../PseudoRandom";
import { ClientID } from "../Schemas";
import {
assertNever,
distSortUnit,
findClosestBy,
minInt,
simpleHash,
toInt,
Expand Down Expand Up @@ -1003,14 +1003,18 @@ export class PlayerImpl implements Player {
type: UnitType,
targetTile: TileRef,
): Unit | false {
const range = this.mg.config().structureMinDist();
const existing = this.mg
.nearbyUnits(targetTile, range, type, undefined, true)
.sort((a, b) => a.distSquared - b.distSquared);
if (existing.length === 0) {
return false;
}
return existing[0].unit;
const closest = findClosestBy(
this.mg.nearbyUnits(
targetTile,
this.mg.config().structureMinDist(),
type,
undefined,
true,
),
(entry) => entry.distSquared,
);

return closest?.unit ?? false;
}

private canBuildUnitType(
Expand Down Expand Up @@ -1176,27 +1180,29 @@ export class PlayerImpl implements Player {
}

nukeSpawn(tile: TileRef, nukeType: UnitType): TileRef | false {
if (this.mg.isSpawnImmunityActive()) {
const mg = this.mg;
if (mg.isSpawnImmunityActive()) {
return false;
}
const owner = this.mg.owner(tile);
// Allow nuking teammates after the game is over (aftergame fun)
const gameOver = this.mg.getWinner() !== null;
const gameOver = mg.getWinner() !== null;
if (owner.isPlayer()) {
if (this.isOnSameTeam(owner) && !gameOver) {
return false;
}
}
const config = mg.config();

// Prevent launching nukes that would hit teammate structures (only in team games).
// Disabled after game-over so players can nuke teammates in the aftergame.
if (
this.mg.config().gameConfig().gameMode === GameMode.Team &&
config.gameConfig().gameMode === GameMode.Team &&
nukeType !== UnitType.MIRV &&
!gameOver
) {
const magnitude = this.mg.config().nukeMagnitudes(nukeType);
const wouldHitTeammate = this.mg.anyUnitNearby(
const magnitude = config.nukeMagnitudes(nukeType);
const wouldHitTeammate = mg.anyUnitNearby(
tile,
magnitude.outer,
Structures.types,
Expand All @@ -1208,15 +1214,14 @@ export class PlayerImpl implements Player {
}

// only get missilesilos that are not on cooldown and not under construction
const spawns = this.units(UnitType.MissileSilo)
.filter((silo) => {
return !silo.isInCooldown() && !silo.isUnderConstruction();
})
.sort(distSortUnit(this.mg, tile));
if (spawns.length === 0) {
return false;
}
return spawns[0].tile();
const bestSilo = findClosestBy(
this.units(UnitType.MissileSilo),
(silo) => mg.manhattanDist(silo.tile(), tile),
(silo) =>
Comment thread
VariableVince marked this conversation as resolved.
silo.isActive() && !silo.isInCooldown() && !silo.isUnderConstruction(),
);

return bestSilo?.tile() ?? false;
}

portSpawn(tile: TileRef, validTiles: TileRef[] | null): TileRef | false {
Expand Down Expand Up @@ -1246,15 +1251,14 @@ export class PlayerImpl implements Player {
if (!this.mg.isOcean(tile)) {
return false;
}
const spawns = this.units(UnitType.Port).sort(
(a, b) =>
this.mg.manhattanDist(a.tile(), tile) -
this.mg.manhattanDist(b.tile(), tile),

const bestPort = findClosestBy(
this.units(UnitType.Port),
(port) => this.mg.manhattanDist(port.tile(), tile),
(port) => port.isActive() && !port.isUnderConstruction(),
);
if (spawns.length === 0) {
return false;
}
return spawns[0].tile();

return bestPort?.tile() ?? false;
}

landBasedUnitSpawn(tile: TileRef): TileRef | false {
Expand Down
31 changes: 23 additions & 8 deletions tests/core/executions/TradeShipExecution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ describe("TradeShipExecution", () => {
let pirate: Player;
let srcPort: Unit;
let piratePort: Unit;
let piratePort2: Unit;
let tradeShip: Unit;
let dstPort: Unit;
let tradeShipExecution: TradeShipExecution;
Expand Down Expand Up @@ -48,27 +49,41 @@ describe("TradeShipExecution", () => {
id: vi.fn(() => 3),
addGold: vi.fn(),
displayName: vi.fn(() => "Destination"),
units: vi.fn(() => [piratePort]),
unitCount: vi.fn(() => 1),
units: vi.fn(() => [piratePort, piratePort2]),
unitCount: vi.fn(() => 2),
canTrade: vi.fn(() => true),
} as any;

piratePort = {
tile: vi.fn(() => 40011),
tile: vi.fn(() => 56),
owner: vi.fn(() => pirate),
isActive: vi.fn(() => true),
isUnderConstruction: vi.fn(() => false),
isMarkedForDeletion: vi.fn(() => false),
} as any;

piratePort2 = {
tile: vi.fn(() => 75),
owner: vi.fn(() => pirate),
isActive: vi.fn(() => true),
isUnderConstruction: vi.fn(() => false),
isMarkedForDeletion: vi.fn(() => false),
} as any;

srcPort = {
tile: vi.fn(() => 20011),
tile: vi.fn(() => 10),
owner: vi.fn(() => origOwner),
isActive: vi.fn(() => true),
isUnderConstruction: vi.fn(() => false),
isMarkedForDeletion: vi.fn(() => false),
} as any;

dstPort = {
tile: vi.fn(() => 30015), // 15x15
tile: vi.fn(() => 100),
owner: vi.fn(() => dstOwner),
isActive: vi.fn(() => true),
isUnderConstruction: vi.fn(() => false),
isMarkedForDeletion: vi.fn(() => false),
} as any;

tradeShip = {
Expand All @@ -80,13 +95,13 @@ describe("TradeShipExecution", () => {
setSafeFromPirates: vi.fn(),
touch: vi.fn(),
delete: vi.fn(),
tile: vi.fn(() => 2001),
tile: vi.fn(() => 32),
} as any;

tradeShipExecution = new TradeShipExecution(origOwner, srcPort, dstPort);
tradeShipExecution.init(game, 0);
tradeShipExecution["pathFinder"] = {
next: vi.fn(() => ({ status: PathStatus.NEXT, node: 2001 })),
next: vi.fn(() => ({ status: PathStatus.NEXT, node: 32 })),
findPath: vi.fn((from: number) => [from]),
} as any;
tradeShipExecution["tradeShip"] = tradeShip;
Expand Down Expand Up @@ -118,7 +133,7 @@ describe("TradeShipExecution", () => {

it("should complete trade and award gold", () => {
tradeShipExecution["pathFinder"] = {
next: vi.fn(() => ({ status: PathStatus.COMPLETE, node: 2001 })),
next: vi.fn(() => ({ status: PathStatus.COMPLETE, node: 32 })),
findPath: vi.fn((from: number) => [from]),
} as any;
tradeShipExecution.tick(1);
Expand Down
Loading