mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 11:00:43 +00:00
d35d0f38cb
## Description: 1. Refactor WarshipExecution so that it takes either attrs or a warship unit. This makes testing much simpler as the unit test can construct a warship and then pass it into a warship execution 2. Have MoveWarshipExecution set the patrol tile, not the move tile so warships stay in new location instead of moving back to original location. 3. Warships no longer target trade ships outside of its patrol range. this prevents warships from wandering 4. Refactored & simplified WarshipExecution 5. Added more tests for warships 6. Move health modification from PlayerExecution to WarshipExecution since Warships are the only unit that have health 7. Move fields from WarshipExecution to the Warship unit itself, this allows other executions & components to see more data about the warship. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: <DISCORD USERNAME>
344 lines
9.8 KiB
TypeScript
344 lines
9.8 KiB
TypeScript
import { renderNumber } from "../../client/Utils";
|
|
import { Config } from "../configuration/Config";
|
|
import { consolex } from "../Consolex";
|
|
import {
|
|
Execution,
|
|
Game,
|
|
MessageType,
|
|
Player,
|
|
PlayerID,
|
|
UnitType,
|
|
} from "../game/Game";
|
|
import { GameImpl } from "../game/GameImpl";
|
|
import { TileRef } from "../game/GameMap";
|
|
import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util";
|
|
|
|
export class PlayerExecution implements Execution {
|
|
private readonly ticksPerClusterCalc = 20;
|
|
|
|
private player: Player | null = null;
|
|
private config: Config | null = null;
|
|
private lastCalc = 0;
|
|
private mg: Game | null = null;
|
|
private active = true;
|
|
|
|
constructor(private playerID: PlayerID) {}
|
|
|
|
activeDuringSpawnPhase(): boolean {
|
|
return false;
|
|
}
|
|
|
|
init(mg: Game, ticks: number) {
|
|
if (!mg.hasPlayer(this.playerID)) {
|
|
console.warn(`PlayerExecution: player ${this.playerID} not found`);
|
|
this.active = false;
|
|
return;
|
|
}
|
|
this.mg = mg;
|
|
this.config = mg.config();
|
|
this.player = mg.player(this.playerID);
|
|
this.lastCalc =
|
|
ticks + (simpleHash(this.player.name()) % this.ticksPerClusterCalc);
|
|
}
|
|
|
|
tick(ticks: number) {
|
|
if (this.mg === null || this.config === null || this.player === null) {
|
|
throw new Error("Not initialized");
|
|
}
|
|
this.player.decayRelations();
|
|
this.player.units().forEach((u) => {
|
|
const tileOwner = this.mg!.owner(u.tile());
|
|
if (u.info().territoryBound) {
|
|
if (tileOwner.isPlayer()) {
|
|
if (tileOwner !== this.player) {
|
|
this.mg!.player(tileOwner.id()).captureUnit(u);
|
|
}
|
|
} else {
|
|
u.delete();
|
|
}
|
|
}
|
|
});
|
|
|
|
if (!this.player.isAlive()) {
|
|
// Player has no tiles, delete any remaining units
|
|
this.player.units().forEach((u) => {
|
|
if (
|
|
u.type() !== UnitType.AtomBomb &&
|
|
u.type() !== UnitType.HydrogenBomb &&
|
|
u.type() !== UnitType.MIRVWarhead &&
|
|
u.type() !== UnitType.MIRV
|
|
) {
|
|
u.delete();
|
|
}
|
|
});
|
|
this.active = false;
|
|
return;
|
|
}
|
|
|
|
const popInc = this.config.populationIncreaseRate(this.player);
|
|
this.player.addWorkers(popInc * (1 - this.player.targetTroopRatio()));
|
|
this.player.addTroops(popInc * this.player.targetTroopRatio());
|
|
const goldFromWorkers = this.config.goldAdditionRate(this.player);
|
|
this.player.addGold(goldFromWorkers);
|
|
|
|
// Record stats
|
|
this.mg.stats().goldWork(this.player, goldFromWorkers);
|
|
|
|
const adjustRate = this.config.troopAdjustmentRate(this.player);
|
|
this.player.addTroops(adjustRate);
|
|
this.player.removeWorkers(adjustRate);
|
|
|
|
const alliances = Array.from(this.player.alliances());
|
|
for (const alliance of alliances) {
|
|
if (
|
|
this.mg.ticks() - alliance.createdAt() >
|
|
this.mg.config().allianceDuration()
|
|
) {
|
|
alliance.expire();
|
|
}
|
|
}
|
|
|
|
const embargoes = this.player.getEmbargoes();
|
|
for (const embargo of embargoes) {
|
|
if (
|
|
embargo.isTemporary &&
|
|
this.mg.ticks() - embargo.createdAt >
|
|
this.mg.config().temporaryEmbargoDuration()
|
|
) {
|
|
this.player.stopEmbargo(embargo.target);
|
|
}
|
|
}
|
|
|
|
if (ticks - this.lastCalc > this.ticksPerClusterCalc) {
|
|
if (this.player.lastTileChange() > this.lastCalc) {
|
|
this.lastCalc = ticks;
|
|
const start = performance.now();
|
|
this.removeClusters();
|
|
const end = performance.now();
|
|
if (end - start > 1000) {
|
|
consolex.log(`player ${this.player.name()}, took ${end - start}ms`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private removeClusters() {
|
|
if (this.mg === null || this.player === null) {
|
|
throw new Error("Not initialized");
|
|
}
|
|
const clusters = this.calculateClusters();
|
|
clusters.sort((a, b) => b.size - a.size);
|
|
|
|
const main = clusters.shift();
|
|
if (main === undefined) throw new Error("No clusters");
|
|
this.player.largestClusterBoundingBox = calculateBoundingBox(this.mg, main);
|
|
const surroundedBy = this.surroundedBySamePlayer(main);
|
|
if (surroundedBy && !this.player.isFriendly(surroundedBy)) {
|
|
this.removeCluster(main);
|
|
}
|
|
|
|
for (const cluster of clusters) {
|
|
if (this.isSurrounded(cluster)) {
|
|
this.removeCluster(cluster);
|
|
}
|
|
}
|
|
}
|
|
|
|
private surroundedBySamePlayer(cluster: Set<TileRef>): false | Player {
|
|
if (this.mg === null || this.player === null) {
|
|
throw new Error("Not initialized");
|
|
}
|
|
const enemies = new Set<number>();
|
|
for (const tile of cluster) {
|
|
const isOceanShore = this.mg.isOceanShore(tile);
|
|
if (this.mg.isOceanShore(tile) && !isOceanShore) {
|
|
continue;
|
|
}
|
|
if (
|
|
isOceanShore ||
|
|
this.mg.isOnEdgeOfMap(tile) ||
|
|
this.mg.neighbors(tile).some((n) => !this.mg?.hasOwner(n))
|
|
) {
|
|
return false;
|
|
}
|
|
this.mg
|
|
.neighbors(tile)
|
|
.filter((n) => this.mg?.ownerID(n) !== this.player?.smallID())
|
|
.forEach((p) => this.mg && enemies.add(this.mg.ownerID(p)));
|
|
if (enemies.size !== 1) {
|
|
return false;
|
|
}
|
|
}
|
|
if (enemies.size !== 1) {
|
|
return false;
|
|
}
|
|
const enemy = this.mg.playerBySmallID(Array.from(enemies)[0]) as Player;
|
|
const enemyBox = calculateBoundingBox(this.mg, enemy.borderTiles());
|
|
const clusterBox = calculateBoundingBox(this.mg, cluster);
|
|
if (inscribed(enemyBox, clusterBox)) {
|
|
return enemy;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private isSurrounded(cluster: Set<TileRef>): boolean {
|
|
if (this.mg === null || this.player === null) {
|
|
throw new Error("Not initialized");
|
|
}
|
|
const enemyTiles = new Set<TileRef>();
|
|
for (const tr of cluster) {
|
|
if (this.mg.isShore(tr) || this.mg.isOnEdgeOfMap(tr)) {
|
|
return false;
|
|
}
|
|
this.mg
|
|
.neighbors(tr)
|
|
.filter(
|
|
(n) =>
|
|
this.mg?.owner(n).isPlayer() &&
|
|
this.mg?.ownerID(n) !== this.player?.smallID(),
|
|
)
|
|
.forEach((n) => enemyTiles.add(n));
|
|
}
|
|
if (enemyTiles.size === 0) {
|
|
return false;
|
|
}
|
|
const enemyBox = calculateBoundingBox(this.mg, enemyTiles);
|
|
const clusterBox = calculateBoundingBox(this.mg, cluster);
|
|
return inscribed(enemyBox, clusterBox);
|
|
}
|
|
|
|
private removeCluster(cluster: Set<TileRef>) {
|
|
if (this.mg === null || this.player === null) {
|
|
throw new Error("Not initialized");
|
|
}
|
|
if (
|
|
Array.from(cluster).some(
|
|
(t) => this.mg?.ownerID(t) !== this.player?.smallID(),
|
|
)
|
|
) {
|
|
// Other removeCluster operations could change tile owners,
|
|
// so double check.
|
|
return;
|
|
}
|
|
|
|
const capturing = this.getCapturingPlayer(cluster);
|
|
if (capturing === null) {
|
|
return;
|
|
}
|
|
|
|
const firstTile = cluster.values().next().value;
|
|
const filter = (_, t: TileRef): boolean =>
|
|
this.mg?.ownerID(t) === this.player?.smallID();
|
|
const tiles = this.mg.bfs(firstTile, filter);
|
|
|
|
if (this.player.numTilesOwned() === tiles.size) {
|
|
const gold = this.player.gold();
|
|
this.mg.displayMessage(
|
|
`Conquered ${this.player.displayName()} received ${renderNumber(
|
|
gold,
|
|
)} gold`,
|
|
MessageType.SUCCESS,
|
|
capturing.id(),
|
|
);
|
|
capturing.addGold(gold);
|
|
this.player.removeGold(gold);
|
|
|
|
// Record stats
|
|
this.mg.stats().goldWar(capturing, this.player, gold);
|
|
}
|
|
|
|
for (const tile of tiles) {
|
|
capturing.conquer(tile);
|
|
}
|
|
}
|
|
|
|
private getCapturingPlayer(cluster: Set<TileRef>): Player | null {
|
|
if (this.mg === null || this.player === null) {
|
|
throw new Error("Not initialized");
|
|
}
|
|
const neighborsIDs = new Set<number>();
|
|
for (const t of cluster) {
|
|
for (const neighbor of this.mg.neighbors(t)) {
|
|
if (this.mg.ownerID(neighbor) !== this.player.smallID()) {
|
|
neighborsIDs.add(this.mg.ownerID(neighbor));
|
|
}
|
|
}
|
|
}
|
|
|
|
let largestNeighborAttack: Player | null = null;
|
|
let largestTroopCount: number = 0;
|
|
for (const id of neighborsIDs) {
|
|
const neighbor = this.mg.playerBySmallID(id);
|
|
if (!neighbor.isPlayer() || this.player.isFriendly(neighbor)) {
|
|
continue;
|
|
}
|
|
for (const attack of neighbor.outgoingAttacks()) {
|
|
if (attack.target() === this.player) {
|
|
if (attack.troops() > largestTroopCount) {
|
|
largestTroopCount = attack.troops();
|
|
largestNeighborAttack = neighbor;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (largestNeighborAttack !== null) {
|
|
return largestNeighborAttack;
|
|
}
|
|
|
|
// fall back to getting mode if no attacks
|
|
const mode = getMode(neighborsIDs);
|
|
if (!this.mg.playerBySmallID(mode).isPlayer()) {
|
|
return null;
|
|
}
|
|
const capturing = this.mg.playerBySmallID(mode);
|
|
if (!capturing.isPlayer()) {
|
|
return null;
|
|
}
|
|
return capturing;
|
|
}
|
|
|
|
private calculateClusters(): Set<TileRef>[] {
|
|
if (this.mg === null || this.player === null) {
|
|
throw new Error("Not initialized");
|
|
}
|
|
const seen = new Set<TileRef>();
|
|
const border = this.player.borderTiles();
|
|
const clusters: Set<TileRef>[] = [];
|
|
for (const tile of border) {
|
|
if (seen.has(tile)) {
|
|
continue;
|
|
}
|
|
|
|
const cluster = new Set<TileRef>();
|
|
const queue: TileRef[] = [tile];
|
|
seen.add(tile);
|
|
while (queue.length > 0) {
|
|
const curr = queue.shift();
|
|
if (curr === undefined) throw new Error("curr is undefined");
|
|
cluster.add(curr);
|
|
|
|
const neighbors = (this.mg as GameImpl).neighborsWithDiag(curr);
|
|
for (const neighbor of neighbors) {
|
|
if (border.has(neighbor) && !seen.has(neighbor)) {
|
|
queue.push(neighbor);
|
|
seen.add(neighbor);
|
|
}
|
|
}
|
|
}
|
|
clusters.push(cluster);
|
|
}
|
|
return clusters;
|
|
}
|
|
|
|
owner(): Player {
|
|
if (this.player === null) {
|
|
throw new Error("Not initialized");
|
|
}
|
|
return this.player;
|
|
}
|
|
|
|
isActive(): boolean {
|
|
return this.active;
|
|
}
|
|
}
|