Merge branch 'main' into nuke_trajectory

This commit is contained in:
Tom
2025-05-12 00:49:23 +02:00
27 changed files with 191 additions and 83 deletions
+3
View File
@@ -112,6 +112,7 @@ export async function createClientGame(
const config = await getConfig(
lobbyConfig.gameStartInfo.config,
userSettings,
lobbyConfig.gameRecord != null,
);
let gameMap: TerrainMapData | null = null;
@@ -239,9 +240,11 @@ export class ClientGameRunner {
this.lobby.gameStartInfo.gameID,
this.lobby.clientID,
);
console.error(gu.stack);
this.stop(true);
return;
}
this.transport.turnComplete();
gu.updates[GameUpdateType.Hash].forEach((hu: HashUpdate) => {
this.eventBus.emit(new SendHashEvent(hu.tick, hu.hash));
});
+37 -13
View File
@@ -16,42 +16,58 @@ import { LobbyConfig } from "./ClientGameRunner";
import { getPersistentIDFromCookie } from "./Main";
export class LocalServer {
// All turns from the game record on replay.
private replayTurns: Turn[] = [];
private turns: Turn[] = [];
private intents: Intent[] = [];
private startedAt: number;
private endTurnIntervalID;
private paused = false;
private winner: ClientSendWinnerMessage = null;
private allPlayersStats: AllPlayersStats = {};
private turnsExecuted = 0;
private lastTurnCompletedTime = 0;
private turnCheckInterval: NodeJS.Timeout;
constructor(
private lobbyConfig: LobbyConfig,
private clientConnect: () => void,
private clientMessage: (message: ServerMessage) => void,
private isReplay: boolean,
) {}
start() {
this.turnCheckInterval = setInterval(() => {
if (this.turnsExecuted == this.turns.length) {
if (
this.isReplay ||
Date.now() >
this.lastTurnCompletedTime +
this.lobbyConfig.serverConfig.turnIntervalMs()
) {
this.endTurn();
}
}
}, 5);
this.startedAt = Date.now();
if (!this.lobbyConfig.gameRecord) {
this.endTurnIntervalID = setInterval(
() => this.endTurn(),
this.lobbyConfig.serverConfig.turnIntervalMs(),
);
}
this.clientConnect();
if (this.lobbyConfig.gameRecord) {
this.turns = decompressGameRecord(this.lobbyConfig.gameRecord).turns;
console.log(`loaded turns: ${JSON.stringify(this.turns)}`);
this.replayTurns = decompressGameRecord(
this.lobbyConfig.gameRecord,
).turns;
}
this.clientMessage(
ServerStartGameMessageSchema.parse({
type: "start",
gameID: this.lobbyConfig.gameStartInfo.gameID,
gameStartInfo: this.lobbyConfig.gameStartInfo,
turns: this.turns,
turns: [],
}),
);
}
@@ -90,7 +106,7 @@ export class LocalServer {
return;
}
// If we are replaying a game then verify hash.
const archivedHash = this.turns[clientMsg.turnNumber].hash;
const archivedHash = this.replayTurns[clientMsg.turnNumber].hash;
if (!archivedHash) {
console.warn(
`no archived hash found for turn ${clientMsg.turnNumber}, client hash: ${clientMsg.hash}`,
@@ -121,10 +137,18 @@ export class LocalServer {
}
}
public turnComplete() {
this.turnsExecuted++;
this.lastTurnCompletedTime = Date.now();
}
private endTurn() {
if (this.paused) {
return;
}
if (this.replayTurns.length > 0) {
this.intents = this.replayTurns[this.turns.length].intents;
}
const pastTurn: Turn = {
turnNumber: this.turns.length,
intents: this.intents,
@@ -139,7 +163,7 @@ export class LocalServer {
public endGame(saveFullGame: boolean = false) {
consolex.log("local server ending game");
clearInterval(this.endTurnIntervalID);
clearInterval(this.turnCheckInterval);
const players: PlayerRecord[] = [
{
ip: null,
+12 -1
View File
@@ -263,7 +263,12 @@ export class Transport {
onconnect: () => void,
onmessage: (message: ServerMessage) => void,
) {
this.localServer = new LocalServer(this.lobbyConfig, onconnect, onmessage);
this.localServer = new LocalServer(
this.lobbyConfig,
onconnect,
onmessage,
this.lobbyConfig.gameRecord != null,
);
this.localServer.start();
}
@@ -318,6 +323,12 @@ export class Transport {
this.connect(this.onconnect, this.onmessage);
}
public turnComplete() {
if (this.isLocal) {
this.localServer.turnComplete();
}
}
private onSendLogEvent(event: SendLogEvent) {
this.sendMsg(
JSON.stringify({
+2 -1
View File
@@ -122,7 +122,8 @@ export class OptionsMenu extends LitElement implements Layer {
init() {
console.log("init called from OptionsMenu");
this.showPauseButton =
this.game.config().gameConfig().gameType == GameType.Singleplayer;
this.game.config().gameConfig().gameType == GameType.Singleplayer ||
this.game.config().isReplay();
this.isVisible = true;
this.requestUpdate();
}
+1
View File
@@ -136,6 +136,7 @@ export interface Config {
defaultNukeSpeed(): number;
nukeDeathFactor(humans: number, tilesOwned: number): number;
structureMinDist(): number;
isReplay(): boolean;
}
export interface Theme {
+3 -2
View File
@@ -12,15 +12,16 @@ export let cachedSC: ServerConfig = null;
export async function getConfig(
gameConfig: GameConfig,
userSettings: UserSettings | null = null,
isReplay: boolean = false,
): Promise<Config> {
const sc = await getServerConfigFromClient();
switch (sc.env()) {
case GameEnv.Dev:
return new DevConfig(sc, gameConfig, userSettings);
return new DevConfig(sc, gameConfig, userSettings, isReplay);
case GameEnv.Preprod:
case GameEnv.Prod:
consolex.log("using prod config");
return new DefaultConfig(sc, gameConfig, userSettings);
return new DefaultConfig(sc, gameConfig, userSettings, isReplay);
default:
throw Error(`unsupported server configuration: ${process.env.GAME_ENV}`);
}
+4
View File
@@ -158,7 +158,11 @@ export class DefaultConfig implements Config {
private _serverConfig: ServerConfig,
private _gameConfig: GameConfig,
private _userSettings: UserSettings,
private _isReplay: boolean,
) {}
isReplay(): boolean {
return this._isReplay;
}
samHittingChance(): number {
return 0.8;
+7 -2
View File
@@ -41,8 +41,13 @@ export class DevServerConfig extends DefaultServerConfig {
}
export class DevConfig extends DefaultConfig {
constructor(sc: ServerConfig, gc: GameConfig, us: UserSettings) {
super(sc, gc, us);
constructor(
sc: ServerConfig,
gc: GameConfig,
us: UserSettings,
isReplay: boolean,
) {
super(sc, gc, us, isReplay);
}
// numSpawnPhaseTurns(): number {
+1 -1
View File
@@ -38,7 +38,7 @@ export class CityExecution implements Execution {
this.active = false;
return;
}
this.city = this.player.buildUnit(UnitType.City, 0, spawnTile);
this.city = this.player.buildUnit(UnitType.City, spawnTile, {});
}
if (!this.city.isActive()) {
this.active = false;
+1 -1
View File
@@ -60,8 +60,8 @@ export class ConstructionExecution implements Execution {
}
this.construction = this.player.buildUnit(
UnitType.Construction,
0,
spawnTile,
{},
);
this.cost = this.mg.unitInfo(this.constructionType).cost(this.player);
this.player.removeGold(this.cost);
+1 -1
View File
@@ -65,7 +65,7 @@ export class DefensePostExecution implements Execution {
this.active = false;
return;
}
this.post = this.player.buildUnit(UnitType.DefensePost, 0, spawnTile);
this.post = this.player.buildUnit(UnitType.DefensePost, spawnTile, {});
}
if (!this.post.isActive()) {
this.active = false;
+1 -1
View File
@@ -73,7 +73,7 @@ export class MirvExecution implements Execution {
this.active = false;
return;
}
this.nuke = this.player.buildUnit(UnitType.MIRV, 0, spawn);
this.nuke = this.player.buildUnit(UnitType.MIRV, spawn, {});
const x = Math.floor(
(this.mg.x(this.dst) + this.mg.x(this.mg.x(this.nuke.tile()))) / 2,
);
+1 -1
View File
@@ -41,7 +41,7 @@ export class MissileSiloExecution implements Execution {
this.active = false;
return;
}
this.silo = this.player.buildUnit(UnitType.MissileSilo, 0, spawn, {
this.silo = this.player.buildUnit(UnitType.MissileSilo, spawn, {
cooldownDuration: this.mg.config().SiloCooldown(),
});
+1 -2
View File
@@ -95,13 +95,12 @@ export class NukeExecution implements Execution {
this.active = false;
return;
}
const maxVertex = this.type == UnitType.MIRVWarhead ? 0 : null;
this.pathFinder.computeControlPoints(
spawn,
this.dst,
this.type != UnitType.MIRVWarhead,
);
this.nuke = this.player.buildUnit(this.type, 0, spawn, {
this.nuke = this.player.buildUnit(this.type, spawn, {
detonationDst: this.dst,
});
if (this.mg.hasOwner(this.dst)) {
+1 -1
View File
@@ -45,7 +45,7 @@ export class PortExecution implements Execution {
this.active = false;
return;
}
this.port = player.buildUnit(UnitType.Port, 0, spawn);
this.port = player.buildUnit(UnitType.Port, spawn, {});
}
if (!this.port.isActive()) {
+1 -1
View File
@@ -99,7 +99,7 @@ export class SAMLauncherExecution implements Execution {
this.active = false;
return;
}
this.sam = this.player.buildUnit(UnitType.SAMLauncher, 0, spawnTile, {
this.sam = this.player.buildUnit(UnitType.SAMLauncher, spawnTile, {
cooldownDuration: this.mg.config().SAMCooldown(),
});
}
+1 -1
View File
@@ -33,8 +33,8 @@ export class SAMMissileExecution implements Execution {
if (this.SAMMissile == null) {
this.SAMMissile = this._owner.buildUnit(
UnitType.SAMMissile,
0,
this.spawn,
{},
);
}
if (!this.SAMMissile.isActive()) {
+1 -1
View File
@@ -24,7 +24,7 @@ export class ShellExecution implements Execution {
tick(ticks: number): void {
if (this.shell == null) {
this.shell = this._owner.buildUnit(UnitType.Shell, 0, this.spawn);
this.shell = this._owner.buildUnit(UnitType.Shell, this.spawn, {});
}
if (!this.shell.isActive()) {
this.active = false;
+1 -1
View File
@@ -45,7 +45,7 @@ export class TradeShipExecution implements Execution {
this.active = false;
return;
}
this.tradeShip = this.origOwner.buildUnit(UnitType.TradeShip, 0, spawn, {
this.tradeShip = this.origOwner.buildUnit(UnitType.TradeShip, spawn, {
dstPort: this._dstPort,
lastSetSafeFromPirates: ticks,
});
+3 -5
View File
@@ -139,11 +139,9 @@ export class TransportShipExecution implements Execution {
}
}
this.boat = this.attacker.buildUnit(
UnitType.TransportShip,
this.troops,
this.src,
);
this.boat = this.attacker.buildUnit(UnitType.TransportShip, this.src, {
troops: this.troops,
});
}
tick(ticks: number) {
+7 -1
View File
@@ -54,11 +54,13 @@ export class WarshipExecution implements Execution {
switch (result.type) {
case PathFindResultType.Completed:
this.warship.setMoveTarget(null);
this.warship.move(this.warship.tile());
return;
case PathFindResultType.NextTile:
this.warship.move(result.tile);
break;
case PathFindResultType.Pending:
this.warship.move(this.warship.tile());
break;
case PathFindResultType.PathNotFound:
consolex.log(`path not found to target`);
@@ -98,11 +100,13 @@ export class WarshipExecution implements Execution {
switch (result.type) {
case PathFindResultType.Completed:
this.patrolTile = this.randomTile();
this.warship.move(this.warship.tile());
break;
case PathFindResultType.NextTile:
this.warship.move(result.tile);
break;
case PathFindResultType.Pending:
this.warship.move(this.warship.tile());
return;
case PathFindResultType.PathNotFound:
consolex.log(`path not found to patrol tile`);
@@ -119,7 +123,7 @@ export class WarshipExecution implements Execution {
this.active = false;
return;
}
this.warship = this._owner.buildUnit(UnitType.Warship, 0, spawn);
this.warship = this._owner.buildUnit(UnitType.Warship, spawn, {});
return;
}
if (!this.warship.isActive()) {
@@ -227,11 +231,13 @@ export class WarshipExecution implements Execution {
case PathFindResultType.Completed:
this._owner.captureUnit(this.target);
this.target = null;
this.warship.move(this.warship.tile());
return;
case PathFindResultType.NextTile:
this.warship.move(result.tile);
break;
case PathFindResultType.Pending:
this.warship.move(this.warship.tile());
break;
case PathFindResultType.PathNotFound:
consolex.log(`path not found to target`);
+50 -14
View File
@@ -148,6 +148,51 @@ export enum UnitType {
Construction = "Construction",
}
export interface UnitParamsMap {
[UnitType.TransportShip]: {
troops?: number;
destination?: TileRef;
};
[UnitType.Warship]: {};
[UnitType.Shell]: {};
[UnitType.SAMMissile]: {};
[UnitType.Port]: {};
[UnitType.AtomBomb]: {};
[UnitType.HydrogenBomb]: {};
[UnitType.TradeShip]: {
dstPort: Unit;
lastSetSafeFromPirates?: number;
};
[UnitType.MissileSilo]: {
cooldownDuration?: number;
};
[UnitType.DefensePost]: {};
[UnitType.SAMLauncher]: {};
[UnitType.City]: {};
[UnitType.MIRV]: {};
[UnitType.MIRVWarhead]: {};
[UnitType.Construction]: {};
}
// Type helper to get params type for a specific unit type
export type UnitParams<T extends UnitType> = UnitParamsMap[T];
export type AllUnitParams = UnitParamsMap[keyof UnitParamsMap];
export const nukeTypes = [
UnitType.AtomBomb,
UnitType.HydrogenBomb,
@@ -276,15 +321,6 @@ export class PlayerInfo {
}
}
// Some units have info specific to them
export interface UnitSpecificInfos {
dstPort?: Unit; // Only for trade ships
lastSetSafeFromPirates?: number; // Only for trade ships
detonationDst?: TileRef; // Only for nukes
warshipTarget?: Unit;
cooldownDuration?: number;
}
export interface Unit {
id(): number;
@@ -391,12 +427,12 @@ export interface Player {
unitsIncludingConstruction(type: UnitType): Unit[];
buildableUnits(tile: TileRef): BuildableUnit[];
canBuild(type: UnitType, targetTile: TileRef): TileRef | false;
buildUnit(
type: UnitType,
troops: number,
tile: TileRef,
unitSpecificInfos?: UnitSpecificInfos,
buildUnit<T extends UnitType>(
type: T,
spawnTile: TileRef,
params: UnitParams<T>,
): Unit;
captureUnit(unit: Unit): void;
// Relations & Diplomacy
+6 -8
View File
@@ -35,7 +35,7 @@ import {
TerraNullius,
Tick,
Unit,
UnitSpecificInfos,
UnitParams,
UnitType,
} from "./Game";
import { GameImpl } from "./GameImpl";
@@ -703,11 +703,10 @@ export class PlayerImpl implements Player {
);
}
buildUnit(
type: UnitType,
troops: number,
buildUnit<T extends UnitType>(
type: T,
spawnTile: TileRef,
unitSpecificInfos: UnitSpecificInfos = {},
params: UnitParams<T>,
): UnitImpl {
if (this.mg.config().isUnitDisabled(type)) {
throw new Error(
@@ -720,14 +719,13 @@ export class PlayerImpl implements Player {
type,
this.mg,
spawnTile,
troops,
this.mg.nextUnitID(),
this,
unitSpecificInfos,
params,
);
this._units.push(b);
this.removeGold(cost);
this.removeTroops(troops);
this.removeTroops("troops" in params ? params.troops : 0);
this.mg.addUpdate(b.toUpdate());
this.mg.addUnit(b);
+11 -9
View File
@@ -1,11 +1,11 @@
import { simpleHash, toInt, withinInt } from "../Util";
import {
AllUnitParams,
MessageType,
Player,
Tick,
Unit,
UnitInfo,
UnitSpecificInfos,
UnitType,
} from "./Game";
import { GameImpl } from "./GameImpl";
@@ -24,6 +24,7 @@ export class UnitImpl implements Unit {
private _lastSetSafeFromPirates: number; // Only for trade ships
private _constructionType: UnitType = undefined;
private _troops: number;
private _cooldownTick: Tick | null = null;
private _dstPort: Unit | null = null; // Only for trade ships
private _detonationDst: TileRef | null = null; // Only for nukes
@@ -34,21 +35,22 @@ export class UnitImpl implements Unit {
private _type: UnitType,
private mg: GameImpl,
private _tile: TileRef,
private _troops: number,
private _id: number,
public _owner: PlayerImpl,
unitsSpecificInfos: UnitSpecificInfos = {},
params: AllUnitParams = {},
) {
this._health = toInt(this.mg.unitInfo(_type).maxHealth ?? 1);
this._lastTile = _tile;
this._dstPort = unitsSpecificInfos.dstPort;
this._detonationDst = unitsSpecificInfos.detonationDst;
this._warshipTarget = unitsSpecificInfos.warshipTarget;
this._cooldownDuration = unitsSpecificInfos.cooldownDuration;
this._lastSetSafeFromPirates = unitsSpecificInfos.lastSetSafeFromPirates;
this._health = toInt(this.mg.unitInfo(_type).maxHealth ?? 1);
this._safeFromPiratesCooldown = this.mg
.config()
.safeFromPiratesCooldownMax();
this._troops = "troops" in params ? params.troops : 0;
this._dstPort = "dstPort" in params ? params.dstPort : null;
this._cooldownDuration =
"cooldownDuration" in params ? params.cooldownDuration : null;
this._lastSetSafeFromPirates =
"lastSetSafeFromPirates" in params ? params.lastSetSafeFromPirates : 0;
}
id() {
+20 -10
View File
@@ -50,9 +50,9 @@ describe("SAM", () => {
});
test("one sam should take down one nuke", async () => {
const sam = defender.buildUnit(UnitType.SAMLauncher, 0, game.ref(1, 1));
const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {});
game.addExecution(new SAMLauncherExecution(defender.id(), null, sam));
attacker.buildUnit(UnitType.AtomBomb, 0, game.ref(1, 1));
attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 1), {});
executeTicks(game, 3);
@@ -60,10 +60,14 @@ describe("SAM", () => {
});
test("sam should only get one nuke at a time", async () => {
const sam = defender.buildUnit(UnitType.SAMLauncher, 0, game.ref(1, 1));
const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {});
game.addExecution(new SAMLauncherExecution(defender.id(), null, sam));
attacker.buildUnit(UnitType.AtomBomb, 0, game.ref(2, 1));
attacker.buildUnit(UnitType.AtomBomb, 0, game.ref(1, 2));
attacker.buildUnit(UnitType.AtomBomb, game.ref(2, 1), {
detonationDst: game.ref(2, 1),
});
attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 2), {
detonationDst: game.ref(1, 2),
});
expect(attacker.units(UnitType.AtomBomb)).toHaveLength(2);
executeTicks(game, 3);
@@ -72,10 +76,12 @@ describe("SAM", () => {
});
test("sam should cooldown as long as configured", async () => {
const sam = defender.buildUnit(UnitType.SAMLauncher, 0, game.ref(1, 1));
const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {});
game.addExecution(new SAMLauncherExecution(defender.id(), null, sam));
expect(sam.isCooldown()).toBeFalsy();
const nuke = attacker.buildUnit(UnitType.AtomBomb, 0, game.ref(1, 2));
const nuke = attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 2), {
detonationDst: game.ref(1, 2),
});
executeTicks(game, 3);
@@ -91,11 +97,15 @@ describe("SAM", () => {
});
test("two sams should not target twice same nuke", async () => {
const sam1 = defender.buildUnit(UnitType.SAMLauncher, 0, game.ref(1, 1));
const sam1 = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {
cooldownDuration: 10,
});
game.addExecution(new SAMLauncherExecution(defender.id(), null, sam1));
const sam2 = defender.buildUnit(UnitType.SAMLauncher, 0, game.ref(1, 2));
const sam2 = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 2), {});
game.addExecution(new SAMLauncherExecution(defender.id(), null, sam2));
const nuke = attacker.buildUnit(UnitType.AtomBomb, 0, game.ref(2, 2));
const nuke = attacker.buildUnit(UnitType.AtomBomb, game.ref(2, 2), {
detonationDst: game.ref(2, 2),
});
executeTicks(game, 3);
+8 -4
View File
@@ -59,11 +59,11 @@ describe("Warship", () => {
test("Warship heals only if player has port", async () => {
const maxHealth = game.config().unitInfo(UnitType.Warship).maxHealth;
const port = player1.buildUnit(UnitType.Port, 0, game.ref(coastX, 10));
const port = player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {});
const warship = player1.buildUnit(
UnitType.Warship,
0,
game.ref(coastX + 1, 10),
{},
);
game.executeNextTick();
@@ -91,8 +91,10 @@ describe("Warship", () => {
// we can obviously directly add it to the player)
const tradeShip = player2.buildUnit(
UnitType.TradeShip,
0,
game.ref(coastX + 1, 7),
{
dstPort: null,
},
);
expect(tradeShip.owner().id()).toBe(player2.id());
@@ -113,8 +115,10 @@ describe("Warship", () => {
// we can obviously directly add it to the player)
const tradeShip = player2.buildUnit(
UnitType.TradeShip,
0,
game.ref(coastX + 1, 11),
{
dstPort: null,
},
);
expect(tradeShip.owner().id()).toBe(player2.id());
+6 -1
View File
@@ -42,7 +42,12 @@ export async function setup(
instantBuild: false,
..._gameConfig,
};
const config = new TestConfig(serverConfig, gameConfig, new UserSettings());
const config = new TestConfig(
serverConfig,
gameConfig,
new UserSettings(),
false,
);
// Create and return the game
return createGame(humans, [], gameMap, miniGameMap, config);